diff --git a/.env.example b/.env.example index f2578b8d..dcde7316 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,10 @@ # # .env 不入库(见 .gitignore);切勿把真实 APP_SECRET 提交进仓库。 -# 凭证列加密密钥,≥16 字符。丢失 = 已存凭证全部不可解,只能重录(无密钥轮换)。 +# 凭证列加密密钥,≥16 字符。丢失 = 已存凭证全部不可解,只能重录。 # 生成:python -c "import secrets; print(secrets.token_urlsafe(32))" +# **占位值/示例值会被拒绝启动**(KNOWN_WEAK_SECRETS);真要轮换见 +# scripts/rotate_app_secret.py(先备份、停机、再重加密,否则凭证全丢)。 APP_SECRET= # 管理台管理员用户名(逗号分隔)。仅引导期生效:DB 里已有角色后不再覆盖。 @@ -33,7 +35,7 @@ DATA_DIR=./data USERS_FILE=secrets/users.txt # 请求未指定模型(或为 auto/空)时使用的默认模型。 -DEFAULT_MODEL=glm-5.2 +DEFAULT_MODEL=deepseek-v4.1-flash # Host 白名单(逗号分隔,防 DNS rebinding);留空 = 放行本地回环与 PUBLIC_BASE_URL 主机。 ALLOWED_HOSTS= @@ -44,8 +46,29 @@ ENABLE_DOCS=false # 是否采信 X-Forwarded-For(仅当本服务前面恰好一层受信反代时开启)。 TRUST_PROXY=false +# ---- 公网部署(Docker + Caddy,见 README「公网部署(Docker)」)---- +# 有公网 IP 的 VPS:填域名即自动申请 / 续期证书;本地与内网部署保持留空 +# (留空时 base compose 不引用这三项,不影响常规启动)。 +# 也可以填**公网 IP**(没有域名时):此时必须配 TLS_MODE=internal 走自签。 +PUBLIC_DOMAIN= +# 域名模式(TLS_MODE 留空 / acme)必填:Let's Encrypt 到期通知收件人,也是 +# ACME 账号标识;自签模式(internal)用不到,留空即可。 +ACME_EMAIL= +# TLS 模式:留空 / acme = 自动申请证书(站点须是域名);internal = Caddy 自带 +# CA 自签(站点是公网 IP,证书浏览器不认、需装根证书或客户端跳过校验)。 +TLS_MODE= + # 按渠道出站代理,格式 `渠道=代理URL;渠道2=代理URL2`;留空 = 全部直连。 -# 渠道取八个渠道名,协议支持 http/https/socks5/socks5h。环境变量 HTTP_PROXY 等一律不采信。 +# 渠道取九个渠道名,协议支持 http/https/socks5/socks5h。环境变量 HTTP_PROXY 等一律不采信。 # ⚠️ 容器里 127.0.0.1 指容器自身,不是宿主:代理跑在宿主时须写 # host.docker.internal(compose 已加 extra_hosts),如 codebuddy=http://host.docker.internal:7897。 PROVIDER_PROXIES= + +# 运维告警出口(留空 = 只留站内记录,不外推)。 +# 通用 Webhook 发完整 JSON + HMAC 签名;飞书/Slack 各按自己的消息格式发送。 +ALERT_WEBHOOK_URL= +ALERT_FEISHU_WEBHOOK_URL= +ALERT_SLACK_WEBHOOK_URL= + +# /metrics 的 Bearer 令牌(留空 = 公开)。对外部署建议设置。 +METRICS_TOKEN= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..ba4e5dee --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# 默认归属:所有路径由维护者评审。 +* @RobbsLuo + +# 敏感路径单独列出,避免以后加了其他 owner 后被静默改掉。 +/.github/ @RobbsLuo +/Dockerfile @RobbsLuo +/docker-compose*.yml @RobbsLuo +/deploy/ @RobbsLuo +/src/db/schema.sql @RobbsLuo +/src/db/migrate.py @RobbsLuo +/src/auth/ @RobbsLuo +/src/webapp/security.py @RobbsLuo diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..0be5e35b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,137 @@ +name: Bug 报告 +description: 功能不符合预期、报错、崩溃、数据不对 +title: "bug: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + 填之前先确认两件事,能省一轮来回: + + 1. **改过 `src/` 之后重启过后端吗?** 只改代码不重启 = 老后端 + 新前端, + 新端点会 404、页面弹无关兜底文案。升级含 migration 的版本同理。 + 2. **`/healthz` 里 `ready` 是不是 0?** 池子没可用凭证时很多现象是 + 「没号可调度」而不是 bug。 + + 漏洞与凭证泄露**不要**贴在这里,走 [私密报告](https://github.com/RobbsLuo/Coding2API/security/advisories/new)。 + + - type: textarea + id: what-happened + attributes: + label: 现象 + description: 期望发生什么,实际发生了什么。有报错就贴原文(错误码、`error_type`、上游返回体)。 + placeholder: | + 期望:/v1/chat/completions 返回 200 + 实际:503 no_healthy_credential,但池子里有 3 个 ready 凭证 + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: 复现步骤 + description: 越具体越好。涉及推理请求请给**脱敏后**的 curl(保留 model、消息结构、关键参数,去掉 Key 与正文)。 + placeholder: | + 1. 管理台 → 凭证 → 添加 Qoder 凭证 + 2. 用模型 qwen3.8-max@qoder 发一次请求 + 3. ... + validations: + required: true + + - type: dropdown + id: channel + attributes: + label: 涉及的渠道 + description: 与渠道无关(管理台、统计、鉴权等)选「与渠道无关」。 + multiple: true + options: + - 与渠道无关 + - codebuddy + - codebuddy_intl + - trae + - zen + - kilo + - qoder + - codearts + - officeace + - raccoon + - 不确定 + validations: + required: true + + - type: dropdown + id: surface + attributes: + label: 出问题的面 + multiple: true + options: + - 推理请求(/v1/chat/completions · /v1/responses · /v1/messages) + - 管理台前端(页面 / 交互) + - 管理台 API(/api/*) + - 凭证导入 / 登录 / 刷新 + - 额度探测 / 签到 / 活跃上报 + - 统计与账单 + - 后台任务(TaskRunner) + - 部署(Docker / Caddy / 反向代理) + - 其他 + validations: + required: true + + - type: input + id: version + attributes: + label: 版本 + description: 镜像 tag 或 commit。`/healthz` 的 `version` 字段与 `git rev-parse --short HEAD` 都行。 + placeholder: v0.13.1 / 6ac9766 + validations: + required: true + + - type: dropdown + id: deploy + attributes: + label: 部署形态 + options: + - Docker Compose + - Docker Compose + Caddy(公网) + - Linux systemd + - macOS launchd + - 裸跑(uv run / python -m src) + - 其他 + validations: + required: true + + - type: textarea + id: healthz + attributes: + label: /healthz 输出 + description: | + `curl -s http://127.0.0.1:8000/healthz | jq` 的输出。 + 只含计数与版本号,不含凭证内容。没有 jq 就直接贴原文。 + render: json + placeholder: | + {"status":"ok","service":"coding2api","version":"0.13.1", + "credentials":{"ready":3,"cooling":0,"paused":0,"disabled":0,"total":3}} + + - type: textarea + id: logs + attributes: + label: 相关日志 + description: | + `docker logs coding2api --tail 200` 或 `journalctl -u coding2api -n 200`。 + **先脱敏**:删掉 `Authorization`、cookie、Key 明文、上游返回里的账号标识。 + 有 `X-Request-ID` 的话贴那几行就够。 + render: shell + validations: + required: false + + - type: checkboxes + id: checks + attributes: + label: 提交前自查 + options: + - label: 改过 `src/` 的话,后端已重启 + required: true + - label: 已脱敏(无 Key、cookie、凭证、对话正文) + required: true + - label: 搜过已有 issue,没有重复 + required: true diff --git a/.github/ISSUE_TEMPLATE/channel_request.yml b/.github/ISSUE_TEMPLATE/channel_request.yml new file mode 100644 index 00000000..3b88ec94 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/channel_request.yml @@ -0,0 +1,107 @@ +name: 渠道接入 +description: 新上游渠道的可行性核实与登记 +title: "channel: " +labels: ["enhancement", "new-channel"] +body: + - type: markdown + attributes: + value: | + 本仓接渠道的前提是**上游协议已实测**,不是「文档说支持 OpenAI 兼容」。 + 没实测过的条目进 `PROPOSAL.md` 时会被标成未验证,调度与错误分类也写不准。 + + 提之前请先读 `PROPOSAL.md` 的「上游可行性核实」章节:现有九个渠道各写了 + 端点、鉴权、额度口径、错误码与实测结论,照着那个粒度填。 + + - type: input + id: channel-name + attributes: + label: 渠道名与厂商 + description: 渠道键(`src/engine/model_resolver.py` 的 `KNOWN_PROVIDERS` 里的写法)+ 产品名。 + placeholder: raccoon / 商汤小浣熊 + validations: + required: true + + - type: textarea + id: endpoints + attributes: + label: 端点 + description: 推理、模型目录、额度、签到、刷新各是哪个 URL。同域还是分域、是否需要特殊 header。 + placeholder: | + 推理:POST https://example.com/api/web/llm/v2/chat/completions + 目录:GET https://example.com/api/web/llm/v2/model_catalog + 额度:GET https://example.com/api/web/points/v1/balance + validations: + required: true + + - type: dropdown + id: auth + attributes: + label: 鉴权方式 + description: 决定能不能做「登录按钮」,以及 token 过期后怎么办。 + options: + - 标准 API Key(长期有效) + - Bearer token + refresh_token(可自动刷新) + - 网页会话 JWT / cookie(只能手工导入,到期重取) + - OAuth 授权码(可做登录按钮 + 回调) + - 设备码 + - 请求签名(HMAC / 私有签名) + - 不确定 + validations: + required: true + + - type: dropdown + id: protocol + attributes: + label: 协议形态 + options: + - 标准 OpenAI 兼容(无信封、无签名) + - OpenAI 兼容 + 私有信封 / 特殊字段 + - 私有协议(需要单独写事件解析) + - 不确定 + validations: + required: true + + - type: textarea + id: quota + attributes: + label: 额度口径 + description: 余额接口返回什么、单位是什么、有没有总量、是否逐笔到期。没有额度接口就写「无」。 + placeholder: | + available_points=4800,单位积分,无总量口径; + 每日 00:00 发 300,7 天有效,逐笔到期在流水 daily_grant.expire_at + validations: + required: true + + - type: textarea + id: measurements + attributes: + label: 实测结果 + description: | + 至少覆盖:流式 / 非流式、`tool_calls`、`role=tool` 回传、`stream_options.include_usage`、 + 并发上限、限流与额度的错误码原文。写「未测」比编一个结论好。 + placeholder: | + 流式 tool_calls:通 + role=tool 回传:通 + include_usage:通 + 并发:5 并发放行,第 6 个 429 user_model_rate_limited + 未知模型:静默回落默认模型并回 200(需两层拦截) + validations: + required: true + + - type: textarea + id: tos + attributes: + label: 条款与风险 + description: 官方条款是否允许第三方转发 / 多用户共享。没写清楚就写「条款未明确」——本仓按 gray 档登记,不假装是 white。 + validations: + required: true + + - type: checkboxes + id: confirm + attributes: + label: 确认 + options: + - label: 上面的实测是我自己跑出来的,不是从文档抄的 + required: true + - label: 我接受该渠道按「逆向接口,仅供学习研究」口径登记,不承诺长期可用 + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..029bd58b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,13 @@ +# Issue 入口配置:关掉空白 issue,逼着填模板(否则「XX 不能用」这种一句话 +# 报告无法定位,来回追问的轮次比填表还多)。 +blank_issues_enabled: false +contact_links: + - name: 安全问题(不要开公开 Issue) + url: https://github.com/RobbsLuo/Coding2API/security/advisories/new + about: 漏洞、凭证泄露、越权等走私密报告,见 SECURITY.md + - name: 使用与部署文档 + url: https://github.com/RobbsLuo/Coding2API/blob/main/README.md + about: 安装、配置、各渠道接入方式、公网部署(Docker + Caddy) + - name: 设计与实现说明 + url: https://github.com/RobbsLuo/Coding2API/blob/main/TECHNICAL.md + about: 模块规格、Provider 协议、请求时序、数据模型 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..3b783101 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,64 @@ +name: 功能建议 +description: 想要新能力、新渠道、改现有行为 +title: "feat: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + 先看一眼 `PROPOSAL.md` 的「非目标」:按 token 计量计费、上游积分→货币换算、 + 通用 provider 网关(只硬编码已知渠道)、语义缓存、正文落盘这些是**明确不做**的。 + 落在非目标里的建议会被直接关掉。 + + - type: dropdown + id: kind + attributes: + label: 类型 + options: + - 新渠道接入 + - 现有渠道的行为 / 兼容性 + - 管理台功能 + - 调度与凭证池 + - 统计与账单 + - 部署与运维 + - 文档 + - 其他 + validations: + required: true + + - type: textarea + id: problem + attributes: + label: 要解决什么问题 + description: 描述场景与当前卡点,不要直接跳到实现方案。有实际请求/响应样例最好。 + placeholder: | + 用 Claude Code 走本服务时,上游返回的 tool_calls 里 arguments 是流式分片, + 客户端拼不起来 → 想加一个分片合并 + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: 建议的做法 + description: 如果有想法写这里。新渠道请附上游协议事实(端点、鉴权方式、额度接口),能实测的写实测结果。 + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: 考虑过的替代方案 + description: 为什么不用现成做法 / 别的渠道 / 客户端侧解决。 + validations: + required: false + + - type: checkboxes + id: scope + attributes: + label: 范围确认 + options: + - label: 这不是「非目标」清单里的条目 + required: true + - label: 涉及新渠道的话,我读过 `PROPOSAL.md` 的渠道登记格式,愿意提供实测事实 + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..12804c19 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,47 @@ +## 改动 + + + +## 为什么 + + + +Fixes # + +## 影响面 + + + +- [ ] 后端逻辑(`src/`) +- [ ] 数据库 schema(`schema.sql` / `migrate.py`) +- [ ] API 语义(端点、请求/响应字段) +- [ ] 上游解析(provider 请求体、事件解析、错误分类) +- [ ] 前端(`web/`) +- [ ] 部署产物(Dockerfile / compose / Caddy) +- [ ] 仅文档 / 注释 + +## 验证 + +```bash +# 后端(门槛:行/分支覆盖 100%) +uv run ruff check src tests scripts +uv run pytest -q --cov=src --cov-report=term --cov-fail-under=100 + +# 前端 +cd web && pnpm exec tsc --noEmit && pnpm exec vitest run && pnpm build +``` + +- [ ] 上述命令本地全绿 +- [ ] 改过 `src/` 的话,**已重启后端**再验(不重启 = 老后端 + 新前端,新端点 404) +- [ ] 未在 CI 覆盖的验证(真机请求、上游实测、多平台)写在下面 + +## schema 变更(没有就删掉本节) + +- [ ] `schema.sql` 只加不改 +- [ ] 已有表的新列走 `migrate.py` 的 `_MIGRATION_COLUMNS` 幂等补列 +- [ ] 删表同时在 `_MIGRATION_DROPS` 补一条,并 `SCHEMA_VERSION + 1` +- [ ] 附老库升级测试(样板见 `tests/db/test_repo_migrate.py`) + +## 备注 + + diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..df8d102c --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,33 @@ +# 自动生成 Release Notes 的分类规则(GitHub「Generate release notes」使用)。 +# +# 只影响自动汇总,不改变手工写的 release body:本仓发版以 `git tag -a` 的 +# 附注为准(见 v0.11.0 的 tag message),这里用于需要快速汇总时的兜底。 +# +# 提交类型约定(Conventional Commits):feat / fix / perf / docs / chore / test / refactor。 +changelog: + exclude: + labels: + - dependencies + authors: + - dependabot + categories: + - title: 新渠道 + labels: + - new-channel + - title: 新能力 + labels: + - enhancement + - title: 修复 + labels: + - bug + - title: 文档 + labels: + - documentation + - title: 依赖与构建 + labels: + - dependencies + - docker + - github_actions + - title: 其他变更 + labels: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0f309fa..8ebcbfbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,30 @@ on: branches: [main] pull_request: +# 最小权限:CI 只需要读代码(checkout),不发布任何制品。 +# GitHub Actions 的默认 GITHUB_TOKEN 是 repo 写权限,被恶意 workflow 改写 +# (或第三方 action 被投毒)时能直接推 main;只读权限把爆炸半径限到零。 +permissions: read-all + +# 同分支推送/PR 只保留最新的那一轮 CI:hotfix 连推几发时旧 run 全部取消, +# 不再空烧 runner 额度。 +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + jobs: backend: runs-on: ubuntu-latest + # 挂死保险丝:覆盖率全量跑 ≈ 4 分钟,给 15 分钟足够;无此限一个死循环 + # 测试会烧 6 小时(GitHub 默认 360 分钟)。 + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 - run: uv sync --frozen - name: Ruff run: uv run ruff check src tests scripts @@ -19,23 +37,29 @@ jobs: security: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 - run: uv sync --frozen - name: bandit(中危及以上,配置见 pyproject [tool.bandit]) run: uvx bandit -c pyproject.toml -r src scripts -ll - name: pip-audit(已知漏洞,含 dev 依赖) - run: uvx pip-audit --skip-editable + # 必须用 `uv run --with`:`uvx pip-audit` 会在**自己的临时环境**里跑, + # 扫到的是 pip-audit 的依赖树(实测 28 个包,含 pip-audit/rich/requests), + # 项目依赖(fastapi/uvicorn/httpx/cryptography)一个都不在内 —— 门一直是 + # 绿的,但没有任何防线。`uv run --with` 注入项目 .venv,实测扫到 58 个包。 + run: uv run --with pip-audit pip-audit --skip-editable frontend: runs-on: ubuntu-latest + timeout-minutes: 15 defaults: run: working-directory: web steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 with: version: 10 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 @@ -61,12 +85,44 @@ jobs: compose: runs-on: ubuntu-latest + # 真实构建 + 启动探活(pnpm install ×2 与 docker build),最重的一环 + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: 校验 compose 语法 run: docker compose config --quiet env: APP_SECRET: ci-placeholder-secret + - name: 校验 compose 公网覆盖文件语法(Caddy 入口,两种 TLS 模式) + env: + APP_SECRET: ci-placeholder-secret + run: | + # acme(域名):PUBLIC_DOMAIN + ACME_EMAIL + PUBLIC_DOMAIN=ai-api.example.com ACME_EMAIL=ops@example.com \ + docker compose -f docker-compose.yml -f docker-compose.public.yml config --quiet + # internal(公网 IP、自签):不设 ACME_EMAIL——自签模式用不到,必须能拼出来 + PUBLIC_DOMAIN=203.0.113.10 TLS_MODE=internal \ + docker compose -f docker-compose.yml -f docker-compose.public.yml config --quiet + # 缺 PUBLIC_DOMAIN 必须报错(覆盖文件里 :? 断言的回归保护) + if docker compose -f docker-compose.yml -f docker-compose.public.yml config --quiet 2>/dev/null; then + echo "::error::缺 PUBLIC_DOMAIN 时 compose 本应报错,却通过了" + exit 1 + fi + - name: 校验 Caddyfile 语法(域名 / 自签两种模式) + run: | + # 只 validate、不起服务:占位域名不申请证书(validate 不联网签发) + for mode in acme internal; do + case "$mode" in + acme) domain=ai-api.example.com; email=ops@example.com ;; + # 自签模式站址是公网 IP,且刻意不传 ACME_EMAIL(tls_internal 片段不引用它) + internal) domain=203.0.113.10; email= ;; + esac + docker run --rm \ + -e PUBLIC_DOMAIN="$domain" -e ACME_EMAIL="$email" \ + -e PORT=8000 -e TLS_MODE="$mode" \ + -v "$PWD/deploy/caddy:/etc/caddy:ro" \ + caddy:2.11.7-alpine caddy validate --config /etc/caddy/Caddyfile + done - name: 构建镜像并启动(验证 compose 可真实运行) run: | mkdir -p data secrets diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bbd8d893..6987cc07 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: tag: - description: '要打的版本号(如 v0.9.1,留空则跳过)' + description: '要打的版本号(如 v0.13.1,留空则跳过)' required: false default: '' @@ -42,9 +42,28 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: steps.meta.outputs.tag != 'skip' - - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 if: steps.meta.outputs.tag != 'skip' + # tag 与 pyproject.toml 的 project.version 必须一致,否则镜像 tag 会与 + # /healthz 自报版本自相矛盾(v0.12.0、v0.13.0 就漏 bump 过)。 + - name: 校验 tag 与版本号一致 + if: steps.meta.outputs.tag != 'skip' + shell: bash + env: + TAG: ${{ steps.meta.outputs.tag }} + run: | + PYVER=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1) + if [ -z "$PYVER" ]; then + echo "::error::读不到 pyproject.toml 的 project.version" + exit 1 + fi + if [ "$TAG" != "v$PYVER" ]; then + echo "::error::tag=$TAG 与 pyproject.toml 的 version=$PYVER 不一致,先补 chore(release)" + exit 1 + fi + echo "版本一致:$TAG" + # tag push 到 v* 时跑后端测试,避免把坏代码发到 GHCR。 # 前端由 Dockerfile 的 vite build 兜底,这里不重复。 - name: 后端测试(tag push 场景) diff --git a/.gitignore b/.gitignore index 2f698b2e..c306a8de 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ web/node_modules/ web/dist/ web/coverage/ web/*.tsbuildinfo + +# Archify 工作目录(中间产物与截图) +.archify/ diff --git a/AGENTS.md b/AGENTS.md index ed44d2d4..c5ef8662 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,42 +1,31 @@ # coding2api 项目级约束 -全局约束见 `~/.claude/CLAUDE.md`(opencode 经 `~/.config/opencode/AGENTS.md` 软链读取);本文件是更具体的项目规则,冲突时以本文件为准。 +全局约束见 `~/.claude/CLAUDE.md`(opencode 经 `~/.config/opencode/AGENTS.md` 软链读取);本文件更具体,冲突以本文件为准。 ## 质量门槛(提交前必须全部通过) - 后端:`uv run ruff check src tests scripts` + `uv run pytest -q --cov=src --cov-report=term --cov-fail-under=100` - - 行/分支覆盖率 100% 是硬门槛;新增/修改的代码必须带测试,覆盖率缺口一律补测试解决, - 禁止用 pragma/排除给「该测没测」的业务分支达标。允许的例外(必须带注释说明为何不可达): - - src/:「按构造不可达」的防御兜底——`__main__` 入口、协议演进防御分支、前置 return - 已排除的死分支 - - tests/:测试替身实现了宽于本用例所需的接口方法(注释「由 XX 调用 / 本用例不调用」) + - 行/分支覆盖率 100% 硬门槛;新增/修改代码必须带测试,缺口一律补测试;禁止用 pragma/排除给「该测没测」业务分支达标。例外(必须注释为何不可达): + - src/:「按构造不可达」的防御兜底——`__main__` 入口、协议演进防御分支、前置 return 已排除的死分支 + - tests/:测试替身实现宽于本用例所需的接口方法(注释「由 XX 调用 / 本用例不调用」) - 平台差异分支不用 pragma,必须 monkeypatch 测全(见下条) - 前端(web/):`pnpm exec tsc --noEmit` + `pnpm exec vitest run` + `pnpm build` -- 注意平台差异:本地 macOS 通过不代表 CI(ubuntu-latest)通过;平台相关分支 - (platform.system/machine 等)必须用 monkeypatch 测全所有分支 -- **测试不得依赖本地 `.env`**:本地 `.env` 补上 `APP_SECRET` 等必填项,CI 没有。 - 构造 `Settings` 必须显式传值;验证时临时移走 `.env` 再跑全量 -- **改 `src/` 必须重启服务才算验证完成**(只改前端则重新构建即可)。否则出现 - 「新前端 + 旧后端」错配:新端点 404,前端弹出与真实原因无关的兜底文案。 - 具体重启命令随部署形态而定,见 `docs/local-environment.md` 或 README「部署注意」 +- 平台差异:本地 macOS 通过不代表 CI(ubuntu-latest)通过;平台相关分支(platform.system/machine 等)必须 monkeypatch 测全所有分支 +- **测试不得依赖本地 `.env`**:本地 `.env` 补 `APP_SECRET` 等必填项,CI 没有。构造 `Settings` 必须显式传值;验证时移走 `.env` 跑全量 +- **改 `src/` 必须重启服务才算验证完成**(只改前端则重新构建)。否则「新前端 + 旧后端」错配:新端点 404,前端弹无关兜底文案。重启命令随部署形态,见 `docs/local-environment.md` 或 README「部署注意」 ## GitHub Actions(硬约束) -- push 到 main(或 PR)会触发 CI(.github/workflows/ci.yml):backend / frontend / compose / security 四个 job -- **推送后必须确认 CI 全绿才算完成**:`gh run watch $(gh run list --limit 1 --json databaseId -q '.[0].databaseId')` - 或 `gh run list --limit 1` -- CI 失败必须修复直到通过;不允许留下红色 main 分支 -- compose job 会在 Docker 内真实构建并启动服务(health 检查),改 Dockerfile / 依赖 / - 启动逻辑时先本地 `docker build` 预演 -- **`web/.npmrc` 与 `web/pnpm-lock.yaml` 强耦合**:`auto-install-peers=false` 必须与 lock 里的 - `settings.autoInstallPeers` 一致,且 `Dockerfile` 必须 `COPY web/.npmrc`。改任一处都要 - 本地 `docker build` 验证,否则 compose job 报 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` +- push 到 main(或 PR)触发 CI(.github/workflows/ci.yml):backend / frontend / compose / security 四个 job +- **推送后必须确认 CI 全绿才算完成**:`gh run watch $(gh run list --limit 1 --json databaseId -q '.[0].databaseId')` 或 `gh run list --limit 1` +- CI 失败必须修到通过;不允许红色 main 分支 +- compose job 在 Docker 内真实构建并启动服务(health 检查);改 Dockerfile/依赖/启动逻辑先本地 `docker build` 预演 +- **`web/.npmrc` 与 `web/pnpm-lock.yaml` 强耦合**:`auto-install-peers=false` 必须与 lock 的 `settings.autoInstallPeers` 一致,且 `Dockerfile` 必须 `COPY web/.npmrc`。改任一处都需本地 `docker build` 验证,否则 compose job 报 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` ## 其他 - 覆盖率/测试之外的行为变更(API 语义、DB schema、上游解析)需同步 PROPOSAL.md / README.md - SQLite schema 变更:schema.sql 只加不改(列注释可改) - 已有表的新列必须走 `src/db/migrate.py` 的 `_MIGRATION_COLUMNS` 幂等补列 - - 删表必须在 schema.sql 删定义**同时**在 `_MIGRATION_DROPS` 补一条 - (`CREATE TABLE IF NOT EXISTS` 对老库无效,不补删则遗留死表),并 `SCHEMA_VERSION + 1` + - 删表必须在 schema.sql 删定义**同时**在 `_MIGRATION_DROPS` 补一条(`CREATE TABLE IF NOT EXISTS` 对老库无效,不补删则遗留死表),并 `SCHEMA_VERSION + 1` - 两者都需附老库升级测试(`tests/db/test_repo_migrate.py` 有现成样板) diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..d051fd4b --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,44 @@ +cff-version: 1.2.0 +message: "引用本服务时请使用下列信息(版本号以 /healthz 的 version 字段为准)。" +title: "Coding2API" +abstract: >- + 把 CodeBuddy(国内站 + 国际)、TRAE SOLO、OpenCode Zen、Kilo、Qoder、 + CodeArts、OfficeAce、小浣熊、WPS 灵犀等上游渠道封装为 OpenAI / Responses / Anthropic + 兼容 API 的单进程网关:公共凭证池、统一模型名与调度、按人用量统计。 + 上游均为逆向接口,仅供学习研究。 +type: software +license: MIT +repository-code: "https://github.com/RobbsLuo/Coding2API" +url: "https://github.com/RobbsLuo/Coding2API" +version: "0.13.1" +date-released: "2026-11-09" +keywords: + - api-gateway + - openai-compatible + - anthropic + - llm + - reverse-engineering + - codebuddy + - trae + - qoder + - codearts + - raccoon + - lingxi + - wps +authors: + - family-names: Luo + given-names: Hongsheng + alias: RobbsLuo + email: work@robbs.vip + website: "https://github.com/RobbsLuo" +preferred-citation: + type: software + title: "Coding2API" + year: 2026 + authors: + - family-names: Luo + given-names: Hongsheng + alias: RobbsLuo + version: "0.13.1" + url: "https://github.com/RobbsLuo/Coding2API" + license: MIT diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..7c31bb08 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# 参与贡献 + +单人维护的逆向接口网关。合并前请先开 issue 对齐范围——尤其是「新渠道」与 +「改调度/计费语义」这两类,直接提 PR 很可能因为前提不成立而白写。 + +## 提 issue + +| 想做的事 | 入口 | +|---|---| +| 报 bug | [Bug 报告](https://github.com/RobbsLuo/Coding2API/issues/new?template=bug_report.yml) | +| 要新能力 | [功能建议](https://github.com/RobbsLuo/Coding2API/issues/new?template=feature_request.yml) | +| 接新渠道 | [渠道接入](https://github.com/RobbsLuo/Coding2API/issues/new?template=channel_request.yml) | +| 报漏洞 | [私密报告](https://github.com/RobbsLuo/Coding2API/security/advisories/new)(**不要**开公开 issue) | + +`PROPOSAL.md` §1 的「非目标」是硬边界:按 token 计量计费、上游积分 → 货币换算、 +通用 provider 网关、语义缓存、对话正文落盘都不做。 + +## 提 PR + +模板见 [`.github/pull_request_template.md`](.github/pull_request_template.md)。核心门槛: + +```bash +uv run ruff check src tests scripts +uv run pytest -q --cov=src --cov-report=term --cov-fail-under=100 # 行/分支 100% + +cd web +pnpm exec tsc --noEmit && pnpm exec vitest run && pnpm build +``` + +- **覆盖率 100% 是硬门槛**,新增分支必须带测试。禁止用 `pragma: no cover` 给 + 「该测没测」的业务分支达标;确属按构造不可达的防御兜底,需注释说明原因。 +- **平台差异分支必须 monkeypatch 测全**:本地 macOS 通过不代表 CI(ubuntu-latest)通过。 +- **改过 `src/` 必须重启后端再验**,否则是「老后端 + 新前端」,新端点 404、 + 页面弹无关兜底文案。 +- 测试不得依赖本地 `.env`(CI 没有),构造 `Settings` 一律显式传值。 +- **schema 只加不改**:已有表的新列走 `src/db/migrate.py` 的 `_MIGRATION_COLUMNS`; + 删表要在 `_MIGRATION_DROPS` 补一条并 `SCHEMA_VERSION + 1`,且附老库升级测试。 +- 行为变更(API 语义、schema、上游解析)需同步 `PROPOSAL.md` / `README.md`。 +- **推送后确认 CI 全绿**(backend / frontend / compose / security 四个 job)。 + +## 写文档 + +中文文档为主,`README.en.md` 为英文镜像。改动涉及配置项时,`README` 的配置表、 +`.env.example`、`docker-compose.yml`、`src/config.py` 四处要一致。 + +## 引用 + +学术或衍生作品引用见 [`CITATION.cff`](CITATION.cff)(GitHub 侧边栏「Cite this +repository」直接可导出 APA / BibTeX)。 + +## 授权 + +贡献即视为以 MIT 授权(见 [`LICENSE`](LICENSE))。上游渠道署名见 [`NOTICE`](NOTICE)。 diff --git a/PROPOSAL.md b/PROPOSAL.md index 5a77a9b6..069cecad 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -1,253 +1,267 @@ # Coding2API 设计文档 -把 **CodeBuddy**(国内站 `codebuddy` + 国际版 `codebuddy_intl`)、**TRAE SOLO**、**OpenCode Zen**、**Kilo**、**Qoder**、**CodeArts**、**OfficeAce** 八个上游渠道封装为 OpenAI 兼容 API,提供公共凭证池、统一模型名与调度、按人用量统计。 +**CodeBuddy**(国内 `codebuddy` + 国际 `codebuddy_intl`)、**TRAE SOLO**、**OpenCode Zen**、**Kilo**、**Qoder**、**CodeArts**、**OfficeAce**、**小浣熊**、**灵犀** 九个上游厂商、十个渠道 → OpenAI 兼容 API:公共凭证池、统一模型名与调度、按人用量统计。 -> 现状文档:只记录仍然成立的目标、上游事实、架构与约束。实现细节见 [TECHNICAL.md](TECHNICAL.md),使用与部署见 [README.md](README.md)。 +> 只记仍成立的目标、上游事实、架构与约束。实现见 [TECHNICAL.md](TECHNICAL.md),使用与部署见 [README.md](README.md)。 ## 1. 目标与非目标 -**目标**:单一 OpenAI 兼容出口后挂八个上游,支持 `模型@渠道` 精确指定;凭证由 admin 集中维护、全员共享,调度器自动挑健康号、上游死亡自动冷却;按人统计用量(请求数 / 成功率 / token / 耗时 / 首字延迟)与估算成本。 +**目标**:单一 OpenAI 兼容出口挂十个渠道;`模型@渠道` 精确指定;凭证 admin 集中维护、全员共享;调度器自动挑健康号,上游死亡自动冷却;按人统计用量与估算成本。 -**非目标(明确不做)**:**不做配额/限流**(上游是订阅制通道,成本不随 token 线性增长;小规模团队靠统计页可见性约束滥用);**不做通用 provider 网关**(只硬编码上述八个上游,不做插件系统);**不做货币换算**(上游积分单位互不通用,分开记录);不做旧项目数据迁移、不做自更新脚本、不做 mTLS。 +**非目标**:不做按 token 的计量计费(上游订阅制,成本不随 token 线性增长;Key / 用户级配额只是可选的旁路治理,默认全关、判定异常放行,**不是安全边界**);不做通用 provider 网关(只硬编码这十个渠道);不做**上游积分 → 货币**的换算(各渠道积分互不通用,分开记流水);成本只按刊例价估算,USD 落库并按可配汇率另记一份 CNY 展示值;不做数据迁移、自更新、mTLS。 ## 2. 上游可行性核实 ### 2.1 CodeBuddy 国内站(`codebuddy`) -- 端点 `https://copilot.tencent.com`;聊天 `POST /v2/chat/completions`,**只支持流式**(非流式由本地聚合);头 `Authorization` + `X-User-Id` + `X-Domain` + `X-Enterprise-Id` + `X-Department-Info`(部门名须 UTF-8 百分号编码)。认证为设备码:`POST /v2/plugin/auth/state?platform=CLI` 拿 `authUrl`/`state`,轮询 `POST /v2/plugin/auth/token?state=...`;账号切换 `/v2/plugin/login/account`、`/v2/plugin/accounts`。 -- 额度:个人版 `POST /v2/billing/meter/get-user-resource`(`CycleCapacity*Precise`),企业版 `POST /v2/billing/meter/get-enterprise-user-usage`(`credit` 已用、`limitNum` 总额)。签到 `POST /billing/meter/daily-checkin`,状态 `POST /billing/meter/checkin-activity-status`。 -- **成长中心**(前缀 `/v2/activity/growth`):只读 `buddy/travel/status`、`buddy/travel/config`、`tasks`、`streak`、`redeem/summary`、`lottery/chances`、`buddy/quota`、`energy`;写入 `buddy/travel/claim`、`buddy/travel/depart`、`tasks/accept`、`/tasks/{code}/claim`、`makeup-cards/use`、`redeem`、`lottery/draw`、`buddy/open`。契约:`accept_status` 五态、`/tasks/accept` 收复数数组 `{"task_codes": [...]}`(传单数一律 400)、`/redeem` 的 `tier` 是档位标识字符串(`"7d"`/`"14d"`/`"28d"`,传档位名得 400)、实发字段为 `*_granted`;进程内 OAuth bearer 可直连,无需桌面端凭据文件。 -- **凭证身份可能为空**:OAuth 路径下上游未回填 `account_uid`/`user_id`,同账号隔离必须回落到 `credential_id`,否则第二个账号会被静默跳过。**活跃上报**(`POST /v2/report`,默认关闭):body 为事件数组(`eventCode=chat_request_send`),`userId` 必填——缺失时上游 HTTP 200 `code:0` 但静默丢弃;身份为空时回落 bearer JWT 的 `sub`。与积分、调度无关。 -- **reasoning 客户端给什么透传什么**(默认口径),`reasoning_content` 不剥离(实测 71 份真实 dump);`reasoning_effort` 缺失时补 `medium`、显式值不覆盖(不发该字段上游退化为「可见推演」:思考写进 `delta.content`、`reasoning_content` 恒空、`reasoning_tokens` 恒 0),`reasoning_tokens` 在 `completion_tokens_details` 里、顶层恒缺。**可选机制(默认全关,不改默认口径)**:出站 `REASONING_EFFORT_MAP`(按渠道改写客户端显式档位)、Anthropic 入站 `ANTHROPIC_THINKING_EFFORTS`(`thinking.budget_tokens` → 档位)、`FORCE_DISABLE_THINKING`(响应侧剥离思考内容,只过滤响应、不改请求体)。三者默认关 / 留空时行为与上面逐字节一致;内置默认矩阵仍不做(未核实上游接受性),见 TECHNICAL §3.11。**输出上限键名不对称**:CB 上游完全忽略 `max_completion_tokens`,只认 `max_tokens`;TRAE 对两个键都不生效——本网关不做键映射,客户端限额原样透传。 -- 错误码:`6004` 模型用量超限(模型级)、`14003` 模型饱和(模型级,国际版 `Model busy. Please switch models or retry later`)、`11102` 该后端无此模型(负缓存)、`14018` 积分耗尽、`1005` 权益/套餐耗尽、`11101/11115/11128/11135` 请求级(不罚号)。`6004` 与 `14003` 都是「换模型即可用」,必须落模型级;落账号级 SOFT 会让整张凭证的其他模型一起被 60s 冷却。 +- 端点 `https://copilot.tencent.com`;聊天 `POST /v2/chat/completions`,**只支持流式**(非流式本地聚合);头 `Authorization` + `X-User-Id` + `X-Domain` + `X-Enterprise-Id` + `X-Department-Info`(部门名须 UTF-8 百分号编码)。设备码认证:`POST /v2/plugin/auth/state?platform=CLI` 拿 `authUrl`/`state`,轮询 `POST /v2/plugin/auth/token?state=`;账号切换 `/v2/plugin/login/account`、`/v2/plugin/accounts`。 +- 额度:个人版 `POST /v2/billing/meter/get-user-resource`(`CycleCapacity*Precise`);企业版 `POST /v2/billing/meter/get-enterprise-user-usage`(`credit` 已用、`limitNum` 总额)。签到 `POST /billing/meter/daily-checkin`,状态 `POST /billing/meter/checkin-activity-status`。 +- **成长中心**(`/v2/activity/growth`):只读 `buddy/travel/status|config`、`tasks`、`streak`、`redeem/summary`、`lottery/chances`、`buddy/quota`、`energy`;写入 `buddy/travel/claim|depart`、`tasks/accept`、`/tasks/{code}/claim`、`makeup-cards/use`、`redeem`、`lottery/draw`、`buddy/open`。契约:`accept_status` 五态、`/tasks/accept` 收复数数组、`/redeem` 的 `tier` 是档位标识(`"7d"`/`"14d"`/`"28d"`)、实发字段 `*_granted`;进程内 OAuth bearer 可直连。 +- **凭证身份可能为空**:OAuth 路径上游未回填 `account_uid`/`user_id` → 同账号隔离回落 `credential_id`。**活跃上报**(`POST /v2/report`,默认关):body 事件数组(`eventCode=chat_request_send`),`userId` 必填——缺失 → HTTP 200 `code:0` 但静默丢弃;身份为空回落 JWT `sub`。 +- **reasoning 客户端给什么透传什么**;`reasoning_effort` 缺失补 `medium`(不发则上游把思考写进 `delta.content`、`reasoning_content` 恒空、`reasoning_tokens` 恒 0)。**可选机制(默认全关)**:`REASONING_EFFORT_MAP`、`ANTHROPIC_THINKING_EFFORTS`、`FORCE_DISABLE_THINKING`;全关行为逐字节不变。**输出上限键名不对称**:CB 忽略 `max_completion_tokens`,只认 `max_tokens`;TRAE 两键都不生效——不做键映射。 +- 错误码:`6004` 模型用量超限、`14003` 模型饱和(均模型级)、`11102`/`11158` 无此模型/套餐不允许(负缓存)、`14018` 积分耗尽、`1005` 权益耗尽、`11140` 账号被上游风控限制(账号级,**换号/重登无效**)、`11101/11115/11128/11135` 请求级(不罚号)。`6004`/`14003` 只落**模型级** → 只冷却 `(凭证, 模型)`,同号其他模型照常可用(落账号级 SOFT 会误伤整条凭证,见 §3.3)。**`6004` 不给 `Retry-After` 头**,恢复时刻只在 `msg` 文案(`your usage will reset at 2026-10-25 22:34:25 UTC+8`)→ 须解析正文,见 §3.3。 ### 2.2 TRAE SOLO(`trae`) -- Host:Agent `https://trae-api-cn.mchost.guru`、UG `https://api.trae.cn`、OAuth `https://api.trae.com.cn`;聊天 `POST /api/agent/v3/llm_utils_chat`,模型 `POST /api/ide/v1/get_detail_param`,均仅流式。认证:浏览器登录 → 302 回调 → `ExchangeToken` → `GetUserInfo`;刷新 `POST /cloudide/api/v3/trae/oauth/ExchangeToken`(refreshToken 轮换);凭证 JSON 里的 `apiHost` 是用户可控输入,导入时按官方地址白名单校验。签到 `/trae/api/v2/ug/checkin_credits/{status,claim}`;额度 `/trae/api/v2/pay/ide_user_ent_usage`。 -- **签到成功必须「确认到账」,不能只看返回码**:`claim` 对当天已签过的账号也返回 `code:0 success`(幂等),判定需 `checked_in` 为真且回查 `credits` 确有增加。**签到 9074 按设备标识处理**:数字串是必要非充分条件;每次 claim 生成新的 16 位数字串,一轮内最多换号重试 2 次,其余交给 10 分钟周期。 -- SSE 事件序列 `metadata` → `timing_cost` → `output`×N → `extra_info` → `token_usage` → `done`;`token_usage` 含 `cache_read_input_tokens` / `cache_creation_input_tokens`,映射为统计的 `cached_tokens`,**无 per-request credit**;流内 `error` 码 `1005` = 权益不足,`4008` = 请求额度/配额耗尽(实测 `Your requests have exceeded the quota.`,与 `14018` 同为 CREDIT:冷却到次日签到),`4001` = 参数/模型不可用。接受客户端传来的 `reasoning_effort`,不认 `thinking` 对象;`developer` 角色上游不认(静默空流),已归一为 `system`。 +- Host:Agent `https://trae-api-cn.mchost.guru`、UG `https://api.trae.cn`、OAuth `https://api.trae.com.cn`;聊天 `POST /api/agent/v3/llm_utils_chat`,模型 `POST /api/ide/v1/get_detail_param`,均仅流式。认证:浏览器登录 → 302 回调 → `ExchangeToken` → `GetUserInfo`;刷新 `POST /cloudide/api/v3/trae/oauth/ExchangeToken`(refreshToken 轮换);凭证 `apiHost` 是用户可控输入 → 导入时按官方白名单校验(§6)。签到 `/trae/api/v2/ug/checkin_credits/{status,claim}`;额度 `/trae/api/v2/pay/ide_user_ent_usage`。 +- **签到成功必须确认到账**:`claim` 对当天已签也回 `code:0` → 需 `checked_in` 为真且回查 `credits` 有增加。**9074 按设备标识处理**:每次 claim 生成新 16 位数字串,一轮内最多换号重试 2 次。 +- SSE 序列 `metadata` → `timing_cost` → `output`×N → `extra_info` → `token_usage` → `done`;`token_usage` 含 `cache_read_input_tokens` / `cache_creation_input_tokens` → 映射 `cached_tokens`,**无 per-request credit**;流内 `1005` 权益不足、`4008` 配额耗尽(同为 CREDIT,冷却到次日签到)、`4001` 参数/模型不可用。接受 `reasoning_effort`,不认 `thinking` 对象;`developer` 角色归一 `system`。 -### 2.3 Qoder(`qoder`,真实账号) +### 2.3 Qoder(`qoder`) -- 域:国内 `openapi.qoder.com.cn` / 网关 `gateway.qoder.com.cn`;国际 `openapi.qoder.sh` / `api1.qoder.sh`。聊天 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`,**只支持流式**(信封式 SSE)。签名为自定义 Base64 变体(三段轮转 + 自定义字母表 + `=`→`$`),`Authorization: Bearer COSY..`,整套 `cosy-*` 请求头(含按 `uid` 稳定派生的 `cosy-machineid`/`cosy-machinetoken`)。登录为**设备码 PKCE(S256)**:`{website}/device/selectAccounts` 生成 challenge/nonce → 轮询 `GET {openapi}/api/v1/deviceToken/poll`(404/202 = 待授权)→ `POST /api/v1/deviceToken/refresh` 续期 → `GET /api/v1/userinfo` 补 `uid`/昵称/组织。 -- 模型 `GET {gateway}/algo/api/v2/model/list?Encode=1`,**必须带整套 COSY 签名头**(裸 GET 403,带头 POST/PUT 被 400 拒绝,故方法固定 GET);额度 `GET {openapi}/api/v2/quota/usage`(`userQuota` + `addOnQuota`);套餐 `GET /api/v2/user/plan`。 -- **入站消息归一**:`build_chat_body` 在转发前丢弃**孤立 `tool` 消息**(没有紧邻的、声明了该 `tool_call_id` 的 `assistant.tool_calls`)——上游对这类历史一律 400 `Messages with role 'tool' must be a response to a preceding message with 'tool_calls'`。只丢弃、不重排、不合并、不改 content;丢弃条数记 warning,不落消息正文。并行 tool_calls(一个 assistant 多条 tool)与 `/v1/responses` 拆出的连续多条 assistant 声明都按并集识别,避免误删真实结果。流内错误正文保留到 2000 字符(200 会截在转义 `details` 中间,根因永久丢失)。 -- 签到(**活动制**):`GET /sash/api/v1/me/campaigns` → 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"` → `POST /sash/api/v1/me/campaigns/{campaignId}/claim`;**两个请求都必须带 `Cosy-ClientType: 10`**(缺则上游静默返回空 campaign 列表);`replayed:true` 与 `BLOCKED`+`SAME_PERSON_ALREADY_CLAIMED` 归一为当日已签。旧 `/sash/api/v1/me/daily-check-in/{status,claim}` 仅作回退(当前旧 status 返回 `DISABLED`);新协议不提供连续天数。**活动每日 10:00(UTC+8)刷新**,故封账到当前窗口结束而非自然日。**国际版该端点 404 → 本区域无此接口,不算错误**。 -- **上游节点故障包成 400**:`[FAIL]node:… msg:Execution failed` 归模型级瞬时故障(只冷却该模型),不误判为模型不存在。 +- 域:国内 `openapi.qoder.com.cn` / 网关 `gateway.qoder.com.cn`;国际 `openapi.qoder.sh` / `api1.qoder.sh`。聊天 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`,**只支持流式**(信封式 SSE)。签名自定义 Base64 变体(三段轮转 + 自定义字母表 + `=`→`$`),`Authorization: Bearer COSY..`,整套 `cosy-*` 头(含按 `uid` 稳定派生的 `cosy-machineid`/`cosy-machinetoken`)。登录设备码 PKCE(S256):`{website}/device/selectAccounts` → 轮询 `GET {openapi}/api/v1/deviceToken/poll`(404/202 = 待授权)→ `POST /api/v1/deviceToken/refresh` → `GET /api/v1/userinfo`。 +- 模型 `GET {gateway}/algo/api/v2/model/list?Encode=1`,**必须带整套 COSY 签名头**(裸 GET 403,带头 POST/PUT 400 → 方法固定 GET);额度 `GET {openapi}/api/v2/quota/usage`(`userQuota` + `addOnQuota`);套餐 `GET /api/v2/user/plan`。 +- **逐包到期只有网页接口有**:`/api/v2/quota/usage` 仅两桶汇总、顶层 `expiresAt` 为 `9999-12-31` 哨兵;网页账号页 `GET {website}/api/v2/me/usages/big_model_credits`(需网页 Cookie,device token 401)按 `plan_quota` / `resource_package_quota` / `dedicated_resource_package_quota` 给 `quota_detail[]`,每包带 `remaining_value` + `expires_at`(毫秒)。凭证可选存 `web_cookie`(加密),探测优先走网页接口并填 `expiry_ladder`;Cookie 失效/形状变化回退汇总口径,不判探测失败。网页 Cookie 不进导出件。 +- **入站消息归一**:`build_chat_body` 丢弃孤立 `tool` 消息(无紧邻声明该 `tool_call_id` 的 `assistant.tool_calls`)——上游对这类历史一律 400。只丢弃、不重排、不改 content;丢弃条数记 warning,不落正文。并行 tool_calls 与 `/v1/responses` 拆出的连续 assistant 声明按并集识别;流内错误正文保留到 2000 字符。 +- 签到(活动制):`GET /sash/api/v1/me/campaigns` → 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"` → `POST .../{campaignId}/claim`;**两个请求都必须带 `Cosy-ClientType: 10`**(缺则静默返回空列表);`replayed:true` 与 `BLOCKED`+`SAME_PERSON_ALREADY_CLAIMED` 归一为已签。旧 `daily-check-in` 仅作回退。**活动每日 10:00(UTC+8)刷新**,封账到窗口结束而非自然日。**国际版该端点 404 → 本区域无此接口**。 +- **上游节点故障包成 400**(`[FAIL]node:…`)归模型级瞬时故障,不误判为模型不存在。 +- **状态码分类不对称**:`401` 一律 `DEAD`(实测只有凭据/会话失效形态,放宽会让被吊销的 token 在 60s 轮换里反复撞);`403` 只在带会话失效标记时 `DEAD`,其余 `SOFT`(多为瞬时/风控抖动)。排队信号(`isQueued` / `RESOURCE_LIMIT`)优先于两者。 -### 2.4 CodeArts(`codearts`,真实账号) +### 2.4 CodeArts(`codearts`) -- 域:snap 引擎 `snap-access.cn-north-4.myhuaweicloud.com`、STS `sts.cn-north-4.myhuaweicloud.com`、门户 `codearts.huaweicloud.com`。聊天 `POST /api/v2/chat/completions`;鉴权为华为云 **`SDK-HMAC-SHA256`**(AK/SK + `X-Security-Token`)。OAuth2 PKCE → `POST {snap-manager}/v1/oauth2/tokens` 换 AK/SK/security_token/refresh_token。模型 `GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`);额度 `GET {snap}/snap-manager/v1/statistics/plugin`(头 `Agent-Type: PromptCenter`)。 -- **令牌刷新是刚性的**:`refresh_token` 与 `client_id=codearts-agent` + DPoP 私钥三者绑定、**一次性**,刷新走 **DPoP ES256/P-256**,刷后必须回写新 `refresh_token`,由 token 预刷新任务独占轮转。续期凭据被上游作废时标记「需重新登录」并停止自动重试。 -- **每日签到**:`GET {snap}/v1/ops/delivery?channel=IDE` → `POST /v1/ops/claim`(`{campaignId:1, idempotentKey, channel:"IDE"}`)→ `POST /v1/ops/confirm`(`{campaignId:1}`);**`/v1/ops/*` 挂在 snap 引擎根、不带 `snap-manager` 前缀**。`status` 四态必须区分:`ELIGIBLE`→claim+confirm;`CLAIMED`(已领未确认,非终态)→只补 confirm;`CONFIRMED`/`CONSUMED`→已签不写。**claim 时积分即到账**,confirm 只推进状态;业务码走 HTTP 200,不能只看状态码。 -- **额度=积分**:额度探测打 `GET {snap}/snap-manager/v1/statistics/plugin`(前缀**必带** `snap-manager`,头 `Agent-Type: PromptCenter`)——内置模型(GLM-5.2 / OpenPangu 等)按积分计费,这是本渠道唯一的额度来源。上游数值本身即积分,**不做任何单位折算**;`usageTotalPackageCredit` 直接进 `quota_remaining` / `quota_total`,分项包(基础/按需/福利)走 `quota_packages` 明细,套餐 `end_date` 登记到期阶梯(积分用不完作废,故也参与「快过期的先用」)。签到发的积分也计入这本账。 -- **凭证页一个渠道一个组**(后端始终是单个 `codearts` provider):额度列=积分账,变动流水读 `ledger=credits`。**不拆成两条凭证行**:`refresh_token` 一次性且与 `client_id` + DPoP 私钥绑定,两条凭证行会各刷一次、第二条必报 `the refresh token has been used` 并烧掉票据。 -- **并发上限**:上游硬限**每账号每约 60s 最多 3 个会话**,超出即 `400 TM.00001041`;配在途上限 `CODEARTS_MAX_CONCURRENCY` 与滑动窗口 `CODEARTS_REQUEST_WINDOW_SECONDS`。流式 `text` 是**累计全文(替换语义)**,解析层还原为增量事件。 -- **流内业务错误分类**:业务错误内嵌在 HTTP 200 的 SSE 里(`InferHub.<码>`),由 executor 转成冷却 + 轮换。分类统一由 `Event.error_kind` 承载,`UpstreamStreamError.kind()` 与 `executor._event_kind` 共用 `provider.base.event_error_kind` 兜底(缺失 → `OTHER`),避免两处各写一份而漂移。非多模态模型收到图片(`InferHub.001001020.406 The request model is not multimodal`)归 `REQUEST`:请求侧错误不冷却、不累计、不落库,仍换号重试。 +- 域:snap 引擎 `snap-access.cn-north-4.myhuaweicloud.com`、STS `sts.cn-north-4.myhuaweicloud.com`、门户 `codearts.huaweicloud.com`。聊天 `POST /api/v2/chat/completions`;鉴权 `SDK-HMAC-SHA256`(AK/SK + `X-Security-Token`)。OAuth2 PKCE → `POST {snap-manager}/v1/oauth2/tokens` 换 AK/SK/security_token/refresh_token。模型 `GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`);额度 `GET {snap}/snap-manager/v1/statistics/plugin`。 +- **令牌刷新刚性**:`refresh_token` 与 `client_id=codearts-agent` + DPoP 私钥三者绑定、一次性,走 DPoP ES256/P-256;刷后必须回写新 `refresh_token`,由预刷新任务独占轮转。被作废 → 标记「需重新登录」并停止自动重试。 +- **每日签到**:`GET {snap}/v1/ops/delivery?channel=IDE` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`;`/v1/ops/*` 挂 snap 引擎根、不带 `snap-manager`。活动按 `type=USER_LOGIN` 挑、`campaignId` 取当轮响应(上游改过 ID:1→34,面额 1000→100,写死 ID 会让签到静默失效)。`ELIGIBLE`→claim+confirm;`CLAIMED`(已领未确认,非终态)→只补 confirm;`CONFIRMED`/`CONSUMED`→已签。**claim 时积分即到账**,confirm 只推进状态;业务码走 HTTP 200。**同一账号重新登录按 `uid` 合并既有凭证**(更新整份凭证并解除硬禁用,账号级冷却按来源保留上游点名的几类,保留 paused/pinned/标签/统计归属),不新增重复行。身份按别名集合取交集(`uid` 与 `domain_id|user_name` 任一命中即同账号),避免身份补全失败时新增重复行。 +- **额度=积分**:`usageTotalPackageCredit` 直接进 `quota_remaining`/`quota_total`,不做单位折算;分项包走 `quota_packages`,`end_date` 只作套餐到期日展示(`cycle_end` / `packages[].end`),**不进到期阶梯**。**福利积分按批次到账、各自到期**:实测同一响应里 `end_date` 距今 2.5 天而「7 天内到期量」只有 4,868 / 24,391,若 `end_date` 会清零整份余额,7 天内到期量必然等于全部余额——故 `end_date` 不是逐笔积分的作废时刻。无网页 Cookie 时,到期阶梯只认 `package_credit_expiring_amount`(7 天内到期量,分项求和后挂到「探测时刻 + 7 天」这一个桶:36h 窗口不误报,7 天窗口拿到真实数字)。**逐笔到期走网页 Cookie**:portal `GET /portal/promptcenter/v1/ops/credit/overview` 列活动 → `GET .../campaign/{id}?pageNum=&pageSize=` 分页给每笔 `claimedAmount/consumedAmount/expiredAmount/expireAt`;只认控制台会话 Cookie(AK/SK 签名打不通,另需 `X-Requested-With: XMLHttpRequest`)。有 Cookie 时福利积分按笔展开、`expiry_ladder` 用真实 `expireAt`,基础/按需仍取汇总;Cookie 失效/形状变化静默回退 7 天窗口口径,不判探测失败。`expiredAmount`/`status` 滞后(实测过期后仍为 0/`CLAIMED`),判死只看 `expireAt <= now`;已过期的逐笔不展示。portal 回登录页/空响应时标 `web_cookie_state=expired`(管理台提示重贴),重新粘贴复位;控制台会话无法自动刷新。 +- **凭证页一个渠道一个组**:不拆两条凭证行——`refresh_token` 一次性,两条会各刷一次、第二条必报 `the refresh token has been used` 并烧掉票据。 +- **并发上限**:上游硬限每账号每约 60s 最多 3 个会话,超出 `400 TM.00001041`;配 `CODEARTS_MAX_CONCURRENCY` 与 `CODEARTS_REQUEST_WINDOW_SECONDS`。流式 `text` 是**累计全文(替换语义)**,解析层还原增量。 +- **流内业务错误分类**:错误内嵌 HTTP 200 的 SSE(`InferHub.<码>`),分类由 `Event.error_kind` 承载,`UpstreamStreamError.kind()` 与 `executor._event_kind` 共用 `provider.base.event_error_kind` 兜底。非多模态模型收图片归 `CAPABILITY`(`InferHub.001001020`):跳过整个上游、不冷却、不累计,只落一行 `model_capability` 统计(与 `invalid_request` 分开,避免运维去查参数)。 ### 2.5 OpenCode Zen(`zen`,虚拟凭证) -- 端点 `https://opencode.ai`,`/zen/v1/chat/completions` 与 `/zen/v1/models`。协议是标准 OpenAI SSE,**不逆向**。 -- 渠道私有的是**免费层门禁伪装**:UA `opencode/` ≥1.18.0(否则 426;无版本号 403)、`x-opencode-session`、body `stream:true`、body `tools` 必须同时含 `bash` 与 `read`。对策:只在缺失时补骨架工具;模型调用注入名时改写为客户端声明的同义工具(`bash`→`shell`/`execute`、`read`→`read_file`)并复制其 schema,无同义工具才丢弃并记 WARNING。 -- **免费模型清单完全动态**:免鉴权 `/zen/v1/models` 返回全部模型(含付费)且不带免费标记,唯一权威信号是匿名可用性(付费恒 401 `Missing API key.`),按 `-free` 后缀收窄 + 并发探活只留 2xx,判活按 30 分钟缓存;免费模型标 **x0 倍率**(`credit_rate=0.0`)。服务层列表拉取**连续失败按 2^(n-1)×TTL 退避重试**(封顶 2h、成功即清零),日志首次 WARNING 其后降 INFO——上游按出口 IP 长期拦截时不至每 5 分钟重探十几秒再刷一条同样的告警。 -- **凭证用虚拟凭证行**:无凭证/无额度接口,`probe_quota` 恒 `probe_failed=True` → health「未知」而非耗尽;删除后重启复活,永久停用请用「暂停」。401 归 `INVALID`;**403 FreeTierError 抖动在客户端内退避重试一次**,否则单虚拟凭证下 REQUEST 类错误会耗尽轮换预算、直接 503。 +- 端点 `https://opencode.ai`,`/zen/v1/chat/completions` 与 `/zen/v1/models`。标准 OpenAI SSE,**不逆向**。 +- 渠道私有:**免费层门禁伪装**——UA `opencode/` ≥1.18.0(否则 426;无版本号 403)、`x-opencode-session`、body `stream:true`、`tools` 必须同时含 `bash` 与 `read`。只在缺失时补骨架工具;注入名改写为客户端声明的同义工具并复制 schema,无同义工具才丢弃并记 WARNING。 +- **免费清单完全动态**:免鉴权 `/zen/v1/models` 返回全部模型且不带免费标记,唯一权威信号是匿名可用性(付费恒 401);按 `-free` 后缀收窄 + 并发探活只留 2xx,缓存 30 分钟;免费模型标 **x0 倍率**。列表拉取**连续失败按 2^(n-1)×TTL 退避**(封顶 2h,成功清零),日志首次 WARNING 其后 INFO。 +- **虚拟凭证行**:无额度接口,`probe_quota` 恒 `probe_failed=True` → health「未知」而非耗尽;删除后重启复活 → 永久停用用「暂停」。401 归 `INVALID`;**403 FreeTierError 在客户端内退避重试一次**,否则单虚拟凭证下 REQUEST 类错误会耗尽轮换预算直接 503。 ### 2.6 Kilo(`kilo`,虚拟凭证) -- 端点 `https://api.kilo.ai/api/gateway`,`/chat/completions` + `/models`。**标准 OpenAI 兼容**,无私有信封、无门禁伪装。**免费模型有权威标记**:`/models` 每条带 `isFree` 布尔,据此直接过滤、**不做探活**(探活会真发推理、白耗网关级约 200 req/h/IP 的极小配额);免费模型标 **x0 倍率**。思考字段是 **`delta.reasoning`**(不是 Zen 的 `reasoning_content`)。凭证用虚拟凭证行(同 Zen)。 -- 错误分类:401→`INVALID`、400/404/422→`INVALID`、403→`REQUEST`、**429 与 502/503/504→`MODEL`(模型级冷却)**——实测 429/5xx 点名具体模型、同批其他免费模型仍 200,归账号级会因单模型问题冷却整条渠道;本包不自建熔断。 +- 端点 `https://api.kilo.ai/api/gateway`,`/chat/completions` + `/models`。标准 OpenAI 兼容,无私有信封、无门禁伪装。**免费模型有权威标记**:`/models` 每条带 `isFree` → 据此过滤、**不探活**(网关级约 200 req/h/IP 的配额极小);免费模型标 **x0**。思考字段 `delta.reasoning`。凭证用虚拟凭证行。 +- 错误分类:401 / 400 / 404 / 422→`INVALID`,403→`REQUEST`,**429 与 502/503/504→`MODEL`**(实测点名具体模型,同批其他免费模型仍 200)。本包不自建熔断。 ### 2.7 CodeBuddy 国际(`codebuddy_intl`) -- 端点 `https://www.codebuddy.ai`。**与国内站同协议**(同一套 `/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*` 路径,同一 Keycloak realm),但是**两套独立账号体系**(各自注册、各自计费),故作为独立渠道存在。**为什么不是「改环境变量切站点」**:`CODEBUDDY_API_ENDPOINT` 是进程级单值,切过去会让所有存量国内站凭证立刻打到国际站(额度错配 / 403);两站可同时持有凭证,各走各的端点、节流器与登录流程。 -- **白名单刻意互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有国际站主机,**不含**国内站主机。两站 Token 由不同网关签发,跨站发送即凭证泄露,填错会在**启动时**报错。 -- **能力差异**:聊天、模型列表、额度探测、token 刷新、**活跃上报**全部启用;**每日签到、成长中心未启用**——签到是渠道侧就没有这个活动(`checkin-activity-status` 回 `active: false`),成长中心**无法验证**(整个 `/v2/activity/*` 前缀挂在网关认证插件后,连不存在的子路径也返回 401 而非 404)且含不可逆动作,不盲跑。活跃上报端点 `/v2/report` 与成长中心不是同一路径,实测国际站可用。 -- **模型列表按套餐探活**:上游 `/v3/config` 返回全量模型,免费号也会看到 `gpt-5.5` / `gpt-5.6-luna` 等调用时才回 `400 / 11158` 的模型。国际站列表用当前刷新凭证逐个发一次最小请求(只读状态行即断开),只保留可用模型;`11158`(套餐不允许)与 `11102`(无此模型)剔除,`429` / 5xx / 超时等不确定结果**保留模型**。结论按账号缓存 6 小时。 -- **唯一协议行为差异:首条 system 强制注入**——国际站网关要求 `messages[0]` 是 system,否则整单 400 + `{"code":11128,"msg":"first message is not system prompt"}`;国内站无此要求。同名模型在两站是**一条**、同时挂两个渠道。 +- 端点 `https://www.codebuddy.ai`。与国内站同协议(同一套 `/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*`,同一 Keycloak realm),但两套独立账号体系 → 独立渠道。**不合并的原因**:`CODEBUDDY_API_ENDPOINT` 是进程级单值,切过去会让存量国内站凭证打到国际站。 +- **白名单刻意互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有国际站主机,填国内站启动即报错(原因见 §6)。 +- **能力差异**:聊天、模型列表、额度探测、token 刷新、活跃上报启用;**签到、成长中心未启用**(签到渠道侧无此活动;成长中心无法验证且含不可逆动作)。活跃上报端点 `/v2/report` 与成长中心不同路径,国际站可用。 +- **模型列表按套餐探活**:上游 `/v3/config` 返回全量模型,免费号也会看到调用时才回 `400/11158` 的模型 → 用当前凭证逐个发一次最小请求(只读状态行即断开),`11158` 与 `11102` 剔除,`429`/5xx/超时**保留**。结论按账号缓存 6 小时。 +- **唯一协议差异:首条 system 强制注入**——国际站要求 `messages[0]` 是 system,否则 400 + `11128`;国内站无此要求。同名模型两站是一条、挂两个渠道。 -### 2.8 OfficeAce(`officeace`,真实账号) +### 2.8 OfficeAce(`officeace`) + +- 端点:控制面 `https://agentarts.cn-southwest-2.myhuaweicloud.com`(OAuth + 开通校验),模型网关 `modelgw-XXXX.officeace.*` **每账号一个**、由 `client-permission-validate` 下发并存在凭证里——不进配置、不进白名单。 +- **两套凭据、两套签名、两种寿命**: -- 端点:控制面 `https://agentarts.cn-southwest-2.myhuaweicloud.com`(OAuth + 开通校验),模型网关 `modelgw-XXXX.officeace.*` **每账号一个**、由 `client-permission-validate` 下发并存在凭证里——故它不进配置、也不进白名单(那是本服务写不错的值)。 -- **两套凭据、两套签名、两种寿命**(本渠道最容易踩错的地方): | 凭据 | 寿命 | 用途 | 签名 | |---|---|---|---| - | `Basic model_app_key:model_app_secret` | 不过期 | 聊天(`{网关}/v2/chat/completions`) | HTTP Basic | - | 临时 AK/SK + `security_token` + `project_id` | 约 2h | 积分 / 签到 / 费率(`/v1/subscription*`、`/v1/model-token-rates`) | `V11-HMAC-SHA256` | - | 同上,打控制面(`/v1/claw/*`) | 同上 | 开通校验 | `SDK-HMAC-SHA256` | - 混用得 `APIG.0303 app not found`;丢失区别的现象是「聊天正常但积分空白、签到一直失败」。 -- **V11 的三个必须原样**(写错任一处恒 401 `verify ak sk signature fail`,已逐项实测对照):① 派生密钥外层 key=AK、msg=SK;② `HMAC(HMAC(AK,SK), scope+0x01)[:32]` 要转 **hex 文本** 当下一轮密钥;③ **不要**加 `X-Sdk-Content-Sha256` 头(多一个签名头即失败)。另:query 必须参与签名,`CanonicalURI` 每段 percent-encode 且末尾补 `/`。 -- **登录(poll 轨道)**:`POST /v1/claw/auth/state` 取云端 state → 本地生成 PKCE + DPoP → 用户浏览器登录授权 → `GET /v1/claw/auth/code?state=` 轮询取码(202 = 还没登完)→ STS `POST /v1/oauth2/tokens` 换临时 AK/SK → `client-permission-validate` 取网关与 app key。**回调由云端承接,本服务不监听端口、也不需要粘链接**。`client_id=pdp5_for_agentarts`,refresh_token 与 CodeArts 的**互不通用**(混用回 `STS5.1806 invalid client id`),故 OAuth 与凭证类型独立实现,只复用 `codearts/dpop.py` 的 ES256 低 S 签名原语。 -- **未开通的账号要可行动**:`subscription: null` 时给「请先在 OfficeAce 客户端兑换邀请码完成开通」,而不是含糊的登录失败。 -- **三种上游特例**:① 限流走**伪 200**(HTTP 200 + `data:{"error":…}`,也出现过首帧正常、第二帧才错);② `81004`/`81009` 走 403/404 但属**模型级**,业务码判定必须先于状态码,否则会把整条凭据打死;③ `max_tokens` 是「推理 + 正文」总预算,`≥16` 追加 1024 再夹到模型硬上限(`glm-5*` 131072 / `qwen3*` 65536 / `deepseek-v4.1-flash` 393216),`<16` 原样透传,未给不补。 -- **积分即主账**:单位 `officeace_points`(新用户一次性 + 每日签到),**没有并行的 token 池**,故 `Quota.remaining`/`total` 直接是积分;每笔奖励独立到期,口径写「逐笔到期」。签到 `POST /v1/subscription/bonus/claim` 是**写操作**(调用即真发积分),body 必须给 {} + JSON `Content-Type`(空 body 会 500 `OfficeAce.11020001`),上游对当日重复领取幂等;收获按 `bonus_skus` **剩余量增量**计算(`points` 是活动累计发放量,由多渠道共同贡献)。 -- **不做主动模型探活**:目录 34 个里实测仅 11 个可用,探活要真发推理、消耗积分,改由真实请求按 `BLOCKED` 负缓存(成功即撤销)。**不做 credit 估算**:`cost_coefficient` 与 `tokens_per_point` 的叠乘关系官方未说明,折算会与真实扣点对不上。 -- **单账号单用户**:官方《套餐管理》原文「个人版面向个人用户,**限制单账号单用户使用**」,故不做多人共享,每人各加自己的凭证。 - -### 2.9 冲突与陷阱 + | `Basic model_app_key:model_app_secret` | 不过期 | 聊天 | HTTP Basic | + | 临时 AK/SK + `security_token` + `project_id` | 约 2h | 积分 / 签到 / 费率 | `V11-HMAC-SHA256` | + | 同上,打控制面 | 同上 | 开通校验 | `SDK-HMAC-SHA256` | + + 混用得 `APIG.0303 app not found`;现象「聊天正常但积分空白、签到一直失败」。 +- **V11 的三个必须原样**(写错任一处恒 401):① 派生密钥外层 key=AK、msg=SK;② `HMAC(HMAC(AK,SK), scope+0x01)[:32]` 转 **hex 文本** 当下一轮密钥;③ **不要**加 `X-Sdk-Content-Sha256` 头。query 必须参与签名,`CanonicalURI` 每段 percent-encode 且末尾补 `/`。 +- **登录(poll 轨道)**:`POST /v1/claw/auth/state` → 本地生成 PKCE + DPoP → 浏览器授权 → `GET /v1/claw/auth/code?state=`(202 = 未登完)→ STS `POST /v1/oauth2/tokens` → `client-permission-validate` 取网关与 app key。回调由云端承接,不监听端口。`client_id=pdp5_for_agentarts`,refresh_token 与 CodeArts **互不通用**(混用回 `STS5.1806`),只复用 `codearts/dpop.py` 的 ES256 低 S 签名原语。 +- **未开通的账号要可行动**:`subscription: null` → 提示「请先在客户端兑换邀请码」,不用含糊的登录失败。 +- **三种上游特例**:① 限流走伪 200(HTTP 200 + `data:{"error":…}`,也见过首帧正常、第二帧才错);② `81004`/`81009` 走 403/404 但属模型级,业务码判定先于状态码;③ `max_tokens` 是推理 + 正文总预算,`≥16` 追加 1024 再夹到模型硬上限,`<16` 原样透传,未给不补。 +- **积分即主账**:单位 `officeace_points`,`Quota.remaining`/`total` 直接是积分;每笔奖励独立到期。签到 `POST /v1/subscription/bonus/claim` 是写操作,body 必须是 `{}` 且带 `Content-Type: application/json`(裸 POST / 空 body 会 500 `OfficeAce.11020001`),上游对当日重复领取幂等;收获按 `bonus_skus` 剩余量增量算。**同一账号重新登录按 `account_id` 合并既有凭证**(同 §2.4 CodeArts 的合并纪律),不新增重复行。 +- **不做主动探活 / 不做 credit 估算**:探活要真发推理消耗积分;`cost_coefficient` 与 `tokens_per_point` 叠乘关系官方未说明。(`GET /v1/subscription` 读余额是**只读**、不耗积分,不在此列——它也是 `APIG.1009` 的 DEAD 收口时现场复探用的接口。) +- **单账号单用户**:官方《套餐管理》限制单账号单用户,每人各加自己凭证。 + +### 2.9 小浣熊(`raccoon`) + +- 端点:`https://xiaohuanxiong.com`(网页与 API 同域)。推理 `POST /api/web/llm/v2/chat/completions`、模型目录 `GET /api/web/llm/v2/model_catalog`、积分余额 `GET /api/web/points/v1/balance`,**标准 OpenAI 兼容**,无私有信封、无签名、无设备指纹。 +- **凭据是网页会话 JWT**:浏览器 cookie `xhx_token`(`iss=xiaohuanxiong.com/iam`,约 30 天)直接当 `Authorization: Bearer`。没有官方 API Key,也没有可用的 OAuth 回调(桌面端只注册 `gateway.qwenwork.cn` 那一类服务端回调),故**只能手工导入**:管理台「JSON 导入」粘贴 `{"access_token":""}`(也接受裸 JWT / 整段 cookie)。到期需重新登录取值。 +- **未知模型会静默回落默认模型**(实测 `definitely-not-a-model` → 200 + `model=raccoon-090dd3`):provider 发请求前按目录校验,流首帧再比对上游回显的 `model`,不一致即失败——宁可这次报错,也不让客户端「问 A 答 B」。 +- **计费按请求**:每笔 LLM 调用记 `points=-1`,与 token 量无关(4K 与 205K token 同价);每日 00:00 发 300 积分,**当天登录过才延期到 7 天**(未登录当日 24:00 清零,实测心跳不算登录)。`Quota.remaining`/`total` = 可用积分,账本记 `credits`。目录的 `billing_effective_multiplier` 直接当 `credit_rate`(0 = 限免,调度器优先选)。 +- **并发上限约 5**(6 并发第 6 个 `429 user_model_rate_limited`):pacer 用 `RACCOON_MAX_CONCURRENCY`(默认 2)+ `CONCURRENCY` 固定短冷却;不是账号故障。 +- **登录轨道不实现**:没有 `start_auth`/`poll_auth`/`complete_callback`——写了也只是永远不被调用的死代码(与 Zen/Kilo 同一分工)。 +- **签到=页面访问 + 流水核对**:上游没有领取接口,`checkin` = `user_info`(页面级访问,触发延期/账单物化)→ 摸余额 → 读流水;只认流水(同 CodeArts「claim 成功但 confirm 失败按失败报」的纪律,发放规则见上条)。今天没有新发放但仍有未过期 `daily_grant` 视为已签;凌晨 00:00~01:30 只封账到 01:30,避免 00:02 误报。流水用上游服务端过滤 **`type=income`**(只留 `daily_grant` / `reward_grant`)——`type=all` 里消费流水挤占条数窗口,活跃账号的每日发放会看不到(2026-11-10 实测排到第 62 条,60 条窗口内为空,误报「尚未发放」)。**换新 token(重新登录)会立刻补发当天 300**(`created_at` 是登录后几秒、`grant_at` 回填 00:00);只拿旧 token 访问 `user_info` / `home` / 心跳不触发,故缺当日发放的号只能人工重登。 +- **活跃上报=页面访问 + 设备心跳**:`GET /api/web/auth/v1/user_info` 后 `POST /api/web/auth/v1/devices_current_heartbeat`;缺 `x-client-device-id` 等头族回 `100002 params_invalid_error`;device_id 由 uid 稳定派生(随机值会被当成新设备)。心跳只刷 `last_seen_at`,不算登录。 +- **额度明细**:`balance` 给四个池(每日/奖励/月度/充值);`quota_packages` **按 `type=income` 的发放流水逐笔展开**(每笔带真实 `expire_at`)。上游不给逐笔剩余,每日积分的池内消耗按「到期早的先消耗」分摊(各笔剩余之和 = `daily_points`,推断);奖励积分池值等于各笔之和。流水拿不到时退回四池口径;流水失败不阻断余额结论。到期阶梯(`expiry_ladder`)由明细派生(每笔 `total - used`):组头「1.5 天 / 7 天窗口内到期」按它汇总,与明细、额度合计口径一致,也用于「快过期先用」的选号排序。 +- 能力实测(2026-11-04):非流式/流式 `tool_calls`、`role=tool` 回传、`stream_options.include_usage` 全通;411K token 输入 14.2s 返回。 + +### 2.10 灵犀(`lingxi`) + +- 端点:账号面 `https://lingxi.wps.cn`(kdocs 账号回落 `https://lingxi.kdocs.cn`)。**三条请求才是一次对话**:建 cowork 会话 `POST /api/aioffice/v1/cowork/sessions` → 领 run `POST /api/public/v1/credits/runs` → 对话 `POST /api/public/v1/agent-runtime/chat/completions`(私有 SSE)。模型目录 `GET /api/aioffice/v1/sessions/plans`、智点余额 `GET /api/public/v1/credits/balance`、任务中心 `GET /api/public/v1/tasks` + `POST …/{key}/claim`。 +- **凭据只有 `wps_sid` cookie,没有 refresh 接口**:与网页版同一套 WPS 账号 SSO,程序换不出新票。插件实测有效期约一年(无官方承诺),真正的判据是上游 401;故不写死过期时间(按时长猜会把还能用的号摘掉),过期只能重新粘贴。登录轨道不实现,走管理台粘贴(裸 sid / 整段 Cookie 均可)。 +- **同一浏览器只保留最近一次登录**(2026-11-09 实测):同一浏览器配置里登录另一个 WPS 账号后,旧 `wps_sid` 立刻 401,且额度探测同样 401。多账号必须用独立浏览器配置 / 无痕窗口分别登录后各取一次 Cookie;手工导入按 `uid` 就地合并(同一账号重登不新增行),新号导入后复探其它灵犀凭证,确认 401 的旧号硬禁用并显示「需重新登录」,不靠「新登录=旧号必死」的猜测误伤仍有效的多账号。 +- **按 run 计费**:一个 run 内多次调用只扣一次智点 → 会话 + run 按「账号 × 模型」缓存 1 小时(**不滑动续期**)+ 并发单飞;上游回 `SQLNoRows` /「会话不存在」时丢弃缓存重建一次。 +- **伪装桌面客户端**:`X-Client-Type: lingxi_claw` + `x-kso-app-name/app-version/device-id`(固定可复现值)。这是私有通路的代价,也是 ToS 灰区(见 `provider/tos.py`)。 +- **三个必须照抄、改了就坏**:① `usage.total_tokens` 是**当前上下文大小**,必须自算 `prompt+completion`(否则用量虚高几十倍);② `cached_tokens` / `reasoning_tokens` 是扁平字段,方向与 OpenAI 相反;③ `max_tokens` 要塞进 `base_llm_arguments`、`reasoning_effort` 顶层与 `llm_arguments` 各一份,**工具 schema 必须先压平**(`anyOf`/`oneOf`/`$ref`/数组型 `type` 会让整轮失败,只回一句「AI服务异常」)。 +- **私有 SSE**:正文在 `choices[].text`(不是 `delta.content`),**不发 `[DONE]`**(收尾靠 `finish_reason`);帧内 `code != Success` 是业务错误,走 `classify_code`。上游每帧是单行 `data:{…}`,不能用「空行分帧」的通用解析器(会吞帧)。 +- **错误分类**:401 → `DEAD`(重贴 Cookie);402/额度文案 → `CREDIT`;403 → `SOFT`;404 → `INVALID`;429 → `MODEL`(点名模型);审核拦截(`AuditFailed`)→ `INVALID`(换号无用);run 过期 → `SOFT` + 重建一次。 +- **额度=智点**:`total_balance` 直接进 `remaining`,赠送批次 `bonus_credits[].expire_time` 进到期阶梯(15 天内窗口);账本记 `credits`,不做 token 换算。模型目录的 `multiplier` 直接当 `credit_rate`。 +- **签到**:任务中心 `daily_check_in`,`status=claimed` 即今日已签;上游对重复领取回 200 假成功(`result=ok` 而余额不动),故奖励按**领前/领后余额差**算,差为 0 时读任务状态判「已签」,绝不看 HTTP 200。 + +### 2.11 冲突与陷阱 | 问题 | 事实 | 对策 | |---|---|---| -| 模型 ID 撞车 | 多渠道都有 `glm-5.2`、`DeepSeek-V4-Pro`、`kimi-k3` 等 | 扁平名 + 归一键合并 + 健康度路由 + `@渠道` 后缀 | -| 积分语义不同 | CB 有周期会重置;TRAE / CodeArts 各自权益包独立到期 | 健康分统一为百分比;展示层标注周期语义 | -| credit 可得性 | CB 有 per-request;TRAE 只有账户总额 | 统计表 credit 可空;TRAE 按官方单价推算并标 `credit_estimated` | -| 登录机制 | CB/Qoder/CodeArts/OfficeAce 轮询或粘贴(后端出网);TRAE 浏览器回调 | 三轨(poll / callback / paste),回调统一走主端口 | -| 同一厂商两套票 | OfficeAce 与 CodeArts 同属华为云 STS,但 client_id 不同,refresh_token 互不通用(混用回 `STS5.1806`) | 各自独立实现 OAuth 与凭证类型,只复用 DPoP 签名原语 | -| 上游 SSE 格式变更 | 各渠道 SSE 形状不同 | 各渠道解析成中立事件;样本存 fixture 做契约测试 | -| 图片能力标志不可信 | 八渠道出站均为 OpenAI `image_url` 内容块原样透传,上游全部接受(实测 2026-10-15);而模型目录的 `supportsImages` 与实际行为不一致(CB `glm-5.0` 标 false 却能识图;`hunyuan-chat` 标 false 却接受并自行降级) | 入站统一归一为 `image_url` 透传,**不做模型能力门禁**,能否识图交上游裁决 | +| 模型 ID 撞车 | 多渠道都有 `glm-5.2`、`DeepSeek-V4-Pro`、`kimi-k3` | 扁平名 + 归一键合并 + 健康度路由 + `@渠道`(§3.4) | +| 积分语义不同 | CB 有周期重置;TRAE / CodeArts 各自权益包独立到期 | 健康分统一百分比;展示层标注周期 | +| credit 可得性 | CB 有 per-request;TRAE 只有账户总额 | credit 可空;TRAE 按官方单价推算并标 `credit_estimated` | +| 登录机制 | CB/Qoder/CodeArts/OfficeAce 轮询或粘贴;TRAE 浏览器回调 | 三轨(poll / callback / paste),回调统一走主端口(§3.5) | +| 同一厂商两套票 | OfficeAce 与 CodeArts 同属华为云 STS,client_id 不同,refresh_token 互不通用 | 各自独立实现 OAuth 与凭证类型,只复用 DPoP 原语(§2.8) | +| 图片能力标志不可信 | 出站统一归一为 `image_url` 原样透传(2026-10-15 实测覆盖当时九家渠道;灵犀 2026-11 新增,沿用同一归一逻辑但未单独实测);目录 `supportsImages` 与实际不一致 | 入站统一归一 `image_url`,**不做模型能力门禁**,能否识图交上游裁决 | ## 3. 架构 ### 3.1 分层 -一个 FastAPI 进程内按**三个平面**组织,三面共享同一份 SQLite(WAL + `busy_timeout=5000`:账号 / 加密凭证 / 统计 / 审计)与 Provider 客户端: +一个 FastAPI 进程内三平面,共享同一份 SQLite(WAL + `busy_timeout=5000`)与 Provider 客户端: -- **请求面**(鉴权 = API Key):`/v1/chat/completions` · `/v1/responses` · `/v1/messages` · `/v1/models` · `/v1/user/balance` → 协议层(OpenAI / Responses / Anthropic 规范化与适配、模型名解析 `name` | `name@provider`)→ 执行引擎(调度器 · 冷却状态机 · 会话粘性 · 截断续写 · 统计采集)→ 中立事件流 → 八个 Provider。 -- **管理面**(鉴权 = 会话 Cookie + RBAC):React 管理台 → `/api/*`,含用户管理、审计、凭证运维、API Key、统计、任务与配置、上游状态、模型列表。 -- **后台面**(无 HTTP 入口,`TaskRunner` 起 11 类循环):额度探测 · token 预刷新 · 每日签到 · 成长中心 · 活跃上报 · 明细清理 · 数据库备份 · 渠道模型列表刷新 · 模型目录刷新(OpenRouter)· 运维告警 · 上游状态页聚合 → Provider 客户端 → 上游。 +- **请求面**(API Key 鉴权):`/v1/chat/completions` · `/v1/responses` · `/v1/messages` · `/v1/models` · `/v1/user/balance` → 协议层 → 执行引擎(调度 · 冷却 · 会话粘性 · 截断续写 · 统计采集)→ 中立事件流 → 十个渠道 Provider。 +- **管理面**(会话 Cookie + RBAC):React 管理台 → `/api/*`。 +- **后台面**(无 HTTP 入口,`TaskRunner` 起 12 类循环):额度探测 · token 预刷新 · 签到 · 成长中心 · 活跃上报 · 明细清理 · 备份 · 渠道模型列表兜底刷新 · 模型可用性探活 · 模型目录刷新(OpenRouter 刊例价 + 能力分)· 运维告警 · 上游状态聚合。列表与 `src/tasks/status.py` 的 `TASK_SPECS` 一一对应。 -**为什么按平面切**:请求面全程无状态、可并发,且不知道「人」是谁(只认 Key 的归属用户);管理面与请求面**共用同一个执行引擎与仓储**,不做独立服务——自托管小实例里进程隔离只换来部署复杂度。两个鉴权面互不替代:API Key 进不了管理台,会话 Cookie 也进不了 `/v1`。后台面与请求面**共用 Provider 客户端与节流器**:各渠道风控按最小间隔生效,不能因为「后台签到」与「前台对话」是两条代码路径就各发各的。三面共享一份 SQLite,故升级只需重启**一个**进程。 +**按平面切的原因**:请求面无状态、可并发,不知「人」是谁(只认 Key 归属用户);管理面与请求面共用执行引擎与仓储,不做独立服务。两鉴权面互不替代(Key 进不了管理台,Cookie 进不了 `/v1`)。后台面与请求面共用 Provider 与节流器——风控按最小间隔生效,不能各发各的。三面共享一份 SQLite,升级只重启一个进程。 ### 3.2 Provider 接口边界 -Provider 承担上游协议私有部分:发请求、解析事件、分类错误,以及凭证生命周期与额度探测;调度、冷却、重试、统计全在共享引擎。接口分三类——凭证生命周期(`start_auth` / `poll_auth` / `complete_callback` / `import_credential` / `refresh`)、额度探测(`probe_quota`)、执行与分类(`stream_chat` / `classify` / `list_models` / `aclose`)。 +Provider 只管上游私有部分(发请求、解析事件、分类错误、凭证生命周期、额度探测);调度、冷却、重试、统计全在共享引擎。接口三类:凭证生命周期(`start_auth` / `poll_auth` / `complete_callback` / `import_credential` / `refresh`)、额度探测(`probe_quota`)、执行与分类(`stream_chat` / `classify` / `list_models` / `aclose`)。 ### 3.3 调度器 -统一实现,各 provider 共用。**选号前先在 `Executor._selectable` 做两道过滤**(不进 `Candidate.is_selectable`):① 凭证限额护栏(触顶的号不参与,见 §限额护栏);② 在途租约 `MAX_INFLIGHT_PER_CREDENTIAL`(全局热更,默认 0 = 不限)——同一账号并发达到上限后**整号不参与选号**,避免把单账号打进上游限流/封号。在途计数进程内、租约在请求结束时释放(含断开/异常),另有 600s TTL 兜底漏释放;跨渠道全局,与 CodeArts 的渠道内 `CODEARTS_MAX_CONCURRENCY` 正交。**在途计数兼作负载键**:`Executor._selectable` 把在途数注入 `Candidate.inflight`,调度器按「最少连接」排(见下);上限为 0 时也照常登记,故不限并发时负载键仍可用。过滤后按下列排序链出号: +**选号前 `Executor._selectable` 两道过滤**(不进 `Candidate.is_selectable`):① 凭证限额护栏;② 在途租约 `MAX_INFLIGHT_PER_CREDENTIAL`(默认 0 = 不限)——同账号并发达上限 → 整号不参与选号。在途计数进程内、请求结束释放(含断开/异常),600s TTL 兜底;跨渠道全局,与 `CODEARTS_MAX_CONCURRENCY` 正交。**在途计数兼作负载键**,上限为 0 也照常登记。 +过滤后按序出号: -1. **免费优先档**:候选池存在 `credit_rate == 0` 的渠道时,免费渠道整体排在付费渠道之前。`credit_rate=None`(目录未就绪 / 缺该模型 / 上游未给倍率)**不算免费**,与 `0.0` 严格区分。**为什么排在到期额度之前**:省下的真金白银比「几百积分快过期先用」值钱。 -2. **手动 pin 优先**;会话粘性命中且可选时直接复用,不参与排序。 -3. **在途最少优先**(最少连接负载均衡):在途数少的号排前,并发请求摊到空闲号。**为什么排在到期额度之前**:到期/健康度是静态指标,按它们排出的次序确定,所有并发会算出同一个最优号并全压上去(实测 codebuddy_intl 六号全打平、2 小时内一个号吃 180+ 次、其余闲置);在途把它拆开,顺序请求(在途恒 0)不受影响。免费档仍压过在途。 +1. **手动 pin 优先**:候选池里有 `pinned` → **只在 pinned 之间排序**(绝对过滤,不是同档提权;否则免费档能随时顶掉管理员指定,指定变成「偶尔生效」)。指定了付费号就先用它,哪怕池里另有免费渠道。会话粘性命中且可选 → 直接复用,不参与排序。 +2. **免费优先**:无 pin 时 `credit_rate == 0` 的渠道整体排前(`None` 不算免费,与 `0.0` 严格区分);排在到期额度之前,因为省下的钱比「快过期先用」值钱。**x0 必须被实测扣费校准**:目录是上游自报,改计费常不改目录——2026-11-03 起 `codebuddy_intl/deepseek-v4.1-flash` 目录仍写 x0,每次实扣 ~0.14 积分,免费档压过所有到期积分(officeace 三条凭证 15.8 小时内到期的 100 积分包整夜闲置)。校准读 `usage_events` 的 `credit > 0 AND credit_estimated = 0`(上游真值,排除 TRAE 推算值),按 (渠道, 归一键) 聚合、24h 窗口、600s 快照;无观测行时仍信目录,目录给正数或 `None` 时不校准。 +3. **在途最少优先**:排在到期额度之前——到期/健康度是静态指标,并发会全压同一个号(实测 2 小时内一个号吃 180+ 次、其余闲置),在途把并发拆开;顺序请求(在途恒 0)不受影响,免费档仍压过在途。 4. **到期额度两级字典序**:先比主窗口(`QUOTA_EXPIRY_WINDOW_SECONDS`,默认 36h)内将过期的额度,打平再比次窗口(`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`,默认 7 天)。 -5. **健康度三态**:`known` 降序 > `unknown` > `exhausted`;健康度打平时**账户剩余积分多者优先**,再同分按 `credential_id` 稳定。无可用返回 None。 +5. **健康度三态**:`known(0-100)` > `unknown` > `exhausted`;打平比剩余积分,再按 `credential_id` 稳定。 -**为什么冷却分「账号级」与「模型级」两层**:上游拒绝语义不都是账号级问题——`429 + 6004` 是「这个模型在当前账号上用超了」,`400/404 + 11102` 是「当前账号没有这个模型」。一律记账号级会让一次模型级限流把整个账号踢出池。故账号级写 `credentials.cooling_until`,模型级另建 `credential_model_cooldowns`;账号级冷却出现时清空该凭证的模型级条目,防「切模型」绕过。业务码识别只认 `"code": N` 键值形态,不搜裸数字。冷却时长:`PLAN` 12h、`SOFT` 60s(不累计)、`OTHER` 连续 3 次→10m、`MODEL` 600s 起翻倍封顶 2h、`BLOCKED`(无此模型负缓存)6h 起翻倍封顶 24h、`CONCURRENCY` 固定 60s(不翻倍)、`CREDIT` 冷却到次日 04:00。**传输层失败归 `SOFT`**:`httpx.TransportError`(连接超时 / 写请求体超时 / 读超时)与 `UpstreamTransportError` 统一在 `executor._classify` 兜住——这类异常从 `client.stream()` 的 `__aenter__`、`aiter_bytes()`、写请求体等多个位置抛出,逐点包装必漏,集中在一处才不会让「链路瞬态」逃成 `internal_error` 帧(逃逸后既不换号也不冷却、不记账)。刻意不泛化到 `OSError` / `ConnectionError`:磁盘满、句柄耗尽也是它们的子类,误判会去冷却一个完全健康的凭证。**上游给出的重置时刻可覆盖上述常量**:响应头 `Retry-After`(整秒 / HTTP-date)或 `Try again in 17h 59m` 这类文本经 `parse_retry_after` 解析后,按该错误分类的作用域写入冷却,并**双端钳制**在 `[now + 该 ErrKind 现有常量, now + 24h]`——上游给 0 或过去时刻回落常量,否则一个 `Retry-After: 0` 就能把所有号瞬间打爆。无该头时行为与常量完全一致。 +**冷却分两层的原因**:`429 + 6004` 是「此模型在当前账号用超」,`400/404 + 11102` 是「当前账号没此模型」;一律记账号级 → 一次模型级限流踢掉整号。故账号级写 `credentials.cooling_until`(带来源 `cooling_kind`;人工 `revive` 默认保留上游点名的限流/额度类,`force` 才清),模型级另建 `credential_model_cooldowns`;账号级冷却出现时清空该凭证模型级条目,防「切模型」绕过。业务码只认 `"code": N` 键值形态。时长:`PLAN` 12h、`SOFT` 60s、`OTHER` 连续 3 次→10m、`MODEL` 600s 起翻倍封顶 2h、`BLOCKED` 6h 起封顶 24h、`CONCURRENCY` 固定 60s、`CREDIT` 到次日 04:00、`RESTRICTED` 2h 起按命中次数翻倍(封顶 7 天;同渠道窗口内第二个不同凭证再中招 → 该渠道整体 24h,见 §2.1 `11140`)。**传输层失败归 `SOFT`**:`httpx.TransportError`、`ssl.SSLError`、`UpstreamTransportError` 统一在 `executor._classify` 兜住(异常可从 `__aenter__`、`aiter_bytes()`、写请求体多处抛出,逐点包装必漏),不泛化到 `OSError` / `ConnectionError`。`ssl.SSLError` 必须单列:anyio 只把 `SSLEOFError` 转 `BrokenResourceError`,其余 TLS 中断(`record layer failure` 等)原样上抛 → 不列就逃到 `stream_guarded` 变 `internal_error`:不换号、不冷却、统计零痕迹。**上游重置时刻可覆盖常量**:`Retry-After`(整秒/HTTP-date)或 `Try again in 17h 59m` 经 `parse_retry_after` 解析后按作用域写入,双端钳制 `[now + 常量, now + 24h]`。**头缺失看响应体**:CB 国际站 `429+6004` 的恢复时刻只在正文(见 §2.1),由 `parse_reset_at` 解析;头优先于正文,`UTC±N` 偏移缺失则放弃。不解析正文 → 固定 600s 后重试再撞 429(实测同一号一天被爆 42 次)。 -**为什么是「窗口内积分总量」而不是「是否即将过期」**:实测 CodeBuddy 额度不是整块周期,而是几十个各自独立到期的小包(每日 100 积分 × N),只存一个日期没有区分度。四个取舍:① 统计窗口内的到期积分总量,账号之间才有可比高低;② **落库到期阶梯**(`[(到期 epoch, 包剩余积分)]`)而非预计算数字,窗口是运行时参数,改阈值立刻生效;③ 过滤条件必须是 `end > now` 且排除已用完的包(上游会把已过期套餐一起返回);④ **两级窗口**,只比 36h 会大量账号同为 0、排序退化成健康度。主窗口 `≤0` 等于关闭整套到期排序。TRAE 的权益包各自独立到期,同构填 `expiry_ladder`;CodeArts 的积分包按各自的套餐 `end_date` 登记到期。**展示与调度指标分开存**:`quota_packages`(含包名/用量,仅展示)与 `quota_expiry_ladder`(只有 `[到期, 剩余]`,选号指标)。 +**「窗口内积分总量」的原因**:实测 CB 额度是几十个各自独立到期的小包(每日 100 积分 × N),只存一个日期没有区分度。四个取舍:① 统计窗口内到期积分总量,账号间才可比;② 落库到期阶梯 `[(到期 epoch, 包剩余积分)]` 而非预计算数字;③ 过滤必须 `end > now` 且排除已用完的包;④ 两级窗口——只比 36h 会大量账号同为 0。**展示与调度指标分开存**:`quota_packages`(含包名/用量)与 `quota_expiry_ladder`(只有 `[到期, 剩余]`)。 -**健康度归一化**:`known(0-100) | unknown | exhausted`。CB 取 `CycleCapacityRemainPrecise`/`CycleCapacitySizePrecise`,TRAE 取 `credits_limit - credits_amount` / `credits_limit`。**不用响应顶层的 `TotalDosage`**:它 = Σ(size − CapacityUsed),而 `CapacityUsed` 是上周期口径,本周期消耗记在 `CycleCapacityUsed`/`CycleCapacityRemain`,用它会出现 `remaining == total`、健康度恒 100。**为什么必须三态**:探测失败被算成 0 分会让这类凭证在有健康号时永远轮不到。 +**健康度归一化**:CB 取 `CycleCapacityRemainPrecise`/`CycleCapacitySizePrecise`,TRAE 取 `credits_limit - credits_amount` / `credits_limit`。**不用 `TotalDosage`**:它 = Σ(size − CapacityUsed),`CapacityUsed` 是上周期口径 → 出现 `remaining == total`、健康度恒 100。**必须三态**:探测失败算 0 分会让其永远轮不到。 -**会话粘性**:识别按可靠性分三级——① 请求体显式会话标识 `conversation_id` / `conversationId` / `prompt_cache_key`;② **请求头会话标识** `x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id`(按序取第一个合法值,须匹配 `[A-Za-z0-9._:-]{1,128}`,经 contextvar 传入不污染中间层签名,键带 `header:` 前缀避免与 body 同值撞键);③ 回落「消息增量前缀指纹」。**头为什么夹在中间**:body 显式键最精确;头是客户端主动声明的会话身份,比指纹猜测可靠;而指纹只是启发式兜底。头值与其他键一样掺 `username` 再哈希——头是客户端可写的,不掺等于允许一个用户靠猜头值占用别人的账号。TTL(`CONVERSATION_STICKY_SECONDS`,默认 1h)内固定复用,避免中途换号触发上游风控并丢提示词缓存。请求体带 `user_id` 时**不派生**第 3 级前缀兜底键(同用户并行对话前缀可能相同,会误钉;头照常生效)。手动 pin 优先于粘性;凭证报错仍走正常轮换;条目纯内存。`prompt_cache_key`(OpenAI 官方顶层参数)与 `metadata.user_id`(Anthropic 官方字段)已核实;`conversation_id` / `conversationId` / 顶层 `user_id` 非两家标准键,属客户端惯用约定,作为兼容探测接受。 +**会话粘性**三级识别:① 请求体 `conversation_id` / `conversationId` / `prompt_cache_key`;② 请求头 `x-session-id` / `x-opencode-session` / `x-session-affinity` / `thread-id` / `session-id` / `conversation-id`(须匹配 `[A-Za-z0-9._:-]{1,128}`,经 contextvar 传入,键带 `header:` 前缀避免与 body 撞键);③ 消息增量前缀指纹。**头夹中间**:body 显式键最精确,头是客户端主动声明的身份、比指纹可靠。头值掺 `username` 再哈希——否则可猜头值占用别人账号。TTL(`CONVERSATION_STICKY_SECONDS`)默认 1h;带 `user_id` 时**不派生**前缀兜底键(同用户并行对话前缀可能相同)。手动 pin 优先;凭证报错仍走轮换;条目纯内存。 ### 3.4 模型名三字段 -八条渠道每个模型统一为三个字段,同一条链派生:**原代号**(`raw_id`,渠道请求时真正发的 key,**永不改动**,转发时经别名表换回)、**归一键**(剥掉免费档标记 `-free` / 路径段 `free`、新版标记 `-new` 与命名空间前缀后 slug 化,模型列表按它判断同一模型并合并)、**展示名**(`name`,清洗后的可读文本,上游可读名优先、无则由原代号派生、品牌缩写按官方写法纠正)。 +同一链派生:**原代号**(`raw_id`,转发时经别名表换回,永不改动)、**归一键**(剥掉 `-free` / `free` 路径段、`-new` 与命名空间前缀后 slug 化,合并键)、**展示名**(`name`,上游可读名优先,无则从原代号派生)。 -**合并键 = 归一键**;`/v1/models` 对外 `id` 一律是归一键,原代号进 `by_provider.{渠道}.raw_id`;**原代号、展示名、归一键三种写法都能直接请求**。同模型异名由显式规范表 `MODEL_SYNONYMS` 收敛(键与展示名一起),**只登记人工核实的等价、不做模糊匹配**。哨兵名(`auto` / `default`)语义只在本渠道内成立,不跨渠道合并;同渠道内归一键重复或对外 id 撞车时退回原代号 / 加后缀。黑名单按**归一后的对外写法**匹配(原代号、其归一键、展示名、展示名归一键四种任一命中即滤)。 +`/v1/models` 对外 `id` 一律归一键,原代号进 `by_provider.{渠道}.raw_id`。同模型异名由 `MODEL_SYNONYMS` 收敛,只登记人工核实等价。哨兵名(`auto` / `default`)只在本渠道内成立。黑名单按归一后的对外写法匹配。 ### 3.5 登录三轨 -`AuthFlow` 三值:`poll`(后端轮询上游:CB / Qoder)、`callback`(浏览器 302 回本服务 `/authorize`:TRAE)、`paste`(用户把回调链接粘回:CodeArts,服务端监听不到本机回环端口)。 +`AuthFlow` 三值:`poll`(CB 国内 / CB 国际 / Qoder / OfficeAce)、`callback`(TRAE,302 回 `/authorize`)、`paste`(CodeArts,服务端监听不到本机回环端口)。 -- **回调统一走主端口** `/authorize`;远程部署只需暴露一个端口。回调地址要写进登录 URL,故必须可配:`PUBLIC_BASE_URL`。 -- 前端在「凭证管理」页内实现:**poll** 拿到 `authUrl` 开新窗、轮询 `/api/auth/upstream/poll`;**callback** 开授权窗、浏览器 302 回 `/authorize` 直接落库,前端轮询凭证列表出现新条目即视为完成(上游不回传 state)。`/authorize` 无鉴权(浏览器 302 不带 key),故按**发起人会话** + **`loginTraceID`**(TRAE 原样回显登录 URL 里的 `(machine_id + device_id)[-16:]`,攻击者拿不到)双重绑定,且待完成登录有 TTL(10 分钟)。 +- 回调统一走主端口 `/authorize`,远程部署只需暴露一个端口。回调地址写进登录 URL → 必须可配 `PUBLIC_BASE_URL`;基址是回环/`0.0.0.0`(默认值)时改用当前请求 origin,远程与容器部署忘配也不会「授权完成却不回调」。 +- 前端在「凭证管理」页内实现:**poll** 拿 `authUrl` 开新窗、轮询 `/api/auth/upstream/poll`;**callback** 开授权窗、302 回 `/authorize` 落库,前端轮询列表出现新条目即完成(发起时把实际回调地址写进提示,超时提示指向 `PUBLIC_BASE_URL`);浏览器打不开回调地址(NAS / 远程部署常见「网络错误」)时,同一对话框给出粘贴入口,把地址栏整条链接交给 `POST /api/auth/upstream/complete` 用同一条 pending 完成兑换。`/authorize` 无鉴权 → 按**发起人会话** + **`loginTraceID`** 双重绑定(Cookie 不在回调入口时按 trace 反查发起人),待完成登录 TTL 10 分钟;浏览器收到中文说明页而非 JSON。 ### 3.6 中立事件层 -上游 SSE 解析到「中立事件」(`EventKind`:`content` / `reasoning` / `tool_calls` / `usage` / `finish` / `error`)独立成层。出口协议适配器(OpenAI / Responses / Anthropic)只消费中立事件,不动上游逻辑。 +上游 SSE 解析到中立事件(`EventKind`:`content` / `reasoning` / `tool_calls` / `usage` / `finish` / `error`),出口适配器(OpenAI / Responses / Anthropic)只消费中立事件。 -**入口语义收口**(不静默降级,也不把畸形请求打到上游把凭证打凉):chat 入站的 `response_format` 只放行 `text` / `json_object` / `json_schema`(后者要求非空 `name` 与 `schema`,未知字段丢弃),Responses 的 `text.format` 复用**同一个**校验函数(`compat/openai/request.py: normalize_response_format`,`prefix` 只改报错路径)。Anthropic 的 `cache_control` **默认原样透传**(落点:`system` 块、user/assistant 块、`tool_result` 挂 tool 消息顶层、`tool_calls` 元素顶层、`tools` 元素顶层),只校验结构(对象 + 非空字符串 `type`,`ttl` 若给必须字符串),取值交给上游裁决;无标记时映射结果逐字节不变,会话粘性指纹与上游前缀缓存不受影响。 +**入口语义收口**:chat 的 `response_format` 只放行 `text` / `json_object` / `json_schema`(后者要求非空 `name` 与 `schema`,未知字段丢弃),Responses 的 `text.format` 复用同一校验函数。Anthropic 的 `cache_control` **默认原样透传**,只校验结构,取值交上游裁决;无标记时映射结果逐字节不变。 ### 3.7 账号与权限面 -管理面身份来自 SQLite `users` 表。三角色:`admin`(用户管理、运行时配置、凭证写操作、全量统计、审计、运维告警、`require_admin`)、`operator`(凭证写操作:导入 / 删除 / 启停 / pin / 切换账号 / 签到 / 成长,全量统计,上游状态、模型列表,`require_operator`)、`viewer`(只读:仪表盘、凭证列表、Playground、统计;审计为 admin-only,登录即可)。 +三角色:`admin`(用户管理、运行时配置、凭证写、全量统计、审计、告警)、`operator`(凭证写、全量统计、上游状态、模型列表)、`viewer`(只读:仪表盘、凭证列表、Playground、统计)。 -- **鉴权链**:签名 Cookie(HMAC,payload 带用户名与 `session_epoch`)→ 用户仍存在且启用 → epoch 与 DB 一致 → 角色现读 DB。四者缺一即 401;**角色不进 Cookie**,降级立即生效。 -- **吊销不建会话表**:`users.session_epoch` 在改密 / 改角色 / 启停 / 硬删时 `+1`,旧 Cookie 立即失效。 -- **建号与重置走一次性激活令牌**:库里只存 SHA-256 摘要 + 过期时间,明文仅在响应里回显一次,用户在 `/activate` 自设密码。被重置的用户改密前只放行「看会话 / 改密 / 登出」。 -- **删除语义与审计**:**禁用是主路径**(可逆、保住用量归属),硬删只在 CLI(`scripts/create_user.py --delete --force`),管理台不暴露 `DELETE`;登录成败、账号变动、凭证写操作全部入 `audit_events`,**绝不记密码或令牌明文**。 -- **防锁死三层**:Web 端「最后一个活跃 admin」+ 自我降级 / 自禁用守卫;CLI 拒绝硬删最后一个活跃 admin;bootstrap 发现无活跃 admin 直接启动失败。**引导**:`users.txt` 启动时一次性导入(幂等、不覆盖已存在),`ADMIN_USERNAMES` 只标引导期提权。 +- **鉴权链**:签名 Cookie(HMAC,带用户名与 `session_epoch`)→ 用户仍存在且启用 → epoch 与 DB 一致 → 角色现读 DB。缺一即 401;**角色不进 Cookie**,降级立即生效。 +- **吊销不建会话表**:`users.session_epoch` 在改密/改角色/启停/硬删时 `+1`。 +- **建号与重置走一次性激活令牌**:库里只存 SHA-256 摘要 + 过期时间。被重置用户改密前只放行「看会话/改密/登出」。 +- **删除语义**:禁用是主路径(可逆、保住用量归属),硬删只在 CLI,管理台不暴露 `DELETE`;登录成败、账号变动、凭证写操作全部入 `audit_events`,绝不记密码或令牌明文。 +- **引导与防锁死**:`ADMIN_USERNAMES` 只在引导期提权(库里已有活跃 admin 就不再改写任何人的角色——否则管理台的降级会被下一次重启撤销);三层防锁死 = Web 端守卫 + CLI 拒绝硬删最后一个活跃 admin + bootstrap 发现无活跃 admin 直接启动失败。 ## 4. 数据模型 -DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 **13 张表**,`SCHEMA_VERSION = 29`。变更纪律:schema.sql **只加不改**;新增列走 `_MIGRATION_COLUMNS` 幂等补列,删表须在 `_MIGRATION_DROPS` 补一条。 +DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 14 张表,`SCHEMA_VERSION = 32`(v31 加 `checkin_events`,v32 加 `credentials.cooling_kind`)。变更纪律:schema.sql **只加不改**;新增列走 `_MIGRATION_COLUMNS` 幂等补列,删表须在 `_MIGRATION_DROPS` 补一条并 `SCHEMA_VERSION + 1`。迁移细节见 TECHNICAL.md §8。 | 表 | 用途 | |---|---| -| `api_keys` | API Key(SHA-256 摘要 + 预览 + 渠道绑定 `provider_binding` + 来源 IP 白名单 `allowed_ips` + 模型白名单 `allowed_models` + 到期 `expires_at` + 配额 `quota`) | -| `credentials` | 加密凭证(`data_enc` 走 Fernet)+ 调度状态(`enabled`/`disabled`/`pinned`/`health`/`cooling_until`/`err_count`)+ 额度与到期阶梯 + token 到期列 + 成长中心最近结果 + 运维标签 `tags`(仅展示筛选,不参与调度) | -| `credential_limits` | 凭证级限额护栏(保留积分 / 每日 token / 每日 credit / 按模型 token);**缺行 = 不受限** | -| `usage_events` | 请求明细(token / credit / 缓存命中 / 成本 / 耗时 / 首字延迟 / 请求 ID / 发起请求的 `api_key_id`),保留 90 天 | -| `usage_hourly` | 小时汇总(永久保留),渠道 / 模型 / 用户维度统计读此表 | -| `growth_events` | 成长中心每轮一行汇报(仅 CodeBuddy);活跃上报复用同表 | -| `credit_events` | 两次额度探测之间的**净变化**(含 `window_start`、归因已知度 `source` 与账本 `ledger`=`pool`/`credits`),不是动作归因 | +| `api_keys` | Key(SHA-256 摘要 + 预览 + 渠道绑定 + IP 白名单 + 模型白名单 + 到期 + 配额) | +| `credentials` | 加密凭证(Fernet)+ 调度状态 + 额度与到期阶梯 + token 到期 + 成长结果 + 运维标签 | +| `credential_limits` | 凭证级限额护栏;**缺行 = 不受限** | +| `usage_events` | 请求明细,保留 90 天 | +| `usage_hourly` | 小时汇总(永久),渠道/模型/用户维度读此表 | +| `growth_events` | 成长中心每轮一行(仅 CodeBuddy);活跃上报复用 | +| `checkin_events` | 签到审计日志:每次签到一行(结果 / 领取积分 / 上游业务码 / 触发方式),成功失败都记 | +| `credit_events` | 两次探测之间的**净变化**(`window_start`、`source`、`ledger`),不是动作归因 | | `credential_model_cooldowns` | (凭证, 模型) 级冷却与「无此模型」负缓存,含 `hits` 供指数退避 | -| `runtime_settings` | 运行时配置覆盖(纯 key/value,只存管理台改过的 key),白名单校验在 `runtime_settings.HOT_SETTINGS` | -| `users` | 账号(PBKDF2 密码哈希 + `role` + `enabled` + `must_change_password` + `session_epoch` + 一次性激活令牌摘要) | -| `audit_events` | 审计流水(登录 / 账号变动 / 凭证写操作) | -| `alert_events` | 运维告警事件(四类规则命中落一行;`resolved_at` 非空 = 迟滞确认恢复) | +| `model_probes` | 模型可用性探活状态(`state` / `failures` / 人工接管 `manual`),决定「恢复展示 / 继续避让」 | +| `runtime_settings` | 运行时配置覆盖(纯 key/value),白名单在 `runtime_settings.HOT_SETTINGS`(85 项) | +| `users` | 账号(PBKDF2 + `role` + `enabled` + `must_change_password` + `session_epoch` + 激活令牌摘要) | +| `audit_events` | 审计流水 | +| `alert_events` | 告警事件(九类规则见 §5;`resolved_at` 非空 = 迟滞确认恢复) | -- 调度状态与到期阶梯**落库**,重启不丢冷却状态与到期阶梯。签到去重、模型列表缓存、后台任务运行态均**进程内实现、不建表**(运行态重启归零,不编造重启前记录)。 -- 模型目录落盘到 `DATA_DIR/model_catalog.json`(未过滤原始表),启动同步读回;OpenRouter 目录落盘 `data/openrouter_catalog.json`。 -- **脱敏纪律**:不存提示词、回答、请求头、Token、工具参数、原始错误体、会话 ID。唯一例外是诊断开关 `DUMP_REQUEST_BODIES=true`(默认关)把 `/v1` 原始请求体落盘到 `data/dumps/`(有界保留 200 份),**含完整对话内容**,不得长期开启、不得随库交付。 +- 调度状态与到期阶梯**落库**,重启不丢;签到**去重**、模型列表缓存、任务运行态**进程内**(重启归零,不编造记录)——去重是运行态,与 `checkin_events` 的审计日志是两回事。 +- 模型目录落盘 `DATA_DIR/model_catalog.json`,OpenRouter 目录落盘 `DATA_DIR/openrouter_catalog.json`(默认 `DATA_DIR=./data`)。 +- **脱敏纪律**:不存提示词、回答、请求头、Token、工具参数、原始错误体、会话 ID。唯一例外 `DUMP_REQUEST_BODIES=true`(默认关,有界保留 200 份,含完整对话内容,不得长期开启)。 ## 5. API 契约 -**外部(API Key 鉴权)**:`POST /v1/chat/completions`(流式 + 非流式,`n` 只接受 1——出口结构只能产生单个 choice,其余值 400 `invalid_request`);`POST /v1/responses`(Responses 子集,Codex CLI);`POST /v1/messages` 与 `POST /v1/messages/count_tokens`(Anthropic Messages 子集,`count_tokens` 本地估算不转发上游)——三者共用同一调度链路;`GET /v1/models`(对外 `id` 为归一键 + `name` + `providers` + `by_provider.{渠道}.{raw_id, credit_rate}` + 可选 `benchmarks` / `cost`(`input`/`output`/`cache_read`,USD/1M,缺价不带该字段)/ `reasoning_efforts`(该渠道生效档位去重集合,未配置不带);自定义别名条目另带 `custom_alias: true`);`GET /v1/models/{id}`(单模型查询,`:path` 容纳带命名空间的渠道原代号;对外 id / 展示名 / 任一渠道原 id / 自定义别名四种写法、大小写不敏感;不存在**或不在该 Key 模型白名单内**一律 404 + `model_not_found`,不泄露池中模型);`GET /v1/usage?days=1|7|30`(自助用量:只查**该 Key 自己**的本地明细,不触发上游,返回 `requests/ok/failed/token/cost_usd` + `limits`/`remaining`/`rate`/`resets_at`,不含凭证 id、昵称或池级信息);`GET /v1/user/balance`(DeepSeek 兼容余额,读探测缓存聚合,不实时打上游);`GET /health`(纯存活)、`GET /healthz`(存活 + 凭证池五类计数,无鉴权)、`GET /metrics`(Prometheus text 格式指标,默认开、无鉴权,`METRICS_ENABLED=false` 关闭)。 - -**管理台(会话 Cookie + RBAC)**:凭证管理、API Key 管理(含 Key 级配额 `quota`:日请求 / 日 token / 日成本 / RPM / TPM / 并发,`0`=不限;超限 429 + `Retry-After`,判定异常一律**放行**并记 warning)、用量统计(`/api/stats/*`)、Playground、模型列表(operator,含 `GET /api/models/availability` 疑似下线只读视图)、用户管理(admin-only)、审计日志(admin-only)、运维告警(admin-only)、上游状态(operator)、任务与配置(admin-only)、**服务端日志(admin-only,只读)**。 - -- 账号:`GET|POST /api/users`、`PATCH /api/users/{username}`、`POST /api/users/{username}/{disable|enable|reset-password}`(**不提供 DELETE**);自助改密 `POST /api/auth/password`;无鉴权的一次性激活流 `GET|POST /api/auth/activate`;审计查询 `GET /api/audit`。 -- 凭证运维:`POST /api/credentials/{id}/checkin|toggle|revive|probe|accounts/select`、`PUT /api/credentials/{id}/limits`(限额护栏整体替换,全关时删行)、`GET|POST /api/credentials/{id}/growth`、`GET /api/credentials/{id}/credit-events|accounts`。 -- 凭证批量运维:`POST /api/credentials/batch`(`{action: probe|toggle|checkin|revive, ids: [...]}`,可选 `enabled` 用于 `toggle`)。逐条隔离执行——单条失败不影响其余条,响应含 `{total, succeeded, failed, results[]}`;`toggle`/`revive` 落一条汇总审计,`probe`/`checkin` 与单条端点同为只读语义、不写审计。 -- 运行时配置与任务运行态:`GET|PUT /api/settings` + `GET /api/tasks` + `POST /api/tasks/{key}/run`(手动跑一轮)。列表端点接受统一 `sort`(白名单键)+ `order`(`asc`/`desc`);未知键 / 非法值回落到默认,不报错。回调(无鉴权):`GET /authorize`。 -- 运维告警:`GET /api/alerts`(倒序回看,分页)+ `POST /api/alerts/{id}/resolve`(人工标记一条已恢复,admin + CSRF + 审计;用于规则已下线 / 误报等状态机无法自动判定的情况)。后台正常路径会在规则不再命中时自动写 `resolved_at`,且 `AlertTask` 启动时从库里未恢复的行重建「告警中」集合,重启后仍能自愈。 -- 上游状态聚合:`GET /api/upstream-status`(operator)返回后台任务抓取的内存快照(`summary` + `sources`)与任务的当前 `enabled` / `interval_seconds` / `server_time`;**不落盘**,重启后为「尚未抓取」。四种解析器(Statuspage v2 / OpenCode / Better Stack / Atom)归一化到六档,加源只需在 `SOURCE_SPECS` 加一行。 -- 用量统计:`GET /api/stats/{overview,by-provider,by-model,by-user,by-credential,timeline,model-timeline}`(分组表的延迟列可选 P50/P90/P95/P99 四档,默认 P95;`p95_*` 字段与排序键保持兼容;另有输出速度 `tps_visible`(正文)/ `tps`(总产出含思考)/ `tps_estimated` 列,排序键 `tps_visible` / `tps`)、`GET /api/stats/errors`(**失败归因**:窗口内 `ok=0` 明细按 `(provider, model, error_type)` 分组计数 + 最近失败时刻,`limit` ≤ 5000)、`GET /api/stats/events`(rowid 游标 / LIMIT-OFFSET 双模式,`provider`/`model`/`credential_id` 钻取过滤,`ok=0` 只看失败,每行带逐请求速度 `tps` / `tps_visible`、排序键同名,`?format=csv` 导出)、`GET /api/stats/events/stream`(**SSE 实时明细**:`StatsCollector.record` 事务提交后由进程内 `LiveEventHub` 广播刚写入的行,不轮询数据库;每订阅者有界缓冲 200 条、并发上限 8 超出回 429 + `Retry-After`;15s 注释帧保活,`Cache-Control: no-store` + `X-Accel-Buffering: no`;作用域/过滤在订阅者侧逐条判定,与 `/api/stats/events` 同口径)。 -- 服务端日志:`GET /api/logs?limit=`(默认 200,上限 = 缓冲容量)+ `GET /api/logs/stream`(SSE,先回放缓冲再推实时,按 `seq` 去重)。**只读**、admin-only;`LOG_BUFFER_LINES=0` 时两端点均 404。只收本进程 `logging` 记录,不读文件;每行发出前过脱敏白名单。 -- 侧栏数据提醒:`GET /api/nav-badges` 一次返回侧栏各项的待办计数(凭证需重登/耗尽、未恢复告警、上次失败任务、异常上游源、待激活账号、本人将到期 Key、模型目录陈旧)。**按角色裁剪 payload**——缺失的键 = 无权看(admin-only 的告警/任务/用户不下发给 operator/viewer,operator 的上游/目录不下发给 viewer);这是聚合端点,**不做整体 403**(viewer 也要看自己的 API Key)。各计数与对应页面数据**不是同一口径**:只数「待办」(如告警只数 `resolved_at IS NULL`、任务用「上次失败」而非「连续失败达阈值」)。 - -实现以代码为准,使用说明见 [README.md](README.md)。 +**外部(API Key)**:`POST /v1/chat/completions`(`n` 只接受 1)、`POST /v1/responses`、`POST /v1/messages` + `POST /v1/messages/count_tokens`(本地估算)——三者共用同一调度链路;`GET /v1/models`(`id` 为归一键 + `name` + `providers` + `by_provider` + 可选 `benchmarks` / `cost` / `reasoning_efforts`;自定义别名带 `custom_alias: true`);`GET /v1/models/{id}`(`:path` 容纳带命名空间的原代号;三种写法、大小写不敏感:对外 id / 展示名 / 任一渠道原 id;不存在或不在 Key 白名单内一律 404 + `model_not_found`);`GET /v1/usage?days=1|7|30`(只查该 Key 自己的本地明细);`GET /v1/user/balance`(读探测缓存);`GET /health`、`GET /healthz`、`GET /metrics`(均无鉴权,`METRICS_ENABLED=false` 关闭)。 + +**管理台(Cookie + RBAC)**:凭证管理、API Key(含六项配额,超限 429 + `Retry-After`,判定异常一律放行并记 warning)、用量统计、月度账单、Playground、模型列表、用户管理、审计、告警、上游状态、任务与配置、服务端日志。**出口 IP 粘性**(`CREDENTIAL_PROXY_POOL`):按凭证 id 稳定分配出口,推理与后台任务同出口。 + +- 账号:`GET|POST /api/users`、`PATCH /api/users/{username}`、`PUT /api/users/{username}/policy`(用户级模型白名单 + 配额)、`POST /api/users/{username}/{disable|enable|reset-password}`(**无 DELETE**);`POST /api/auth/password`;`GET|POST /api/auth/activate`;`GET /api/audit`。 +- 凭证运维:`GET|POST /api/credentials`(列表 / 新增;列表每条附 `checkin_today` = 今日(北京时间)最近一条签到结果,供组头汇总)+ `DELETE /api/credentials/{id}`;动作端点 `checkin`(GET 查状态 / POST 执行)、`toggle`、`revive`、`probe`、`activity`、`pin`、`accounts/select`、`export`(单条 JSON 导出);`PUT /api/credentials/{id}/limits|tags`、`GET|POST /api/credentials/{id}/growth`、`GET /api/credentials/{id}/credit-events|checkin-events|accounts`。 +- 模型:`GET /api/model-catalog`、`GET /api/models/availability`、`POST /api/models/probes/{probe,manual}`(`manual=on|off` 人工接管,优先于自动探活)。 +- 批量:`POST /api/credentials/batch`(`probe|toggle|checkin|revive`)。逐条隔离,响应 `{total, succeeded, failed, results[]}`;`toggle`/`revive` 落一条汇总审计。 +- 批量导入:`POST /api/credentials/batch-import`(`items[]`,≤100 条)。逐条校验,响应 `{created[], skipped[], failed[]}` 均带 `index`;同渠道内容完全一致去重;逐条审计 + 入队探测。 +- 备份:`GET/POST /api/backups`、`GET/DELETE /api/backups/{name}`、`POST /api/backups/restore`(原始字节,≤512MB)。恢复用「校验 → 暂存 → 下次启动应用」而不是运行中换库;启动应用失败只留档、不阻断(`db/backup.apply_pending_restore`)。 +- 配置与任务:`GET|PUT /api/settings` + `GET /api/tasks` + `POST /api/tasks/{key}/run`。列表端点统一 `sort` + `order`;未知键回落默认。回调:`GET /authorize`。 +- 告警:`GET /api/alerts` + `POST /api/alerts/{id}/resolve`。`AlertTask` 启动时从库里未恢复的行重建集合。规则九类(rule id:`pool_empty` / `task_failed` / `token_expiring` / `error_rate` / `key_burst` / `off_hours` / `budget` / `channel_dead` / `checkin_failing`):池耗尽、任务连续失败、token 到期、上游错误率、单 Key 用量暴涨、非工作时段请求、**预算分层(50/80/95/100%)**、**渠道零成功(窗口内有流量但一次都没成功)**、**签到连续失败(同一凭证连续 N 轮业务失败,「任务跑成功但上游回失败」任务级计数看不见)**(单 Key / 非工作时段 / 预算三类默认阈值 0 = 关闭;渠道零成功与签到连续失败默认开启;不存 IP,故不做「多 IP 同 Key」)。 +- 上游状态:`GET /api/upstream-status`(operator)返回内存快照 + 任务运行态;**不落盘**。四种解析器归一化到六档,加源只在 `SOURCE_SPECS` 加一行。 +- 统计:`GET /api/stats/{overview,by-provider,by-model,by-user,by-credential,timeline,model-timeline,percentile-timeline}`(延迟分位 P50/P90/P95/P99 可切,默认 P95;输出速度 `tps_visible` / `tps` / `tps_estimated`)、`GET /api/stats/errors`(失败归因,默认窗口 24 小时、`limit` ≤ 5000;硬失败 DEAD/RESTRICTED/INVALID/CAPABILITY 每次尝试一行,限流/额度/超时/上下文等其余分类每次请求一行且类型取最后一次错误的真实归因,`rescued` 为同用户同 `request_id` 另有成功行的次数,`failures − rescued` 是未被救回的失败尝试数)、`GET /api/stats/events`(游标/OFFSET 双模式,钻取过滤,`ok=0`,`?format=csv`)、`GET /api/stats/events/stream`(SSE,订阅者有界缓冲 200、并发上限 8)。 + - `timeline` / `model-timeline` 的 `metric` 取值:`requests` / `tokens` / `cost` / `latency` / `ttfb` / `success_rate` / `cache_rate` / `cost_per_request`;**取值域外的拼写显式 400**(不静默回落到 `requests`——否则客户端拼错参数会拿到一份「看起来正常」的请求数图,错误被完全吞掉)。派生指标(成功率、缓存命中率、单请求成本)在**聚合后相除**(`sum/sum`),不是逐小时比率的平均;无样本给 `null` 而非 0。 + - 时间轴是**稠密**的:按小时补齐缺失桶(跨度上限 `MAX_DENSE_HOURS` = 92 天,超出则只回观测小时)。缺失桶按指标语义补值——可加量(`requests` / `tokens`)补 0,均值 / 比率 / 成本补 `null`(前端断线,不画成 0)。 + - `timeline` 每个点带 `__total`(全渠道合计):均值与比率必须按同一小时的 `sum/count` 重新相除,前端不得把各渠道值相加。 + - `percentile-timeline`(`metric=latency|ttfb`,其余显式 400)返回按小时的 `{hour, p50, p95, samples}`,读明细表(仅 90 天),只统计成功请求(实测均值 10 s 时 P95 达 21 s);缺省窗口 24 小时(防任意登录用户对 90 天明细反复跑窗口函数做 DB CPU DoS)。 +- 账单:`GET /api/billing?month=YYYY-MM&format=json|csv|jsonl`(operator)。按人读小时汇总、按 Key 读明细(90 天)并补归属用户与 Key 名;`csv` 按人带 BOM、`jsonl` 逐条明细;两种导出都写 `ACTION_DATA_EXPORT` 审计。 +- 日志:`GET /api/logs` + `GET /api/logs/stream`(只读、admin-only,`LOG_BUFFER_LINES=0` 时 404)。 +- 侧栏提醒:`GET /api/nav-badges`,**按角色裁剪 payload**(缺失 = 无权看),不做整体 403。各计数只数「待办」,与页面数据不是同一口径。 ## 6. 安全边界 -- **上游端点白名单**:真实 Token 只发往显式配置的地址。CB 启动时强制校验;国际版白名单刻意不含国内站主机;TRAE 的 `apiHost` 导入时按官方地址白名单校验;Zen / Kilo / Qoder / CodeArts 各有独立白名单。 -- **凭证加密**:`data_enc` 走 Fernet,密钥走 `APP_SECRET`(≥16 字符,拒绝低熵值);**丢失 = 已存凭证全部不可解,只能重录**,不做密钥轮换。解密失败返回可行动错误码,不暴露裸 500。 -- TLS 校验默认开启;Host / Origin 白名单;CSP `frame-ancestors 'none'`、`object-src 'none'`、`base-uri 'self'`、`form-action 'self'`。**请求体上限**:登录 8KB、其余 16MB(ASGI 层按实际字节计数,`chunked` 不能绕过)。登录三级限流(全局 / IP / 用户名)+ PBKDF2 并发上限;失败键有数量上限 + 过期淘汰(防内存 DoS)。 -- **API Key 仅存摘要**,明文只在创建时返回一次;可按 Key 限定渠道绑定、来源 IP 白名单(默认**不信** `X-Forwarded-For`,仅 `TRUST_PROXY=true` 时取 XFF 最后一个条目)、模型白名单(fnmatch,无法判定模型名时 fail closed)与到期时间。 -- **会话**:Cookie `SameSite=Lax` + 写操作 CSRF 校验(含 logout);`Secure` **按请求实际协议动态判定**(`X-Forwarded-Proto` 首个值优先,无该头则仅当请求 Host 命中 https 的 `PUBLIC_BASE_URL` 主机才判 Secure)——同一实例 LAN(http) 与隧道(https) 双入口写死任一侧都会打断另一侧;**无 Cookie 的写端点(登录 / `/authorize`)也做跨站防护**:登录按 Origin/Referer 同源校验(防 login CSRF),`/authorize` 按上游原样回显的 `loginTraceID` 绑定到**发起登录那一次**(TRAE 回调本就跨源,Origin/Referer 既拦不住真攻击也可能误杀真回调);会话与 API Key 除签名 / 摘要外校验用户仍存在、启用且 epoch 一致。 -- **错误响应不回流上游正文**:`/v1` 与外层的错误文案只带受控标识(HTTP 状态码 / 上游业务码 / 异常类名),上游错误体摘要只进服务端日志;未匹配的 `/api`、`/v1` 路径返回 JSON 404(不落到 SPA 的 200 + HTML)。**客户端读体中途断开不是服务端错误**:按网关惯例回 `499 client_disconnected`,不落 500 + ASGI Traceback(噪音 + 语义错)。**日志脱敏**:不打印 Token、完整请求体;审计写 INFO(含操作人),**绝不记密码/令牌明文**。 -- **请求 ID 是客户端可写输入**:入站 `X-Request-ID` 按白名单字符集与长度(`[A-Za-z0-9._:-]{1,128}`)校验后才采信与回显,不合法一律丢弃改用自生成——否则日志与统计列会被人塞进换行/超长串。响应头回显最终 ID(含自生成值),不伪装成上游追踪。 -- **服务端日志页同样只出不进**:`src/webapp/log_buffer.py` 的 `redact_or_drop` 在行离开进程前整行过滤——独立敏感词(token/secret/authorization/cookie/password/api_key 及复数和连字符形态)、键值形态(`access_token=`、`X-Api-Key:`、`set-cookie:`、JSON 键)、`sk-…` 形状、`Bearer <值>` 命中即丢弃;空行也丢。代价是 `token 已刷新` 这类正常行会被一起吞掉,取舍是**宁可漏一条日志,也不把凭证推进浏览器**。页面无任何写入/执行入口,订阅者只能读。 -- **请求级结构化日志**(`src/webapp/access_log.py`,`REQUEST_LOG_ENABLED` 可热更):每个业务请求(`/api/*`、`/v1/*`、`/authorize`)一行 logfmt,含 `request_id` / 路由**模板** / 状态码 / 耗时。**不写 query string**(可能被客户端塞 token)、**不写请求体 / 请求头 / 用户名**(身份靠 `request_id` 关联 `usage_events`);未匹配路由时回落为清洗并截断的原始路径,防换行注入。静态资源与探针不记(uvicorn 已覆盖)。 -- **租户隔离**:`/api/stats/events` 与 `/api/stats/by-credential` 的**凭证昵称**取自全局共享池(常含邮箱/手机),仅 admin/operator 可见;viewer 即便只看自己的记录也不下发该字段。`/api/stats/events?format=csv`(整份明细导出)同样仅 admin/operator;`/api/stats/events/stream` 的广播 payload 不含昵称,仅 admin/operator 的连接由服务端逐条补 `credential_name`。 -- **热更配置写入即校验**:float 拒 NaN/±inf;字符串类有长度上限;告警 webhook 只接受 `http(s)` 并做 **SSRF 校验**(写入期拒字面量内网 / 回环 / 链路本地 / 保留 / 多播 / 未指定地址,**投递前再按 DNS 解析结果复核一次**、解析失败即阻断),外发载荷对 `nickname` 打码,多地址并发投递并加总预算。告警出站 client `trust_env=False`,不吃宿主代理环境变量。 -- **上游流解析有界**:SSE 单行与单帧缓冲设上限,非 2xx 错误体有界读取;OAuth state store 的 owner/consume 有 TTL 判定。**入站解析把关**:三个出站协议在入口拒绝越界数值字段(`max_tokens` ≤0 或超上限、`temperature`/`top_p` 为 NaN/Inf)与 JSON 里的 `NaN`/`Infinity`。 -- **OAuth 登录弹窗**只允许 `http(s)` scheme,弹窗 `opener` 置空。**库版本守卫**:`user_version` 高于本程序时 `apply_schema` 拒绝执行。`scripts/hash_password` / `create_user` 支持 `--password-stdin`;拒绝含 `:` / 换行的用户名。 -- **不做**:mTLS;面向管理台与端口的 IP 限制(交给反向代理)。不做全量请求审计(统计表已是脱敏的请求级记录)。 +- **上游端点白名单**:真实 Token 只发往显式配置地址,Zen / Kilo / Qoder / CodeArts / OfficeAce / 小浣熊 / 灵犀各有独立 `*_ALLOWED_ENDPOINTS`。CB 启动时强制校验;国际版白名单刻意不含国内站(**白名单里出现国内站主机也一律拒绝**——只校验「端点在白名单里」不够,把国内站加进国际站白名单后端点校验会通过、Token 照样跨站发);TRAE 的 `apiHost` 是用户可控输入,导入时按 `ALLOWED_OAUTH_HOSTS` 拒绝,旧凭证刷新时退回官方地址。OfficeAce 的模型网关由上游逐账号签发,不进配置也不进白名单(§2.8)。 +- **凭证加密**:Fernet,密钥走 `APP_SECRET`(≥16 字符,拒绝低熵);**丢失 = 凭证全部不可解**,不做轮换。解密失败返回可行动错误码。 +- TLS 校验默认开;Host / Origin 白名单;CSP `frame-ancestors 'none'`、`object-src 'none'`、`base-uri 'self'`、`form-action 'self'`。**请求体上限**(ASGI 最外层按实际字节计数):登录 8KB、备份恢复 512MB、其余 16MB。登录三级限流 + PBKDF2 并发上限;失败键有数量上限 + 过期淘汰。 +- **API Key 仅存摘要**,明文只在创建时返回一次;可限渠道、IP(默认不信 `X-Forwarded-For`,仅 `TRUST_PROXY=true` 时取最后一个)、模型(fnmatch,无法判定时 fail closed)、到期。**发 / 删 Key 与明细导出都进审计**:明文只回显一次,事后只能靠审计行回答「这把能刷全模型的 Key 是谁建的」。 +- **用户级策略**(`users.allowed_models` / `users.quota`,`PUT /api/users/{username}/policy`):会话 Cookie 只回答「你是谁」,Playground 是**免 Key** 的会话直连入口,因此模型白名单与配额必须落在人身上——否则任何登录用户(含 viewer)都能直调任意模型,把 Key 的白名单与配额整体绕过。配额用量按用户名聚合(该用户经任何入口的流量都计入),与 Key 那套同判据、同 429 形状。 +- **kill switch**(`GATEWAY_PAUSED` + `GATEWAY_PAUSE_REASON`,热更):开启后所有 Key 鉴权的 `/v1` 终点立即返 503 `service_paused`,**管理台与登录保持可用**(否则开了就关不上)。开关动作进审计并单独记 WARNING;暂停原因只进日志,不回显给调用方。 +- **会话**:`SameSite=Lax` + 写操作 CSRF(含 logout);`Secure` 按请求实际协议动态判定;无 Cookie 的写端点(登录 / `/authorize`)也做跨站防护(登录按 Origin/Referer 同源校验,`/authorize` 见 §3.5);会话与 Key 校验用户存在、启用且 epoch 一致。 +- **错误响应不回流上游正文**:只带受控标识,上游错误体摘要只进日志。未匹配的 `/api`、`/v1` 返回 JSON 404(**任何 HTTP 方法**都如此——只注册 GET 时未匹配的 POST 会落到 Starlette 的 405 `{"detail":…}`,调用方按 `error.code` 解析会踩空)。**客户端读体断开不是服务端错误** → 回 `499 client_disconnected`。**日志脱敏**:不打印 Token、完整请求体;审计写 INFO,绝不记明文。 +- **请求 ID 是客户端可写输入**:按 `[A-Za-z0-9._:-]{1,128}` 校验后才采信,不合法改用自生成。 +- **服务端日志页只出不进**:`redact_or_drop` 在行离开进程前整行过滤(独立敏感词、键值形态、`sk-…`、`Bearer <值>`),空行也丢。代价是「token 已刷新」这类正常行一起被吞——宁可漏一条,不把凭证推进浏览器。 +- **请求级结构化日志**(`REQUEST_LOG_ENABLED`):一行 logfmt,含 `request_id` / 路由模板 / 状态码 / 耗时;**不写 query string、请求体、请求头、用户名**;未匹配路由回落为清洗截断的原始路径。 +- **租户隔离**:凭证昵称取自全局共享池,仅 admin/operator 可见;CSV 导出同样仅 admin/operator;SSE 广播 payload 不含昵称,仅 admin/operator 连接由服务端补 `credential_name`。 +- **热更写入即校验**:float 拒 NaN/±inf;字符串有长度上限;webhook 只接受 `http(s)` 并做 SSRF 校验(写入期拒内网/回环/保留地址,**投递前再按 DNS 解析复核**),外发载荷对 `nickname` 打码。告警出站 `trust_env=False`。 +- **上游流解析有界**:SSE 单行与单帧缓冲设上限,非 2xx 错误体有界读取(`BOUNDED_BODY_LIMIT` = 256KB);OAuth state store 有 TTL。**入站解析把关**:拒绝越界数值字段与 JSON 里的 `NaN`/`Infinity`,**含溢出成 `inf` 的合法数字(`1e999`)**——字面量闸拦不住,httpx 序列化会抛 `ValueError` 变 500;拦在解析层(三个出口共用一个入口),逐字段补齐既写三遍又漏 chat 出口的未知透传键。 +- **OAuth 弹窗**只允许 `http(s)`,`opener` 置空。**库版本守卫**:`user_version` 高于本程序时拒绝执行。`--password-stdin` 支持;拒绝含 `:` / 换行的用户名。 +- **不做**:mTLS;管理台与端口的 IP 限制(交给反向代理);全量请求审计。 ## 7. 风险清单 | 风险 | 等级 | 对策 | |---|---|---| -| CB 协议复杂度高(私有信封 + 多业务码 + 成长中心) | 高 | 先 bearer-only 跑通再补 OAuth;成长中心不可逆动作用配置开关总控 | -| 上游 SSE / 私有协议格式变更 | 中 | 各 provider 的 SSE 样本存 fixture 做契约测试;解析失败不静默 | -| 模型 ID 撞车导致路由错误 | 中 | 扁平名 + 归一键合并 + `@渠道` 后门;统计行强制带 provider 字段 | +| CB 协议复杂度高 | 高 | 先 bearer-only 跑通再补 OAuth;不可逆动作用配置开关总控 | +| 上游 SSE / 私有协议变更 | 中 | 样本存 fixture 做契约测试;解析失败不静默 | +| 模型 ID 撞车导致路由错误 | 中 | 见 §2.11:扁平名 + 归一键合并 + `@渠道`;统计行强制带 provider | | 上游 credit 不保证可得 | 低 | credit 仅辅助展示;健康度只依赖额度探测接口 | -| 积分语义混淆(周期 vs 账户余额 vs 权益包到期) | 低 | 健康分仅用于调度;展示层标注周期与到期语义 | -| 第三方能力分被误读为本服务实测 | 中 | 三处 UI 均标注「Artificial Analysis 指数,经 OpenRouter 公开接口;非本服务实测」;不做排序 | -| 能力分取了旧快照的评测变体 | 中 | 上游把浮动别名钉在旧快照上并换变体(实测 24.7 vs 39.5)。按 `canonical_slug` 日期归族,浮动别名取同族最新快照;显式快照 id 不改写;`source_model` 指向真正出分的上游 id | -| 上游模型写法差异导致错配 | 中 | 只登记显式等价规则 + 命中不唯一即拒配 | -| CodeArts 一次性 refresh_token 烧废即账号失效 | 中 | 刷新由 `RefreshTask` 独占轮转、刷后回写;终态异常标记「需重新登录」并停重试 | -| 一次性激活令牌 / API Key 明文泄露 | 中 | 明文仅响应回显一次,库里只存 SHA-256 摘要 | -| 部署契约:只改前端时后端可能仍是旧进程 | 中 | 改 `src/` 必须重启进程;升级后按 README「升级后必须重启后端」核对 schema 版本与路由 | -| License 溯源不全 | 中 | NOTICE 列明 7 个参考项目(含各自上游来源)的署名与协议 | - -## 8. NOTICE 三方溯源 - -署名清单以仓库根目录的 [`NOTICE`](NOTICE) 为唯一真源。当前列出的参考项目及其借鉴方向: - -- [IceeAn/codebuddy2api](https://github.com/IceeAn/codebuddy2api):CodeBuddy 上游协议、凭证管理、脱敏统计 -- [connectedGraph/trae2api-web](https://github.com/connectedGraph/trae2api-web):TRAE SOLO 上游协议、账号池冷却状态机 -- [88lin/workbuddy-auto-signin](https://github.com/88lin/workbuddy-auto-signin):成长中心与签到接口的逆向结论与运行经验 -- [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api):活跃上报 `/v2/report` 的协议形状与实测结论 -- [ithtelab/workbuddy-manager](https://github.com/ithtelab/workbuddy-manager):后台任务可视化页面的设计参考 -- [shuishuipingan/qoder2api-hub](https://github.com/shuishuipingan/qoder2api-hub):Qoder COSY 签名、自定义 Base64、信封 SSE、设备码登录、签到与额度 -- [HITZY2002/codearts2api](https://github.com/HITZY2002/codearts2api):CodeArts SDK-HMAC-SHA256、DPoP(ES256) 刷新、累计全文 SSE、福利模型 - -均为 MIT License,完整版权行与「其上游」子项见 `NOTICE`。本项目代码为独立实现,不复制上述项目的源代码;上游服务的协议细节来自对客户端行为的观察,不属于上述项目的版权范围。 - -## 9. 其它文档 - -技术栈、模块规格、Provider 协议、请求时序与测试策略见 [`TECHNICAL.md`](TECHNICAL.md);使用与部署、配置清单见 [`README.md`](README.md);架构图 / 请求时序图 / 凭证调度状态机见 [`diagrams/`](diagrams/)。 diff --git a/README.en.md b/README.en.md index 689a0c69..c3dc5d81 100644 --- a/README.en.md +++ b/README.en.md @@ -1,29 +1,55 @@ # Coding2API -Unified OpenAI-compatible gateway for the **CodeBuddy** (CN + international), -**TRAE SOLO**, **OpenCode Zen**, **Kilo**, **Qoder**, **CodeArts**, and **OfficeAce** -coding-agent upstreams, with a shared credential pool, unified scheduling, and per-user usage stats. +[![CI](https://github.com/RobbsLuo/Coding2API/actions/workflows/ci.yml/badge.svg)](https://github.com/RobbsLuo/Coding2API/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/RobbsLuo/Coding2API)](https://github.com/RobbsLuo/Coding2API/releases) +[![License: MIT](https://img.shields.io/github/license/RobbsLuo/Coding2API)](LICENSE) +[![Image](https://img.shields.io/badge/image-ghcr.io%2Frobbsluo%2Fcoding2api-blue)](https://github.com/RobbsLuo/Coding2API/pkgs/container/coding2api) + +Ten channels — **CodeBuddy** (CN + international, two separate channels), **TRAE SOLO**, **OpenCode Zen**, **Kilo**, **Qoder**, **CodeArts**, **OfficeAce**, **Raccoon**, **Lingxi** — wrapped into an OpenAI-compatible API, with shared credential pool, unified model names and scheduling, per-user usage stats. > [!WARNING] -> This project bridges reverse-engineered third-party endpoints; study and research use -> only. Not security-audited. Do not expose it to the public internet without a reverse -> proxy, authentication, and an IP allowlist. +> Reverse-engineered endpoints; study and research only, not security-audited. Public deployment needs reverse proxy + auth + IP allowlist. ## Features -- **OpenAI-compatible surface**: `/v1/chat/completions` (streaming + non-streaming), `/v1/responses` (for the Codex CLI), `/v1/messages` (Anthropic Messages / Claude Code), `/v1/models` plus `/v1/models/{id}` (single-model lookup), `/v1/user/balance` (DeepSeek-compatible). -- **Unified scheduling**: bare model names auto-route by health, `model@provider` pins an upstream. When free channels are candidates, **free wins**; then **least in-flight first** (concurrent requests spread across idle credentials), then expiring credits, then three-state health with tiered cooldowns, ties broken by **largest balance first**. A model-scoped limit sidelines only that model (the same account's other models stay available); a model held by a single cooling channel returns "model temporarily unavailable" instead of fanning out. A multi-turn conversation keeps one credential and rotates only on error. -- **Expiry-aware burn**: credits expiring within the main window (default 36h) burn largest-first (near-expiry quota not wasted), ties fall to a 7-day window. Units unify to credits. -- **Credential-gated model list**: only channels with a currently usable credential are fetched and shown, so a never-connected channel never shows phantom models; pausing or losing credentials hides a channel's models until it recovers. -- **Shared credential pool**: admins maintain credentials, everyone shares them, encrypted at rest (`APP_SECRET`); device-code login, account switching, quota probing, daily check-in, token pre-refresh. -- **Three-role accounts**: `admin` / `operator` / `viewer` in SQLite; one-time activation links (no shared initial secret), forced password change on first login, changing a role or disabling an account revokes its sessions immediately; logins and writes are audited. -- **Growth center** (CodeBuddy only): travel gifts, Buddy dispatch, task rewards, streak repair, streak redemption, blind boxes; irreversible steps off via `GROWTH_IRREVERSIBLE_ACTIONS=false`; runnable from the admin UI. -- **Privacy-preserving stats**: no conversation content stored; 90-day request detail, permanent hourly rollups; per user / channel / model / credential views; cost estimated from OpenRouter list prices (CNY primary); a **Live** toggle in the request-detail panel streams just-committed requests over SSE. -- **Sidebar to-dos**: count badges on sidebar entries for things you would otherwise only notice by visiting the page (credentials needing re-login or out of quota, unresolved alerts, failed background tasks, abnormal upstream sources, pending activations, your own expiring API keys, stale model catalog). One aggregate endpoint, role-filtered, counts only outstanding items. -- **Admin security**: login rate limiting, CSRF checks, body limits, Host allowlist. -- **Responsive tables**: wide tables (credentials, request detail, model list, …) turn into one card per row below 768px — column names collapse into a row of tappable sort chips and each cell shows its field name inline. The DOM is unchanged, so screen-reader semantics match the wide layout. +- **OpenAI-compatible surface**: `/v1/chat/completions` (streaming / non-streaming), `/v1/responses` (Codex CLI), `/v1/messages` (Claude Code), `/v1/models` (incl. single-model lookup), `/v1/user/balance`. +- **Unified scheduling**: bare model names route by health; `model@provider` pins upstream. Free channels first → least in-flight → expiring credits → health with tiered cooldowns → largest balance. Admins can set a per-credential **weight** (`-1000..1000`): it only reorders within the same health tier (positive first, negative as standby) and never promotes cooling or exhausted credentials. Catalog x0 rates are calibrated against actual charges in the last 24h, so an upstream that changed billing without updating its catalog can't hold the free tier forever. Model-scoped limit sidelines only that model; multi-turn sticks to one credential, rotates only on error. +- **Expiry-aware burn**: credits expiring inside main window (default 36h) burn first; ties fall to 7-day window. Units unify to credits. +- **Credential-gated model list**: only channels with usable credential appear; no phantom models. +- **Shared credential pool**: admins maintain, everyone shares, encrypted at rest (`APP_SECRET`); device-code login, account switching, quota probing, daily check-in, token pre-refresh. +- **Three roles**: `admin` / `operator` / `viewer` in SQLite; one-time activation links, forced password change on first login, role change or disable revokes sessions immediately; logins and writes audited. +- **Growth center** (CodeBuddy only): travel gifts, Buddy dispatch, task rewards, streak repair/redemption, blind boxes; irreversible steps off via `GROWTH_IRREVERSIBLE_ACTIONS=false`. +- **Privacy-preserving stats**: no conversation content; 90-day detail, permanent hourly rollups; per user / channel / model / credential views; cost from OpenRouter list prices; output speed in two scopes; SSE live stream in detail panel. +- **Monthly billing** (`operator`+): requests, tokens and cost by month, per user / per API key; CSV (per user) / JSONL (row-level detail) export, audited. Key-level rows are limited by the 90-day detail retention. +- **Tool-call robustness**: missing `id` filled from index and remembered; leaked DeepSeek DSML read back into structured calls with `finish_reason` rewritten; malformed non-streaming `arguments` fail closed (400, credential not cooled). +- **Channel ToS risk registry**: import shows green / gray / red with reasons (operational judgement) and the level is audited; advisory only, never blocks scheduling. +- **Egress IP stickiness** (optional): with non-empty `CREDENTIAL_PROXY_POOL`, one credential per egress (sha256-stable, survives restarts); chat, probing, check-in, pre-refresh, growth and single-item console actions all use it. +- **Upstream status aggregation**: official status pages side by side, separating "upstream outage" from "our credential / quota problem". Explanation only, never scheduling; memory only. +- **Sidebar to-dos**: count badges (re-login / exhausted, unresolved alerts, failed tasks, abnormal upstreams, pending activations, expiring keys, stale catalog), role-filtered. See [below](#sidebar-to-dos-navigation-badges). +- **Admin hardening**: login rate limiting, CSRF checks, body limits, Host allowlist. +- **Responsive tables**: below 768px one row = one card; column names collapse into tappable sort chips. DOM unchanged. + +Protocol-level implementation (private envelopes, signing, gate spoofing) in [`TECHNICAL.md`](TECHNICAL.md) (Chinese). + +## Screenshots + +Admin console (captured from local instance; accounts and IPs masked): + +| Credentials: pool overview, grouped by channel | Usage: requests, tokens, cost | +|---|---| +| ![Credentials](assets/screenshots/credentials.webp) | ![Usage](assets/screenshots/stats.webp) | +| **Playground: models × channels and multipliers** | **Model list: OpenRouter list prices and ability score** | +| ![Playground](assets/screenshots/playground.webp) | ![Model list](assets/screenshots/models.webp) | +| **Upstream status: upstream outage vs. our problem** | **API keys: client setup and quotas** | +| ![Upstream status](assets/screenshots/upstream.webp) | ![API keys](assets/screenshots/apikeys.webp) | +| **Users: three roles, one-time activation** | **Audit log: successful and failed logins** | +| ![Users](assets/screenshots/users.webp) | ![Audit log](assets/screenshots/audit.webp) | +| **Server logs: ring buffer with redaction** | **Ops alerts: nine risk rules, periodic** | +| ![Server logs](assets/screenshots/logs.webp) | ![Ops alerts](assets/screenshots/alerts.webp) | + +**Tasks & settings: 85 hot options, DB overrides win over `.env`** -Protocol-level implementation (private envelopes, signing, gate spoofing) is in [`TECHNICAL.md`](TECHNICAL.md) (Chinese). +![Tasks and settings](assets/screenshots/settings.webp) ## Quick start @@ -31,41 +57,35 @@ Requirements: Python 3.12+, [uv](https://docs.astral.sh/uv/), Node.js 24+ and pn ```bash uv sync -# Create the first admin (add the rest from the "Users" page later) -uv run python scripts/create_user.py admin --role admin +uv run python scripts/create_user.py admin --role admin # first admin cd web && pnpm install && pnpm build && cd .. -APP_SECRET="pick-a-random-string" \ - uv run python -m uvicorn src.main:build_app --factory --port 8000 +APP_SECRET="pick-a-random-string" uv run python -m src.main ``` -Open and sign in. +Open . Add rest from the *Users* page. -> `scripts/create_user.py` writes directly to SQLite (`--db`, or `DATA_DIR`; default `data/coding2api.sqlite3`). On startup, an existing `secrets/users.txt` is imported once (existing usernames are never overwritten). +> Use `python -m src.main`, not `uvicorn src.main:build_app`: the former reads `HOST`/`PORT` from `.env` and holds the single-instance lock (`data/.instance.lock`). One `DATA_DIR` admits one process — two instances each run background tasks, probe the same credentials twice and overwrite each other's stats. A second instance prints "another instance is already using data (PID xxx)" and exits cleanly. -### Docker +`scripts/create_user.py` writes SQLite directly (`--db` or `DATA_DIR`; default `data/coding2api.sqlite3`). Existing `secrets/users.txt` imported once on startup (never overwritten). + +Docker: ```bash cat > .env <<'EOF' APP_SECRET=pick-a-random-string PUBLIC_BASE_URL=http://127.0.0.1:8000 EOF -docker compose build -docker compose up -d +docker compose up -d --build docker compose exec coding2api python scripts/create_user.py admin --role admin ``` -Pull the GHCR image instead of building: +Pull image: `docker compose pull` (or `docker pull ghcr.io/robbsluo/coding2api:v0.13.1`). Pushing `v*` tag to main triggers `.github/workflows/publish.yml`, publishing `` + `:latest`. -```bash -docker compose pull -# or pin a version: docker pull ghcr.io/robbsluo/coding2api:v0.9.1 -``` - -Pushing a `v*` tag to main triggers the publish workflow (`.github/workflows/publish.yml`), tagging both `` and `:latest` on GHCR. +Running the container on a **VPS with a public IP** and wanting an https edge → see [Public deployment (Docker + Caddy)](#public-deployment-docker--caddy-automatic-https). ## Users and roles -Accounts live in SQLite's `users` table; the *Users* page (admins only) handles day-to-day management. +Accounts live in SQLite's `users` table; the *Users* page (admins only) handles management. | Role | Can do | |---|---| @@ -73,46 +93,48 @@ Accounts live in SQLite's `users` table; the *Users* page (admins only) handles | `operator` | Credential read/write (import / delete / pause / check-in / growth / account switch), all stats, upstream status, model list | | `viewer` | Read-only: dashboard, credential list, stats, Playground | -**Adding users**: an admin sets a username + role and gets a **one-time activation link** (valid 24h, shown once). The user opens `/activate` and sets their own password — no "admin knows an initial password" secret exists. "Reset password" issues a new link. - -**Role change / disable**: effective immediately — all issued session cookies for that account are invalidated (re-login required). Disabling is the default way to "remove" someone (reversible, usage still attributed to the username); **hard delete is CLI-only** (it would leave orphan `usage_events` rows). - -**Self-service password change**: user menu → *Change password*, requires the current password. +*Monthly billing* has the same floor as *Upstream status* / *Model list* (`operator`+): billing is pool-level data, while a `viewer` only sees their own usage in their own stats. -**Lockout guard**: you cannot disable or demote the **last active admin**, nor demote or disable yourself. Add another `admin` first. +- **Adding users**: username + role → one-time activation link (24h, shown once); user opens `/activate`, sets own password. No "admin knows initial password" secret; reset same way. +- **Role change / disable**: all session cookies invalidated immediately. Disable = reversible default "remove" (usage stays attributed); hard delete CLI-only. +- **Self-service password change**: user menu → *Change password*, current password required. +- **Lockout guard**: cannot disable or demote last active admin, nor demote or disable yourself. -**CLI fallback**: +CLI fallback: ```bash python3 scripts/create_user.py alice --role admin # interactive password python3 scripts/create_user.py --list python3 scripts/create_user.py alice --delete --force # hard delete, irreversible -echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password-stdin # password off argv / shell history +python3 scripts/create_user.py --db data/x.sqlite3 ... +echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password-stdin ``` -`ADMIN_USERNAMES` and `secrets/users.txt` are **bootstrap-only**: on startup, `users.txt` is imported into the DB and the named users are promoted to `admin`; afterwards roles come from the DB alone. +`ADMIN_USERNAMES` and `secrets/users.txt` are bootstrap-only; afterwards roles come from the DB alone. "Bootstrap" means the DB has no active admin yet — once one exists, env no longer rewrites anyone's role, so a role change made in the console is not undone by the next restart. ## Audit log -The *Audit log* page (admins only) records logins and writes, filterable by actor/action and paginated: **logins** (success and failure), **account changes** (create, activate, role change, enable/disable, password change/reset, hard delete), **credential writes** (import, delete, pause, pin, revive, account switch) and **manual alert resolution**. Each row has time, actor, action, target, detail and source IP. **No passwords or token plaintext** (the one-time activation token appears once in the create response; only its SHA-256 digest is stored). Retention matches request detail (90 days), trimmed by the background cleanup task. +*Audit log* page (admins only) records logins and writes, filterable by actor / action, paginated: logins (success and failure), account changes, credential writes, manual alert resolution. Each row: time, actor, action, target, detail, source IP. **No passwords or token plaintext** (activation tokens store only SHA-256 digest). Retention matches request detail (90 days). ## Server logs -The *Server logs* page (`/logs`, admins only) is a **read-only** live view of this process's own logging: `GET /api/logs?limit=` returns recent lines, `GET /api/logs/stream` is SSE (buffer replay, then live). The page offers level filtering, keyword search, follow-latest, pause and clear — **no write or execute entry point**, subscribers can only read. +*Server logs* page (`/logs`, admins only) = read-only live view of this process's logging: `GET /api/logs` returns recent lines; `GET /api/logs/stream` is SSE (replay then live). Level filter, keyword search, follow-latest, pause, clear — **no write entry point**. -- **This process only**: it attaches a `logging.Handler`, it never tails a file. Under multiple processes or containers each one sees its own slice; that is deliberate, since log rotation and permissions make a file view lie. -- **Redaction is a hard precondition**: every line passes an allow-list before leaving the process. Anything matching `token` / `secret` / `authorization` / `cookie` / `password` / `api_key` (including `X-Api-Key:`, `client_secret=` and JSON key forms), an `sk-…` shape or `Bearer ` is **dropped whole**. Losing a log line beats pushing a credential into a browser. -- **Bounded buffer**: size is `LOG_BUFFER_LINES` (default 500, hot-reloadable), oldest overwritten first; lowering it trims immediately. `0` disables the page (both endpoints 404 and the handler is detached), matching how `/metrics` turns off. -- Each line is capped at 4000 chars and carries `seq` (replay/live dedupe), timestamp, level, logger and `request_id` when available. Subscriber limit is 4; beyond that 429 + `Retry-After`. +- This process only; it never tails file. Under multiple processes each sees its own slice. +- **Redaction is hard precondition**: lines matching `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`, an `sk-…` shape or `Bearer ` dropped whole. +- Buffer `LOG_BUFFER_LINES` (default 500, hot), oldest overwritten first; `0` disables page (both endpoints 404). +- 4000 chars per line; each carries `seq`, timestamp, level, logger, `request_id`. **Click the `#first8` chip** to filter by that request id and collapse one request's whole chain (every rotation / cooldown / downgrade in the executor shares it); pasting the `X-Request-ID` response header into the keyword box does the same. stdout / journald / launchd files carry `[request_id]` too (background tasks show `-`). Subscriber limit 4, beyond → 429 + `Retry-After`. ## Usage -1. **Credentials** page — add credentials: +1. **Credentials** — add credentials: - **CodeBuddy** (incl. international): device-code login, or paste `{"token":"..."}`. - - **TRAE**: paste the credential JSON (`accessToken`/`uid`/`refreshToken`) or the callback URL (`apiHost` must be an official address). - - **Qoder / CodeArts**: device-code / OAuth2 authorization-code login (CodeArts: paste the unreachable local callback URL back into the page). - - **OfficeAce**: click *Log in to OfficeAce* for the Huawei Cloud authorization (the callback is handled in the cloud — no URL to paste, no local port). - - **OpenCode Zen / Kilo**: nothing to add — a virtual credential is seeded at startup (re-added from the same panel if deleted). + - **TRAE**: paste the credential JSON (`accessToken`/`uid`/`refreshToken`) or the callback URL (`apiHost` must be official). + - **Qoder / CodeArts**: device-code / OAuth2 authorization-code login (CodeArts: paste the unreachable local callback URL back). + - **OfficeAce**: *Log in to OfficeAce*, Huawei Cloud authorization (callback handled in the cloud). + - **Raccoon / Lingxi**: no login button — paste a web session credential instead (Raccoon `{"access_token":""}`, Lingxi the whole Cookie or a bare `wps_sid`). + - **Any channel**: the *JSON import* box also accepts a raw Cookie header and a browser DevTools → Network → right-click request → **Copy as cURL (bash)** command. The latter carries the `Cookie:` / `Authorization:` header, so HttpOnly cookies work too; unambiguous markers (`wps_sid`, `xhx_token`, TRAE fields, AK/SK) auto-select the channel, and the last used channel is remembered. + - **OpenCode Zen / Kilo**: nothing to add — a virtual credential is seeded at startup (re-addable from the same panel). 2. **API keys** — create an `sk-...` (plaintext shown once). 3. Call it: @@ -123,560 +145,851 @@ curl http://127.0.0.1:8000/v1/chat/completions \ -d '{"model":"glm-5.2","messages":[{"role":"user","content":"hello"}]}' ``` -`model@provider` pins an upstream (`glm-5.2@trae`, `mimo-v2.5-free@zen`); bare model names route automatically. Any OpenAI-compatible client can connect (Base URL `http://127.0.0.1:8000/v1`, key `sk-...`, model names per `GET /v1/models`); the *Playground* page tests with the session login, no API key. +`model@provider` pins upstream (`glm-5.2@trae`, `mimo-v2.5-free@zen`); bare names route automatically. Any OpenAI-compatible client connects (Base URL `http://127.0.0.1:8000/v1`). *Playground* tests with session login, no API key. ### Three model-name fields -Every model on the eight channels is unified into three fields: +1. **Raw id** (`raw_id`): key actually sent upstream, never changed. Raw ids differ per channel (Qoder `kmodel_latest` = TRAE `kimi-k3-1`), may carry namespace prefix (Kilo `kilo-auto/free`). +2. **Canonical key**: free-tier markers (`-free` / `free` path segment) + namespace prefixes stripped, then slugified (`kilo-auto`). List merges by it. +3. **Display name** (`name`): cleaned readable text, from upstream when given, otherwise derived; brand casing normalized (`DeepSeek` / `MiMo` / `GLM` / `GPT`). -1. **Raw id** (`raw_id`): the key actually sent upstream, **never changed**. Raw ids differ per channel (Qoder `kmodel_latest` = TRAE `kimi-k3-1`) and may carry a namespace prefix (Kilo's `kilo-auto/free`). -2. **Canonical key**: free-tier markers (`-free` / path segment `free`) and namespace prefixes stripped, then slugified — `kilo-auto`, `longcat-2.5-preview`. The list merges entries by it, so Zen's free tier and TRAE's paid tier collapse into one row. -3. **Display name** (`name`): cleaned readable text, from upstream when given (`Qwen3.8-Max` → `Qwen3.8 Max`), otherwise derived; brand casing normalized (`DeepSeek` / `MiMo` / `GLM` / `GPT`). +Every `id` in `GET /v1/models` is canonical key; requests translate back to raw id (`by_provider.{channel}.raw_id`). **All three spellings directly requestable.** Duplicate canonical keys within channel fall back to raw id. -Every `id` in `GET /v1/models` is the canonical key; requests are translated back to the channel's raw id (`by_provider.{channel}.raw_id`). **All three spellings are directly requestable** (`kmodel_latest`, `Kimi K3`, `kimi-k3` all resolve to the same model). +**Cost and levels**: entries may carry `cost {input, output, cache_read}` (USD / 1M tokens, same price table as cost estimation) + `reasoning_efforts` (effective levels for that channel). Only when known. -**`GET /v1/models/{id}` single-model lookup** (same shape as OpenAI): all three spellings resolve, case-insensitively. The route uses `:path`, so namespaced raw ids (`dots-studio/dots-3-note-preview:free`) work too. A model that does not exist **or is outside the calling key's model allowlist** returns the same 404 with an OpenAI error body (`code=model_not_found`) — allowlisted-out and missing look identical, so a client cannot probe "what else is in the pool". Benchmarks and refresh timing come from the same merged snapshot as the list, so a model shown in the list never 404s on lookup. +**Custom aliases** (`MODEL_ALIASES`, hot): `alias=model@channel;alias2=model`; `@channel` optional. List and request resolution share one table; entries carry `custom_alias: true`. Collisions, missing targets, or channel not holding model log ERROR and skip that segment; no alias chains. + +**Single-model lookup** (`GET /v1/models/{id}`): all three spellings + custom aliases, case-insensitive, namespaced raw ids work. Missing / outside key allowlist → 404 (`code=model_not_found`), so client cannot probe pool. + +**`n` only accepts 1**: the outbound shape produces single choice; anything else is 400. ### Streaming usage and reasoning tokens -- **Streaming usage frame**: after the finish chunk and before `data: [DONE]`, a `choices: []` usage frame (standard OpenAI shape) lets stream-reading clients show tok/s, context occupancy and session totals; nothing is sent when the upstream reported no usage (never a fake `0`), and unreported fields are `null`. -- **Reasoning tokens need `reasoning_effort`**: CodeBuddy defaults to `medium` when the client omits it (the upstream would otherwise write the whole reasoning pass into the visible answer); explicit values pass through untouched. +- After finish chunk, before `data: [DONE]`, a `choices: []` usage frame is appended; nothing when upstream reported no usage; unreported fields are `null`. +- Reasoning tokens need `reasoning_effort`; CodeBuddy defaults to `medium` when absent (otherwise reasoning lands in visible answer). Explicit values pass through. + +### Streaming timeouts (off by default) + +Upstream streams have no read timeout, so the gateway supplies its own: + +- **First-event timeout** (`STREAM_FIRST_EVENT_TIMEOUT_SECONDS`, default 0): fires before any content reaches client → retrying on another credential is safe. Comment frames / heartbeats do not reset it. +- **Idle timeout** (`STREAM_IDLE_TIMEOUT_SECONDS`, default 0): after first frame, stream closed with error frame, **not** retried (partial output cannot be rolled back). + +Both default off on purpose: slow contexts measured 40–71s to first token. Enable per deployment (recommended 60 / 300). ### Playground advanced parameters -The Playground request form carries optional `temperature` / `max_tokens` / `reasoning_effort` inputs; **leaving one empty omits the key entirely** (each channel then applies its own default). Client-side validation matches `compat/openai/request.py` (`temperature` up to 1000, `max_tokens` a positive integer up to 4,000,000) so nothing passes the UI only to be rejected as a 400. The `reasoning_effort` control appears only when the model metadata reports `supports_reasoning` or a `default_effort`, offers the standard levels (`low`/`medium`/`high`, plus the upstream-declared default if it is outside that set), and starts at "not sent" — the upstream decides whether a given level is acceptable. +Optional `temperature` / `max_tokens` / `reasoning_effort`; **leaving one empty omits the key**. Client validation matches backend (`temperature` ≤ 1000, `max_tokens` ≤ 4,000,000). `reasoning_effort` appears only for reasoning models, starts at "not sent". ## Channels -> CodeBuddy (incl. international) / TRAE / Qoder / CodeArts / OfficeAce need credentials; **OpenCode Zen and Kilo are credential-less free tiers** — the pool holds one virtual placeholder row (so scheduling / cooldown / stats work as usual), the quota column reads "free tier (no quota API)", and health stays "no probe" (unknown ≠ exhausted). The virtual row **re-seeds on restart after deletion**; use *pause* to disable permanently. +> CodeBuddy (incl. international) / TRAE / Qoder / CodeArts / OfficeAce / Raccoon / Lingxi need credentials; **Zen and Kilo are credential-less free tiers** — one virtual placeholder row (scheduling / cooldown / stats as usual), quota reads "free tier (no quota API)", health stays "no probe". Virtual row re-seeds on restart after deletion; *pause* disables permanently. + +### OpenCode Zen (`zen`) + +Free models at [opencode.ai/zen](https://opencode.ai), no login. The upstream list mixes unmarked paid models, so gateway narrows by the `-free` suffix and probes each candidate, exposing only what answers anonymously (liveness cached 30 min; free models marked **x0**). -### OpenCode Zen free tier (`zen`) +**Free-tier gate**: upstream wants official client (UA version, session header, `stream:true`, `tools` with `bash`/`read`). Gateway satisfies it; injected `bash`/`read` are empty shells, their calls filtered out. UA via `ZEN_OPENCODE_VERSION`; endpoint via `ZEN_API_ENDPOINT` (inside `ZEN_ALLOWED_ENDPOINTS`). Paid models return unavailable even when pinned. -Free models at [opencode.ai/zen](https://opencode.ai), no account or login. The upstream list mixes unmarked paid models, so the gateway narrows by the `-free` suffix and probes each candidate, exposing **only free models that answer anonymously** (liveness cached 30 min, no static allowlist; free models marked **x0**). The upstream list has no display names, so names are derived from the id. +### Kilo (`kilo`) -**Free-tier gate**: the upstream wants to believe it is talking to the official client (UA version, session header, `stream:true`, `tools` with `bash`/`read`). The gateway satisfies this; the injected `bash`/`read` are empty shells and their calls are filtered out, so you never see functions you did not declare. UA version via `ZEN_OPENCODE_VERSION`, endpoint via `ZEN_API_ENDPOINT` (must be inside `ZEN_ALLOWED_ENDPOINTS`). Paid models return "unavailable" even when pinned. +Free models at [Kilo](https://kilo.ai) (`api.kilo.ai/api/gateway`), no login. Standard OpenAI protocol; `/models` carries `isFree` → gateway filters directly, **does not probe** (saves quota). -### Kilo free tier (`kilo`) +**Quota**: gateway-wide ~200 requests/hour/IP. Upstream 429 and 502/503/504 are model-scoped → only named model cools. Endpoint via `KILO_API_ENDPOINT` (inside allowlist). -Free models at [Kilo](https://kilo.ai) (`api.kilo.ai/api/gateway`), no account or login. Standard OpenAI protocol, no private envelope or gate spoofing; the upstream `/models` carries an authoritative `isFree` flag, so the gateway filters on it directly and **does not probe** (probing burns the tiny quota). Free models marked **x0**. +### Qoder (`qoder`) -**Quota and limits**: the free tier is small (gateway-wide ~200 requests/hour/IP) and is a forward of OpenRouter's free pool; upstream **429 and 502/503/504 are model-scoped** — only the named model cools, and the gateway adds no circuit breaker. Endpoint via `KILO_API_ENDPOINT` (must be inside `KILO_ALLOWED_ENDPOINTS`). +[Qoder](https://qoder.com), real account: *Log in to Qoder* for device-code authorization (token never touches browser). Upstream speaks private COSY protocol, signed and unwrapped here; surface stays OpenAI. -### Qoder (`qoder`, real account) +**Quota and check-in**: quota probing (`/api/v2/quota/usage`) and campaign-based check-in (credits accumulate). Campaigns (`/sash/api/v1/me/campaigns`), old `daily-check-in` as fallback. Campaigns **refresh daily at 10:00 (UTC+8)** (valid 30 days); task seals per that window. No streak count. **International site has no check-in** (404) → "no such endpoint in this region". -[Qoder](https://qoder.com), **real account** required: click *Log in to Qoder*, complete device-code authorization in the browser (the backend polls; the token never touches the browser). The upstream speaks a private COSY protocol (custom Base64 + envelope SSE), signed and unwrapped by the gateway; the surface stays standard OpenAI. +**Endpoints**: CN by default (`openapi.qoder.com.cn` + `gateway.qoder.com.cn`); international via `QODER_API_ENDPOINT=https://openapi.qoder.sh` with allowlist extended. -**Quota and check-in**: quota probing (`GET /api/v2/quota/usage`) and campaign-based daily check-in. Check-in uses campaigns (`/sash/api/v1/me/campaigns`, needs `Cosy-ClientType`) with the old `daily-check-in` as fallback; claims run automatically and can be triggered from the admin UI. Campaigns **refresh daily at 10:00 (UTC+8)** (valid 30 days after claiming) and the task seals per that window, so an "already claimed" just after midnight seals only until 09:59 and the new campaign is claimed at 10:00. No streak count is shown. **The international site has no check-in** (404), reported as "no such endpoint in this region", not an error. +**Upstream node failures**: Qoder sometimes wraps own node failures in a 400 (`[FAIL]node:…`) → model-scoped transient fault, only that model cools; exhausted candidates → 503 (`no_healthy_credential`). -**Endpoints and pacing**: CN site by default (`openapi.qoder.com.cn` + `gateway.qoder.com.cn`); international via `QODER_API_ENDPOINT=https://openapi.qoder.sh` with the whitelist extended (signing carries full `cosy-*` headers, so the whitelist prevents misdelivery). Default `QODER_CHAT_MIN_INTERVAL=5` (own pacer), hot-updatable. +**Keepalive frames and queueing**: the upstream sends keepalive frames with `body:"null"` (outer status 200) mid-stream → skipped, never interrupting output; "queued" arrives as a 403 whose queue marker hides inside a **serialized string** → classified as QUEUE, waits the upstream's named seconds and reopens on the same credential instead of hard-disabling it. "Daily billing count exhausted" (403 `code 110`, `Billing daily count exceeded`) counts as quota → cools until 04:00 next day, likewise never hard-disabling (same predicate for HTTP and in-stream envelopes). -**Upstream node failures**: Qoder sometimes wraps its own inference-node failures in a 400 (`[FAIL]node:… msg:Execution failed`). The gateway treats these as a **model-scoped transient fault** — only that model cools, and an exhausted candidate set returns a "model temporarily unavailable" 503 (`no_healthy_credential`), not a missing-model error. +### CodeArts (`codearts`) -### CodeArts (`codearts`, real account) +[Huawei Cloud CodeArts](https://codearts.huaweicloud.com), real account: *Log in to CodeArts*, authorize, then paste the **unreachable local URL** back; server exchanges it for STS credentials (AK/SK + security_token + refresh_token + DPoP key) and stores them encrypted. -[Huawei Cloud CodeArts](https://codearts.huaweicloud.com), **real account** required: *Log in to CodeArts*, complete OAuth2 authorization, then paste the **unreachable local URL** (`http://127.0.0.1:12800/oauth/callback?code=…`) back into the page; the server exchanges it for STS credentials (AK/SK + security_token + refresh_token + DPoP key) and stores them encrypted. +**Token refresh is rigid**: refresh_token, `client_id=codearts-agent` and DPoP key are bound and single-use; new refresh_token must be written back. Handled by pre-refresh task (DPoP ES256/P-256). Window capped at 45 minutes and must be wider than polling interval. Revoked credential marked "re-login required", stops automatic retries. -**Token refresh is rigid**: refresh_token, `client_id=codearts-agent` and the DPoP key are bound together and **single-use** — the new refresh_token must be written back or the account breaks; the pre-refresh task does this automatically (DPoP ES256/P-256). The pre-refresh window is capped at 45 minutes and must be **wider** than the polling interval (`REFRESH_INTERVAL_MINUTES`, default 30 min). A revoked renewal marks the credential "re-login required" and stops automatic retries until restored. +**Daily check-in**: `GET /v1/ops/delivery` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`. The campaign is picked by `type=USER_LOGIN` and **`campaignId` comes from that response** (upstream switched it from 1 to 34 and the reward from 1000 to 100 credits in Nov 2026; hard-coding the id made check-in report success while the web task stayed pending and credits never grew). `CLAIMED` means claimed-but-unconfirmed, so both steps always run. **Re-login merges**: the same upstream account (`uid`) updates its existing credential row (fresh tokens, revival from "re-login required"), keeping paused/pinned/tags/stats attribution; only a different account creates a new row. -**Daily check-in**: a "1000 credits/day" campaign (`GET /v1/ops/delivery` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`). The task claims automatically and can be triggered from the admin UI; `claim` alone is **not** enough (`CLAIMED` is a "claimed but unconfirmed" state), so both steps always run. The credits go into the channel's single ledger (see below). +**Quota = credit balance**: probing hits `GET {snap}/snap-manager/v1/statistics/plugin` (`snap-manager` prefix mandatory; header `Agent-Type: PromptCenter`). Upstream values are credits, no conversion; sub-packages go into popover, plan expiry registered (unused credits voided too). -**Quota = credit balance**: probing hits `GET {snap}/snap-manager/v1/statistics/plugin` (the `snap-manager` prefix is **mandatory**; header `Agent-Type: PromptCenter`), because the built-in models (GLM-5.2 / OpenPangu) are billed in credits. The upstream values **are** credits — no unit conversion is applied. Sub-packages (`基础积分` / `按需积分` / `福利积分`) go into the "套餐 N 个" detail popover, and the plan's expiry date (`end_date`, verified to differ per account) is registered in the expiry ladder — unused credits are voided too, so "burn near-expiry first" also burns credits first. A failing endpoint only logs; it never breaks the rest of the pipeline. +**Per-batch expiry (web cookie)**: the statistics API only gives a 7-day expiring total. Per-grant `expireAt` lives in the web console portal: `GET /portal/promptcenter/v1/ops/credit/overview` lists campaigns, `GET .../campaign/{id}?pageNum=&pageSize=` pages through grants (`claimedAmount` / `consumedAmount` / `expiredAmount` / `expireAt`). The portal accepts **console session cookies only** (AK/SK signatures do not work; `X-Requested-With: XMLHttpRequest` is required). After pasting via *More → Web Cookie*, probing uses the portal: bonus credits expand per grant and real expiry drives the expire-first ranking, while base/on-demand stay on the summary. An expired cookie or changed shape silently falls back to the 7-day window; probing is never marked failed. `expiredAmount` and `status` lag behind (observed 0 / `CLAIMED` after expiry) — death is decided by `expireAt <= now` only; expired grants are **not shown**. When the cookie dies (portal redirects to login or returns an empty body), the credential row shows "web cookie expired, paste it again"; re-pasting resets the state and quota totals are unaffected (console sessions cannot be refreshed automatically). -**Change events**: each probe diff writes one `credit_events` row with `ledger=credits` (see below). +**Signing / cumulative SSE / pacing**: Huawei `SDK-HMAC-SHA256` (AK/SK + `X-Security-Token`); allowlist needs snap engine, STS, portal. Streaming `text` is cumulative (replace semantics) → reduced to incremental events. Upstream hard-limits 3 concurrent sessions per account → `CODEARTS_MAX_CONCURRENCY` + `CODEARTS_REQUEST_WINDOW_SECONDS`. -**Signing / cumulative SSE / pacing**: the upstream requires Huawei `SDK-HMAC-SHA256` (AK/SK + `X-Security-Token`), and `CODEARTS_ALLOWED_ENDPOINTS` must include the snap engine, STS and portal hosts. Streaming `text` is **cumulative (replace semantics)**, reduced to incremental events for clients. Default `CODEARTS_CHAT_MIN_INTERVAL=5`; the upstream hard-limits **3 concurrent sessions per account**, so an in-flight cap (`CODEARTS_MAX_CONCURRENCY`) and a sliding window (`CODEARTS_REQUEST_WINDOW_SECONDS`, 3 starts per 60s) keep bursts ahead of `400 TM.00001041`. All hot-updatable. +### OfficeAce (`officeace`) -### OfficeAce (`officeace`, real account) +[Huawei Cloud OfficeAce](https://www.huaweicloud.com/product/agentarts.html), real account: *Log in to OfficeAce* and finish Huawei Cloud authorization — **callback handled in cloud**, so no local port and no URL to paste. Backend polls `/v1/claw/auth/code` every 2.5s. -[Huawei Cloud OfficeAce](https://www.huaweicloud.com/product/agentarts.html) (果办 / AgentArts office agents), **real account** required: click *Log in to OfficeAce* and finish the Huawei Cloud authorization in the browser — **the callback is handled in the cloud** (`/v1/claw/auth/callback`), so this service listens on no port and the user never pastes a URL; the backend polls `/v1/claw/auth/code` every 2.5s for the authorization code (the same mechanism the official desktop client uses). +**Provisioning prerequisite**: account must first sign in to OfficeAce client and redeem invitation code. Unprovisioned account completes login but gets no model key; service names the account and says OfficeAce not activated. -**Provisioning prerequisite**: the account must first sign in to the OfficeAce client and redeem an invitation code. An unprovisioned account completes the login but gets no model key (`subscription: null` from the cloud); the service names the account and reports it has not activated OfficeAce instead of a vague login failure. +**Re-login merges the existing credential**: signing in again with the same account (`account_id`) updates that row in place (model keys, temporary AK/SK, refresh_token, DPoP key) and clears "re-login required"/cooldowns instead of adding a duplicate. `paused` / `pinned` / tags / stats attribution are preserved. -**Two credential sets with very different lifetimes** (the most common mistake on this channel): +**Two credential sets, very different lifetimes**: | Credential | Lifetime | Used for | |---|---|---| -| `Basic model_app_key:model_app_secret` | **never expires** | chat forwarding (`{gateway}/v2/chat/completions`) | -| temporary AK/SK + `security_token` + `project_id` | **~2 hours** | quota lookup, check-in (`/v1/subscription*`) | +| `Basic model_app_key:model_app_secret` | never expires | chat forwarding | +| temporary AK/SK + `security_token` + `project_id` | ~2 hours | quota lookup, check-in | -Mixing them yields `APIG.0303 app not found`. The failure is confusing either way: chat keeps working (the Basic key never expires) while the quota column goes blank and check-in keeps failing (the temporary credential expired). The pre-refresh task renews the temporary half automatically — `refresh_token` is single-use and bound to `client_id=pdp5_for_agentarts` plus the DPoP key, and **is not interchangeable with the CodeArts ticket** (`STS5.1806 invalid client id`). +Mixing them yields `APIG.0303 app not found`. Temporary half renewed by pre-refresh task; `refresh_token` single-use, bound to `client_id=pdp5_for_agentarts` + DPoP key (not interchangeable with CodeArts ticket). -**The upstream is plain OpenAI protocol**, so no private envelope parsing is needed; three upstream quirks do need handling: +The upstream is plain OpenAI protocol, with three quirks: -- **Rate limits arrive as fake 200s**: HTTP 200 + `data:{"error":…}`, and "first frame fine, second frame is the error" has been observed. Error frames flow to the engine as usual; `81111`/`81114` cool down only that credential × model. -- **Model-level rejection must not kill the credential**: `81004` (account lacks the model) / `81009` (unknown name) would otherwise be read from the status code as a dead credential. Business codes are checked before status codes and classified as a model-level negative cache (cleared on first success). -- **`max_tokens` is a combined "reasoning + answer" budget**: 9 of the 11 usable models are reasoning models, and a small budget leaves the answer empty. Values `≥16` get 1024 extra headroom clamped to the model ceiling (`glm-5*` 131072 / `qwen3*` 65536 / `deepseek-v4.1-flash` 393216); `<16` passes through untouched (probes / exact tests); absent stays absent. +- **Rate limits arrive as fake 200s**: HTTP 200 + `data:{"error":…}`. Error frames flow to engine as usual; `81111`/`81114` cool only that credential × model. +- **Model-level rejection must not kill credential**: `81004` / `81009` arrive as 403/404; business codes checked before status codes → model-level negative cache (cleared on success). +- **`max_tokens` = reasoning + answer budget**: `≥16` gets 1024 headroom clamped to model ceiling; `<16` passes through; absent stays absent. -**Credits are the primary ledger**: the unit is `officeace_points` (one-off new-user grant + a daily check-in grant). Unlike CodeArts there is no parallel token pool — `remaining`/`total` are simply credits. Each grant **expires independently**, hence the quota label reads "per-grant expiry"; scheduling still burns near-expiry first. +**Credits are main ledger**: unit `officeace_points`; `remaining`/`total` are credits. Each reward expires independently → quota reads "per-reward expiry". -**Daily check-in**: `POST /v1/subscription/bonus/claim` (**a write — it really grants credits**; the body must be {} with a JSON `Content-Type`, an empty body returns 500 `OfficeAce.11020001`). Re-claiming on the same day is idempotent upstream, so no local "already claimed" flag is needed. The gain is computed from the **remaining-quantity delta** of `bonus_skus` (`points` is the campaign's cumulative total, contributed to by several channels, so using it as "what I just earned" would overstate). The seal runs to the next Beijing midnight: the next day's campaign triggers an automatic catch-up. +**Daily check-in**: `POST /v1/subscription/bonus/claim` (a write — it really grants credits; body must be `{}` with a JSON `Content-Type`). The upstream is idempotent for repeat claims on same day. Gains are computed as remainder delta. Sealed until next Beijing midnight. -**No active model probing**: only 11 of the 34 catalog entries actually work, but probing sends real inference and burns credits, which are scarce here. Model-level rejection is handled by the `BLOCKED` negative cache from real requests instead. **No credit estimation either**: upstream exposes `cost_coefficient` and `tokens_per_point`, but their multiplication is undocumented, and a guess would desynchronize the whole cross-channel quota ordering. +**No active probing / no credit estimation**: probing burns credits; the `cost_coefficient` × `tokens_per_point` relation is undocumented. Model-level rejections are handled by real requests via the `BLOCKED` negative cache. -**Signing and endpoints**: the control plane (`/v1/claw/*`) uses Huawei `SDK-HMAC-SHA256`; the model gateway (`/v1/subscription*`, `/v1/model-token-rates`) accepts only **`V11-HMAC-SHA256`** (an extra region scope plus key derivation) and answers `APIG.0624` or 401 otherwise. `OFFICEACE_ALLOWED_ENDPOINTS` only needs the control-plane host — the model gateway differs per account and is issued by `client-permission-validate` into the credential, so it is not something this service can misconfigure. `OFFICEACE_CHAT_MIN_INTERVAL` defaults to `0` (no CodeArts-style hard concurrency limit) and is hot-updatable. +**Signing and endpoints**: control plane `/v1/claw/*` uses `SDK-HMAC-SHA256`; model gateway accepts only `V11-HMAC-SHA256` (wrong one → `APIG.0624` or 401). `OFFICEACE_ALLOWED_ENDPOINTS` needs only control-plane host (model gateway is per-account, issued by upstream). -**One account, one user**: the official plan page states the personal edition is "limited to a single user per account", so this channel is not shared between people — each user adds their own credential. +**Single account, single user**: the official plan limits one user per account; no sharing. -**Switching accounts**: the Huawei Cloud session is **per browser**. If a previous account's session is still alive, the cloud silently re-approves it and "add a credential" becomes "log the same account in again". The login row therefore has a second **Switch account** action (`force_login=true`) that hits `/authui/logout?service=…` first. An account that has not opened OfficeAce is reported by name instead of a generic login failure. +**Switching accounts**: *Switch account* button (`force_login=true`) clears the Huawei Cloud browser session first. -### CodeBuddy international (`codebuddy_intl`, real account) +### Raccoon (`raccoon`) -The international site [www.codebuddy.ai](https://www.codebuddy.ai), **same protocol** as the CN site (same `/v2/chat/completions`, `/v3/config`, `/v2/plugin/auth/*`, `/v2/billing/*` routes, same Keycloak realm) but a **separate account system** (separate signup, billing) — hence a **separate channel**. Login is identical to the CN site (device code, token never in the browser). +[SenseTime Raccoon](https://xiaohuanxiong.com), real account (web login is enough). **No login button**: the credential is a web session JWT, importable only by hand — DevTools → Application → Cookies → copy the `xhx_token` value and paste `{"access_token":""}` into *JSON import* (a bare JWT, the whole cookie string, or a *Copy as cURL* of a request to that site all work). Expires in ~30 days; grab a fresh one then. -**Not a site switch**: `CODEBUDDY_API_ENDPOINT` is process-wide, so flipping it would send every CN-site credential to the international site (quota mismatch / 403). Both sites can hold credentials, each with its own endpoint, pacer and login flow. +**Protocol**: plain OpenAI-compatible (`/api/web/llm/v2/chat/completions`), no signing, no device fingerprint. Streaming/non-streaming `tool_calls`, `role=tool` echo, `include_usage` all verified; 411K-token input returned in 14.2s. -**Whitelists deliberately exclude each other**: `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` holds only `https://www.codebuddy.ai`; the two sites' tokens are issued by different gateways, so a cross-site send is credential leakage and pointing the international endpoint at the CN host fails **at startup**. +**Unknown models silently fall back to the default** (observed): a bogus model name still returns 200 with a different model. Two layers of interception — validate against the catalog before sending, then compare the echoed `model` on the first frame; a mismatch fails rather than answering B for A. -**Feature split**: chat, model list, quota probe, token refresh and **activity reporting** are enabled; **daily check-in and the growth center are off** — check-in simply **doesn't exist on that site** (`checkin-activity-status` returns `active: false`), and the growth center **cannot be verified** (the international gateway puts the whole `/v2/activity/*` prefix behind its auth plugin, answering 401 rather than 404) while performing irreversible actions. Both menu entries are hidden for this channel. Activity reporting posts to `/v2/report`, a different path, verified working, so it stays enabled. +**Quota = credits**: each request costs 1 point regardless of tokens; 300 points granted daily at 00:00 (valid 7 days); ledger records credits. `billing_effective_multiplier` from the catalog becomes the multiplier directly, **0 = free tier** (e.g. `sn-sensenova-6-8-flash` free until 11-30) → the scheduler prefers it. -**No channel suffix on shared model names**: models on both sites are listed **once** with both providers attached; genuinely different upstream models (`-x`, `-sg`) still get their own entry. +**Quota breakdown**: upstream splits credits into four pools (daily / reward / monthly / top-up); per-grant expiry (7-day validity) is derived from `daily_grant.expire_at` in the bills and feeds the "expiring first" ordering. Pools (`quota_packages`) and expiry ladder (`expiry_ladder`) have different semantics and are stored separately. -**Pacing**: `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` defaults to 5s on its **own** pacer (sharing one would make the two sites queue behind each other), hot-updatable. +**Daily check-in**: there is no claim endpoint — the daily 300 is granted automatically at 00:00, and is only extended to 7 days if the account logs in that day; otherwise it expires at 24:00. A device heartbeat only refreshes `last_seen_at`, **not** login (observed: several days of heartbeat-only still lost every grant at midnight), so activity reporting now does a page visit (`GET /api/web/auth/v1/user_info`) before the heartbeat, and check-in also visits once. Bill sync is delayed by minutes: the 11-06..11-08 `daily_grant` rows only appeared at 11-09 01:11. "Check-in" = visit → balance probe → bills read; **bills are the only evidence, HTTP 200 is not**. If there is no new grant today but an **unexpired** `daily_grant` remains, it counts as already checked in; before 01:30 the task only seals until 01:30, and a missing grant defers retry to 01:30 instead of hammering from 00:02. -### Per-channel outbound proxy (optional) +**Activity reporting**: `POST /api/web/auth/v1/devices_current_heartbeat` (the web client's real device heartbeat); with the `x-client-*` header family it returns 200 and is idempotent, refreshing `last_login_at`. Off by default; shares `ACTIVITY_REPORT_ENABLED` / `ACTIVITY_REPORT_HOUR` with CodeBuddy. Device id is derived stably from uid (a random one is treated as a new device). + +**Concurrency cap ~5** (the 6th concurrent request returns `429 user_model_rate_limited`) → `RACCOON_MAX_CONCURRENCY` defaults to 2, with its own pacer. + +### Lingxi (`lingxi`) + +[WPS Lingxi](https://lingxi.wps.cn), real WPS account required. **No login button**: the credential is the WPS account cookie `wps_sid` — log in on the web app (`lingxi.kdocs.cn` works as well), copy the whole Cookie from DevTools → Application → Cookies, and paste it into the *Login channels* panel (whole cookie string or a bare sid both work). Upstream has **no refresh endpoint**, and expiry is never guessed from age — the only real signal is a 401, after which you paste a fresh cookie. + +**One active login per browser profile**: signing into another WPS account in the same browser profile immediately invalidates the previous `wps_sid` (verified). For multiple accounts, log in from separate browser profiles / incognito windows and capture one Cookie from each; an old credential that now returns 401 is re-probed after a new import and marked *relogin required* instead of staying in the scheduler pool. + +**One conversation = three requests**: create a cowork session → claim a credits run → `agent-runtime/chat/completions` (private SSE). Upstream **bills per run** (several calls inside one run charge one credit), so sessions + runs are cached per account × model for 1 hour (no sliding renewal) with in-flight single-flight; `SQLNoRows` / "session not found" drops the cache and rebuilds once. + +**Private SSE and usage semantics**: the text lives in `choices[].text` (not `delta.content`) and **no `[DONE]` is sent** (the stream ends on `finish_reason`); `usage.total_tokens` is the **current context size**, so it is recomputed as `prompt+completion`; `cached_tokens` / `reasoning_tokens` are flat fields with the opposite direction from OpenAI. + +**Tool schemas must be flattened first**: `anyOf` / `oneOf` / `$ref` / array-valued `type` make the whole round fail with a single "AI service error"; `max_tokens` goes into `base_llm_arguments`, while `reasoning_effort` is sent at top level *and* inside `llm_arguments`. + +**Quota = credits (智点)**: `total_balance` maps straight to remaining, bonus batches carry `bonus_credits[].expire_time` into the expiry ladder (15-day window); the ledger records credits, no token conversion. The catalog `multiplier` becomes the rate directly. + +**Check-in**: the task center exposes `daily_check_in`; `status=claimed` means already checked in. Upstream answers duplicate claims with a fake 200 (`result=ok` while the balance does not move), so the reward is the **balance delta before/after**, and a zero delta falls back to the task status — HTTP 200 is never trusted. + +**Error classification**: 401 → dead credential (re-paste cookie); 402 / quota wording → credit exhausted; 403 → transient; 404 → invalid request; 429 → named model only; audit rejection (`AuditFailed`) is not fixed by switching accounts; run expiry rebuilds once. + +**ToS grey area**: reuses the desktop / web login state and impersonates the desktop client (`X-Client-Type: lingxi_claw` plus `x-kso-app-name/app-version/device-id`), see `provider/tos.py`. + +### CodeBuddy international (`codebuddy_intl`) + +[www.codebuddy.ai](https://www.codebuddy.ai), same protocol as CN site but separate account system (separate registration and billing) → separate channel. Same device-code login. + +Why not one channel: `CODEBUDDY_API_ENDPOINT` is single process-wide value → switching would send every existing CN credential to international site. Both sites can hold credentials simultaneously with own endpoints, pacers, login flows. + +**Allowlists deliberately exclude each other**: `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` defaults to international host only; adding CN host fails at startup (cross-site sending leaks credential). + +**Capability differences**: chat, model list, quota probing, token refresh, activity reporting enabled; **check-in and growth center are not** (no such campaign on this channel; growth center unverifiable and includes irreversible actions). -`PROVIDER_PROXIES` gives each channel its own egress proxy: +**Model list probes plan**: each credential sends one minimal request per model, keeps only usable ones; `11158` (plan not allowed) and `11102` (no such model) dropped; `429` / 5xx / timeouts keep model. Results cached per account 6 hours. + +**Same-name models carry no channel suffix**: models on both sites are one row attached to both channels; genuinely different models (`-x` / `-sg`) get own rows. + +### Per-channel outbound proxy (optional) ``` PROVIDER_PROXIES="codebuddy=http://127.0.0.1:7890;qoder=socks5://127.0.0.1:1080" ``` -Format `channel=url;channel2=url2`, schemes `http/https/socks5/socks5h`, empty = all direct. It covers **all** of that channel's outbound requests (chat streaming, quota/model fetches, background tasks and OAuth login). **Startup-only** (restart required). Parsing is strict: an unknown channel, unsupported scheme or missing `=` fails startup — "thought it used a proxy but went direct" is worse than a startup error. Environment proxies (`HTTP_PROXY`, …) are **never honoured** (`trust_env=False`), so a deployment-wide proxy cannot silently hijack token-bearing requests. +Format `channel=proxyURL;channel2=proxyURL2`; `http/https/socks5/socks5h`; empty = all direct. Applies to every outbound request of that channel (chat, quota, model fetch, background tasks, OAuth). **Startup-time.** Unknown channel / bad scheme / missing `=` fails startup. `HTTP_PROXY` and friends ignored (`trust_env=False`). + +> Inside containers `127.0.0.1` is container itself. Use `host.docker.internal` for host proxy: +> `PROVIDER_PROXIES="codebuddy=http://host.docker.internal:7897"` +> compose declares `extra_hosts`. Parsing validates format only, so wrong address does not fail startup. ## Outbound protocols ### Responses API (Codex CLI) -`POST /v1/responses` serves a Responses subset for clients that only speak the Responses API, such as the [Codex CLI](https://github.com/openai/codex). It shares the same credential selection, cooldown, rotation, accounting and session affinity as `/v1/chat/completions`; only the inbound mapping and outbound translation differ: +`POST /v1/responses` provides a Responses subset; shares scheduling / cooldown / rotation / stats / stickiness with chat: ```bash export CODING2API_KEY=sk-your-key codex -c "model_providers.coding2api={ name='coding2api', base_url='http://127.0.0.1:8000/v1', wire_api='responses', env_key='CODING2API_KEY' }" \ - -c model_provider=coding2api \ - -c model='glm-5.2' \ - 'your task' + -c model_provider=coding2api -c model='glm-5.2' 'your task' ``` -Supports streaming text, reasoning summaries, function tool calls and `finish_reason=length` → `response.incomplete`. `text.format` maps to chat's `response_format` (`text` / `json_object` pass through; `json_schema` is expanded). Content parts map as `input_text`/`text`/`output_text` → chat text and `input_image` → chat `image_url` (the URL is passed through as-is). **Not supported**: `store=true`, `previous_response_id` (stateless server, not faked), `input_file` (needs server-side file storage), Responses-only tools — explicit 400, never silently degraded. `include=["reasoning.encrypted_content"]` is accepted and ignored. +Supports text, streaming output, reasoning summaries, function tool calls, `finish_reason=length` → `response.incomplete`. `text.format` → chat `response_format`. Content parts: `input_text` / `text` / `output_text` → text; `input_image` → `image_url`. **Unsupported**: `store=true`, `previous_response_id`, `input_file`, Responses-private tools → explicit 400. `include=["reasoning.encrypted_content"]` accepted, ignored. ### chat `response_format` validation -`POST /v1/chat/completions` no longer forwards `response_format` verbatim: only the three OpenAI-defined shapes pass, and malformed or unknown shapes are an **explicit 400** (never silently degraded, and never sent upstream where a 4xx would cool the credential): - -- `{"type": "text"}` -- `{"type": "json_object"}` -- `{"type": "json_schema", "json_schema": {"name": , "schema": , "description"?: , "strict"?: }}` — unknown keys inside `json_schema` are dropped (not part of the chat spec, not honored upstream); a missing `name`, a missing `schema` and an unknown `type` each get their own 400 message. - -The Responses `text.format` → `response_format` mapping shares **the same validator** (`normalize_response_format` in `src/compat/openai/request.py`), so the two inbound protocols cannot drift; the `prefix` argument only changes the field path in error messages (Responses passes `text.format`). +Only three OpenAI shapes pass; malformed / unknown → explicit 400: `{"type":"text"}`, `{"type":"json_object"}`, `{"type":"json_schema","json_schema":{name, schema, description?, strict?}}` (unknown inner fields dropped). Responses `text.format` reuses same validation. ### Anthropic Messages API (Claude Code) -`POST /v1/messages` serves an Anthropic Messages subset for [Claude Code](https://docs.anthropic.com/en/docs/claude-code): - ```bash export ANTHROPIC_BASE_URL=http://127.0.0.1:8000 -export ANTHROPIC_AUTH_TOKEN=sk-your-key # or ANTHROPIC_API_KEY (sent via x-api-key) +export ANTHROPIC_AUTH_TOKEN=sk-your-key # or ANTHROPIC_API_KEY (x-api-key header) claude ``` -**Auth**: both `x-api-key` and `Authorization: Bearer` are accepted. **Streaming**: `message_start` → `content_block_start/delta/stop` → `message_delta` (with `stop_reason` and usage) → `message_stop`; no `[DONE]` sentinel, and thinking blocks get a `signature_delta` before `content_block_stop`. **Cache hit**: usage is split Anthropic-style — `input_tokens` counts only misses, hits are reported separately as `cache_read_input_tokens` (the internal `input_tokens` is OpenAI's `prompt_tokens`, which includes hits), so Claude Code shows cached tokens instead of always 0. **`count_tokens`**: `POST /v1/messages/count_tokens` estimates input tokens locally. **Images**: `image` blocks (`source.type=base64` becomes a `data:` URL, `source.type=url` passes through) are normalized to chat `image_url` parts, and images inside `tool_result.content` are kept too — pure-text content is still folded into a string, so image-free requests map byte-for-byte as before. **Not supported**: `document` blocks (PDF, no chat equivalent), `source.type=file` and Anthropic-only server tools — explicit 400. - -**`cache_control` pass-through** (always on, no flag): inbound cache markers are forwarded verbatim, at the same places Anthropic accepts them — `system` content blocks, user/assistant content blocks, `tool_result` (attached to the **tool message top level**, avoiding a synthetic empty text block), `tool_calls` elements, and `tools` elements. Only **structure** is validated (must be an object with a non-empty string `type`; `ttl`, if present, must be a string); values are not interpreted (an unknown `type` still passes through — a gateway should not gatekeep cache types upstream may add later). Malformed markers are an explicit 400 rather than a silent drop. **Without markers the mapping is byte-for-byte unchanged** (pure text still folds into a string), so session-affinity fingerprints and upstream prefix caching are unaffected. Six of the eight channels accept the markers in testing (codebuddy / codebuddy_intl / trae / qoder / codearts / kilo); **zen and OfficeAce are unverified** (zen's upstream returns `FreeTierError` 403 even with `--plain`, unrelated to the markers; OfficeAce was not probed). Probe script: `scripts/probe_cache_control.py`. +- **Auth**: both `x-api-key` and `Authorization: Bearer`. +- **Streaming**: `message_start` → `content_block_*` → `message_delta` → `message_stop`; no `[DONE]`; thinking blocks get `signature_delta` before `content_block_stop`. +- **Cache hits**: usage splits in two — `input_tokens` counts misses only, hits go to `cache_read_input_tokens`. +- **`count_tokens`**: estimated locally, never forwarded. +- **Images**: `image` blocks normalize to chat `image_url`; images inside `tool_result.content` are preserved. +- **Unsupported**: `document` blocks, `source.type=file`, Anthropic server tools — explicit 400. +- **`cache_control` passthrough** (on by default): landing spots match Anthropic (`system`, message content blocks, `tool_result` top level, `tool_calls` top level, `tools` top level). Structure validated only; values not interpreted. 6 of 10 channels accept it (codebuddy / codebuddy_intl / trae / qoder / codearts / kilo); zen, Raccoon and Lingxi unverified, OfficeAce not separately probed. Script `scripts/probe_cache_control.py`. ### CC Switch one-click import -The API Keys page can generate [CC Switch](https://github.com/farion1231/cc-switch) `ccswitch://` import deep links (one button each for Claude Code / Codex CLI / OpenCode) that write the endpoint, key and model into the desktop client after a confirmation dialog. There are two entry points: the **dialog shown right after a key is created** (using the freshly generated once-only cleartext key; closing the dialog drops it) and the **"Import to CC Switch" button on each key-list row** (the list only stores a masked `preview`, so you paste the full key you saved at creation time — it stays in dialog memory only). Both offer a "default model" dropdown sourced from the same list as Playground (`/api/playground/models`), grouped by channel. A deep link **only fires on a user click** (browsers block automatic redirects) and **carries the key in cleartext**, so it is generated only when the user opens the dialog and supplies a key. Protocol and `app` / `apiFormat` values: see CC Switch's "Deep Link Protocol" manual; requires CC Switch v4.0.10+. Note the **endpoint form depends on `apiFormat`**: the Anthropic format (Claude Code) takes the bare origin **without `/v1`**, the two OpenAI formats take `/v1`. This gateway has no Gemini egress, so no `app=gemini` entry is offered. +*API keys* page generates `ccswitch://` deep links (Claude Code / Codex CLI / OpenCode). Entry points: post-creation dialog, per-row *Import to CC Switch*. Links contain plaintext key → generated only on explicit action. Requires CC Switch v4.0.10+. Endpoint form depends on `apiFormat`: Anthropic to root (no `/v1`); OpenAI to `/v1`. No Gemini surface. ### Balance query -`GET /v1/user/balance` is DeepSeek-shaped, Bearer `sk-...`, for clients like Cherry Studio / cc-switch. Balances come from the credential pool's quota-probe cache (`QUOTA_PROBE_MINUTES`), aggregated over usable credentials, in upstream credits. `balance_known` is `false` when no credential has ever probed successfully (unknown, not 0). +`GET /v1/user/balance` is DeepSeek-shaped, Bearer `sk-...`, for Cherry Studio / cc-switch. Values from cached quota probe, aggregated over usable credentials: + +```json +{ + "is_available": true, + "balance_infos": [{"currency": "credits", "total_balance": "60.00", + "granted_balance": "0.00", "topped_up_balance": "0.00"}], + "balance_known": true, + "providers": [{"provider": "codebuddy", "remaining": 50.0, "total": 150.0, "credentials": 2}] +} +``` + +`balance_known` is `false` when nothing has ever been probed (unknown, not 0). ### Pool health -`GET /health` is a pure liveness probe. `GET /healthz` adds credential-pool counts (`total` / `ready` / `cooling` / `paused` / `disabled`), so monitors can alert when the pool is exhausted (`ready=0`: alive but unusable). Both are unauthenticated; buckets are mutually exclusive and sum to `total`. +- `GET /health`: `{"status":"ok"}`, for liveness probes. +- `GET /healthz`: adds pool counts and returns 503 when `ready == 0` (readiness). -### Prometheus metrics (`GET /metrics`) +Neither is authenticated; both accept `HEAD`. Four mutually exclusive counts (`ready` / `cooling` / `paused` / `disabled`) sum to `total`, same as scheduler. **Point liveness at `/health`**; `/healthz` for readiness / alerting. + +> Upgrade note: `/healthz` changed from always-200 to 503 when `ready=0`. -`GET /metrics` serves the Prometheus text format (`text/plain; version=0.0.4`) for direct scraping. On by default, **unauthenticated** (same stance as `/healthz`); `METRICS_ENABLED=false` (hot-updatable) makes the endpoint return 404. +### Prometheus metrics (`GET /metrics`) -Five metric families: +On by default, **unauthenticated**; `METRICS_ENABLED=false` (hot) returns 404. Set `METRICS_TOKEN` to require `Authorization: Bearer ` (401 otherwise). | Metric | Type | Labels | Source | | --- | --- | --- | --- | -| `coding2api_requests_total` | counter | `provider` / `model` / `status`(ok\|error) | `usage_hourly` (permanent, monotonic across restarts) | -| `coding2api_tokens_total` | counter | `provider` / `type`(input\|output\|reasoning\|cached) | `usage_hourly` | -| `coding2api_credentials` | gauge | `state`(ready\|cooling\|paused\|disabled) | credential pool (same stance as `/healthz`) | -| `coding2api_upstream_errors_total` | counter | `provider` / `kind` | in-process counter (resets on restart) | -| `coding2api_task_failures_total` | counter | `task` | in-process task state (resets on restart) | +| `coding2api_requests_total` | counter | `provider` / `model` / `status` | `usage_hourly` | +| `coding2api_tokens_total` | counter | `provider` / `type` | `usage_hourly` | +| `coding2api_credentials` | gauge | `state` | credential pool | +| `coding2api_upstream_errors_total` | counter | `provider` / `kind` | in-process (resets on restart) | +| `coding2api_task_failures_total` | counter | `task` | in-process task state | -**No high-cardinality labels**: no `credential_id` / `api_key_id` / `username`. `requests_total` / `tokens_total` read the **permanent** hourly rollup (not the 90-day detail table), so scraping never scans details and counts stay monotonic; the in-process counters reset on restart, which Prometheus handles as a counter reset. +No `credential_id` / `api_key_id` / `username` (high cardinality). `requests_total` / `tokens_total` read permanently retained hourly rollup, monotonic across restarts. Rollups have no `error_type` dimension → `kind` from in-process counter. ## API key policy -A key can be restricted by channel, model, source IP and expiry: - -- **Channel binding**: pins the key to one channel; a model owned by another channel gets a 400 naming the real owner (no silent reroute). Empty = automatic. A `model@channel` conflicting with the binding is also a 400. -- **Source IP allowlist**: comma-separated IP/CIDR (empty = unrestricted), normalized on write; an off-list source gets 403. `X-Forwarded-For` is **ignored by default**; only with `TRUST_PROXY=true` is the *last* entry used (exactly one trusted reverse proxy). -- **Model allowlist**: comma-separated names or fnmatch globs (e.g. `glm-*,kimi-k3`), case-insensitive, `@channel` suffix excluded; out-of-list models get a 400 and `/v1/models` lists only allowlisted models. -- **Expiry**: epoch seconds; empty = never; an expired key gets a 401 (same copy as "key not found", so nothing is enumerable). +- **Channel binding**: the key only picks credentials from that channel; model owned by another channel returns 400 naming actual owner. Empty = cross-channel. +- **Source IP allowlist**: comma-separated IPs or CIDR, normalized on write (`10.0.0.1` → `10.0.0.1/32`); outside → 403. `X-Forwarded-For` untrusted by default; only with `TRUST_PROXY=true`, last entry. +- **Model allowlist**: comma-separated names or fnmatch globs (`glm-*,kimi-k3`), case-insensitive, `@channel` suffix ignored. Outside → 400; `/v1/models` filters too. +- **Expiry**: epoch seconds; empty = never. Expired → 401 (same wording as "key not found"). +- **Quota**: six independent limits, `0` or empty = unlimited — `daily_requests` / `daily_tokens` / `daily_cost_usd` / `rpm` / `tpm` (estimated) / `max_concurrency`. Daily ones use Beijing calendar day; rate / concurrency in-process sliding windows. Over limit → 429 + `quota_exceeded`, `Retry-After` points at retry moment. Failing check always allows and logs warning. +- **Self-service**: `GET /v1/usage?days=1|7|30` returns own usage, remaining quota, current rate usage (local detail only). ## Credential pause -The per-row *Pause* action removes a credential from **chat traffic only**: quota probing, token pre-refresh, daily check-in, growth center and activity reporting keep running (they honour only the system hard-disable `disabled`). Use it to "stop chatting but keep earning". *Resume* returns it immediately. Different from *Disabled* (the upstream rejected the session; needs re-login + *Restore*). Same toggle at `POST /api/credentials/{id}/toggle`. +*Pause* removes credential from chat traffic and skips daily check-in / token pre-refresh; other background tasks (quota probe, growth center, activity reporting) keep running. Useful for "no chat, keep collecting credits". *Unpause* returns it to pool immediately. ≠ *disabled* — the channel-driven session-invalid state requiring *revive* after re-login. Same switch: `POST /api/credentials/{id}/toggle`. -> Zen / Kilo are credential-less: *pause* is the only way to disable them permanently (deletion re-seeds on restart). Pausing also hides the channel's models. +> Zen / Kilo are virtual rows; **pause is the only permanent disable** (deletion re-seeds on restart). Pausing also removes channel from model list. -## Per-credential in-flight cap +## Credential batch operations -`MAX_INFLIGHT_PER_CREDENTIAL` (default `0` = unlimited) stops a credential from being selected once its concurrent request count reaches the cap, keeping a single account from being hammered into upstream rate limits or bans; it is global across channels (orthogonal to the per-channel `CODEARTS_MAX_CONCURRENCY`). The in-flight count doubles as the **load key**: the executor injects it into `Candidate.inflight` and the scheduler sorts by **least in-flight first** (after the free tier, before expiring credits), so concurrent requests spread across idle credentials; with the cap at `0` leases are still registered, so the load key stays live even when concurrency is unlimited. +The credentials page offers multi-select plus batch bar (probe / check-in / pause / unpause / revive). -- The counter is **in-process** (single-instance semantics, like the `/api/tasks` state). -- Leases release when the request ends (success, rotation, error, client disconnect), with a **600s TTL** reclaiming any that leak—better to occasionally admit one extra request than to silently retire a credential. -- When every candidate is at its cap, the response is the same "no healthy credential" 503 as the limits guard. -- Hot-updatable on the *Tasks & settings* page; the next request picks it up (no snapshot cache). +- **Endpoint**: `POST /api/credentials/batch`, body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?}`. Up to 200 ids, duplicates deduped. +- **Batch import**: `POST /api/credentials/batch-import`, body `{items: [{provider, credential, nickname?, tags?}]}`. Up to 100 items; validated one by one, bad entries only land in `failed` (with `index` and reason); identical decrypted content within one channel counts as duplicate → `skipped`. The credentials page's *Batch import* accepts a JSON array / JSONL or a file. +- **Per-item isolation**: one failure never blocks the rest. Response `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`. +- **A failed check-in is a result, not a failure**: an upstream refusal (account disabled / session expired / rate limited) returns `ok=false` with a readable `message`, never 500 — otherwise the console only says "check-in failed" and hides the real reason. +- **Pause is explicit state, not toggle-flip**: `enabled` comes from body → bar has separate *Pause* / *Unpause* buttons. +- **Revive ≠ unpause**: revive clears system hard-disable (needs re-login first); unpause sets `toggle enabled=true`. +- Only actions with existing single-item endpoints: `refresh` and `delete` are excluded. +- **Audit writes one summary row** (`toggle` / `revive`); audit failure logs warning and does not fail operation. -## Credential pool: filtering and grouping +## Credential limit guards -Once the pool grows, the page splits "find that one account" into three layers, while the first screen shows only the channel-level overview: +Optional per-credential guards, configured from row menu. Four independent limits; all empty = unlimited. Judged before selection; background tasks keep running. -- **Filter bar**: search (nickname / channel / id, case-insensitive) × channel × state × tag. The four are **AND**-combined and purely client-side — the backend still returns the full set ordered by `sort` / `order`. When any filter is active it shows "showing N / total" plus a *Clear* action; no match yields an empty state rather than a blank table. Counts in the channel / state / tag dropdowns come from the full set, so narrowing one filter never re-narrows the others. The **tag dropdown renders only when some credential actually carries a tag** (no permanent "all tags" control otherwise), and tags are a structured dimension that **does not participate in the search term** — otherwise finding a nickname that happens to be "prod" would be impossible. -- **Tags**: each credential can carry any number of ops tags (comma-separated, up to 16 tags of ≤ 24 chars; half-width and full-width commas and semicolons all separate), edited via the row's *More → Set tags*. Tags are **display/filter only** — they never affect selection and never change request behaviour, since using them as policy would make a rename silently change traffic. A row shows at most two tags, collapsing the rest into `+N` with the full list in the hover title. -- **Group sort**: a separate control reorders only the channel *groups* (never the rows inside them, which still follow the column header / backend `sort`). Default is **quota high→low**, ranked by the group's summed remaining quota so the channels that can still serve the most surface first; switchable to **channel order** (fixed, so 30s polling cannot make headers jump). Channels with no quota API (free tier) sort last rather than being treated as `0`, and ties fall back to channel order. -- **Grouped by channel**: rows aggregate per channel; each group header is a compact **channel dashboard** — brand icon + channel name + account count, plus a **state breakdown** (icon + count, listing only states that occur, using the same `credentialState` as the state badge), a **total quota** figure (a share ring plus the number; the ring is a pure gauge and the exact percentage sits on the "total quota / total credits" label line; sums the group's non-null values, and the free tier zen / kilo shows `—` rather than `0` with no ring), and an **expiring credits** figure (one block for the primary 1.5-day window; the secondary 7-day window appears only when it holds **more** than the primary — the 7-day window is a superset, so an equal number means nothing expires beyond 1.5 days). Clicking a header collapses / expands that channel — **collapsed by default** (see structure first, expand to inspect accounts); an active filter auto-expands matching channels, and clearing it returns to the collapsed overview. Group order is "quota high→low" by default and switchable from the toolbar to the fixed site-wide channel order (CodeBuddy, TRAE first); it does not follow the sort key. The channel name appears only in the header, never repeated per row. **One real channel = one group**: CodeArts quota *is* credits, so its group totals label the figure "total credits"; other channels label it "total quota". -- **Column headers follow the expanded region**: there is no global table header; the column row (with sort buttons and column hints) renders inside each expanded channel group and sticks while the container scrolls. When collapsed, the group header *is* the overview and the column names have nothing to label; only an expanded group needs the "what is this column" anchor. Several groups can be expanded at once, and every group's header shares one sort state (clicking any of them writes the same backend `sort` parameter). The **"growth center" column appears only for channels that support it** (CodeBuddy); other channels get an empty placeholder cell — the column count stays constant, otherwise auto layout recomputes the widths and groups expanded below misalign. +- **Reserve credits**: account remaining ≤ the value disables whole credential. Unknown balance is never guessed. +- **Daily token cap** / **daily credit cap**: whole credential disabled when day's total is reached. +- **Per-model token cap**: `{model: limit}` disables only that model. +- **"Day" = Beijing calendar day**, resetting at midnight. +- All candidates filtered out → `no_healthy_credential` 503; model-level cooldown → `model_unavailable`. +- Written via `PUT /api/credentials/{id}/limits` (full replace; all four off → deletes row). Effective immediately. Audit action `credential.limits`. -- **One group per channel**: the header label is just the channel display name and `ProviderGroup.key === provider`; CodeArts is no longer split into "内置 / 福利" groups (the benefit path is gone — its quota is simply the credit ledger). Each row reads its own channel's ledger: CodeArts is credits (`ledger=credits`), every other channel is plan / daily balance (`ledger=pool`), derived by `providerLedger()`. -- **Scrollable long list**: the table is capped at 70vh, and the group's sticky column header and row actions stay visible deep in the list. +## Per-credential in-flight cap -The quota number is dotted-underlined: hovering reveals **that row's cycle semantics** (this-cycle remainder / account balance / credit balance / free tier with no quota API); the per-row caption lives in that hover hint. +`MAX_INFLIGHT_PER_CREDENTIAL` (default 0 = unlimited): once credential concurrency reaches cap, it stops being selected. -## Model list loading +- In-process counting (single-instance semantics). +- Leases release at request end, with a 600s TTL backstop. +- All candidates saturated → 503, same as limit guards. +- Hot. +- **Count doubles as load key**: ordered after "free first", before expiring credits → concurrency spreads to idle credentials. -`GET /v1/models` and the Playground dropdown merge only channels with a **currently usable credential** (at least one not-paused, not-dead row). The check runs before the cache, so a never-connected channel never shows phantom models; pausing or losing credentials hides a channel's models until it recovers; a cold start lists only `zen` / `kilo` (their virtual credentials are seeded). A cooling credential still counts — it is only temporarily limited, so the list does not flicker. +## Credential pool: filtering and grouping -**Ordering**: CodeBuddy, TRAE, Qoder and CodeArts models come first, dictionary-ordered within a group; multi-channel models take their highest-priority channel's slot. Display-only — it does not change scheduling. +- **Filter bar**: search (nickname / channel / id) × channel × state × tag, all AND, front-end only. Shows "N of total after filter" + *Clear*. Tag dropdown renders only when tags exist. Tags do not participate in text search. +- **Tags**: comma-separated, ≤16, each ≤24 chars. Filtering / display only, never scheduling. Two shown per row, rest collapse to `+N`. +- **Group sort**: reorders channel groups only, never rows within group. Default by total quota descending, switchable to fixed channel order. Free tiers without quota API sink to bottom. +- **Grouping by channel**: header = compact dashboard — icon + channel + account count, models (count + lowest rate, paid only; "all free" only when whole group free; custom aliases not separate models), state distribution, quota total, expiring credits (one block per 1.5 days in main window; 7-day window only when larger). Collapsed by default; filtering auto-expands. Click header to toggle. +- **Column headers follow expanded area**: headers render inside each expanded group and stick on scroll, sharing one sort state. *Growth* column appears only for CodeBuddy; other channels keep empty placeholder to preserve column widths. +- **Long lists scroll**: table height 70vh, sticky headers and action button stay visible. -**Routing honours this cache**: when the catalog proves which channels own a model, those are the only candidates — if all are unavailable (cooling / paused) the request gets a 503 instead of fanning out (each upstream would only answer 11102 / 4001 / 401 and write hours-long "no such model" negative caches). A model absent from the catalog still routes to all candidates; pinned `model@channel` and key channel binding are unaffected. +Quota numbers are underlined with dotted line; hover shows period semantics for that row. + +## Model list loading + +`GET /v1/models` and Playground dropdown merge only channels with **currently usable credential** (≥1 not paused, not session-invalid). Cooling credentials still count. Order: CodeBuddy (domestic → international), TRAE, Qoder, CodeArts, OfficeAce, Raccoon, Lingxi, then Zen / Kilo; alphabetical within group; multi-channel models take the highest-priority channel (`_PROVIDER_RANK` in `src/api/model_merge.py`). + +**Request routing trusts this cache**: catalog-proven ownership → only those channels are candidates; all unavailable → 503 instead of trying each. Models absent from catalog still route across all candidates; explicit pinning and key channel binding unaffected. ### Catalog snapshot and startup -Besides the in-process TTL cache (300s), every successful fetch is snapshotted to `DATA_DIR/model_catalog.json` and **read back synchronously at startup**, so the model→channel table is ready in the first second (flat model names no longer fan out while zen's 10+ second probes run). A corrupt or >7-day-old snapshot is just dropped. `MODEL_CATALOG_MINUTES` provides a background refresh so the catalog stays fresh even when nobody calls `/v1/models`. +Besides in-process TTL cache (300s), every successful fetch writes `DATA_DIR/model_catalog.json` and reads it back synchronously at startup → ownership ready the moment process restarts. Corrupt or >7-day-old snapshot just loses cache. `MODEL_CATALOG_MINUTES` background task keeps it fresh. -Playground and `GET /v1/models` use **stale-while-revalidate**: with a cache they return the old list immediately and refresh in the background; only a channel with **no cache at all** is awaited synchronously. The list lags by at most one TTL. +Playground and `/v1/models` use stale-while-revalidate: cached lists return immediately and refresh in background; only channel with no cache at all waits synchronously. Repeated failures back off at 2^(n-1)×TTL (capped at 2 hours), logging WARNING on first failure only. ### Admin *Model list* page -The admin *Model list* page (`GET /api/model-catalog`, visible to admins and operators) is a read-only view of the public OpenRouter catalog: prices (input / output / cache read / cache write, USD per million tokens, switchable to CNY), context/output limits, input→output modalities, capabilities (reasoning / tool calling / attachments / structured output), knowledge cutoff, and the **Artificial Analysis indices** (intelligence / coding / agentic, via the public OpenRouter API — **not measured by this service**, never sorted). A missing catalog shows an empty state and does not affect chat or cost. +(`GET /api/model-catalog`, admin / operator) read-only OpenRouter catalog: list prices, context / output limits, modalities, capabilities, knowledge cutoff, three Artificial Analysis indices (not measured here, no sorting). Each row carries `available`; default-off *only current channels* switch narrows locally. + +**Suspected-offline view**: `GET /api/models/availability` (operator) aggregates `upstream_blocked` / `model_not_found` / `invalid_request` failures over last N days (default 7, 1–30) by `(channel, model)`. *Add to blocklist* appends to `model_blocklist` via settings endpoint + audit row. + +**Floating aliases take newest snapshot score**: OpenRouter attaches both floating alias and dated snapshots to one model, declaring one release via `canonical_slug`. Gateway runs reasoning on by default, matching Max variant → takes newest snapshot in family; ids with explicit date stamp keep own score. Families derive only from canonicals carrying `-YYYYMMDD`. + +### Active probing and manual takeover + +Probe panel (`GET /api/models/probes`, operator) probes two kinds of `(channel, model)` pairs: those still locked by `blocked` negative cache, and those already declared dead (`state='failed'`). + +Three outcomes: + +- **Success** → clears that channel's `blocked` cache and returns the model to the list (the only recovery path). +- **`failed`** (upstream says `BLOCKED` / `INVALID`) → keeps cache and increments consecutive failures. +- **`inconclusive`** (quota, rate limit, network, timeout, empty stream, any exception) → never disables, resets consecutive counter. + +Probing is isolated from production: calls `provider.stream_chat` directly, writes no `usage_events`, touches no cooldown table, changes no health. Results go to separate `model_probes` table. Panel hides models matched by `MODEL_BLOCKLIST`. + +**Dead models also drop out of model list**: only that `(channel, model)` pair is hidden — other channels serving the same model still show it. **Hiding has no expiry** — recovery only happens on a successful re-probe, not on negative-cache `cooling_until` (nothing would re-probe after it expires, i.e. permanent hiding). The background loop does re-probe dead rows, but its interval `MODEL_PROBE_MINUTES` defaults to `0` (off), so on a default deployment the only way back is *Probe now*. + +**Manual takeover** (`POST /api/models/probes/manual`): skips automatic probing for that row, cache untouched. *Probe now* recovers immediately. + +### Admin *Upstream status* page -OpenRouter lists hundreds of models and most are not connected here. Each row carries an `available` boolean (whether the model is **currently served by a channel with a usable credential**, same rule as `/v1/models`), and the page offers an **off-by-default** "only current channels" toggle that narrows the table locally to models fetched from current channels (no extra request). +(`GET /api/upstream-status`, admin / operator) aggregates official status pages to answer "upstream down, or my credential / quota". -The 能力分 (capability score) header is sortable by the **intelligence index** (智); rows without a score sort last (never treated as 0). Only this one sub-score has a sort entry — one header column maps to one entry, and sorting all three would crowd the header into a row of buttons. +- **Sources**: OpenCode Zen (per-model TTFT / tps), Kilo, Moonshot / Kimi, MiniMax, OpenAI, Anthropic, Together AI, DeepSeek. Grouped by `scope`: `channel` first, `model` after. +- **Six normalized levels**: `operational` / `degraded` / `partial` / `major` / `maintenance` / `unknown`. Overall = worst source, `unknown` last. +- **Explanation only, never decisions**; not part of scheduling or circuit breaking. +- **Memory only**; background task (`UPSTREAM_STATUS_MINUTES`, default 5) fetches; single failing source only marks its own row. +- **Conclusion first, detail after**: summary bar on top, then two scope groups with abnormal sources expanded; *needs attention* strip hoists non-normal sources. Component detail beyond 6 items truncates (abnormal first). +- **Not covered**: sources that only offer HTML (Groq, Tencent Cloud, Alibaba Cloud, Huawei Cloud, SiliconFlow, Qoder, Zhipu, TRAE). ### Token expiry visibility -A "token remaining" column, red below `TOKEN_EXPIRY_WARNING_SECONDS` (default 1h). Expiry uses explicit `expires_at`, falling back to the JWT `exp`; both missing → `—`, **never guessed**. +The **token remaining** column shows how long access token lasts, red below `TOKEN_EXPIRY_WARNING_SECONDS` (default 1 hour). Prefers upstream `expires_at`, falls back to JWT `exp`; neither → `—`, never guessed local TTL. + +## Sidebar to-dos (navigation badges) + +Sidebar is on every page. To-dos you would only notice by visiting a page show as count badges, fed by one aggregate endpoint `GET /api/nav-badges`, polled every 60s. + +| Entry | Threshold | Badge | +|---|---|---| +| Credentials | operator+ | credentials with `disabled` or `health < 0` | +| Alerts | admin | unresolved alerts | +| Tasks & settings | admin | tasks whose last run failed | +| Upstream status | operator+ | abnormal sources (incl. maintenance) | +| Users | admin | accounts with an activation token | +| API keys | everyone (own only) | keys expired or expiring within 7 days | +| Model list | operator+ | stale snapshot (boolean text) | + +- **Role-filtered**: backend sends only keys the role may see; missing key = no permission (not 0). Credential badge withheld from viewers. +- **Counts only to-dos**: alerts page `total` includes resolved history, badge does not; tasks use "last run failed", not "consecutive failures ≥ threshold". +- **Credential badge ≠ `/healthz` ready count**: latter ignores health → out-of-quota credential still counts as `ready`. +- **Alert badges self-heal**: `AlertTask` rebuilds its unresolved set from the DB at startup. +- **Stale catalog**: never fetched is not stale; stale means 2× `OPENROUTER_CATALOG_MINUTES` without refresh. ## Stats ### Scope -The time range defaults to the **last 24 hours** (also 7 days / 30 days / all): the first question when opening the stats page is "how is the pool doing right now", and 7 days flattens out the day's variation. +Default range **last 24 hours** (also 7 / 30 days, all, custom). Custom takes two `datetime-local` inputs (browser-local timezone), one side may be empty; start not earlier than end voids window with message. Every stats endpoint accepts `until` (epoch seconds, half-open `[since, until)`). + +**Failure types** = controlled enum: exhausted model-level cooldown → `model_unavailable`; genuinely empty pool → `no_healthy_credential`; upstream explicitly says "no such model" → `model_not_found` (kept apart from `invalid_request`: one means the model was withdrawn, the other means the request was malformed) — same outbound code, different stats meaning. + +**Request ID**: every response carries `X-Request-ID`. Valid inbound value reused, else generated; charset `[A-Za-z0-9._:-]`, length 1–128. Lands in `usage_events.request_id`, shows on hover in detail time column. **Never forwarded upstream** (outbound header semantics differ per channel). + +**Credit**: CodeBuddy = upstream truth; TRAE derived from official unit prices, marked `≈` (table `src/provider/trae/pricing.py`; unlisted not derived). Backfill with `scripts/backfill_trae_credit.py`. + +**Cost (estimated, ≈)**: tokens × public list price, not real charge. Prices from OpenRouter public catalog: `(input − cached) × input + cached × cache price + output × output price`, converted with `USD_CNY_RATE` in effect at write time. + +- **Fixed at write time**: cost computed when detail row written, never recalculated. Pre-release rows `NULL`; backfill with `scripts/backfill_cost.py --apply`. +- **No match, no cost**: local names match OpenRouter `id` / display name exactly (ambiguous skipped) → aggregate shows `—`, so cost is a lower bound. Channel-specific ids map to base model only via explicit equivalence rules (verified ids only, no generic date/suffix stripping). -### Credit, cost and cache hit rate +**Cache hit rate**: cached ÷ input tokens (1 decimal). `—` when unreported or input 0. Group / detail tables show it next to tokens. -The **Credit** column: CodeBuddy is the real upstream value; TRAE's `token_usage` frame carries only tokens, so its credit is **estimated** from official unit prices (`≈`). The price table lives in `src/provider/trae/pricing.py`; unmatched models show `—`. +**Output speed (tok/s)**: total tokens ÷ total generation time, not average of per-request ratios. Same denominator, different numerators: **visible speed** uses `output_tokens - reasoning_tokens`; **total output speed** includes reasoning. UI shows `visible / total`. Only successful requests routed here that produced ≥ 1 token. Generation time = `latency_ms - ttfb_ms`; windows under 200 ms or single samples over 1000 tok/s dropped as unmeasurable. `ttfb_ms` = `NULL` excluded from primary scope; row or group with no primary sample falls back to end-to-end time (prefixed `≈`). No samples → `—`. -**Cost (est.)** estimates tokens × public list prices, not the real charge. Prices come from the **OpenRouter public catalog** (USD per million tokens), computed as `(input − cached) × input + cached × cache_read + output × output`, converted to CNY at the **rate in effect when the row was written** (`USD_CNY_RATE`). Costs are **fixed at write time** (`usage_events.cost_usd` / `cost_cny`) and **never recomputed**; pre-upgrade rows are `NULL` and can be backfilled with `scripts/backfill_cost.py --apply`. Matching compares canonical keys for equality and **drops ambiguous hits**; unmatched models or requests without input tokens contribute nothing and aggregates render `—` (never a fake `0`), so the figure is a **lower bound**. +Aggregate speed can only come from per-request detail → covers last 90 days (overview card too; other cards read hourly rollup). Detail table shows each request's own speed, no fallback; rows without generation window show `—`. Speed columns sort via `sort=tps_visible` / `sort=tps`. -**Cache hit rate**: the *Token usage* card adds cached ÷ input (1 decimal), from the upstream `cached_tokens`; `—` when never reported or input is 0. The **Grouped stats** table (after *cached*) and the **Request detail** table (after *hit*) each add a display-only **hit rate** column on the same formula. +**Chart metrics**: requests / tokens / cost / cost per request / latency / TTFB / success rate / cache hit rate — switch to compare trends. Derived metrics (success rate, cache hit rate, cost per request) divide **after** aggregation (`sum ÷ sum`), not an average of per-hour ratios; no samples → `—` (line breaks), never 0. Latency / TTFB get an extra **percentile chart** (P50 median + P95 upper bound, read from per-request detail, 90-day window) — the mean flattens the long tail. The time axis is padded per hour (gaps keep their position instead of being squeezed out of time). A `metric` outside the accepted set returns 400 (no silent fallback to "requests" — a typo would otherwise yield a plausible-looking chart). An invalid custom range (start ≥ end) pauses fetching instead of issuing an unbounded query. ### Credit record -The drawer under a credential's quota number lists the **net change between consecutive quota probes**, attributing nothing to check-in / growth / chat (the upstream logs nothing), so the UI says "net change". The first probe records only a baseline; unchanged balances are skipped; a balance going unknown still records a row. 90-day retention. +Arrow after quota number expands change ledger = **net change between two quota probes**, not action attribution: check-in, growth and chat all change balance and upstream logs nothing → UI always says "net change". First probe only sets baseline; unchanged balances not recorded; unknown balance recorded without delta; 90-day retention. -**Split per ledger**: the table carries a `ledger` column (`pool` / `credits`) and each probe writes one row according to the credential's own channel — CodeArts quota comes from the credit endpoint, so it records `credits`; other channels record `pool`. A CodeArts row expands the credit history (`ledger=credits`), other channels expand the plan / daily-balance history (`ledger=pool`); `GET /api/credentials/{id}/credit-events` takes a `ledger` query parameter (default `pool`, unknown values fall back to `pool`). **They must be read separately**: the two ledgers differ in magnitude and meaning, and mixing them reads as consecutive samples of one ledger (on an old database a CodeArts `pool` row is the retired daily token pool, an order of magnitude away from credits). On upgrading an old database every historical row lands in `pool` — those rows were produced by the then-current balance diff and are no longer shown. +**Split by ledger**: rows carry `ledger` (`pool` / `credits`). CodeArts uses `credits`, others `pool`. Endpoint `GET /api/credentials/{id}/credit-events?ledger=` (default `pool`). Two ledgers differ in magnitude and meaning → read separately. ### Grouping and re-rollup -The **Grouped stats** panel tabs four dimensions (channel / model / user / credential) over one table (requests / ok / success rate / input / output / cached / hit rate / credit / cost), endpoints `GET /api/stats/by-provider|by-model|by-user|by-credential`. Default sort is requests descending; **user and credential tabs are admin/operator only**. **Scope differs**: channel / model / user read the **hourly rollup** (permanent; the last 5 minutes are not yet counted); the **credential** dimension reads request detail (the rollup key has no `credential_id`), covering the **last 90 days**. +Four tabs (channel / model / user / credential) share one table (channel / requests / credit / cost / tokens / speed / average latency / P95 latency). Endpoints `GET /api/stats/by-provider|by-model|by-user|by-credential`. Default sort by requests descending; merged columns open sub-column menu. **Latency percentile: P50 / P90 / P95 / P99** (default P95, nearest-rank, n = samples with value). User / credential views admin/operator only. -**Click-to-drill**: clicking any group row drills the **Request detail** panel below into that dimension (channel / model / credential filter by the same-named column; the user dimension goes through the scope parameter). Conditions stack across dimensions, the active filters show as chips above the panel, and one click clears them. `GET /api/stats/events` therefore accepts `provider` / `model` / `credential_id` filters (AND semantics). +**Scope differences**: channel / model / user read hourly rollup (permanent; last 5 minutes not yet counted); credential reads detail table (90 days only). -**Merged columns**: the Request detail table merges same-kind columns into one, showing a readable default and the full breakdown on hover: **Token** (defaults to "input / output · hit rate", hover adds cached tokens) and **Latency** (defaults to "duration / TTFB"). Column count drops from 14 to about 10, and the narrow-screen cards carry half as many fields. Sorting is preserved: clicking a merged header opens a dropdown to pick the sub-column (Token → input / output / cached / hit rate; Latency → duration / TTFB), and selecting one sorts by it. The **hit rate** sub-column uses backend `sort=cache_rate` (cached ÷ input; `NULL` when input is 0 or never reported, matching the sort semantics of other nullable columns). +**Two latency scopes**: successful requests only. Average = `latency_sum ÷ successes` (hourly rollup); percentile from per-request detail (90 days). `NULL` `ttfb_ms` ranks independently in each column. No samples → `—`. -**CSV export**: the **Export CSV** button (admin/operator only) exports request detail for the **current time range and drill filters**, via `GET /api/stats/events?format=csv`. The file carries a UTF-8 BOM (so Excel detects the encoding), `\r\n` line endings, ISO 8601 (UTC) timestamps, and prefixes a leading apostrophe on **text** cells starting with `= + - @` (or tab/CR) to block spreadsheet formula injection (numbers, negatives included, are left as-is). Export is capped at 10000 rows; beyond that it truncates and flags `X-Export-Truncated: 1` in the response headers, and the page tells the user to narrow the conditions. +**Failure attribution**: panel below group table, `GET /api/stats/errors` (operator+) aggregates `ok=0` detail by `(channel, model, error_type)` → failure count, **unrescued count**, last failure time (descending, `limit` ≤ 5000). **Row granularity**: hard failures (`credential_unavailable` / `account_restricted` / `invalid_request` / `model_capability`) land **one row per attempt**; rate-limit / quota / timeout / context-overflow and the remaining classes land **one terminal row per request** (typed by the last real error attribution) — otherwise one request with three attempts inflates `usage_hourly.requests` and every key's daily request quota. A failure row whose `request_id` also has a success row for the **same user** counts as rescued; `unrescued = failures − rescued` (a request failing twice counts twice; not the number of lost requests). Special predicates: a credential 401 lands `credential_unavailable` — **except when the quota-exhaustion evidence holds** (officeace returns the same `401 APIG.1009` when its points run out), which is downgraded to `CREDIT` (cooled until the next 04:00 CN check-in, not hard-disabled, no such row); if the local quota reading is positive or unprobed the executor re-probes once before deciding, and only a still-positive reading or a failed probe keeps it `DEAD`. An upstream account restriction (11140) lands `account_restricted` (2h account-level cooldown, stop rotating within that channel; a second distinct credential in the same window escalates to a 24h channel pause). 11102 / 11158 land `model_not_found` (writes an (account, model) negative cache, does not cool the account); 11115 lands `context_overflow` and 001001020 lands `model_capability`, neither penalizes the credential; when rotation exhausts without ever picking a credential the row records the **candidate channel** rather than `-`. Full classification and cooldown timings in TECHNICAL.md §3.2. Click row → detail with *failed only* (`GET /api/stats/events?ok=0`); admin/operator only. `/metrics` `upstream_errors_total` stays an in-process counter, not merged. -**Live stream (SSE)**: the **Live** toggle in the request-detail panel subscribes with `EventSource` to `GET /api/stats/events/stream` and shows requests **just committed** within the current filter range. Live rows render in their own panel above the paginated history, so the table you are paging through never jumps. Events are broadcast after the `StatsCollector.record` transaction commits via an in-process `LiveEventHub` (no DB polling); the client keeps the newest 50, newest first, and closing the toggle closes the connection. +**Row drill-down**: clicking group row filters detail by that row (channel / model / credential; user dimension uses scope), stacking conditions. `GET /api/stats/events` accepts `provider` / `model` / `credential_id` (AND) + `ok=0`. -- **Scope and filters**: identical to `/api/stats/events` — admin/operator may pass `username` (omitted = everything), viewers are pinned to themselves; `provider` / `model` / `credential_id` stack. Changing a filter reopens the connection (`EventSource` cannot change its query string). -- **Credential names**: the broadcast payload carries no nickname (same redacted field set as the detail endpoint, plus `rowid`); only admin/operator connections get `credential_name` filled in per event **server-side**, so viewers never see pool nicknames. -- **Backpressure and limits**: each subscriber gets a **bounded** buffer (200 events); when full the **oldest** is dropped (a live view cares about now). At most 8 concurrent subscribers; beyond that the endpoint answers 429 with `Retry-After` instead of queueing or quietly degrading the service. -- **Keep-alive and caching**: a comment frame (`:`) every 15s keeps proxies from dropping an idle connection; the response sets `Cache-Control: no-store` and `X-Accel-Buffering: no` so no middle layer caches or fully buffers the stream. Browsers auto-reconnect; the page reports a disconnect once the browser gives up (401 / 429). +**Merged detail columns**: tokens, latency, speed and channel / credential each merge into one column with readable default + hover breakdown. Time column splits two lines (time / date). Clicking merged header opens sub-column sort menu (`sort=cache_rate` / `tps_visible` / `tps` / `provider` / `credential`). -If history looks light (pre-upgrade detail not covered, or detail backfilled without re-rolling), run `python3 scripts/rollup_hourly.py --apply` (preview by default, backs up before writing, idempotent). It reports gaps and warns about intervals whose detail has passed the 90-day retention and cannot be restored. +**CSV export**: `GET /api/stats/events?format=csv` (admin / operator only), honoring current range + drill-down filters. UTF-8 BOM, `\r\n`, ISO 8601 timestamps (UTC); text cells starting with `= + - @` get leading quote to prevent formula injection. Capped at 10000 rows with `X-Export-Truncated: 1`. + +**Live stream (SSE)**: `GET /api/stats/events/stream` shows requests that just landed within current filter. Events are broadcast by in-process `LiveEventHub` after `StatsCollector.record` commits; the UI keeps latest 50. + +- Same scope and filters as `/api/stats/events`; changing filters reconnects. +- Broadcast payload has no credential nickname; only admin/operator connections get `credential_name` server-side. +- Bounded buffer of 200 per subscriber (oldest dropped); 8 concurrent subscribers, beyond that 429. +- 15s comment frames keep connection alive; `Cache-Control: no-store` + `X-Accel-Buffering: no`. + +History looks small → `python3 scripts/rollup_hourly.py --apply` (preview by default, backs up first, idempotent). ### List sorting -Admin lists are **sortable by clicking column headers**: the first click uses the column's natural direction (numbers descending, text ascending), subsequent clicks toggle. Sorting is done by the **backend** — every `GET` list endpoint accepts `sort` (whitelist key) and `order` (`asc` / `desc`). An unknown `sort` key or invalid `order` falls back to the endpoint's default (no error); `sort` is a whitelist key, never a SQL column, so the injection surface is zero. +Click header to sort: first click uses column's natural direction (numeric descending, text ascending), second flips. Server-side; list endpoints accept `sort` (allowlisted key) + `order` (`asc` / `desc`). Unknown `sort` / invalid `order` falls back to default, no error. + +Applies to `/api/credentials`, `/api/users`, `/api/api-keys`, `/api/audit`, `/api/alerts`, `/api/model-catalog`, `/api/credentials/{id}/credit-events`, `/api/credentials/{id}/accounts`, `/api/stats/by-*`, `/api/stats/events`. + +### Pagination + +Shared pagination: `page N / M`, 10 / 20 / 50 / 100 per page (default 50). Flat lists paginate server-side — endpoints accept `limit` + `offset` and return filtered `total`. `/api/model-catalog` sorts in memory (sort keys include derived fields) and also accepts `q` / `only_available`. + +Grouped tables (credentials by channel, stats group table) are not paginated. ## Background tasks -Nine tasks — quota probe, token pre-refresh, daily check-in, growth center, activity reporting, detail cleanup, channel catalog refresh, OpenRouter catalog refresh, and operations alerts — are scheduled by `TaskRunner`, failing independently. Each task's last run, latest result and error appear on the *Tasks & settings* page (30s refresh); task state is **in-process only** and resets on restart. +Twelve task types — quota probe, token pre-refresh, daily check-in, growth center, activity reporting, detail cleanup, database backup, channel model refresh, model catalog refresh, operations alerts, upstream status, model probing — scheduled by `TaskRunner`, failures isolated. *Tasks & settings* page shows last run / result / error (30s refresh); runtime state lives only in this process. + +Each card has *Run now* (admin + CSRF): bypasses time window, not on/off switch; already running → `busy=true`, no queueing. -**Channel catalog refresh** (`MODEL_CATALOG_MINUTES`, default 30, floor 5) keeps the catalog fresh when nobody visits `/v1/models`; 30 minutes aligns with zen's 30-minute liveness cache, so going denser would only hit other channels' `/models` for nothing. +**Channel model refresh** (`MODEL_CATALOG_MINUTES`, default 30) is safety net so models update even with no traffic. -**Growth center** is CodeBuddy-only; Buddy travels return every 1–4 hours, so the default cycle is 60 minutes (`GROWTH_INTERVAL_MINUTES`). Lottery / streak redemption / blind boxes / streak-repair cards are **irreversible** and skipped when `GROWTH_IRREVERSIBLE_ACTIONS=false`. +**Growth center** is CodeBuddy-only. Default period 60 minutes (`GROWTH_INTERVAL_MINUTES`). + +### Activity reporting (optional, off by default) + +With `ACTIVITY_REPORT_ENABLED=true`, each account gets one `chat_request_send` event per day in `ACTIVITY_REPORT_HOUR` (default 10) window to keep streak alive. Unrelated to credits or scheduling. Applies to CodeBuddy and international site. + +> **Risk**: official terms forbid scripted tampering with activity data; penalties include disqualification and clawing back gifts. Off by default — assess account risk first. Breaks as soon as upstream changes. Missing `userId` → upstream silently discards event; empty OAuth `user_id` falls back to JWT `sub`, skips when still missing. ### Operations alerts -A periodic task evaluates four risks and persists hits to `alert_events`, reviewed on the *Operations alerts* page (admins only): +Nine rules are evaluated periodically and written to `alert_events` (the *Alerts* page, admin-only): -| Rule | Fires when | +| Rule | Trigger | |---|---| -| `pool_empty` | The pool has credentials but fewer than `ALERT_POOL_READY_MIN` are usable | -| `task_failed` | A background task fails `ALERT_TASK_FAILURES` times in a row (a success resets it) | -| `token_expiring` | A credential's token has less than `ALERT_TOKEN_EXPIRY_HOURS` left | -| `error_rate` | The window's upstream failure ratio reaches `ALERT_ERROR_RATE_THRESHOLD` with enough samples | +| `pool_empty` | ready count below `ALERT_POOL_READY_MIN` | +| `task_failed` | a task fails `ALERT_TASK_FAILURES` times consecutively | +| `token_expiring` | token lifetime within `ALERT_TOKEN_EXPIRY_HOURS` | +| `error_rate` | failure share reaches `ALERT_ERROR_RATE_THRESHOLD` with enough samples | +| `key_burst` | one API key burns `ALERT_KEY_BURST_TOKENS` within the window (abuse / leak signal, not an error) | +| `off_hours` | requests outside `[ALERT_OFF_HOURS_START, ALERT_OFF_HOURS_END)` reach `ALERT_OFF_HOURS_MIN_REQUESTS` | +| `budget` | monthly cost reaches a level in `ALERT_BUDGET_LEVELS` of `ALERT_BUDGET_CNY`, highest level only; 100% is critical | +| `channel_dead` | a channel takes `ALERT_CHANNEL_DEAD_MIN_REQUESTS` requests in the window with **zero successes** (catches a whole channel dying: a batch of quota 401s gets diluted by fallback and never moves `error_rate`) | +| `checkin_failing` | the same credential's last `ALERT_CHECKIN_FAIL_MIN_STREAK` check-in rounds all failed (one success resets; covers "task succeeded, upstream business call failed") | -- **Silence window** (`ALERT_SILENCE_MINUTES`): the same `(rule, target)` is recorded / pushed only once per window. -- **Hysteresis** (`ALERT_FOR_TICKS` / `ALERT_CLEAR_TICKS`): consecutive hits before firing, consecutive misses before confirming recovery. On recovery every **still-unresolved** row for that `(rule, target)` gets `resolved_at` (the reminder rows of the same incident close together), and a `status="resolved"` webhook is emitted. -- **Restart self-healing**: hysteresis **counters** live in-process and restart from zero, but "which alerts are still open" is a persisted fact — `AlertTask` rebuilds it from the unresolved rows at startup, so an alert recorded before a restart whose condition has since cleared is still marked resolved. -- **Manual resolution**: each unresolved row has a *Mark resolved* button (`POST /api/alerts/{id}/resolve`, admin + CSRF + audit) for cases the state machine cannot judge (rule retired, false positive). It touches that row only. -- **Retention**: same as request detail (90 days), trimmed by the cleanup task. +- **Silence window**: one row / push per `(rule, target)` per window. +- **Hysteresis**: alerts fire after N consecutive hits, resolve after M consecutive misses. Resolution stamps `resolved_at` on every unresolved row for that `(rule, target)` and emits `status="resolved"` webhook. +- **Multiple sinks**: generic webhook (full JSON + signature) + Feishu bot (text) + Slack incoming webhook can be configured together; one failing sink does not stop the others, but the run is still recorded as undelivered. +- **Restart self-heal**: hysteresis counters are in-process; unresolved set is rebuilt from the DB. +- **Manual resolution**: *Mark resolved* (admin + CSRF + audit) marks that row only. +- **Retention**: same as request detail (90 days). -### Activity reporting (optional, off by default) +### Database backup and restore -The growth center's streak counts daily client chat events; an account driven only by the gateway would break the streak. With `ACTIVITY_REPORT_ENABLED=true`, a task posts **one** `chat_request_send` event per account per day at `ACTIVITY_REPORT_HOUR` (default 10:00) to keep the streak alive — unrelated to credits or scheduling, and failures do not affect chat. Applies to CodeBuddy and CodeBuddy international. +**Automatic** (`BACKUP_INTERVAL_MINUTES`, default daily) writes consistent snapshot into `DATA_DIR/backups/`, keeping newest `BACKUP_KEEP` (default 7). -> ⚠️ **Risk**: the official terms forbid scripted tampering with activity data (disqualification + gift clawback). Off by default; evaluate the account risk first. Event names and request shapes depend on the upstream and **break without notice** — not a reliability feature. Measured (2026-09-21): without `userId` the upstream returns HTTP 200 `{"code":0}` and **silently drops** the event; when a credential's `user_id`/`account_uid` is empty, the gateway falls back to the bearer JWT `sub`, and skips if still missing (never fabricated). +- Uses `VACUUM INTO`, not file copy (WAL would lose uncheckpointed transactions). +- Runs `PRAGMA integrity_check` immediately and deletes bad snapshot. Files `0600`, directory `0700`. +- Failures only log; service keeps running. + +Manual backup: + +```bash +python3 scripts/backup_db.py # into DATA_DIR/backups/, keep 7 +python3 scripts/backup_db.py --keep 14 # keep 14 (0 = never prune) +python3 scripts/backup_db.py --out /path/dir +``` + +Restore (**stop service first**): + +```bash +python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 # preview: verify only +python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --apply # verify → safety copy → atomic replace +``` + +Flow: verify integrity → `.pre-restore-` copy → atomic replace → remove stale `-wal`/`-shm`. **Restart afterwards.** + +The *Backup & restore* page (admin) offers the same capability: + +- `GET /api/backups`: lists `backups/*.sqlite3` plus the pending restore file (`pending`). +- `POST /api/backups`: back up now. +- `GET /api/backups/{name}` / `DELETE /api/backups/{name}`: download / delete (regular files inside the directory only; paths and symlinks rejected). +- `POST /api/backups/restore?filename=…`: raw byte upload → integrity + core table + schema version checks → staged at `DATA_DIR/restore/pending.sqlite3`. **No live swap**: the next start replaces the DB atomically before opening connections. A pending file that fails validation is archived as `.rejected-*` and the current DB is kept, so a bad upload never pins the service in a startup-failure loop. ## Configuration -Key variables below; the full list is in `src/config.py` (authoritative), and **every one is plumbed through `docker-compose.yml`**. In containers use `HOST`/`PORT` to change the listen address (`PORT` also sets the host mapping). +Full list in `src/config.py` (authoritative); every item wired through `docker-compose.yml`. Inside containers use `HOST`/`PORT` to change listen address. | Variable | Default | Notes | |---|---|---| -| `APP_SECRET` | **required** | Credential encryption key, ≥16 chars; loss = all credentials void, no rotation. Low-entropy values are rejected at startup | -| `ADMIN_USERNAMES` | empty | **Bootstrap-only**: users promoted to `admin` on first start; inert once the DB has roles | -| `USERS_FILE` | `secrets/users.txt` | **Bootstrap-only**: legacy user file imported once (idempotent) | -| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | Browser-reachable address; TRAE login callback depends on it | -| `DATA_DIR` | `./data` | SQLite and runtime data directory | -| `DEFAULT_MODEL` | `deepseek-v4.1-flash` | Model when empty/`auto` | -| `QUOTA_PROBE_MINUTES` | `60` | Quota probe interval (floor 1) | -| `REFRESH_INTERVAL_MINUTES` | `30` | Token pre-refresh polling interval (floor 5); short-lived channels cap their window wider than this | -| `MODEL_CATALOG_MINUTES` | `30` | Channel catalog fallback refresh (floor 5) | -| `OPENROUTER_CATALOG_MINUTES` | `1440` | Catalog refresh (floor 60): one fetch from the OpenRouter public API covers prices, metadata and the Artificial Analysis capability indices | -| `OPENROUTER_MODELS_URL` | `https://openrouter.ai/api/v1/models` | Catalog source (anonymous, no key); restart required | -| `USD_CNY_RATE` | `6.70` | Cost-estimate FX rate; hot-updatable, affects only later writes | -| `GROWTH_INTERVAL_MINUTES` | `60` | Growth-center cycle (CodeBuddy only; floor 5) | -| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | Allow lottery, streak redemption, blind boxes, repair cards | -| `ACTIVITY_REPORT_ENABLED` | `false` | Post one chat event per account per day. **Off by default** — terms forbid tampering | -| `ACTIVITY_REPORT_HOUR` | `10` | Activity hour (local Beijing time) | -| `CHECKIN_TIMES` | `""` | Daily check-in times: local `HH:MM`, comma separated (e.g. `09:37,21:10`). A round runs only at those times; **empty = every 10 minutes all day** (the old behaviour, unchanged after upgrade). Bad segments are ignored with a warning; all-bad is treated as empty. Check-in is idempotent upstream, so extra times only add retry opportunities. Once times are set the loop wakes **every 60 seconds** — otherwise a zero-jitter window lasts one minute and the day is silently missed. Hot-reloadable | -| `CHECKIN_JITTER_MINUTES` | `0` | Check-in jitter (minutes): run a random `0..N` minutes **before** the configured time so multiple deployments do not hit upstream in the same minute. Deduplication keys on *date + time*, so jitter never makes the same slot run twice. `0` = off. Hot-reloadable | -| `LOG_BUFFER_LINES` | `500` | In-process ring-buffer size for the admin *Server logs* page (collects this process's `logging` records only, never tails a file). `0` disables the page (both `GET /api/logs` and `/api/logs/stream` return 404). Every line passes a redaction allow-list first: anything matching `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`, an `sk-…` shape or `Bearer ` is **dropped entirely**. Hot-reloadable | -| `ALERT_ENABLED` | `true` | Operations-alert evaluation (pool exhausted / repeated task failures / token near expiry / error-rate spike), persisted to `alert_events` | -| `ALERT_WEBHOOK_URL` | empty | Webhook POST (JSON, body carries `status`: `firing` / `resolved`), comma-separated (≤16, `http(s)` only, private/loopback/link-local blocked against SSRF, re-checked against DNS resolution right before delivery and skipped if it fails to resolve), concurrent delivery, all must succeed (15s budget); requests always carry `X-Coding2API-Signature: sha256=`; empty = in-app records only | -| `ALERT_INTERVAL_MINUTES` | `5` | Alert evaluation cycle (floor 1) | +| `APP_SECRET` | **required** | Credential encryption key, ≥16 chars; losing it voids every credential. Low-entropy values rejected. **After rotating it, old credentials no longer decrypt**: a single probe returns an actionable error code, while chat and background tasks just skip that one bad credential and carry on | +| `ADMIN_USERNAMES` | empty | Bootstrap only (no active admin in the DB yet): promoted to `admin` on first start | +| `USERS_FILE` | `secrets/users.txt` | Bootstrap only: legacy user file imported once | +| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | Browser-reachable address; TRAE login callback; HSTS when https. Does not decide the session cookie `Secure` flag | +| `DATA_DIR` | `./data` | SQLite and runtime data | +| `DEFAULT_MODEL` | `deepseek-v4.1-flash` | Used when the model is empty / `auto` | +| `QUOTA_PROBE_MINUTES` | `60` | Quota probe period (min 1) | +| `REFRESH_INTERVAL_MINUTES` | `30` | Token pre-refresh poll period (min 5) | +| `MODEL_CATALOG_MINUTES` | `30` | Channel model refresh period (min 5) | +| `SHORT_COOLDOWN_WAIT_SECONDS` | `60` | When the whole pool is in short cooldown, wait in place for the shortest remaining one (≤ this value), then re-pick once with the credential released from `tried`, instead of failing with 503. Waiting holds no in-flight lease. `0` = off. Hot-reloadable; for non-streaming requests also enable `NONSTREAM_KEEPALIVE_SECONDS=15` so middleboxes don't drop the connection first | +| `OPENROUTER_CATALOG_MINUTES` | `1440` | Model catalog refresh period (min 60) | +| `OPENROUTER_MODELS_URL` | `https://openrouter.ai/api/v1/models` | Catalog source (anonymous); restart required | +| `USD_CNY_RATE` | `6.70` | Cost estimation rate; hot, affects new rows only | +| `TOOL_OUTPUT_COMPRESS_ENABLED` | `false` | Compress oversized plain-text `role=tool` output (strip ANSI, fold repeated lines). Hot | +| `TOOL_OUTPUT_COMPRESS_MAX_CHARS` | `8000` | Char threshold for tool-output compression; `<=0` disables. Hot | +| `GROWTH_INTERVAL_MINUTES` | `60` | Growth center period (min 5) | +| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | Allow lottery / streak redemption / blind boxes / makeup cards | +| `ACTIVITY_REPORT_ENABLED` | `false` | Activity reporting, off by default | +| `ACTIVITY_REPORT_HOUR` | `10` | Local hour window for activity reporting | +| `CHECKIN_TIMES` | `""` | Check-in times `HH:MM`, comma-separated; empty = every 10 minutes. Hot | +| `CHECKIN_JITTER_MINUTES` | `0` | Jitter before the time point. Hot | +| `CHECKIN_SKIP_PROVIDERS` | `""` | Channels to skip for check-in (comma-separated, e.g. `raccoon`); chat unaffected. Hot | +| `LOG_BUFFER_LINES` | `500` | Server log buffer lines; `0` disables the page. Hot | +| `ALERT_ENABLED` | `true` | Operations alerts on/off | +| `ALERT_WEBHOOK_URL` | empty | Webhooks, comma-separated (≤16, http(s) only, internal / loopback rejected), signed with `X-Coding2API-Signature` | +| `ALERT_FEISHU_WEBHOOK_URL` | empty | Feishu bot webhook; sends `msg_type=text`, URL validated like the generic webhook | +| `ALERT_SLACK_WEBHOOK_URL` | empty | Slack incoming webhook; sends `{text}`, URL validated like the generic webhook | +| `ALERT_INTERVAL_MINUTES` | `5` | Alert evaluation period | | `ALERT_SILENCE_MINUTES` | `30` | Silence window per `(rule, target)`; `0` disables | -| `ALERT_FOR_TICKS` | `1` | Enter-hysteresis: consecutive hits before actually alerting; `1` = fire immediately | -| `ALERT_CLEAR_TICKS` | `2` | Clear-hysteresis: consecutive misses before confirming recovery (writes `resolved_at`, pushes a `status="resolved"` notice) | -| `ALERT_POOL_READY_MIN` | `1` | Usable-credential threshold; `0` disables the rule | -| `ALERT_TASK_FAILURES` | `3` | Consecutive task failures before alerting; `0` disables | -| `ALERT_TOKEN_EXPIRY_HOURS` | `24` | Alert when a token's remaining lifetime is below this; `0` disables | -| `ALERT_ERROR_RATE_THRESHOLD` | `0.5` | Upstream error-rate threshold; `0` disables | -| `ALERT_ERROR_RATE_MIN_REQUESTS` | `20` | Minimum sample size | -| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | Error-rate window (from `usage_events` detail) | -| `BACKUP_INTERVAL_MINUTES` | `1440` | Background DB backup cycle (minutes, floor 60); `VACUUM INTO` consistency snapshot into `DATA_DIR/backups/` | -| `BACKUP_KEEP` | `7` | Backup retention: only the newest N are kept, older ones deleted by time; `0` = never prune | -| `UPSTREAM_STATUS_ENABLED` | `true` | Upstream-status aggregation: a background job concurrently fetches the official status pages of the model / gateway providers and normalizes them; the admin *Upstream status* page is read-only. **Explains, never decides**; the snapshot lives in memory and resets on restart | -| `UPSTREAM_STATUS_MINUTES` | `10` | Aggregation cycle (floor 1) | -| `UPSTREAM_STATUS_TIMEOUT_SECONDS` | `10.0` | Per-source HTTP timeout (seconds): 8 sources are fetched concurrently, so one slow source must not stall the round | -| `MODEL_PROBE_MINUTES` | `0` | Active model probing cycle (floor 1 minute, `0` = off). Probes only `(channel, model)` pairs still locked by the `blocked` negative cache (11102 "no such backend model"), using one minimal chat to tell "the model is really gone" from "quota / rate limit / network". A success clears that channel's negative cache (the only early-recovery path); only an explicit upstream "no such model" counts as failure, everything else is "undecidable" and **never disables** anything. Probes hit providers directly, write no `usage_events`, touch no cooldown and take no part in selection; results land in the separate `model_probes` table. Enabling it sends real upstream requests and burns quota — decide by remaining balance; the negative cache itself starts at 6h, so ≥60 minutes is the practical floor. Hot-reloadable, and any single model can be probed from the *Model list* page | -| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | Main expiry window: credits expiring within it burn account-first; `≤0` disables expiry sorting | -| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | Tie-breaking window; `≤0` disables | -| `CONVERSATION_STICKY_SECONDS` | `3600` | Session-affinity TTL. Precedence: **explicit request-body session id** (`conversation_id` / `prompt_cache_key`, `metadata` first) > **session header** (`x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id`) > **message-prefix fingerprint** fallback; a manual pin beats all. Header and fingerprint values are salted with `username` before hashing, so two users sending the same value never share an account; headers must match `[A-Za-z0-9._:-]{1,128}` or they are dropped. `user_id` still suppresses the prefix fallback (headers keep working); `≤0` disables | -| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,seed-code-pro-*,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,minimax-m2.5,deepseek-v3-*,hunyuan-*,hy3*,deepseek-v4-*,qwen3.7-*,auto,nemotron-3-*,glm-5.2*,nemotron-3*,lfm2*,north-mini-code,openrouter-models-router,fast,balanced,deep,primary,hy4-preview,inkling-small,minimax-m2.7,kimi-k2.*,minimax-m3,gpt-5.4,gpt-5.5,laguna*,qwen3-vl-235b,seed-2.1*,xing4.0-29b-a4,glyph-cluster,qwen3.8-flash,seed-evolving` | Model-list blocklist (fnmatch, display only), matched on the outward spellings | -| `MODEL_ALIASES` | `""` | Custom model aliases: `outward=model@channel;outward2=model`, `@channel` optional (auto-picked). The list and request parsing share one table; a segment is skipped with an ERROR when it collides with a real id or an auto alias, the target does not exist, or the pinned channel does not hold the model. Empty = disabled. Hot-reloadable | -| `MODEL_FALLBACK_GROUPS` | `""` | Cross-channel fallback groups: `name=m1,m2;name2=m3`; members may carry `@channel`; switches only **before any frame is emitted** | -| `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | Streaming **first-event** timeout (seconds): if the upstream accepts the connection but never emits the first data frame, switch accounts and retry **before any frame is out** (safe, the client has nothing yet). Comment frames / heartbeats do not reset it. `0` disables. **Must stay off by default** — slow contexts measured 40–71s to first byte; use `60` if needed. Hot-reloadable | -| `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | Streaming **idle** timeout (seconds): after frames started, a stalled upstream ends the stream with one error frame — half output cannot be rolled back, so **no account switch**. `0` disables (recommended `300`). Hot-reloadable | -| `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | Short-cooldown "wait and retry" (seconds): when the whole pool is in short cooldown with no immediately usable candidate, and the shortest remaining cooldown is within this value, wait and re-select once instead of returning 503. The wait holds no in-flight lease and does not count against the rotation budget. `0` disables (fail fast, old behaviour). Hot-reloadable | -| `ALLOWED_HOSTS` | empty | Host allowlist (DNS rebinding) | -| `ALLOW_TEST_HOST` | `false` | Also allow the TestClient host `testserver`. **Tests only** | -| `TRUST_PROXY` | `false` | Honour `X-Forwarded-For` (last entry); exactly one trusted proxy only | +| `ALERT_FOR_TICKS` | `1` | Hits before firing | +| `ALERT_CLEAR_TICKS` | `2` | Misses before resolving | +| `ALERT_POOL_READY_MIN` | `1` | Pool-empty threshold; `0` disables | +| `ALERT_TASK_FAILURES` | `3` | Consecutive task failures; `0` disables | +| `ALERT_TOKEN_EXPIRY_HOURS` | `24` | Token remaining hours; `0` disables | +| `ALERT_ERROR_RATE_THRESHOLD` | `0.5` | Error rate threshold; `0` disables | +| `ALERT_ERROR_RATE_MIN_REQUESTS` | `20` | Minimum samples | +| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | Error rate window | +| `ALERT_KEY_BURST_TOKENS` | `0` | Single-key burst threshold (tokens); `0` disables | +| `ALERT_KEY_BURST_WINDOW_MINUTES` | `15` | Single-key usage window; `0` disables | +| `ALERT_CHANNEL_DEAD_MIN_REQUESTS` | `20` | Minimum requests in the window for `channel_dead`; `0` disables | +| `ALERT_CHANNEL_DEAD_WINDOW_MINUTES` | `60` | `channel_dead` window (minutes) | +| `ALERT_CHECKIN_FAIL_MIN_STREAK` | `3` | Consecutive failed check-in rounds for `checkin_failing`; `0` disables | +| `ALERT_CHECKIN_FAIL_WINDOW_HOURS` | `24` | Look-back window (hours) for `checkin_failing` | +| `ALERT_OFF_HOURS_START` / `_END` | `0` / `0` | Working hours `[start, end)` (local time, may cross midnight); equal = off | +| `ALERT_OFF_HOURS_MIN_REQUESTS` | `0` | Off-hours request threshold; `0` disables | +| `ALERT_BUDGET_CNY` | `0` | Monthly cost budget (CNY); `0` disables. Priced rows only | +| `ALERT_BUDGET_LEVELS` | `50,80,95,100` | Budget tiers; only the highest reached tier fires, 100% is critical | +| `BACKUP_INTERVAL_MINUTES` | `1440` | Backup period (min 60) | +| `BACKUP_KEEP` | `7` | Snapshots retained; `0` = never prune | +| `UPSTREAM_STATUS_ENABLED` | `true` | Upstream status page on/off | +| `UPSTREAM_STATUS_MINUTES` | `5` | Fetch period (min 1) | +| `UPSTREAM_STATUS_TIMEOUT_SECONDS` | `10.0` | Per-source HTTP timeout | +| `MODEL_PROBE_MINUTES` | `0` | Probe period (`0` = off). Probes `(channel, model)` pairs locked by `blocked` cache or already declared dead; success restores the model and clears the cache, everything else never disables. Hot | +| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | Main expiry window; `≤0` disables | +| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | Secondary window; `≤0` disables | +| `CONVERSATION_STICKY_SECONDS` | `3600` | Stickiness TTL. Priority: body key > header > message-prefix fingerprint; pin wins. `≤0` disables | +| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,fast,balanced,deep,primary,auto,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,glm-5.2*,glm.5.2*,kimi-k2.*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,kimi-k3,minimax-m2.5,minimax-m2.7,minimax-m3,deepseek-v3-*,deepseek-v4-*,qwen3.7-*,qwen3-*,qwen3.6*,qwen3-vl-235b,gpt-5.4,gpt-5.5,gpt-5.6*,hy3*,hy4-preview,hunyuan-*,seed-2.1*,seed-2-1-turbo,doubao-seed-2.1-turbo,seed-code-pro-*,seed-evolving,space-bunny,nemotron-3-*,nemotron-3*,lfm2*,laguna*,north-mini-code,inkling-small,xing4.0-29b-a4,glyph-cluster,openrouter-models-router,deepseek-v4.1-flash-0930,deepseek-v4.1-flash-bnt,glm-5.3-test,glm-5.3-zx,kimi-k3-test-1009,kimi-k3-1019` | Model list blocklist (fnmatch, display only), matched against normalized spellings | +| `MODEL_FALLBACK_GROUPS` | `""` | Cross-channel fallback groups: `name=member1,member2;…`. Empty = off | +| `ALLOWED_HOSTS` | empty | Host allowlist against DNS rebinding | +| `ALLOW_TEST_HOST` | `false` | Allow `testserver`; tests only | +| `TRUST_PROXY` | `false` | Trust `X-Forwarded-For`; only behind exactly one trusted proxy | | `ENABLE_DOCS` | `false` | Expose `/docs` and `/openapi.json` | -| `DUMP_REQUEST_BODIES` | `false` | Diagnostic: dump `/v1` bodies to `data/dumps/` (**contains conversation content**) | -| `LOG_LEVEL` | `INFO` | Audit log is INFO — switching to `WARNING` turns it off | -| `HOST` / `PORT` | `127.0.0.1` / `8000` | Listen address/port (compose defaults to `0.0.0.0`) | -| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | CodeBuddy upstream; add it to `CODEBUDDY_ALLOWED_ENDPOINTS` when changed | -| `CODEBUDDY_ALLOWED_ENDPOINTS` | both official sites (see compose) | Token only goes to allowlisted hosts | -| `CODEBUDDY_INTL_API_ENDPOINT` | `https://www.codebuddy.ai` | CodeBuddy international upstream; add it to its own allowlist | -| `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | `https://www.codebuddy.ai` | **Deliberately excludes** the CN host (cross-site = leakage; misconfig fails startup) | -| `ZEN_API_ENDPOINT` / `ZEN_ALLOWED_ENDPOINTS` | `https://opencode.ai` | Zen endpoint + allowlist | -| `ZEN_OPENCODE_VERSION` | `1.18.0` | Spoofed `opencode/` UA (below threshold → 426) | -| `KILO_API_ENDPOINT` / `KILO_ALLOWED_ENDPOINTS` | `https://api.kilo.ai/api/gateway` | Kilo endpoint + allowlist | -| `QODER_API_ENDPOINT` / `QODER_GATEWAY_ENDPOINT` | `https://openapi.qoder.com.cn` / `https://gateway.qoder.com.cn` | Qoder openapi (quota/login) + gateway (chat); international: `openapi.qoder.sh` / `api1.qoder.sh` | -| `QODER_ALLOWED_ENDPOINTS` | CN + international (see compose) | COSY-signed requests only to allowlisted hosts | -| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap engine; add it to its allowlist | -| `OFFICEACE_API_ENDPOINT` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce control plane (OAuth + provisioning); add it to its allowlist | -| `OFFICEACE_ALLOWED_ENDPOINTS` | the control-plane host only | The model gateway differs per account and is issued into the credential, so it is **not** allowlisted here | -| `CODEARTS_ALLOWED_ENDPOINTS` | snap + STS + portal (see compose) | AK/SK-signed requests only to allowlisted hosts | -| `PROVIDER_PROXIES` | `""` | Per-channel egress proxy (startup-only); empty = all direct | -| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB pacer (seconds), per-credential bucket; `0` disables | -| `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` | `5` | CB international pacer, **independent**; `0` disables | -| `ZEN_CHAT_MIN_INTERVAL` / `KILO_CHAT_MIN_INTERVAL` | `0` | Zen / Kilo pacers, independent, off by default | -| `QODER_CHAT_MIN_INTERVAL` / `CODEARTS_CHAT_MIN_INTERVAL` | `5` | Qoder / CodeArts pacers; `0` disables | -| `OFFICEACE_CHAT_MIN_INTERVAL` | `0` | OfficeAce pacer, independent; `0` disables | -| `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts in-flight cap per account; `0` disables | -| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts sliding window (3 starts / 60s with the cap above); `0` disables | -| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | Neutralize official-client fingerprints in outbound `system`/`assistant` text (upstream 11128 content filter); outbound copy only | -| `REFRESH_SKEW_HOURS` | `24` | Pre-refresh window; short-lived channels cap it (CodeArts 45 min) | -| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | Admin warning threshold; `≤0` disables (display only) | -| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | Global pacer random wait (seconds). When the bounds are equal (the `*_CHAT_MIN_INTERVAL` shape) the wait is additionally jittered **upwards** by ±25% so instances do not fire in lockstep; the lower bound is never breached | +| `LOG_LEVEL` | `INFO` | Log level; audit logging is INFO and disappears at WARNING | +| `HOST` / `PORT` | `127.0.0.1` / `8000` | Listen address and port (compose defaults to `0.0.0.0`); `PORT` also sets the host mapping | +| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | Upstream address; update the allowlist too | +| `CODEBUDDY_ALLOWED_ENDPOINTS` | both official sites (see compose) | Allowlist; real tokens only go to listed hosts | +| `CODEBUDDY_INTL_API_ENDPOINT` | `https://www.codebuddy.ai` | International address | +| `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | `https://www.codebuddy.ai` | International allowlist, deliberately excluding the CN host | +| `ZEN_API_ENDPOINT` | `https://opencode.ai` | Zen upstream | +| `ZEN_ALLOWED_ENDPOINTS` | `https://opencode.ai` | Zen allowlist | +| `ZEN_OPENCODE_VERSION` | `1.18.0` | Gate-spoofing UA version (below threshold → 426) | +| `ZEN_API_ROOT` | `/zen/v1` | Upstream API root; can switch to `/inference/v1` (both return 200 today) | +| `ZEN_GATE_TOOLS` | `bash,read` | Gate `tools` set; switch to `bash,glob,grep,read` if upstream tightens | +| `ZEN_USER_AGENT` | empty | UA template (`{version}` substituted); empty = `opencode/` | +| `KILO_API_ENDPOINT` | `https://api.kilo.ai/api/gateway` | Kilo upstream | +| `KILO_ALLOWED_ENDPOINTS` | same | Kilo allowlist | +| `QODER_API_ENDPOINT` | `https://openapi.qoder.com.cn` | Qoder openapi (quota / login); international `https://openapi.qoder.sh` | +| `QODER_GATEWAY_ENDPOINT` | `https://gateway.qoder.com.cn` | Qoder inference gateway; international `https://api1.qoder.sh` | +| `QODER_ALLOWED_ENDPOINTS` | CN + international (see compose) | Qoder allowlist | +| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap engine | +| `CODEARTS_ALLOWED_ENDPOINTS` | snap + STS + portal (see compose) | CodeArts allowlist | +| `OFFICEACE_API_ENDPOINT` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce control plane | +| `OFFICEACE_ALLOWED_ENDPOINTS` | the control-plane host only | Control plane only (the model gateway is issued upstream) | +| `OFFICEACE_CHAT_MIN_INTERVAL` | `0` | OfficeAce chat pacing; hot-reloadable | +| `PROVIDER_PROXIES` | `""` | Per-channel outbound proxy; startup-time | +| `CREDENTIAL_PROXY_POOL` | `""` | Egress IP stickiness: global proxy pool (semicolon-separated), assigned by stable hash of credential id; startup-time | +| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB chat pacing (per-credential buckets); hot-reloadable | +| `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` | `5` | CB international pacing, independent; hot-reloadable | +| `ZEN_CHAT_MIN_INTERVAL` | `0` | Zen pacing; hot-reloadable | +| `KILO_CHAT_MIN_INTERVAL` | `0` | Kilo pacing; hot-reloadable | +| `RACCOON_API_ENDPOINT` | `https://xiaohuanxiong.com` | Raccoon upstream | +| `RACCOON_ALLOWED_ENDPOINTS` | `https://xiaohuanxiong.com` | Raccoon allowlist | +| `RACCOON_CHAT_MIN_INTERVAL` | `0` | Raccoon chat pacing; hot-reloadable | +| `RACCOON_MAX_CONCURRENCY` | `2` | Raccoon in-flight cap per account; `0` disables; hot-reloadable | +| `LINGXI_API_ENDPOINT` | `https://lingxi.wps.cn` | Lingxi upstream | +| `LINGXI_ALLOWED_ENDPOINTS` | `https://lingxi.wps.cn,https://lingxi.kdocs.cn` | Lingxi allowlist (cookie/chat fall back across both hosts) | +| `LINGXI_CHAT_MIN_INTERVAL` | `0` | Lingxi chat min interval; hot-reloadable | +| `QODER_CHAT_MIN_INTERVAL` | `5` | Qoder pacing; hot-reloadable | +| `CODEARTS_CHAT_MIN_INTERVAL` | `5` | CodeArts pacing; hot-reloadable | +| `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts in-flight cap per account; `0` disables; hot-reloadable | +| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts sliding window; `0` disables; hot-reloadable | +| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | Replace "spoofed official client" fingerprints in outbound text (upstream 11128 risk control) | +| `REFRESH_SKEW_HOURS` | `24` | Pre-refresh window before expiry; short-lived channels cap it themselves | +| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | Token expiry warning threshold; `≤0` disables. Display only | +| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | Global pacer random wait range | | `AUTO_CONTINUE_MAX` | `10` | Auto-continue attempts on `finish_reason=length`; `0` disables | -| `CONTEXT_COMPRESS_ENABLED` | `true` | Trim over-long conversations to the catalog's input limit; models without a known limit are untouched | -| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | Output tokens reserved by the compression budget | +| `CONTEXT_COMPRESS_ENABLED` | `true` | Trim long conversations to the model input limit | +| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | Output tokens reserved in the compression budget | | `CONTEXT_COMPRESS_MIN_KEEP_MESSAGES` | `4` | Recent messages always kept | -| `CONTEXT_COMPRESS_SAFETY_RATIO` | `0.95` | Budget as a fraction of the model limit | -| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | Non-streaming aggregate timeout (retried as transient); `≤0` disables | -| `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | Per-credential in-flight cap (global across channels): a credential at its cap stops being selected; `0` = unlimited. The count also serves as the scheduler's least-in-flight load key. Leases release when the request ends, with a 600s TTL guarding leaked ones | -| `METRICS_ENABLED` | `true` | Expose `GET /metrics` (Prometheus text format, **unauthenticated**, same stance as `/healthz`); `false` disables it (404). Hot-updatable | -| `REQUEST_LOG_ENABLED` | `true` | Request-level structured log (logfmt): one line per business request (`/api/*`, `/v1/*`, `/authorize`) — `event=request request_id=… method=… route=… status=… duration_ms=…`. The route is the template (`/api/credentials/{credential_id}`), never the real ID; **no query string, no body, no username** — correlate via `request_id` against `usage_events`. 5xx at WARNING, the rest at INFO; static assets and probes are not logged. `false` falls back to uvicorn's plain access log. Hot-updatable | -| `FORCE_DISABLE_THINKING` | `false` | Strip reasoning from outbound responses: no more `reasoning_content` / Anthropic `thinking` blocks / Responses reasoning items. **Response-side only — the request body is untouched** (upstream still reasons). Applies to all three exits, streaming and non-streaming. Hot-updatable | -| `REASONING_EFFORT_MAP` | `""` | Outbound `reasoning_effort` remap: `channel=from:to,from:to;*=from:to` (channel is a `KNOWN_PROVIDERS` id, `*` is the fallback); a target of `none` deletes the key and lets the channel default apply. Only rewrites values the client **explicitly** sent — a channel's own `setdefault` is untouched. Empty = pass through. Hot-updatable | -| `ANTHROPIC_THINKING_EFFORTS` | `""` | Anthropic inbound `thinking.budget_tokens` → upstream `reasoning_effort`: `budget:effort,budget:effort,disabled:effort` (`disabled` matches `thinking.type=="disabled"`; otherwise the largest threshold ≤ `budget_tokens` wins, and budgets below the smallest threshold are not mapped). Empty = no mapping at all (`thinking` stays ignored). Hot-updatable | +| `CONTEXT_COMPRESS_SAFETY_RATIO` | `0.95` | Budget share of the model limit | +| `CONTEXT_COMPRESS_STICKY_ENABLED` | `true` | Sticky trim: cut once to 60% of budget, lock the window start, keep the cross-turn prefix stable so upstream prefix cache hits | +| `PREFIX_DEBUG_ENABLED` | `false` | Prefix-cache diagnostics: one payload-fingerprint line per request (digests only), to locate the divergence point when hit rate is off. Hot-reloadable | +| `DUMP_REQUEST_BODIES` | `false` | Dump the raw `/v1` request body (full conversation) to `DATA_DIR/dumps/` — diagnostics only. Hot-reloadable; dir 0700 / files 0600 / 200 files and 24h max; the toggle is audited | +| `GATEWAY_PAUSED` | `false` | Kill switch: every API-key-authenticated `/v1` endpoint returns 503 (`code=service_paused`) while the admin console and login keep working. Hot-reloadable; audited and logged | +| `GATEWAY_PAUSE_REASON` | empty | Why the gateway is paused — logs and audit only, never echoed to callers | +| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | Non-streaming aggregate timeout; `≤0` disables | +| `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | First-event timeout (retryable before any frame). **Keep off by default**, recommended 60. Hot | +| `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | Idle timeout (closes, never retries), recommended 300. Hot | +| `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | Wait when the whole pool is in short cooldown; `0` = fail fast. Hot | +| `NONSTREAM_KEEPALIVE_SECONDS` | `0` | Non-streaming responses switch to chunked with whitespace heartbeats after N seconds (leading whitespace is legal JSON); `0` = off, only needed behind a CDN, recommended 15. Hot | +| `MODEL_ALIASES` | `""` | Custom aliases; empty = off. Hot | +| `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | Per-account in-flight cap; `0` = unlimited. 600s TTL backstop | +| `METRICS_ENABLED` | `true` | Expose `/metrics`. Hot | +| `METRICS_TOKEN` | empty | When set, `/metrics` requires `Authorization: Bearer `; empty = public (same as `/healthz`). Startup-only | +| `REQUEST_LOG_ENABLED` | `true` | Request-level logfmt logging; no query / body / username. Hot | +| `FORCE_DISABLE_THINKING` | `false` | Strip reasoning from responses only. Hot | +| `REASONING_EFFORT_MAP` | `""` | Outbound level mapping `channel=from:to;*=from:to`; `none` deletes the key. Hot | +| `ANTHROPIC_THINKING_EFFORTS` | `""` | Anthropic `thinking.budget_tokens` → upstream `reasoning_effort`. Hot | ### Runtime settings (*Tasks & settings* page) -**60 settings** apply immediately without editing `.env` or restarting: default model, blocklist, model aliases, context-compression knobs, fallback groups, both expiry windows, sticky TTL, stream timeouts, short-cooldown wait, the per-credential in-flight cap, growth toggles/interval, refresh/probe/catalog intervals, `USD_CNY_RATE`, every per-channel chat interval, CodeArts concurrency/window, pacer bounds, activity toggle/hour, check-in times/jitter, all alerting rules, backup interval/retention, upstream-status toggle/interval, `METRICS_ENABLED`, `REQUEST_LOG_ENABLED`, `LOG_BUFFER_LINES` and the three reasoning knobs (`FORCE_DISABLE_THINKING` / `REASONING_EFFORT_MAP` / `ANTHROPIC_THINKING_EFFORTS`). +The console exposes **85** hot items (`HOT_SETTINGS` in `src/runtime_settings.py`; the table above tags only the common ones); they can be changed without touching `.env` or restarting. -**DB overrides beat `.env`**: once changed, `.env` no longer applies to that key; the row is marked "DB override" and *Reset* removes the override. Values live in the `runtime_settings` table (plain key/value), so adding a hot-updatable key needs no migration; unknown keys and invalid/out-of-range values are rejected on write. Startup-only knobs (`APP_SECRET`, `PORT`, `DATA_DIR`, `USERS_FILE`, `OPENROUTER_MODELS_URL`, endpoint allowlists) are **excluded**. API: `GET /api/settings`, `PUT /api/settings` (admin + CSRF; `null` resets). The page also shows the 12 tasks' state (`GET /api/tasks`, admin, 30s refresh), in-process only. Each task card has a **Run now** button (`POST /api/tasks/{key}/run`, admin + CSRF): it bypasses the due-time window but **not** the enable switch — a disabled task honestly returns `ran=false` instead of pretending it ran; if the same task is already running it returns `busy=true` immediately (no queueing, no concurrent upstream calls); the response is the finished round. +Key points: **DB override beats `.env`** — once overridden `.env` no longer applies to that item; page marks "DB override", *Restore default* deletes row. Values live in `runtime_settings`, so new hot settings need no migration; unknown keys and invalid types / out-of-range values rejected before writing. Startup-time items (`APP_SECRET` / `PORT` / `DATA_DIR` / `USERS_FILE` / `OPENROUTER_MODELS_URL` / upstream allowlists) not hot. `GET /api/settings` / `PUT /api/settings` (admin + CSRF); `null` restores default. Page also shows task runtime state (`GET /api/tasks`, in-process, 30s refresh). ## Deployment notes -- **Mount ownership**: the container runs as uid 1001 (`appuser`); `./data` and `./secrets` must be writable/readable or SQLite cannot open: +- **Mount ownership**: container runs as uid 1001 (`appuser`) → `./data` and `./secrets` must be writable / readable: ```bash mkdir -p data secrets && sudo chown -R 1001:1001 data secrets ``` -- **Timezone**: the image defaults to `TZ=Asia/Shanghai`. +- **Timezone**: image defaults `TZ=Asia/Shanghai`; `.env` `TZ` passed through. Check-in and quota cycles depend on "today at 9" → keep aligned with host. +- **TRAE callback URL**: the default `PUBLIC_BASE_URL=http://127.0.0.1:8000` only works locally; remote / container deployments need a browser-reachable address. **Forgetting it fails loudly, not silently**: when the base is loopback / `0.0.0.0` the callback falls back to the current request origin (LAN IP or tunnel domain), *Add credential* shows the actual address, and an authorization timeout names `PUBLIC_BASE_URL`. **If the callback URL is unreachable from the browser** (common on NAS / remote hosts, where TRAE shows a network error and the refreshToken stays in the address bar), paste the whole address-bar URL into the TRAE field in *Add credential* — login completes without the browser ever reaching this service. +- **Port**: in compose `PORT` sets both container listen port and host mapping. +- **Rebuild after code changes**: `docker compose up -d` without `--build` reuses local image, runs old code. Use `docker compose up -d --build` after changing `src/` or frontend. ### Restart the backend after upgrading -**Changing anything under `src/` requires a process restart, or you get a "new frontend against an old backend" mismatch.** Typical symptom: the admin UI loads (frontend assets are static) but new endpoints fail — the old process lacks the route, unmatched `/api/*` returns a JSON `404`, and the client collapses that into an unrelated error. The frontend never needs a restart (the backend reads `web/dist` per request), but the **backend does** — a process manager restarts on *exit*, not on source change. +**Changes under `src/` require process restart**, otherwise you get "new frontend + old backend": pages load but new endpoints 404. The frontend does not need restart (the backend reads `web/dist` per request); backend does. ```bash docker compose up -d --force-recreate # Docker / compose sudo systemctl restart coding2api # systemd ``` -Confirm the upgrade took effect (version first, then routes): +Confirm upgrade: ```bash -sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, enabled FROM users;" # expect user_version 29 +sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, enabled FROM users;" curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users # expect 401; 404 = old backend ``` ### Frontend assets on the same port -The backend serves the frontend build at `/` (reading from disk per request), so a frontend-only change just needs a rebuild and a browser refresh; `localhost:5173` is the Vite dev server. `scripts/build-web.sh` builds only when stale, or unconditionally with `--force`. +Backend serves built frontend at `/` (reads disk per request), so **frontend-only changes just need build + refresh**. `localhost:5173` is Vite dev server; both coexist. -> **Do not delete `web/.npmrc`, and do not change it only locally**: it disables `auto-install-peers`. `@lobehub/icons` declares `@lobehub/ui` / `antd` as peers, and auto-installing them drags in 400+ unused packages (this project only deep-imports its static icon components, which never reference `@lobehub/ui`); disabling it roughly halves the lockfile and takes `pnpm audit --prod` to zero vulnerabilities. The cost is that peers actually needed **must be declared explicitly** (that is why `@testing-library/dom` is listed in `devDependencies`). The setting is tightly coupled to `settings.autoInstallPeers` in the lockfile, so `Dockerfile` must `COPY web/.npmrc` as well — otherwise `--frozen-lockfile` fails with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`. +```bash +./scripts/build-web.sh # build only when stale +./scripts/build-web.sh --force # unconditional rebuild +``` + +> **`web/.npmrc` must not be deleted**: it disables `auto-install-peers` (else `@lobehub/icons` drags in `@lobehub/ui` / `antd`). Coupled to `settings.autoInstallPeers` in lockfile → `Dockerfile` must `COPY web/.npmrc`, or `--frozen-lockfile` fails with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`. + +> **Deployment notes for this machine**: process management, paths, tool versions in `docs/local-environment.md` (gitignored). + +## Public deployment (Docker + Caddy automatic HTTPS) + +For a VPS with a public IP: one compose command starts "Caddy edge + app", certificates are issued / renewed automatically, and the app port is not exposed. + +``` +client ──443/TLS──> Caddy (TLS termination) ──compose network──> coding2api:8000 +``` + +Prerequisites: Docker Compose ≥ 2.24 (the override uses `!override`), a DNS A / AAAA record pointing at this host for the site address (a domain, or see *Public IP only, no domain* below), ports 80 and 443 (tcp + udp) free (80 is needed for the ACME HTTP-01 challenge). + +Two more `.env` entries (everything else as in *Quick start*): + +```bash +PUBLIC_DOMAIN=ai-api.example.com # required: the certificate is issued for it (public IP when you have no domain) +ACME_EMAIL=ops@example.com # required with a domain: Let's Encrypt expiry notices +# TLS_MODE= # empty = automatic certificates; set internal for a public IP (see below) +# Leave ALLOWED_HOSTS empty: empty = loopback + the PUBLIC_BASE_URL hostname +``` + +Start it and watch certificate issuance: + +```bash +docker compose -f docker-compose.yml -f docker-compose.public.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.public.yml logs -f caddy +curl -fsS https://ai-api.example.com/health +``` -> Deployment specifics for the development machine (process management, paths, tool versions) live in `docs/local-environment.md`, which is gitignored and absent from clones. +What the override (`docker-compose.public.yml`) changes: + +| Change | Why | +|---|---| +| App port bound to `127.0.0.1:${PORT}` | No plaintext-http bypass left; the only way in is Caddy | +| `TRUST_PROXY=true` | Exactly one Caddy in front (and the app port is loopback-only, so it cannot be reached directly), so API keys' `allowed_ips` see the real client IP | +| `ALLOWED_HOSTS` extended with the domain + `127.0.0.1` | Non-empty means *full replacement*: a value already in `.env` would drop the domain (site-wide 400); missing loopback makes the container healthcheck fail → permanently unhealthy | +| `HOST=0.0.0.0` | `.env.example` ships `HOST=127.0.0.1`, which would make the container listen on loopback only and Caddy's upstream unreachable | +| New Caddy service (`deploy/caddy/`) | Automatic HTTPS + HTTP→HTTPS 308; `flush_interval -1` keeps streaming frame-by-frame; `X-Forwarded-For` is overwritten so client spoofing is ignored; no proxy-level timeouts (those stay with the app's `STREAM_*` / `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`) | +| `TLS_MODE` picks the certificate source | `acme` (default, domain) requests Let's Encrypt automatically; `internal` (public IP) signs with Caddy's built-in CA | +| `/metrics` returns 404 | That endpoint has no auth by default (`METRICS_TOKEN` can tighten it); grab it from the host with `curl 127.0.0.1:${PORT}/metrics` when needed | + +### Public IP only, no domain + +Caddy's automatic certificates (ACME) only work for domain names — it will not issue for a bare IP. Two options: + +1. **A certificate browsers trust** → use a public wildcard-DNS name such as sslip.io / nip.io: `203-0-113-10.sslip.io` (or `203.0.113.10.sslip.io` / `.nip.io` — both spellings work) resolves to `203.0.113.10` automatically. Set `PUBLIC_DOMAIN` to it and keep `ACME_EMAIL`; this is plain ACME. Trade-off: certificates are rate-limited per *registered domain*, and sslip.io / nip.io are two domains **shared by every user** (exhausted once in Feb 2026; both got quota increases since) — if issuance fails, switch to the other, or fall back to self-signed. +2. **API clients only, untrusted certificate is fine** → `PUBLIC_DOMAIN=` + `TLS_MODE=internal`. Caddy signs with its built-in CA: the connection is still encrypted, but the certificate is in no public trust store — browsers warn, `curl` needs `-k`, or install the root CA into the client (it lives in the `caddy_data` volume: `docker compose -f docker-compose.yml -f docker-compose.public.yml exec caddy cat /data/pki/authorities/local/root.crt`). + +> The self-signed mode relies on `default_sni {$PUBLIC_DOMAIN}` in the Caddyfile: browsers and curl **send no SNI for IP literals** (RFC 6066 allows hostnames only), so without it Caddy cannot pick a certificate and the client only sees `tlsv1 alert internal error`. It is already in the shipped Caddyfile. + +Notes: + +- **Certificates live in the named volume `caddy_data`**: never `docker compose ... down -v` — re-issuing hits the Let's Encrypt limit of 5 certificates per domain per week. +- **After changing `src/`, rebuild the app container**: `... up -d --build`; restarting Caddy alone does nothing (restarting the app itself is covered under *Restart the backend after upgrading* above). +- Behind CGNAT there is no inbound address, so this path is unavailable → use the frp tunnel below. ## Public entry point (VPS + frp reverse tunnel) -Home broadband behind CGNAT has no inbound address, so the public entry point must be built by **dialing out** from the private side. The free Cloudflare Tunnel tier routes mainland traffic to the LAX edge (measured 1.1–2.4s end-to-end, ~60% packet loss); replacing it with an overseas VPS entry plus an frp tunnel drops that to 80–150ms. **Only the inbound path changes**: outbound calls to upstreams still use the local `PROVIDER_PROXIES`. +Behind CGNAT there is no inbound address, so public entry must be outbound tunnel. Cloudflare Tunnel free tier routes mainland traffic via Los Angeles (measured 1.1–2.4s, ~60% loss); "offshore VPS entry + frp back to origin" gets 80–150ms. Inbound only — outbound still uses `PROVIDER_PROXIES`. ``` -client ──443/TLS──> VPS (nginx) ──frp tunnel──> home Mac:8000 +client ──443/TLS──> VPS(nginx) ──frp tunnel──> home Mac:8000 ``` -Artifacts: `deploy/frp/` (frps/frpc configs), `deploy/nginx/` (reverse proxy + TLS), `deploy/systemd/frps.service` (VPS side), `deploy/launchd/com.coding2api.frpc.plist` (Mac side). Config format targets frp v0.71.1 TOML. +Artifacts in `deploy/frp/`, `deploy/nginx/`, `deploy/systemd/frps.service`, `deploy/launchd/com.coding2api.frpc.plist` (frp v0.71.1 TOML). -### VPS side (Linux + systemd) +VPS side: ```bash sudo apt install -y nginx certbot python3-certbot-nginx -# grab the linux_amd64 archive from https://github.com/fatedier/frp/releases, extract frps -sudo install -m 755 frps /usr/local/bin/frps - +sudo install -m 755 frps /usr/local/bin/frps # from the frp release archive sudo useradd --system --no-create-home --shell /usr/sbin/nologin frps -sudo install -d -m 755 /etc/frp -sudo install -m 644 deploy/frp/frps.toml /etc/frp/frps.toml -# shared token (use the same value when installing the client) +sudo install -d -m 755 /etc/frp && sudo install -m 644 deploy/frp/frps.toml /etc/frp/ openssl rand -hex 32 | sudo tee /etc/frp/frps-token >/dev/null sudo chmod 600 /etc/frp/frps-token && sudo chown frps:frps /etc/frp/frps-token - sudo install -m 644 deploy/systemd/frps.service /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now frps - -# nginx: replace __TUNNEL_DOMAIN__ with your domain sed 's/__TUNNEL_DOMAIN__/ai-api.example.com/g' deploy/nginx/coding2api.conf \ | sudo tee /etc/nginx/sites-available/coding2api.conf >/dev/null sudo ln -sf /etc/nginx/sites-available/coding2api.conf /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx -sudo certbot --nginx -d ai-api.example.com # issues the cert and sets up renewal +sudo certbot --nginx -d ai-api.example.com ``` -Point the domain's A record at the VPS public IP. An overseas VPS needs no ICP filing. +Point the DNS A record at the VPS public IP; offshore VPS needs no ICP filing. -### Home Mac side (launchd) +Home Mac side: ```bash -brew install frp # or download it and set FRPC_BIN -./scripts/install-frpc.sh --server --token +brew install frp +./scripts/install-frpc.sh --server --token launchctl print gui/$(id -u)/com.coding2api.frpc | grep -E 'state|pid' -./scripts/install-frpc.sh --uninstall # uninstall +./scripts/install-frpc.sh --uninstall ``` -`deploy/frp/frpc.toml` reads values through frp's native `{{ .Envs.FRP_SERVER_ADDR }}` templating, so the repo holds nothing machine-specific; the address and token file path are injected by the plist, and the token lives in `secrets/frpc-token` (mode 600, gitignored) rather than in the plist — `launchctl print` dumps the plist verbatim. +`frpc.toml` reads `{{ .Envs.FRP_SERVER_ADDR }}`; address + token come from plist; token lives in `secrets/frpc-token` (600, gitignored). -### Three `.env` values to update +Three `.env` values to update: -| Variable | Value | What breaks if missed | +| Variable | Value | If missed | |---|---|---| -| `ALLOWED_HOSTS` | append the tunnel domain | every tunnel request gets 400 `invalid host header` | -| `PUBLIC_BASE_URL` | `https://` | TRAE login callback base is wrong; no HSTS over https | -| `HOST` | keep `127.0.0.1` | the tunnel loops back locally; there is **no need** to bind `0.0.0.0` | - -`TRUST_PROXY`: there is exactly one nginx hop on the VPS, so `true` is fine and lets an API key's `allowed_ips` see the real client IP (nginx forwards `X-Forwarded-For`). If you still want a direct LAN entry point, keep it `false` — otherwise peers on the same subnet can spoof XFF and bypass the allowlist. +| `ALLOWED_HOSTS` | add the tunnel domain | every tunnel request returns 400 `invalid host header` | +| `PUBLIC_BASE_URL` | `https://` | wrong TRAE callback base, no HSTS | +| `HOST` | keep `127.0.0.1` | the tunnel only needs to reach loopback | -> Restart the service after changing `.env` (see "Deployment notes" above). -> Both frpc and frps log to stdout → `logs/frpc.*.log` on the Mac (rotated by -> newsyslog; `deploy/newsyslog/coding2api.conf` already has the two rules) and -> journald on the VPS. +`TRUST_PROXY`: with exactly one nginx hop on VPS set `true` so key `allowed_ips` sees real client IPs. Keep `false` if LAN direct access must stay safe. ## Logs -The app writes **stdout / stderr** only, with no file logging or self-rotation, so the logs never stop growing on their own — configure rotation once per deployment shape. +The app writes **stdout / stderr** only, never files, and does not rotate. -| Deployment | Where | Rotation | Action | +| Deployment | Where logs go | Rotation | Action | |---|---|---|---| -| **Docker / compose** | `docker logs coding2api` | json-file (`10m × 5`, configured) | **none** | -| **Linux (systemd)** | `journalctl -u coding2api` | journald | **none** (template in `deploy/systemd/`) | -| **Linux (non-systemd)** | `/var/log/coding2api/*.log` | `logrotate` | `sudo cp deploy/logrotate/coding2api /etc/logrotate.d/` | -| **Bare** | terminal stderr | none | redirect and rotate yourself | +| Docker / compose | `docker logs coding2api` | json-file (10m × 5) | none | +| Linux (systemd) | `journalctl -u coding2api` | journald | none | +| Linux (non-systemd) | redirected to files | `logrotate` | `sudo cp deploy/logrotate/coding2api /etc/logrotate.d/` | +| macOS (launchd) | `logs/launchd.{out,err}.log` | `scripts/rotate-logs.sh` | `./scripts/install-logrotate.sh` | +| Bare process | terminal stderr | none | redirect and rotate yourself | -Level via `LOG_LEVEL` (default `INFO`); the audit log is INFO, so `WARNING` turns it off. +> **Do not use newsyslog on macOS**: it only does rename-based rotation, but the fd launchd handed the process still points at the old inode — after rotation the process keeps writing into the archived file, the new path only appears on the next restart, and everything in between is lost (21.7h observed locally). `rotate-logs.sh` copies → compresses → truncates, matching `copytruncate` on the Linux side. + +Level via `LOG_LEVEL` (default `INFO`). **Audit logging is INFO** and disappears at `WARNING`. ### Request-level structured log -Besides uvicorn's plain access log, every business request (`/api/*`, `/v1/*`, `/authorize`) emits one logfmt line: +Every business request (`/api/*`, `/v1/*`, `/authorize`) writes one logfmt line: ``` event=request request_id=9f2c… method=POST route=/v1/chat/completions status=200 duration_ms=1234 ``` -- **`route` is the template** (`/api/credentials/{credential_id}`), not the real path — resource IDs never enter the log and the log doesn't fan out per ID. Unmatched routes (404, rejected by the security middleware) fall back to a sanitized raw path (whitelisted characters, 120-char cap). -- **No query string, body, headers or username.** Query strings may carry client-supplied tokens; identity is correlated through `request_id` against `usage_events.request_id` (which has the `username` column) — logs are kept longer and read by more people, so less is better. +- `route` = route template (`/api/credentials/{credential_id}`); unmatched requests fall back to sanitized raw path. +- No query string, request body, response body, headers or username; identity recovered via `request_id` into `usage_events`. - `request_id` matches the `X-Request-ID` response header. -- **Levels**: `5xx` at WARNING (our own fault, worth catching), everything else at INFO (otherwise 401 probes and typo'd paths drown the alert log). -- **Static assets and probes are not logged** (`assets/*.js`, `/health`, `/healthz`, `/metrics`) — uvicorn already covers them, and duplicating only pushes business lines out of view. -- Toggle `REQUEST_LOG_ENABLED` (default `true`, hot-updatable); turning it off falls back to uvicorn's plain access log. +- `5xx` logs WARNING, everything else INFO. +- Static assets and probes (`/health`, `/healthz`, `/metrics`) are skipped. +- uvicorn's access log is disabled. +- Switch `REQUEST_LOG_ENABLED` (default `true`, hot). + +> `data/dumps/` (written by `DUMP_REQUEST_BODIES=true`) is outside docker log system; the app keeps at most 200 files and no longer than 24 hours (by mtime), directory 0700 / files 0600. This is the only switch that writes conversations to disk — turn it back off right after diagnosing. ## Development @@ -692,18 +1005,25 @@ pnpm exec vitest run pnpm build ``` -## Documentation +Contributing (issue templates, PR gates, schema rules): see [`CONTRIBUTING.md`](CONTRIBUTING.md). -Detailed documentation is written in Chinese: +## Documentation -| Document | Content | +| Document | Contents | |---|---| -| [`PROPOSAL.md`](PROPOSAL.md) | Scope, upstream feasibility findings, architecture, data model, security boundary | -| [`TECHNICAL.md`](TECHNICAL.md) | Stack, module specs, provider protocol, request flow, testing | +| [`PROPOSAL.md`](PROPOSAL.md) | Goals and non-goals, upstream feasibility, architecture, data model, security boundary | +| [`TECHNICAL.md`](TECHNICAL.md) | Stack, module specs, provider protocols, request sequence, test strategy | | [`diagrams/coding2api-architecture.html`](diagrams/coding2api-architecture.html) | Architecture diagram | -| [`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html) | Request main-chain sequence diagram | -| [`diagrams/coding2api-credential-lifecycle.html`](diagrams/coding2api-credential-lifecycle.html) | Credential scheduling lifecycle | +| [`diagrams/coding2api-request-flow.html`](diagrams/coding2api-request-flow.html) | Main request flow (`/v1/responses` and `/v1/messages` share the chain) | +| [`diagrams/coding2api-account-flow.html`](diagrams/coding2api-account-flow.html) | Account and API key lifecycle | +| [`diagrams/coding2api-data-model.html`](diagrams/coding2api-data-model.html) | Data model (13 tables drawn + ownership; `checkin_events` not yet in the diagram — `src/db/schema.sql` has 14) | +| [`diagrams/coding2api-permission-matrix.html`](diagrams/coding2api-permission-matrix.html) | Three-role permission matrix | +| [`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html) | Main request sequence | +| [`diagrams/coding2api-credential-lifecycle.html`](diagrams/coding2api-credential-lifecycle.html) | Credential scheduling state machine | +| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Issue / PR flow and gates | +| [`SECURITY.md`](SECURITY.md) | Vulnerability reporting and deployer trust boundary | +| [`CITATION.cff`](CITATION.cff) | Citation metadata (GitHub "Cite this repository") | ## License -MIT — see [LICENSE](LICENSE). Attribution for the upstream projects this work learned from is in [NOTICE](NOTICE). +MIT, see [LICENSE](LICENSE). Upstream attributions in [NOTICE](NOTICE). diff --git a/README.md b/README.md index 3af4de1c..d0f1bd82 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,141 @@ # Coding2API -把 **CodeBuddy**(国内站 + 国际)、**TRAE SOLO**、**OpenCode Zen**、**Kilo**、**Qoder**、**CodeArts**、**OfficeAce** 八个上游渠道封装为 OpenAI 兼容 API,提供公共凭证池、统一模型名与调度、按人用量统计。 +[![CI](https://github.com/RobbsLuo/Coding2API/actions/workflows/ci.yml/badge.svg)](https://github.com/RobbsLuo/Coding2API/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/RobbsLuo/Coding2API)](https://github.com/RobbsLuo/Coding2API/releases) +[![License: MIT](https://img.shields.io/github/license/RobbsLuo/Coding2API)](LICENSE) +[![Image](https://img.shields.io/badge/image-ghcr.io%2Frobbsluo%2Fcoding2api-blue)](https://github.com/RobbsLuo/Coding2API/pkgs/container/coding2api) + +十路渠道——**CodeBuddy**(国内 + 国际,两个独立渠道)、**TRAE SOLO**、**OpenCode Zen**、**Kilo**、**Qoder**、**CodeArts**、**OfficeAce**、**小浣熊**、**灵犀**——封装成 OpenAI 兼容 API:公共凭证池、统一模型名与调度、按人用量统计。 > [!WARNING] > 逆向接口,仅供学习研究,未做安全审计;公网部署需反向代理 + 鉴权 + IP 白名单。 ## 特性 -- **OpenAI 兼容出口**:`/v1/chat/completions`(流式 + 非流式)、`/v1/responses`(Codex CLI)、`/v1/messages`(Anthropic / Claude Code)、`/v1/models` 与 `/v1/models/{id}`(单模型查询)、`/v1/user/balance`(DeepSeek 兼容余额)。 -- **统一调度**:扁平模型名按健康度自动选号,`模型@渠道` 强制指定。候选含免费渠道时**免费优先**,之后**在途最少优先**(并发摊到空闲号),再按到期额度、三态健康度 + 分级冷却避开坏号,打平则**余额多者优先**。模型级限流只避让该模型(同账号其他模型立即可用);某模型只有一个渠道持有且它在模型级冷却时,直接回「该模型暂不可用」。同一对话多轮粘住同一凭证,出错才轮换。 -- **到期额度优先消化**:主窗口(默认 36h)内将过期的额度多者先用,避免过期浪费;打平再比 7 天窗口。额度单位统一为积分。 -- **模型列表按渠道凭证加载**:只展示**当前有可用凭证**的渠道,未接入不出现幽灵模型;暂停或凭证失效时其模型暂时消失,恢复即回来。 -- **公共凭证池**:admin 集中维护、全员共享、加密入库(`APP_SECRET`);设备码登录、多账号切换、额度探测、每日签到、token 预刷新。 -- **三角色账号体系**:`admin` / `operator` / `viewer`,用户存 SQLite;一次性激活链接自设密码、首登强制改密、改角色/停用即时吊销会话;登录与写操作留审计。 -- **成长中心**(仅 CodeBuddy):自动领 Buddy 旅行礼物、派 Buddy、领任务奖、断登补登、连登兑换、开盲盒;不可逆动作可用 `GROWTH_IRREVERSIBLE_ACTIONS=false` 关停;管理台可手动执行。 -- **脱敏统计**:不存对话内容;明细 90 天、小时汇总永久;按人/渠道/模型/凭证可视化;按 OpenRouter 刊例价估算成本(人民币为主);按「正文 token ÷ 生成时间总和」与「总产出 token ÷ 生成时间总和」两口径给出输出速度(tok/s,剔除批到达的不可测样本;概览卡片、分组表与逐请求明细同源);明细面板带「实时」开关(SSE 推送刚落库的请求)。 -- **上游状态聚合**:把各模型 / 网关提供方的官方状态页(OpenCode Zen、Kilo、DeepSeek、Together、Moonshot、MiniMax、OpenAI、Anthropic)归一化后并排展示,用于区分「上游整体故障」与「本服务凭证 / 额度问题」。只做解释、不参与选号 / 熔断;快照只存内存、重启归零。 -- **侧栏数据提醒**:侧栏对应项上显示待办计数角标(凭证需重登/耗尽、未恢复告警、上次失败任务、异常上游源、待激活账号、本人将到期 Key、模型目录陈旧),不点进去也能看到;只数「待办」、按角色裁剪,见[下文](#侧栏数据提醒导航角标)。 -- **管理台安全加固**:登录限流、CSRF 校验、请求体上限、Host 白名单。 -- **窄屏表格转卡片**:列多的表格(凭证 / 明细 / 模型列表等)在 <768px 自动由「一行 = 一张卡」呈现——列名收成一排可点的排序片,每个字段在自己行首显示字段名;DOM 不变,读屏语义与宽屏一致。 - -协议层实现(私有信封、签名、门禁伪装等)见 [`TECHNICAL.md`](TECHNICAL.md)。 +- **OpenAI 兼容出口**:`/v1/chat/completions`(流式 / 非流式)、`/v1/responses`(Codex CLI)、`/v1/messages`(Claude Code)、`/v1/models`(含单模型查询)、`/v1/user/balance`。 +- **统一调度**:扁平名按健康度选号;`模型@渠道` 强制。优先级:免费渠道 → 在途最少 → 到期额度 → 健康度 + 分级冷却 → 余额多者。管理员可给凭证设**权重**(`-1000..1000`):只在同一健康档内重排(正数优先、负数当备用),不会把冷却/耗尽的号顶上来。目录里的 x0 会被最近 24h 的实测扣费校准(上游改计费不改目录时不至于永远占住免费档)。模型级限流只避让该模型;多轮对话粘同一凭证,出错才轮换。 +- **到期额度优先**:主窗口(默认 36h)先烧将过期额度;打平再比 7 天窗口。单位统一积分。 +- **模型列表按凭证加载**:只列有可用凭证的渠道 → 无幽灵模型。 +- **公共凭证池**:admin 维护、全员共享、加密入库(`APP_SECRET`);设备码登录、多账号切换、额度探测、签到、token 预刷新。 +- **三角色**:`admin` / `operator` / `viewer`,存 SQLite;一次性激活链接、首登强制改密、改角色 / 停用即时吊销会话;登录与写操作留审计。 +- **成长中心**(仅 CodeBuddy):领礼、派 Buddy、领任务奖、断登补登、连登兑换、开盲盒。不可逆动作 `GROWTH_IRREVERSIBLE_ACTIONS=false` 关停。 +- **脱敏统计**:不存对话;明细 90 天、小时汇总永久;按人 / 渠道 / 模型 / 凭证可视化;成本按 OpenRouter 刊例价估算;速度分正文 / 总产出;明细走 SSE。 +- **月度账单**(`operator` 起):按月按人 / 按 API Key 汇总请求、token 与成本;CSV(按人)/ JSONL(逐条明细)导出并写审计。按 Key 明细受 90 天保留期限制。 +- **工具调用健壮性**:`id` 缺失按 index 补并记忆;DeepSeek DSML 泄漏回读成结构化调用并改写 `finish_reason`;非流式坏 `arguments` fail-closed(400,不冷却凭证)。 +- **渠道 ToS 风险登记**:导入凭证时展示 green / gray / red 与理由(运营判断),审计留等级;只提示不阻断调度。 +- **出口 IP 粘性**(可选):`CREDENTIAL_PROXY_POOL` 非空时一号一出口(sha256 稳定分配,重启不漂移);推理、探活、签到、预刷新、成长、管理台单条动作都走同一出口。 +- **上游状态聚合**:并排官方状态页,区分「上游故障」与「本服务凭证 / 额度问题」。只解释、不调度;只存内存。 +- **侧栏数据提醒**:待办角标(需重登 / 耗尽、未恢复告警、任务失败、异常上游、待激活账号、将到期 Key、目录陈旧),按角色裁剪。[口径](#侧栏数据提醒导航角标) +- **管理台加固**:登录限流、CSRF、请求体上限、Host 白名单。 +- **窄屏表格转卡片**:<768px 一行 = 一卡;列名收成可点排序片。DOM 不变,读屏语义一致。 + +协议层(私有信封、签名、门禁伪装)见 [`TECHNICAL.md`](TECHNICAL.md)。 + +## 界面预览 + +管理台(截图取自本地实例,账号 / IP 已脱敏): + +| 凭证管理:池概况与渠道分组 | 用量统计:请求 / token / 成本 | +|---|---| +| ![凭证管理](assets/screenshots/credentials.webp) | ![用量统计](assets/screenshots/stats.webp) | +| **Playground:模型 × 渠道与倍率** | **模型列表:OpenRouter 刊例价与能力分** | +| ![Playground](assets/screenshots/playground.webp) | ![模型列表](assets/screenshots/models.webp) | +| **上游状态:区分上游故障与本服务问题** | **API Key:客户端接入说明与配额** | +| ![上游状态](assets/screenshots/upstream.webp) | ![API Key](assets/screenshots/apikeys.webp) | +| **用户管理:三角色与一次性激活** | **审计日志:登录成败都留痕** | +| ![用户管理](assets/screenshots/users.webp) | ![审计日志](assets/screenshots/audit.webp) | +| **服务端日志:环形缓冲 + 脱敏** | **运维告警:九类风险周期评估** | +| ![服务端日志](assets/screenshots/logs.webp) | ![运维告警](assets/screenshots/alerts.webp) | -## 快速开始 +**任务与配置:85 项可热更,DB 覆盖值优先于 `.env`** -### 前置要求 +![任务与配置](assets/screenshots/settings.webp) -Python 3.12+、[uv](https://docs.astral.sh/uv/)、Node.js 24+ 与 pnpm 10+(仅构建前端需要)。 +## 快速开始 -### 本地运行 +前置:Python 3.12+、[uv](https://docs.astral.sh/uv/)、Node.js 24+、pnpm 10+(仅前端构建)。 ```bash uv sync -# 建首个管理员(之后都在管理台「用户管理」里加人) -uv run python scripts/create_user.py admin --role admin +uv run python scripts/create_user.py admin --role admin # 建首个管理员 cd web && pnpm install && pnpm build && cd .. -APP_SECRET="换成你自己的随机字符串" \ - uv run python -m uvicorn src.main:build_app --factory --port 8000 +APP_SECRET="换成你自己的随机字符串" uv run python -m src.main ``` -打开 登录管理台。 +开 登录;加人走「用户管理」页。 -> `scripts/create_user.py` 直接写 SQLite(`--db` 或 `DATA_DIR`,默认 `data/coding2api.sqlite3`)。老部署若已有 `secrets/users.txt`,启动时自动导入一次(已存在的不覆盖)。 +> 用 `python -m src.main` 而不是 `uvicorn src.main:build_app`:前者读 `.env` 的 `HOST`/`PORT`,并持有单实例锁(`data/.instance.lock`)。同一 `DATA_DIR` 只能起一个进程——两个实例会各自跑后台任务、重复探测凭证并互相覆盖统计。第二个实例会打印「另一个实例已在使用 data(PID xxx)」后干净退出。 -### Docker +`scripts/create_user.py` 直写 SQLite(`--db` 或 `DATA_DIR`;默认 `data/coding2api.sqlite3`)。老部署 `secrets/users.txt` 启动导入一次,不覆盖已存在。 + +Docker: ```bash cat > .env <<'EOF' APP_SECRET=换成你自己的随机字符串 PUBLIC_BASE_URL=http://127.0.0.1:8000 EOF -docker compose build -docker compose up -d -# 建首个管理员(容器内执行;之后在管理台加人,无需再进容器) +docker compose up -d --build docker compose exec coding2api python scripts/create_user.py admin --role admin ``` -拉取 GHCR 镜像(推荐,跳过本地构建): +拉镜像:`docker compose pull`(或 `docker pull ghcr.io/robbsluo/coding2api:v0.13.1`)。tag `v*` 推 main → `.github/workflows/publish.yml` → 发 `` + `:latest`。 -```bash -docker compose pull -# 或指定版本:docker pull ghcr.io/robbsluo/coding2api:v0.9.1 -``` - -推送新版本:打 tag `v*` 推到 main 即触发 publish workflow(`.github/workflows/publish.yml`),同时打 `` 与 `:latest` 到 GHCR;也可在 Actions 页面手动触发。 +容器跑在**有公网 IP 的 VPS** 上要 https 入口 → 见「[公网部署(Docker + Caddy 自动 HTTPS)](#公网部署docker--caddy-自动-https)」。 ## 用户与角色 -账号存在 SQLite 的 `users` 表里,管理台「用户管理」页(仅管理员可见)负责日常增删改。 +账号存 SQLite `users` 表;「用户管理」页(仅管理员)增删改。 | 角色 | 能做什么 | |---|---| | `admin` | 用户管理、任务与配置、凭证读写、全部统计、审计日志、服务端日志、上游状态、模型列表 | -| `operator` | 凭证读写(含导入 / 删除 / 启停 / 签到 / 成长 / 切换账号)、全部统计、上游状态、模型列表 | -| `viewer` | 只读:看仪表盘、凭证列表、统计与 Playground | - -**加人**:管理员填用户名 + 角色,创建后得到**一次性激活链接**(有效 24 小时,只显示这一次)。把链接经可信渠道发给本人,对方打开 `/activate` 自设密码即可登录——系统里不存在「管理员知道但用户不知道」的初始密码。忘记密码同理:点「重置密码」生成新链接。 - -**改角色 / 停用**:即时生效——立刻作废该账号所有已签发会话 Cookie(重新登录即可)。停用是默认的「删人」方式(可逆,用量统计仍归属该用户名);**硬删只在 CLI**(会让 `usage_events` 留下查不到用户名的孤儿记录)。 +| `operator` | 凭证读写(导入 / 删除 / 启停 / 签到 / 成长 / 切换账号)、全部统计、上游状态、模型列表 | +| `viewer` | 只读:仪表盘、凭证列表、统计、Playground | -**自助改密**:右上角用户菜单 →「修改密码」,需要当前密码。 +「月度账单」与「上游状态」「模型列表」同门槛(`operator` 起):账单是池级数据,`viewer` 只在自己的统计里看到本人用量。 -**防锁死**:不能停用/降级**最后一个活跃管理员**,也不能对自己降级或停用。要交接权限,先加另一个 `admin` 再降自己。 +- **加人**:用户名 + 角色 → 一次性激活链接(24 小时有效,只显示一次);对方开 `/activate` 自设密码。无「管理员知道、用户不知道」的初始密码;重置同理。 +- **改角色 / 停用**:即时作废该账号全部会话 Cookie。停用 = 可逆的默认「删人」(用量统计仍归该用户名);硬删只在 CLI。 +- **自助改密**:右上角用户菜单 →「修改密码」,需当前密码。 +- **防锁死**:不能停用 / 降级最后一个活跃管理员;不能对自己降级或停用。 -**CLI 兜底**(无 Web 场景 / 全部管理员失联时): +CLI 兜底(无 Web / 管理员全失联): ```bash python3 scripts/create_user.py alice --role admin # 交互输入密码 python3 scripts/create_user.py --list python3 scripts/create_user.py alice --delete --force # 硬删,不可逆 -python3 scripts/create_user.py --db data/x.sqlite3 ... # 指定库 -echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password-stdin # 密码不进 argv / shell 历史 +python3 scripts/create_user.py --db data/x.sqlite3 ... +echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password-stdin ``` -`ADMIN_USERNAMES` 与 `secrets/users.txt` 只在**引导期**起作用(首个管理员、老部署升级):启动时把 `users.txt` 里的用户导入 DB(已存在的不覆盖),再把 `ADMIN_USERNAMES` 点名的人提权为 `admin`;此后角色只来自 DB。 +`ADMIN_USERNAMES` 与 `secrets/users.txt` 仅引导期生效(首个管理员、老部署升级);此后角色只来自 DB。引导期的判据是「库里还没有活跃 admin」——有了之后 env 不再改写任何人的角色,管理台做的降级不会被下一次重启撤销。 ## 审计 -「审计日志」页(仅管理员可见)记录登录与写操作,可按操作者/动作筛选、分页查看:**登录**(成败都记)、**账号变动**(新建、激活、改角色、禁用/启用、改密、重置密码、硬删)、**凭证写操作**(导入、删除、启停、指定 pin、复活、切换账号)与**告警人工处置**(标记恢复)。每条含时间、操作者、动作、对象、说明与来源 IP。**绝不包含密码或令牌明文**(一次性激活令牌明文只在创建响应出现一次,库里只有 SHA-256 摘要)。保留期与请求明细一致(默认 90 天),由后台清理任务裁剪。 +「审计日志」页(仅管理员)记登录 + 写操作,按操作者 / 动作筛选、分页。类型:登录(成败都记)、账号变动、凭证写、告警处置。每条:时间 / 操作者 / 动作 / 对象 / 说明 / 来源 IP。**不含密码 / 令牌明文**——激活令牌只存 SHA-256 摘要。留 90 天。 ## 服务端日志 -「服务端日志」页(`/logs`,仅管理员可见)是**只读**的进程内实时日志视图:`GET /api/logs?limit=` 取最近若干行,`GET /api/logs/stream` 走 SSE 先回放缓冲、再推新行。页面上可做级别筛选、关键字过滤、跟随最新、暂停与清空——**没有任何写入或执行入口**,订阅者只能读。 +「服务端日志」页(`/logs`,仅管理员)= 只读进程内实时日志。`GET /api/logs` 取最近 N 行;`GET /api/logs/stream` SSE 先回放再推新行。级别筛选、关键字过滤、跟随最新、暂停、清空。**无写入入口**。 -- **只收本进程 `logging` 记录**,不读文件、不 tail:多进程 / 容器下各进程只看得见自己那份,这是刻意的(日志文件轮转与权限差异会让「看文件」变成一个会骗人的视图)。 -- **脱敏是硬前提**:每行发出前过白名单,命中 `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`(含 `X-Api-Key:`、`client_secret=`、JSON 键形态)、`sk-…` 形状或 `Bearer <值>` 的**整行丢弃**。宁可漏一条日志,也不把凭证推进浏览器。 -- **缓冲有界**:容量是 `LOG_BUFFER_LINES`(默认 500,可热更),环形覆盖最旧;调小立即裁掉多余行。`0` 关闭整页(两个端点都 404,handler 也摘下),与 `/metrics` 关闭同口径。 -- 单行上限 4000 字符;每行带 `seq`(回放与实时去重)、时间、级别、logger、`request_id`(有则带上)。订阅者上限 4,超限 429 + `Retry-After`。 +- 只收本进程 `logging`;不读文件、不 tail。多进程各看各的。 +- **脱敏是硬前提**:每行发出前过白名单。命中 `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`、`sk-…` 形状或 `Bearer <值>` → 整行丢弃。 +- 缓冲 `LOG_BUFFER_LINES`(默认 500,可热更),环形覆盖最旧。`0` 关整页(两端点 404)。 +- 单行上限 4000 字符;每行带 `seq`、时间、级别、logger、`request_id`。**点行尾的 `#前8位`** 即按该 request_id 过滤,收起一次请求的全部日志(executor 每次换号 / 冷却 / 降级都带同一个 ID);也可以直接把响应头 `X-Request-ID` 粘进关键字框。stdout / journald / launchd 文件同样带 `[request_id]`(后台任务为 `-`)。订阅者上限 4,超限 429 + `Retry-After`。 ## 使用 1. 「凭证管理」添加凭证: - - **CodeBuddy**(含国际):设备码登录,或粘贴 `{"token":"..."}`。 - - **TRAE**:粘贴凭证 JSON(`accessToken`/`uid`/`refreshToken`)或回调链接(`apiHost` 只接受官方地址)。 + - **CodeBuddy**(含国际):设备码登录,或粘 `{"token":"..."}`。 + - **TRAE**:粘凭证 JSON(`accessToken`/`uid`/`refreshToken`)或回调链接(`apiHost` 只收官方地址)。 - **Qoder / CodeArts**:设备码 / OAuth2 授权码登录(CodeArts 授权后把打不开的本地回调链接粘回页面)。 - - **OfficeAce**:点「登录 OfficeAce」走华为云授权(回调由云端承接,无需粘链接、无需开本地端口)。 - - **OpenCode Zen / Kilo**:无需添加——启动时自动出现虚拟凭证(被删可在同面板点「添加」补回)。 -2. 「API Key」创建 `sk-...`(明文仅显示一次)。 + - **OfficeAce**:点「登录 OfficeAce」走华为云授权(回调由云端承接)。 + - **小浣熊 / 灵犀**:无登录按钮,只能粘网页会话凭据(小浣熊 `{"access_token":""}`,灵犀整段 Cookie 或裸 `wps_sid`)。 + - **通用粘法**:「JSON 导入」框同时接受整段 Cookie 和浏览器 DevTools → Network → 右键请求 → **Copy as cURL (bash)**。后者带 `Cookie:` / `Authorization:` 头,HttpOnly cookie 也能拿到;内容有明确特征(`wps_sid` / `xhx_token` / TRAE 字段 / AK·SK 等)时自动识别渠道,成功导入后会记住该渠道。 + - **OpenCode Zen / Kilo**:无需添加,启动自动出虚拟凭证(删了可在同面板补回)。 +2. 「API Key」建 `sk-...`(明文仅显示一次)。 3. 调用: ```bash @@ -126,199 +145,232 @@ curl http://127.0.0.1:8000/v1/chat/completions \ -d '{"model":"glm-5.2","messages":[{"role":"user","content":"你好"}]}' ``` -`模型@渠道` 强制指定上游(如 `glm-5.2@trae`、`mimo-v2.5-free@zen`),不写则自动选健康渠道。任意 OpenAI 兼容客户端可直接接入(Base URL `http://127.0.0.1:8000/v1`、Key 用 `sk-...`、模型名以 `GET /v1/models` 为准);「Playground」页用登录会话直接测试,无需 API Key。 +`模型@渠道` 强制上游(`glm-5.2@trae`、`mimo-v2.5-free@zen`);不写自动选。任意 OpenAI 兼容客户端直连(Base URL `http://127.0.0.1:8000/v1`)。「Playground」用登录会话测,无需 API Key。 ### 模型名三字段 -八条渠道每个模型统一为三个字段: +每个模型三个字段: -1. **原代号**(`raw_id`):渠道请求时真正发的 key,**永不改动**。各渠道内部代号互不相同(Qoder `kmodel_latest` = TRAE `kimi-k3-1`),也可能带命名空间前缀(Kilo 的 `kilo-auto/free`)。 -2. **归一键**:剥掉免费档标记(`-free` / 路径段 `free`)与命名空间前缀,再 slug 化——`kilo-auto`、`longcat-2.5-preview`。模型列表按它判断**哪个是同一个模型**并合并,故 Zen 的免费档能与 TRAE 的付费档并成一条。 -3. **展示名**(`name`):清洗后的可读文本,如 `LongCat 2.5 Preview`、`Kimi K3`。上游给了可读名就用(`Qwen3.8-Max` → `Qwen3.8 Max`),没有就从原代号派生;品牌与缩写按官方写法纠正(`DeepSeek` / `MiMo` / `GLM` / `GPT`)。 +1. **原代号**(`raw_id`):渠道真发的 key,永不改动。各渠道不同(Qoder `kmodel_latest` = TRAE `kimi-k3-1`),可带命名空间前缀(Kilo `kilo-auto/free`)。 +2. **归一键**:剥免费档标记(`-free` / `free` 路径段)与命名空间前缀 → slug 化,如 `kilo-auto`。模型列表按它判同模型、合并。 +3. **展示名**(`name`):清洗后可读文本,如 `LongCat 2.5 Preview`。上游给就用,否则从原代号派生;品牌写法从官方(`DeepSeek` / `MiMo` / `GLM` / `GPT`)。 -`GET /v1/models` 里**所有条目的 `id` 都是归一键**,转发到某渠道时自动换回该渠道原代号,`by_provider.{渠道}.raw_id` 可见。**原代号、展示名、归一键三种写法都能直接请求**(`kmodel_latest`、`Kimi K3`、`kimi-k3` 指向同一模型)。同渠道内归一键重复时退回原代号,避免撞名误并。 +`GET /v1/models` 的 `id` 全是归一键;转发自动换回原代号,`by_provider.{渠道}.raw_id` 可见。**三种写法都能直接请求**。同渠道内归一键重复 → 退回原代号。 -**价格与档位**:条目可选带 `cost {input, output, cache_read}`(USD / 百万 token,与统计侧成本估算同一份 OpenRouter 价表)与 `reasoning_efforts`(该渠道生效中的 `REASONING_EFFORT_MAP` 目标档位去重集合)。两者都是**有才给**:缺价、未配置档位映射时**不带该字段**,不填 0 也不编造。 +**价格与档位**:条目可选带 `cost {input, output, cache_read}`(USD / 百万 token,与成本估算同价表)与 `reasoning_efforts`(该渠道生效档位)。有才给;缺价不带。 -**自定义模型别名**(`MODEL_ALIASES`,可热更):给客户端一个稳定的对外名,上游改 id 时只改配置。格式 `对外名=模型@渠道;对外名2=模型`,`@渠道` 省略则自动选。**列表与请求解析用同一份别名表**(不会出现「列表有、请求 404」),条目带 `custom_alias: true`。与真实模型 id 或自动别名撞车、目标模型不存在、钉的渠道不持有该模型时,该段记 ERROR 并**跳过**(不静默覆盖真实 id,也不因一条笔误让整份配置失效);不支持别名链。`GET /v1/models/{id}` 同样认别名。 +**自定义别名**(`MODEL_ALIASES`,可热更):`对外名=模型@渠道;对外名2=模型`,`@渠道` 可省。列表与请求解析共用一份;条目带 `custom_alias: true`。撞名 / 目标不存在 / 渠道不持有 → 该段 ERROR 跳过。无别名链。 -**`GET /v1/models/{id}` 单模型查询**(OpenAI 同形):上面三种写法都认(外加自定义别名),大小写不敏感,路径参数用 `:path` 故带命名空间的原代号(`dots-studio/dots-3-note-preview:free`)也能查。找不到、或该模型**不在当前 Key 的模型白名单内**,一律 404 + OpenAI 错误形状(`code=model_not_found`)——白名单外与不存在同形,客户端无法用单查探测「池子里还有哪些模型」。能力分与列表条目同源(同一份合并结果),刷新时机也一致(列表有、单查不会 404)。 +**单模型查询**(`GET /v1/models/{id}`):三种写法 + 自定义别名都认;大小写不敏感;带命名空间的原代号也能查。找不到 / 不在 Key 白名单 → 404(`code=model_not_found`),不泄露池内容。 -**`n` 参数只接受 1**:本网关的出口结构(`choices[0]` + 单 choice 响应体)只能产生一个候选,`n != 1` 一律 400 `invalid_request` 并说明原因,不做「假装支持、静默只返回一个」。 +**`n` 只接受 1**:出口结构只产生一个候选,`n != 1` → 400。 ### 流式 usage 与思考 token -- **流式带 usage 帧**:收尾时在 finish chunk 之后、`data: [DONE]` 之前补一帧 `choices: []` 的 usage(OpenAI 标准形态),只从流里读用量的客户端据此显示 tok/s、上下文占用率与会话 token 统计;上游没上报时不补(不发 0 占位),非流式出口的 `usage` 同理,未上报字段一律为 `null`。 -- **思考 token 需客户端带 `reasoning_effort`**:CodeBuddy 渠道在客户端**未给**时补 `medium`(上游缺此字段会把整段思考以「可见推演」写进正文);显式给的值原样透传、不被覆盖。 +- 收尾:finish chunk 后、`data: [DONE]` 前补一帧 `choices: []` 的 usage。上游没上报就不补;未上报字段一律 `null`。 +- 思考 token 需客户端带 `reasoning_effort`;CodeBuddy 未给补 `medium`(否则思考进正文);显式值原样透传。 ### 流式超时(默认关闭) -八条渠道的上游流读取都设了 `read=None`(长回答不能被读超时打断),因此「上游接了连接却一直不吐数据」只能由网关自己兜: +上游流读取不设读超时(HTTP 读超时交给网关自兜): -- **首事件超时**(`STREAM_FIRST_EVENT_TIMEOUT_SECONDS`,默认 `0`):客户端还**没收到任何内容**时超时,安全地换号重试——记 `upstream_first_event_timeout`,按瞬态错误处理(短冷却、不累计)。注释帧 / 心跳**不**重置计时。 -- **流内空闲超时**(`STREAM_IDLE_TIMEOUT_SECONDS`,默认 `0`):**已出帧**后超时只能断流并以错误帧收尾(记 `upstream_idle_timeout`),**不换号**——半截输出无法回滚,换号会把两个账号的内容拼给客户端。 -- 实现上**不能**用 `asyncio.wait_for(anext(...))`:超时会 cancel 上游生成器直接断流。这里用可复用的 Task + `asyncio.wait`,超时只放弃等待、任务继续跑,下一次继续等同一个任务。 +- **首事件超时**(`STREAM_FIRST_EVENT_TIMEOUT_SECONDS`,默认 0):客户端未收到内容时超时 → 可安全换号重试,记 `upstream_first_event_timeout`。注释帧 / 心跳不重置计时。 +- **流内空闲超时**(`STREAM_IDLE_TIMEOUT_SECONDS`,默认 0):已出帧后超时 → 断流 + 错误帧收尾,**不换号**(半截输出无法回滚)。 -两项都默认关闭是刻意的:实测慢上下文首字可达 40–71s,给一个「看起来合理」的默认值会静默杀掉正常长请求。要用请在管理台按部署实际打开(推荐 `60` / `300`)。 +默认关是刻意的:实测慢上下文首字 40–71s。要用在管理台按部署打开(推荐 60 / 300)。 ### Playground 高级参数 -Playground 请求表单可选填 `temperature` / `max_tokens` / `reasoning_effort`,**留空即不发送该键**(各渠道自取其默认)。前端校验边界与 `compat/openai/request.py` 一致(`temperature` ≤ 1000、`max_tokens` 为 ≤ 4,000,000 的正整数),不会出现「界面放行、后端 400」。`reasoning_effort` 控件只在模型元数据给出 `supports_reasoning` 或 `default_effort` 时出现,候选为标准三档(`low`/`medium`/`high`,上游声明的默认档若不在其中则追加),默认选中「不发送」——某个档位是否被接受由上游裁决。 +可选 `temperature` / `max_tokens` / `reasoning_effort`,**留空即不发该键**。前端校验边界同后端(`temperature` ≤ 1000、`max_tokens` ≤ 4,000,000)。`reasoning_effort` 仅推理模型出现,默认「不发送」。 ## 渠道 -> CodeBuddy(含国际)/ TRAE / Qoder / CodeArts / OfficeAce 需填凭证;**OpenCode Zen 与 Kilo 是无凭证的免费层渠道**——池里是一条虚拟占位行(让调度 / 冷却 / 统计照常工作),额度列显示「免费层(无额度接口)」,健康度恒为「无探测」(未知 ≠ 耗尽)。虚拟行**删除后重启会复活**,永久停用请用「暂停」。 +> CodeBuddy(含国际)/ TRAE / Qoder / CodeArts / OfficeAce / 小浣熊 / 灵犀 需凭证;**Zen 与 Kilo 无凭证免费层**——池里一条虚拟占位行(调度 / 冷却 / 统计照常),额度列「免费层(无额度接口)」,健康度恒「无探测」。删虚拟行重启复活;永久停用 →「暂停」。 + +### OpenCode Zen(`zen`) + +[opencode.ai/zen](https://opencode.ai) 免费模型,免登录。上游清单混付费、无免费标记 → `-free` 后缀收窄 → 逐个探活 → **只剔除确定不可用**(付费 401 / 下线 400 / 区域 403)者进 `/v1/models`;`429`(匿名免费额度耗尽)/ 5xx / 超时**保留**(限流或抖动不该清空整条渠道;判活缓存 30 分钟;免费模型 **x0**)。 + +**免费层门禁**:上游要伪装官方客户端(UA 形态、会话头、`stream:true`、tools 含门禁工具)→ 自动满足。注入空壳工具,真调用被过滤。门禁是**移动靶**,三处都做成配置(改完重启即换、随时回退):UA 形态 `ZEN_USER_AGENT`(模板,`{version}` 代入 `ZEN_OPENCODE_VERSION`)、API 根 `ZEN_API_ROOT`、工具集合 `ZEN_GATE_TOOLS`。默认值 = 本仓实测通过的形态(`opencode/`、`/zen/v1`、`bash,read`)。端点 `ZEN_API_ENDPOINT`(须在 `ZEN_ALLOWED_ENDPOINTS` 内)。付费模型强制 `模型@zen` 也只报不可用。 -### OpenCode Zen 免费层(`zen`) +### Kilo(`kilo`) -接 [opencode.ai/zen](https://opencode.ai) 的**免费模型**,无需账号或登录。上游清单混着付费模型且不带免费标记,本服务按 `-free` 后缀收窄候选、再逐个探活,**只把匿名真正可用的免费模型**放进 `GET /v1/models`(判活缓存 30 分钟,不维护静态白名单;免费模型标 **x0**)。上游清单不提供模型名,展示名由模型 id 派生。 +[Kilo](https://kilo.ai)(`api.kilo.ai/api/gateway`)免费模型,免登录。标准 OpenAI 协议;上游 `/models` 每条带 `isFree` → 直接过滤,**不探活**(省免费配额)。 -**免费层门禁**:上游要求伪装成官方客户端(UA 版本、会话头、`stream:true`、tools 含 `bash`/`read`),本服务自动满足;注入的 `bash`/`read` 是空壳,模型真调用时这类 tool_call 被过滤掉,不泄漏你没声明过的函数。UA 版本用 `ZEN_OPENCODE_VERSION` 调,端点用 `ZEN_API_ENDPOINT`(须在 `ZEN_ALLOWED_ENDPOINTS` 内);付费模型即使强制 `模型@zen` 也只报「不可用」。 +**额度与限流**:网关级约 200 请求/小时/IP。上游 429 与 502/503/504 均模型级 → 只冷却被点名模型。端点 `KILO_API_ENDPOINT`(须在白名单内)。 -### Kilo 免费层(`kilo`) +### Qoder(`qoder`) -接 [Kilo](https://kilo.ai)(`api.kilo.ai/api/gateway`)的**免费模型**,无需账号或登录。对外是**标准 OpenAI 兼容协议**,无私有信封、无门禁伪装;上游 `/models` 每条带权威 `isFree` 布尔,本服务**据此直接过滤**免费集、**不做探活**(探活白耗本就极小的免费配额)。免费模型标 **x0**。 +[Qoder](https://qoder.com),需真实账号:点「登录 Qoder」走设备码授权(Token 不经过浏览器)。上游私有 COSY 协议 → 本服务签名 + 解包;对外仍是标准 OpenAI。 -**额度与限流**:免费层额度很小(网关级约 200 请求/小时/IP),实为 OpenRouter 免费池的转发;上游 **429 与 502/503/504 都是模型级**——只冷却被点名的那个模型,本服务不自建熔断。端点用 `KILO_API_ENDPOINT`(须在 `KILO_ALLOWED_ENDPOINTS` 内)。 +**额度与签到**:探测 `/api/v2/quota/usage`;签到为活动制 `/sash/api/v1/me/campaigns`(积分累积),旧 `daily-check-in` 仅回退。**活动每日 10:00(UTC+8)刷新**,领取后 30 天有效;后台按该窗口封账。新协议无连续天数。**国际站无签到接口**(404)→「本区域无此接口」。 -### Qoder(`qoder`,真实账号) +**逐包到期(网页 Cookie)**:`/api/v2/quota/usage` 只给两桶汇总,顶层 `expiresAt` 是周期重置哨兵(实测 `9999-12-31`),看不到「哪笔积分什么时候作废」。网页账号页的 `GET {website}/api/v2/me/usages/big_model_credits` 才有逐包 `expires_at`,但它**只认网页登录会话**(device token 401)。凭证行「更多 → 网页 Cookie」粘贴该请求的完整 `Cookie` 头后,额度探测改走网页接口:明细按包展开、到期阶梯参与「快过期先用」排序;Cookie 加密落库、不回显、**导出凭证不含它**。网页会话过期(401)时静默回退汇总口径,重新粘贴即可。 -接 [Qoder](https://qoder.com),**需要真实账号**:在「凭证管理 → 登录渠道账号」点「登录 Qoder」,按提示在浏览器完成设备码授权(登录全程后端轮询完成,Token 不经过浏览器)。上游是私有 COSY 协议(自定义 Base64 编码 + 信封式 SSE),本服务负责签名与解包,对外仍是标准 OpenAI 协议。 +**端点**:默认国内版(`openapi.qoder.com.cn` + `gateway.qoder.com.cn`)。国际站改 `QODER_API_ENDPOINT=https://openapi.qoder.sh`,域名加进白名单。 -**额度与签到**:支持额度探测(`GET /api/v2/quota/usage`)与每日签到(积分可累积)。签到为**活动制**(`/sash/api/v1/me/campaigns`,需带 `Cosy-ClientType`),旧 `daily-check-in` 仅作回退;本服务自动查询并领取可领活动,管理台可手动触发。**签到活动每日 10:00(UTC+8)刷新**(领取后 30 天有效),后台任务按该窗口封账:凌晨看到的「已领」只封到当天 09:59,10:00 新一轮出现后自动补签。新协议不提供连续天数,界面不再显示「连续 N 天」。**国际站没有签到接口**(该端点 404),记为「本区域无此接口」,不是错误。 +**上游节点故障**:Qoder 有时把自身节点故障包成 400(`[FAIL]node:…`)→ 判模型级瞬时故障,只冷却该模型。候选耗尽 → 503(`no_healthy_credential`)。 -**端点与节流**:默认国内版(`openapi.qoder.com.cn` + `gateway.qoder.com.cn`),国际站改 `QODER_API_ENDPOINT=https://openapi.qoder.sh` 并确认白名单含国际站域名(签名携带完整 `cosy-*` 头,白名单防误发)。默认 `QODER_CHAT_MIN_INTERVAL=5`(独立节流器),可在「任务与配置」热更。 +**保活帧与排队**:上游在流中发 `body:"null"` 的保活帧(外层 200)→ 跳过、不打断输出;「排队中」包成 403 且排队标记藏在**序列化字符串**里 → 归 QUEUE 等上游点名的秒数后在原号重开,不硬禁用凭证。「每日计费次数用尽」(403 `code 110`,`Billing daily count exceeded`)→ 归额度类,冷却到次日 04:00,同样不硬禁用(HTTP 与流内信封同一判据)。 -**上游节点故障**:Qoder 有时把自身推理节点故障包成 400(`[FAIL]node:… msg:Execution failed`)。本服务识别为**模型级瞬时故障**——只冷却该模型,耗尽候选时返回「该模型暂时不可用」的 503(`no_healthy_credential`),而非误报「模型不存在」;稍后重试通常自愈。 +### CodeArts(`codearts`) -### CodeArts(`codearts`,真实账号) +[华为云 CodeArts](https://codearts.huaweicloud.com),需真实账号:点「登录 CodeArts」→ 授权后浏览器跳到打不开的本地地址 → **整条链接**粘回页面换 STS 凭证(AK/SK + security_token + refresh_token + DPoP 私钥),加密入库。 -接 [华为云 CodeArts](https://codearts.huaweicloud.com),**需要真实账号**:点「登录 CodeArts」,浏览器完成 OAuth2 授权后会跳到打不开的本地地址(`http://127.0.0.1:12800/oauth/callback?code=…`)——把**整条链接**粘回页面,服务端换取 STS 凭证(AK/SK + security_token + refresh_token + DPoP 私钥)并加密入库。 +**令牌刷新刚性**:refresh_token 与 `client_id=codearts-agent`、DPoP 私钥三者绑定、一次性;刷新后必须回写新 refresh_token。预刷新任务自动完成(DPoP ES256/P-256)。窗口封顶 45 分钟,须宽于轮询周期。凭据作废 → 标「需重新登录」+ 停重试。 -**令牌刷新是刚性的**:refresh_token 与 `client_id=codearts-agent`、DPoP 私钥三者绑定且**一次性**,刷新后必须回写新 refresh_token,否则账号失效;由 token 预刷新任务自动完成(DPoP ES256/P-256 签名)。预刷新窗口封顶 45 分钟且必须**宽于**轮询周期(`REFRESH_INTERVAL_MINUTES`,默认 30 分钟)。续期凭据被上游作废时,该凭证标记「需重新登录」并停止自动重试,重新登录后按「恢复」解除。 +**每日签到**:`GET /v1/ops/delivery?channel=IDE` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`,后台每日自动领。活动按 `type=USER_LOGIN` 挑(`type` 缺失时读 `extra.triggerEvent` = `user.login`,两者是同一标识的两个字段),**`campaignId` 取当轮响应**(上游 2026-11 把签到活动从 ID 1 换成 34、面额 1000 → 100;写死 ID 会导致「签到显示成功、官网任务仍在、积分不涨」);响应没给 `campaignId` 时该轮判失败重试,**不带 0 发写请求**。`CLAIMED` =「已领未确认」→ 两步一起做;**claim 成功但 confirm 失败按失败处理**(不回「签到成功」),下一轮补确认,避免「界面显示成功、积分未到账」(实测用户反馈)。失败按连续次数指数退避(10m → 20m → 40m → 封顶 1h,成功清零;管理台「立即运行」不受限制)。 -**每日签到**:上游有「每日签到领 1000 积分」活动(`GET /v1/ops/delivery` → `POST /v1/ops/claim` → `POST /v1/ops/confirm`)。后台任务每日自动领取,管理台可手动触发;`claim` 后**必须** `confirm`(`CLAIMED` 是「已领未确认」而非终态),本服务两步一起做。签到发的积分计入本渠道唯一的账本(见下)。 +**重新登录合并**:同一华为云账号(`uid`)再次登录时**就地更新既有凭证**(新 token / refresh_token / DPoP 私钥),同时解除「需重新登录」硬禁用;账号级冷却里**上游点名的几类(限流/余额/权益/风控)保留**(重登换的是凭证不是配额),本地冷却照常清除。不会在池子里新增一条重复账号。`paused` / `pinned` / 标签 / 统计归属保持不变。不同账号仍新增。 -**额度=积分余额**:额度探测走 `GET {snap}/snap-manager/v1/statistics/plugin`(前缀**必须**带 `snap-manager`,头 `Agent-Type: PromptCenter`),内置模型(GLM-5.2 / OpenPangu 等)按积分计费。上游数值本身即积分,**不做任何单位折算**;分项包(`基础积分` / `按需积分` / `福利积分`)走「套餐 N 个」悬浮明细,并登记**套餐到期日**(`end_date`,实测每账号不同)的到期阶梯——积分用不完同样作废,故调度「快过期的先用」也会先烧积分。该接口失败只记日志、不影响其余链路。 +**额度=积分余额**:探测 `GET {snap}/snap-manager/v1/statistics/plugin`(前缀必须带 `snap-manager`;头 `Agent-Type: PromptCenter`)。上游数值即积分,不折算。分项包走悬浮明细;登记套餐到期日(积分过期同废)。 -**变动流水**:每轮探测按余额 diff 记一条 `credit_events`(`ledger=credits`,见下)。 +**逐笔到期(网页 Cookie)**:statistics 只给「7 天内到期量」,逐笔 `expireAt` 只在网页控制台 portal——`GET /portal/promptcenter/v1/ops/credit/overview` 列活动,`GET .../campaign/{id}?pageNum=&pageSize=` 分页给每次发放(`claimedAmount` / `consumedAmount` / `expiredAmount` / `expireAt`)。portal **只认控制台会话 Cookie**(AK/SK 签名打不通,且必须带 `X-Requested-With: XMLHttpRequest`)。凭证行「更多 → 网页 Cookie」粘贴后,探测改走 portal:福利积分按笔展开、真实到期参与「快过期先用」,基础/按需仍取汇总;Cookie 失效/改版**静默回退** 7 天窗口口径,不判探测失败。`expiredAmount` 与 `status` 会滞后(实测过期后仍为 0 / `CLAIMED`),判死只看 `expireAt <= now`;已过期的逐笔**不再展示**。Cookie 失效(portal 回登录页/空响应)时,凭证行标「网页 Cookie 已失效,请重贴」;重新粘贴后状态复位,额度汇总不受影响(控制台会话无法自动刷新)。 -**签名 / 累计全文 SSE / 节流与并发**:上游要求华为云 `SDK-HMAC-SHA256`(AK/SK + `X-Security-Token`),白名单 `CODEARTS_ALLOWED_ENDPOINTS` 必须含 snap 引擎、STS 与门户三个主机。流式 `text` 是**累计全文(替换语义)**而非增量,本服务在解析层还原为增量事件,对客户端透明。默认 `CODEARTS_CHAT_MIN_INTERVAL=5`;上游硬限**每账号并发会话数 3**,故配在途上限 `CODEARTS_MAX_CONCURRENCY` 与滑动窗口 `CODEARTS_REQUEST_WINDOW_SECONDS`(每账号每 60s 最多启动 3 次),把突发挡在 `400 TM.00001041` 之前。均可热更。 +**签名 / 累计全文 SSE / 节流**:华为云 `SDK-HMAC-SHA256`(AK/SK + `X-Security-Token`);白名单须含 snap 引擎、STS、门户。流式 `text` 为累计全文(替换语义)→ 还原增量。上游硬限每账号并发 3 → `CODEARTS_MAX_CONCURRENCY` + `CODEARTS_REQUEST_WINDOW_SECONDS`。 -### OfficeAce(`officeace`,真实账号) +### OfficeAce(`officeace`) -接 [华为云 OfficeAce](https://www.huaweicloud.com/product/agentarts.html)(果办 / AgentArts 办公智能体),**需要真实账号**:点「登录 OfficeAce」,浏览器完成华为云登录与授权即可——**回调由云端承接**(`/v1/claw/auth/callback`),本服务不监听任何端口、用户也不需要粘链接,后端按 2.5s 轮询 `/v1/claw/auth/code` 取回授权码(与官方桌面端同一机制)。 +[华为云 OfficeAce](https://www.huaweicloud.com/product/agentarts.html),需真实账号:点「登录 OfficeAce」完成华为云授权即可;回调由云端承接,不监听本地端口。后端 2.5s 轮询 `/v1/claw/auth/code` 取授权码。 -**开通前提**:账号必须先在 OfficeAce 客户端里登录并兑换邀请码完成开通。未开通的账号登录会「成功」但拿不到模型密钥(云端 `subscription: null`),本服务据此点名报出「华为云账号 xxx 还没开通 OfficeAce 服务」而不是含糊的登录失败。 +**开通前提**:账号必须先在 OfficeAce 客户端登录并兑换邀请码。未开通的账号登录「成功」但拿不到模型密钥 → 点名报「华为云账号 xxx 还没开通 OfficeAce 服务」。 -**两套凭据,寿命完全不同**(这是本渠道最容易踩错的地方): +**重新登录合并**:同一账号(`account_id`)再次登录时就地更新既有凭证(模型密钥 + 临时 AK/SK + refresh_token + DPoP 私钥),并解除「需重新登录」;账号级冷却里上游点名的几类保留(口径同 CodeArts),不新增重复账号;`paused` / `pinned` / 标签 / 统计归属保留。 + +**两套凭据,寿命不同**: | 凭据 | 寿命 | 用途 | |---|---|---| -| `Basic model_app_key:model_app_secret` | **不过期** | 聊天转发(`{网关}/v2/chat/completions`) | -| 临时 AK/SK + `security_token` + `project_id` | **约 2 小时** | 积分查询、签到(`/v1/subscription*`) | +| `Basic model_app_key:model_app_secret` | 不过期 | 聊天转发 | +| 临时 AK/SK + `security_token` + `project_id` | 约 2 小时 | 积分查询、签到 | -混用会得到 `APIG.0303 app not found`。丢了区别的现象也很迷惑:聊天一直正常(Basic 不过期),但积分列空白、签到一直失败(临时凭据过期)。临时凭据由预刷新任务自动续期,`refresh_token` 一次性轮换且与 `client_id=pdp5_for_agentarts` + DPoP 私钥三者绑定——**与 CodeArts 的票互不通用**(混用回 `STS5.1806 invalid client id`)。 +混用得 `APIG.0303 app not found`。临时凭据由预刷新任务续期;`refresh_token` 一次性轮换,与 `client_id=pdp5_for_agentarts` + DPoP 私钥绑定(与 CodeArts 票不通用)。 -**上游就是标准 OpenAI 协议**,故对话层不写私有信封解析;但有三处上游特例必须处理: +上游就是标准 OpenAI 协议,三处特例: -- **限流走伪 200**:HTTP 200 + `data:{"error":…}`,且实测出现过「首帧正常、第二帧才是错误」。错误帧照常产出交引擎分类,`81111/81114` 只冷却「该凭据 × 该模型」,其它模型不受影响。 -- **模型级拒绝不牵连整条凭据**:`81004`(该号没这个模型)/ `81009`(名字不认)走 403/404,若按状态码判会把整条凭据打死。故业务码判定先于状态码,归类为模型级负缓存(成功即撤销)。 -- **`max_tokens` 是「推理 + 正文」总预算**:可用模型里 9/11 是推理模型,给小了正文直接为空。`≥16` 时自动追加 1024 余量再夹到该模型硬上限(`glm-5*` 131072 / `qwen3*` 65536 / `deepseek-v4.1-flash` 393216),`<16` 原样透传(探活/测通道的精确请求),未给则不补。 +- **限流走伪 200**:HTTP 200 + `data:{"error":…}`。错误帧照常交引擎分类;`81111/81114` 只冷却「该凭据 × 该模型」。 +- **模型级拒绝不牵连整条凭据**:`81004` / `81009` 走 403/404。业务码先于状态码判定 → 模型级负缓存(成功即撤销)。 +- **`max_tokens` = 推理 + 正文总预算**:`≥16` 自动加 1024 余量,再夹到该模型硬上限;`<16` 原样透传;未给不补。 -**积分即主账**:额度单位是 `officeace_points`(新用户一次性 + 每日签到各发一笔),`remaining`/`total` 直接就是积分。每笔奖励**有独立到期时间、不一并重置**,故额度口径写作「逐笔到期」,调度仍按到期阶梯「快过期的先用」。 +**积分即主账**:单位 `officeace_points`;`remaining`/`total` 直接是积分。每笔奖励独立到期、不合并重置 → 额度口径「逐笔到期」。 -**每日签到**:`POST /v1/subscription/bonus/claim`(**写操作,调用即真发积分**;body 必须给 {} + JSON `Content-Type`,空 body 会回 500 `OfficeAce.11020001`)。上游对当日重复领取是幂等的,故不需要本地记「今天领过」。领取收获按**剩余量增量**计算(`points` 是活动累计发放量,由多个渠道共同贡献,拿它当本次收获会虚高)。封账到下个北京时间 0 点:次日新活动一出现就自动补签。 +**每日签到**:`POST /v1/subscription/bonus/claim`——写操作,调用即真发积分;body 必须是 `{}` 且带 JSON `Content-Type`。上游对当日重复领取幂等。收获按剩余量增量算。封账到下个北京时间 0 点。 -**不做主动模型探活**:目录 34 个里实测只有 11 个真能用,但探活要真发推理、消耗积分,而额度是稀缺资源。模型级拒绝改由真实请求按 `BLOCKED` 负缓存处理,比开机烧 34 次推理省得多。同理**不做 credit 估算**:`cost_coefficient` 与 `tokens_per_point` 的叠乘关系官方未说明,凭空折算会与真实扣点对不上。 +**不主动探活 / 不估 credit**:探活要真发推理烧积分;`cost_coefficient` × `tokens_per_point` 关系官方未说明。模型级拒绝改由真实请求按 `BLOCKED` 负缓存处理。 -**签名与端点**:控制面 `/v1/claw/*` 用华为云 `SDK-HMAC-SHA256`;模型网关 `/v1/subscription*`、`/v1/model-token-rates` 只认 **`V11-HMAC-SHA256`**(多一层 region 作用域 + 签名密钥派生),用错回 `APIG.0624` 或 401。`OFFICEACE_ALLOWED_ENDPOINTS` 只需含控制面主机——模型网关每个账号不同,是上游 `client-permission-validate` 签发并存在凭证里的值,不是本服务能配错的东西。默认 `OFFICEACE_CHAT_MIN_INTERVAL=0`(上游没有 CodeArts 那种硬并发限),可热更。 +**签名与端点**:控制面 `/v1/claw/*` 用 `SDK-HMAC-SHA256`;模型网关只认 `V11-HMAC-SHA256`,用错 → `APIG.0624` 或 401。`OFFICEACE_ALLOWED_ENDPOINTS` 只需控制面主机(模型网关每账号不同,由上游签发存凭证)。 -**单账号单用户**:官方《套餐管理》明确「个人版面向个人用户,限制单账号单用户使用」,故本渠道不做多人共用,每个使用者各自添加自己的凭证。 +**单账号单用户**:官方《套餐管理》限制单账号单用户 → 不共用。 -**换账号**:华为云会话按**浏览器**走——浏览器里还留着上一个账号时,云端会直接放行、把「新增账号」变成「同一个账号再登一遍」。故登录按钮旁额外给一个**「换账号」**(`force_login=true`),它先走 `/authui/logout?service=…` 清掉会话再授权。未开通的账号会明确报出是哪个号没开通,而不是笼统的「登录失败」。 +**换账号**:登录按钮旁「换账号」(`force_login=true`),先清浏览器华为云会话再授权。 -### CodeBuddy 国际(`codebuddy_intl`,真实账号) +### 小浣熊(`raccoon`) -接 CodeBuddy 国际站 [www.codebuddy.ai](https://www.codebuddy.ai),**与国内站同协议**(同一套 `/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*` 路径,同一 Keycloak realm),但**是两套独立账号体系**:各自注册、各自计费,故作为**独立渠道**存在。登录链路与国内版完全一致(设备码,Token 不经过浏览器)。 +[商汤小浣熊](https://xiaohuanxiong.com),需真实账号(网页端登录即可)。**没有登录按钮**:凭据是网页会话 JWT,只能手工导入——浏览器 DevTools → Application → Cookies → 复制 `xhx_token` 的值,在「JSON 导入」里粘 `{"access_token":"<值>"}`(也接受裸 JWT、整段 cookie,或直接在 Network 里对该站点请求「Copy as cURL」整段贴入)。约 30 天到期,到期重新取一次。 -**为什么不是「改一个环境变量切站点」**:`CODEBUDDY_API_ENDPOINT` 是进程级单值,切过去会让所有存量国内站凭证立刻打到国际站(额度错配 / 403)。两个站点可同时持有凭证,各走各的端点、节流器与登录流程。 +**协议**:标准 OpenAI 兼容(`/api/web/llm/v2/chat/completions`),无签名、无设备指纹。`tool_calls` 流式/非流式、`role=tool` 回传、`include_usage` 全部实测通过;411K token 输入 14.2 秒返回。 -**白名单刻意互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有 `https://www.codebuddy.ai`,**不含**国内站主机。两站 Token 由不同网关签发,跨站发送即凭证泄露,因此把国内站地址填进来会在**启动时**直接报错。 +**未知模型会静默回落默认模型**(实测):写错模型名上游回 200 但换成默认模型。本服务两层拦截——发请求前按目录校验,回包首帧再比对 `model` 回显,不一致直接失败,不会「问 A 答 B」。 -**能力差异**:聊天、模型列表、额度探测、token 刷新、**活跃上报**全部启用;**每日签到、成长中心未启用**——签到是**渠道侧就没有这个活动**(`checkin-activity-status` 回 `active: false`),成长中心则**无法验证**(国际站把整个 `/v2/activity/*` 前缀挂在网关认证插件后,连不存在的子路径也返回 401 而非 404),而其含抽奖 / 连登兑换 / 盲盒等**不可逆**动作,不盲跑。管理台的「签到」「成长中心」按钮对该渠道不显示。活跃上报端点 `/v2/report` 与成长中心不是同一路径,实测国际站可用,故照常启用。 +**额度=积分**:每笔请求扣 1 分(与 token 量无关),每日 00:00 发 300 分(7 天有效),账本记「积分」。模型目录里的 `billing_effective_multiplier` 直接当倍率,**0 = 限免**(如 `sn-sensenova-6-8-flash` 限免至 11-30),调度器会优先选。 -**模型列表按套餐探活**:上游 `/v3/config` 返回全量模型,免费号也会看到 `gpt-5.5` / `gpt-5.6-luna` 等调用时才回 `400 / 11158` 的模型。国际站列表会用当前刷新凭证逐个发一次最小请求(拿到状态行即断开),只保留真正可用的模型;`11158`(套餐不允许)与 `11102`(无此模型)剔除,`429`、5xx、超时等不确定结果**保留模型**。结论按账号缓存 6 小时,避免按 5 分钟的列表 TTL 重复探测。 +**额度明细**:上游把积分分成四个池(每日 / 奖励 / 月度 / 充值),各自可查;**套餐明细按发放流水逐笔展开**(`type=income` 的 `daily_grant` / `reward_grant`),每笔带真实到期(每日积分 7 天有效)。上游不提供逐笔剩余,只有池的总可用量,故每日积分的消耗按「到期早的先消耗」分摊到各笔(各笔剩余之和 = 池值,这是推断);奖励积分实测「每日额度用光后才扣」,直接按发放额列。流水拿不到时才退回四个池。到期阶梯(`expiry_ladder`)与明细同源(每笔的 `total - used`),组头「1.5 天 / 7 天窗口内到期」按它汇总,因而与明细、额度合计口径一致;同时参与「快过期先用」的选号排序。 -**同名模型不带渠道后缀**:两站共有的模型在 `/v1/models` 里是**一条**、同时挂两个渠道;`-x` / `-sg` 这类**真实存在的不同上游模型**仍各占一条。 +**每日签到**:上游没有「点击领取」的签到接口——每日 300 积分由服务端 **00:00 自动发放**;当天登录过才延期到 7 天,没登录则当日 24:00 清零。设备心跳只刷新 `last_seen_at`,**不算登录**(实测连续几天只发心跳,积分仍在 24:00 清零)→ 活跃上报改为「页面访问 `GET /api/web/auth/v1/user_info` + 心跳」,签到本身也先访问一次。账单同步有几分钟延迟:11-06~11-08 的 `daily_grant` 直到 11-09 01:11 才出现。故「签到」= 访问 → 摸余额 → 读流水核对,**只认流水、不看 HTTP 200**;今天没有新发放但仍有**未过期** `daily_grant` 也算已签,凌晨 00:00~01:30 之间只封账到 01:30,没积分则重试也推到 01:30(不再 00:02 起每 10 分钟空锤)。流水用上游的 `type=income` 过滤(只留发放)——`type=all` 里消费流水会把每日发放挤出条数窗口,活跃账号会被误判「未发放」。**重新登录(换新 token)会立刻补发当天这笔**(几秒内落账、`grant_at` 回填 00:00);只拿旧 token 访问页面 / 心跳不触发,缺当日发放的号需人工重登后重新导入。 -**节流**:`CODEBUDDY_INTL_CHAT_MIN_INTERVAL` 默认 5s,**独立节流器**,与国内站互不排队,可热更。 +**活跃上报**:`POST /api/web/auth/v1/devices_current_heartbeat`(网页端真实设备心跳),补 `x-client-*` 头族即 200、幂等,`last_login_at` 随之刷新。默认关闭,与 CodeBuddy 共用 `ACTIVITY_REPORT_ENABLED` / `ACTIVITY_REPORT_HOUR`。设备 id 由 uid 稳定派生(随机值会被上游当成新设备)。 -### 按渠道出站代理(可选) +**并发上限约 5**(第 6 个回 `429 user_model_rate_limited`)→ `RACCOON_MAX_CONCURRENCY` 默认 2,独立节流器。 + +### 灵犀(`lingxi`) + +[WPS 灵犀](https://lingxi.wps.cn),需真实 WPS 账号。**没有登录按钮**:凭据是 WPS 账号 cookie `wps_sid`,在网页版(`lingxi.kdocs.cn` 同样可用)登录后 DevTools → Application → Cookies 复制整段,粘进「登录渠道」页(整段 Cookie 或裸 sid 均可)。上游**没有 refresh 接口**,且不按时长猜过期——真正的判据是上游 401,过期重贴即可。 + +**同一浏览器只保留最近一次登录**:在同一个浏览器配置里登录另一个 WPS 账号,会让上一次的 `wps_sid` 立刻 401(实测)。多账号需用不同浏览器配置 / 无痕窗口分别登录后各取一次 Cookie;已入库的旧凭证会在新号导入后的复探中确认 401 并标记「需重新登录」,不再参与调度。 + +**一次对话三条请求**:建 cowork 会话 → 领 credits run → `agent-runtime/chat/completions`(私有 SSE)。上游**按 run 计费**:一个 run 内多次调用只扣一次智点 → 会话 + run 按「账号 × 模型」缓存 1 小时(不滑动续期)+ 并发单飞;上游回 `SQLNoRows` /「会话不存在」时丢弃缓存重建一次。 + +**私有 SSE 与用量口径**:正文在 `choices[].text`(不是 `delta.content`),**不发 `[DONE]`**(靠 `finish_reason` 收尾);`usage.total_tokens` 是**当前上下文大小** → 自算 `prompt+completion`;`cached_tokens` / `reasoning_tokens` 是扁平字段,方向与 OpenAI 相反。 + +**工具 schema 必须先压平**:`anyOf` / `oneOf` / `$ref` / 数组型 `type` 会让上游整轮回「AI服务异常」,只回一句错误;`max_tokens` 要塞进 `base_llm_arguments`,`reasoning_effort` 顶层与 `llm_arguments` 各一份。 + +**额度=智点**:`total_balance` 直接进余额,赠送批次 `bonus_credits[].expire_time` 进到期阶梯(15 天内窗口);账本记「积分」,不做 token 换算。模型目录 `multiplier` 直接当倍率。 + +**签到**:任务中心 `daily_check_in`,`status=claimed` 即今日已签。上游对重复领取回 200 假成功(`result=ok` 而余额不动),故奖励按**领前 / 领后余额差**算,差为 0 时读任务状态判「已签」,绝不看 HTTP 200。 + +**错误分类**:401 → 凭证失效(重贴 Cookie);402 / 额度文案 → 额度耗尽;403 → 瞬时;404 → 请求无效;429 → 点名模型;审核拦截(`AuditFailed`)换号无用;run 过期 → 重建一次。 + +**ToS 灰区**:复用桌面 / 网页登录态并伪装桌面客户端(`X-Client-Type: lingxi_claw` + `x-kso-app-name/app-version/device-id` 固定可复现值),见 `provider/tos.py`。 -`PROVIDER_PROXIES` 给每个渠道单独指定出站代理: +### CodeBuddy 国际(`codebuddy_intl`) + +[www.codebuddy.ai](https://www.codebuddy.ai),同国内站协议,但两套独立账号体系(各自注册、各自计费)→ 独立渠道。登录链路同国内版(设备码)。 + +不合并原因:`CODEBUDDY_API_ENDPOINT` 是进程级单值 → 切过去会让存量国内站凭证打到国际站。两站可同时持凭证,各走各的端点、节流器、登录流程。 + +**白名单刻意互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有国际站主机;填国内站地址 → 启动报错(跨站发送即凭证泄露)。 + +**能力差异**:聊天、模型列表、额度探测、token 刷新、活跃上报全启用;**签到、成长中心未启用**(渠道侧无该活动;成长中心无法验证且含不可逆动作)。 + +**模型列表按套餐探活**:当前凭证逐个发一次最小请求,只留真可用模型;`11158`(套餐不允许)、`11102`(无此模型)剔除;`429` / 5xx / 超时保留。结论按账号缓存 6 小时。 + +**同名模型不带渠道后缀**:两站共有模型在 `/v1/models` 一条、挂两渠道;`-x` / `-sg` 这类真不同的各占一条。 + +### 按渠道出站代理(可选) ``` PROVIDER_PROXIES="codebuddy=http://127.0.0.1:7890;qoder=socks5://127.0.0.1:1080" ``` -格式 `渠道=代理URL;渠道2=代理URL2`,渠道取八个渠道名,协议支持 `http/https/socks5/socks5h`,留空 = 全部直连。作用于该渠道**全部出站请求**(聊天流、额度/模型拉取、后台任务与 OAuth 登录)。**启动期项**,改后需重启。**严格解析**:未知渠道 / 非法协议 / 缺 `=` 一律启动失败——「以为走了代理其实直连」比启动报错更糟。环境变量 `HTTP_PROXY` 等一律**不采信**(`trust_env=False`,防部署环境全局代理意外劫持带 Token 的上游请求)。 +格式 `渠道=代理URL;渠道2=代理URL2`;协议 `http/https/socks5/socks5h`;留空 = 全直连。作用该渠道全部出站(聊天、额度、模型拉取、后台任务、OAuth)。**启动期项**,改后重启。未知渠道 / 非法协议 / 缺 `=` → 启动失败。`HTTP_PROXY` 等不采信(`trust_env=False`)。 -> ⚠️ **容器部署时 `127.0.0.1` 指容器自身,不是宿主**。代理跑在宿主上(Clash / mihomo 等)时必须写成 `host.docker.internal`: -> -> ``` -> PROVIDER_PROXIES="codebuddy=http://host.docker.internal:7897" -> ``` -> -> compose 已声明 `extra_hosts: host.docker.internal:host-gateway`(Docker Desktop 自带,Linux / Colima 需显式加)。解析只校验格式、**不校验可达**,所以地址写错不会启动失败,只会让该渠道全部请求连不上——务必用 `docker logs` 确认没有 `All connection attempts failed`。 +> 容器内 `127.0.0.1` = 容器自身。代理跑宿主 → 写 `host.docker.internal`: +> `PROVIDER_PROXIES="codebuddy=http://host.docker.internal:7897"` +> compose 已声明 `extra_hosts`。解析只校验格式、不校验可达 → 写错不启动失败。 ## 出口协议 ### Responses API(Codex CLI) -`POST /v1/responses` 提供 Responses 子集,供 [Codex CLI](https://github.com/openai/codex) 这类只走 Responses 的客户端接入。与 `/v1/chat/completions` 共用同一套选号 / 冷却 / 轮换 / 统计与会话粘性,只换入站映射与出口翻译: +`POST /v1/responses` 提供 Responses 子集;与 chat 共用选号 / 冷却 / 轮换 / 统计 / 会话粘性: ```bash export CODING2API_KEY=sk-你的key codex -c "model_providers.coding2api={ name='coding2api', base_url='http://127.0.0.1:8000/v1', wire_api='responses', env_key='CODING2API_KEY' }" \ - -c model_provider=coding2api \ - -c model='glm-5.2' \ - '你的任务' + -c model_provider=coding2api -c model='glm-5.2' '你的任务' ``` -支持文本、流式正文、思考摘要(`reasoning` item)、函数工具调用与 `finish_reason=length` → `response.incomplete`。`text.format` 映射到 chat 的 `response_format`(`text` / `json_object` 直通,`json_schema` 展开成 `{type, json_schema:{name, description?, schema, strict?}}`)。内容 part 映射为:`input_text` / `text` / `output_text` → chat text,`input_image` → chat `image_url`(URL 原样透传,含 `detail` 时一并带上)。**不支持** `store=true`、`previous_response_id`(服务端无状态,不假装支持)、`input_file`(需服务端文件存储)、Responses 私有工具——一律显式 400,不静默降级。`include=["reasoning.encrypted_content"]`(Codex 每轮必带)接受但忽略。 - -### chat `response_format` 收口 +支持文本、流式正文、思考摘要、函数工具调用、`finish_reason=length` → `response.incomplete`。`text.format` → chat `response_format`。part:`input_text` / `text` / `output_text` → text;`input_image` → `image_url`。**不支持** `store=true`、`previous_response_id`、`input_file` → 400。`include=["reasoning.encrypted_content"]` 收但忽略。 -`POST /v1/chat/completions` 的 `response_format` 不再整包透传,只放行 OpenAI 定义的三种形状,畸形或未知形状**显式 400**(不静默降级、也不打到上游把凭证打凉): +工具表:`function` 直译;`namespace` 只展开默认 `functions` 组(Codex 0.135+ 默认下发;其余命名空间丢弃——客户端按 `(namespace, name)` 查登记表,展平名会被当成不存在的工具);`web_search` / `tool_search` / `custom`(freeform `apply_patch`)等托管工具**跳过不报错**(本网关无服务端搜索 / freeform 执行能力;400 会让整轮请求死掉,客户端不降级)。 -- `{"type": "text"}` -- `{"type": "json_object"}` -- `{"type": "json_schema", "json_schema": {"name": <非空字符串>, "schema": <任意 JSON Schema>, "description"?: <字符串>, "strict"?: <布尔>}}` —— `json_schema` 内的未知字段丢弃(既非 chat 规范、上游也不认),`name` 缺失 / `schema` 缺失 / `type` 未知分别回对应的 400 文案。 +### chat `response_format` 收口 -Responses 出口的 `text.format` → `response_format` 映射与 chat 入站**共用同一段校验**(`src/compat/openai/request.py: normalize_response_format`),两条入站协议不会各写一份而漂移;`prefix` 参数只改报错里的字段路径(Responses 侧是 `text.format`)。 +只放行 OpenAI 三种形状;畸形 / 未知 → 400:`{"type":"text"}`、`{"type":"json_object"}`、`{"type":"json_schema","json_schema":{name, schema, description?, strict?}}`(内部未知字段丢弃)。Responses `text.format` 与 chat 入站共用这段校验。 ### Anthropic Messages API(Claude Code) -`POST /v1/messages` 提供 Anthropic Messages 子集,供 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 接入。与其它出口共用同一套选号 / 冷却 / 轮换 / 统计与会话粘性: - ```bash export ANTHROPIC_BASE_URL=http://127.0.0.1:8000 export ANTHROPIC_AUTH_TOKEN=sk-你的key # 或 ANTHROPIC_API_KEY(走 x-api-key 头) claude ``` -**鉴权**:`x-api-key` 与 `Authorization: Bearer` 都接受。**流式**:`message_start` → `content_block_start/delta/stop` → `message_delta`(含 `stop_reason` 与 usage)→ `message_stop`;无 `[DONE]` 哨兵,thinking 块在 `content_block_stop` 前补 `signature_delta`。**缓存命中**:出口 usage 按 Anthropic 语义拆两笔——`input_tokens` 只计未命中,命中另记 `cache_read_input_tokens`(内部 `input_tokens` 是 OpenAI 口径的 `prompt_tokens`,含命中),Claude Code 据此显示缓存读 token。**`count_tokens`**:`POST /v1/messages/count_tokens` 本地估算输入 token(不转发上游)。**图片**:`image` 块(`source.type=base64` 拼成 `data:` URL、`source.type=url` 直传)归一为 chat 的 `image_url` 内容块,`tool_result.content` 内的图片同样保留——纯文本内容仍折成字符串,故无图请求的映射结果与旧版逐字节一致。**不支持**:`document` 块(PDF,chat 上游无对应表达)、`source.type=file`、Anthropic 服务端工具一律显式 400。 - -**`cache_control` 透传**(默认开启,无开关):入站的缓存提示标记原样带给上游,落点与 Anthropic 一致——`system` 内容块、user/assistant 消息内容块、`tool_result`(挂 tool 消息**顶层**,不造空 text 块)、assistant `tool_calls` 元素顶层、`tools` 元素顶层。只做**结构**校验(必须是对象、必须有非空字符串 `type`、`ttl` 若给必须是字符串),取值不解释(未知 `type` 也透传,上游将来新增的缓存类型不该被本网关挡掉);结构不合法显式 400 而不是静默丢弃。**不带标记时映射结果与旧版逐字节一致**(纯文本仍折成字符串),故会话粘性指纹与上游前缀缓存不受影响。八渠道实测 6 个接受(codebuddy / codebuddy_intl / trae / qoder / codearts / kilo);**zen 未验证**(上游返 `FreeTierError` 403,`--plain` 对照同样失败,与标记无关)、**OfficeAce 未单独验证**(未跑该探针);实测脚本 `scripts/probe_cache_control.py`。 +- **鉴权**:`x-api-key` 与 `Authorization: Bearer` 都接受。 +- **流式**:`message_start` → `content_block_*` → `message_delta` → `message_stop`;无 `[DONE]`。thinking 块在 `content_block_stop` 前补 `signature_delta`。 +- **缓存命中**:出口 usage 拆两笔——`input_tokens` 只计未命中,命中另记 `cache_read_input_tokens`。 +- **`count_tokens`**:本地估算,不转发上游。 +- **图片**:`image` 块归一为 chat `image_url`;`tool_result.content` 内图片同样保留。 +- **不支持**:`document` 块、`source.type=file`、Anthropic 服务端工具 → 400。 +- **`cache_control` 透传**(默认开):落点同 Anthropic——`system`、消息内容块、`tool_result` 顶层、`tool_calls` 顶层、`tools` 顶层。只校验结构,不解释取值。实测 codebuddy / codebuddy_intl / trae / qoder / codearts / kilo 6 家接受;zen、小浣熊、灵犀未验证,OfficeAce 未单测。脚本 `scripts/probe_cache_control.py`。 ### CC Switch 一键导入 -「API Key 管理」页可生成 [CC Switch](https://github.com/farion1231/cc-switch) 的 `ccswitch://` 导入深链(Claude Code / Codex CLI / OpenCode 三个目标各一个按钮),点一下即由桌面端弹确认框写入地址、Key 与模型。两处入口:**创建 Key 成功后的对话框**(直接用刚生成的一次性明文 Key,关闭对话框即不再保留)与 **Key 列表每行的「导入 CC Switch」**(列表只存脱敏 `preview`,需粘贴创建时保存的完整 Key,只留在对话框内存里)。两处都可用「默认模型」下拉选择要写入的模型——与 Playground 同源(`/api/playground/models`),按渠道分组。深链**只能由点击触发**(浏览器拦截自动跳转),且**含 Key 明文**,故仅在用户主动打开、且已给出 Key 时生成。协议与 `app` / `apiFormat` 取值见 CC Switch 用户手册「Deep Link Protocol」,需 CC Switch v4.0.10+。注意 **endpoint 形态随 `apiFormat` 而变**:Anthropic 格式(Claude Code)填到根、**不带 `/v1`**;两个 OpenAI 格式填到 `/v1`。本服务没有 Gemini 出口,故不提供 `app=gemini` 入口。 +「API Key 管理」页生成 `ccswitch://` 深链(Claude Code / Codex CLI / OpenCode 各一按钮)。入口:建 Key 成功后对话框、Key 列表每行「导入 CC Switch」。深链含 Key 明文 → 仅主动打开时生成。需 CC Switch v4.0.10+。endpoint 随 `apiFormat`:Anthropic 到根(无 `/v1`);OpenAI 到 `/v1`。无 Gemini 出口。 ### 余额查询 -`GET /v1/user/balance` 兼容 DeepSeek 余额接口形状,Bearer `sk-...` 鉴权,供 Cherry Studio / cc-switch 等客户端显示余额。余额来自凭证池的额度探测缓存(`QUOTA_PROBE_MINUTES` 周期刷新),按可用凭证汇总,单位为上游 credits: +`GET /v1/user/balance` 兼容 DeepSeek 形状,Bearer `sk-...` 鉴权,供 Cherry Studio / cc-switch 显示余额。数据来自额度探测缓存,按可用凭证汇总: ```json { @@ -330,572 +382,643 @@ claude } ``` -池内凭证从未探测成功时 `balance_known` 为 `false`(余额未知,不是 0)。 +从未探测成功 → `balance_known` = `false`(未知,不是 0)。 ### 池健康检查 -`GET /health` 只回 `{"status":"ok"}`(进程存活,适合容器存活探针);`GET /healthz` 额外给出凭证池计数,**并在 `ready == 0` 时回 HTTP 503**(就绪探针)——那是「进程活着但没有可用凭证」的状态,对话请求必然 503,负载均衡/外部监控应据此摘流量。计数在两种状态下都返回,监控可读到「为什么不可用」。两个端点都不鉴权。**两者同时接受 `HEAD`**(`curl -I` / 多数监控默认发 HEAD):FastAPI 的 `APIRoute` 不像 Starlette 那样自动给 GET 补 HEAD,只注册 GET 时探针会一直吃 405,表现为「服务正常但监控永远红灯」。 +- `GET /health`:只回 `{"status":"ok"}`,适合存活探针。 +- `GET /healthz`:额外给凭证池计数,`ready == 0` → 503(就绪探针)。 -```json -{ - "status": "ok", "service": "coding2api", "version": "0.9.1", - "credentials": {"total": 5, "ready": 4, "cooling": 1, "paused": 0, "disabled": 0} -} -``` - -五类计数互斥且合计 = `total`,与调度器同一口径。`ready=0` 时 `/healthz` 返回 503 且 `status` 为 `unavailable`;请把**存活**探针配在 `/health`(恒 200),把 `/healthz` 用作就绪/告警——把容器 healthcheck 指到 `/healthz` 会在池子耗尽时把容器标成 unhealthy,而重启并不能修好一个没有可用凭证的池子。 +两端点都不鉴权,都接受 `HEAD`。四类计数互斥(`ready` / `cooling` / `paused` / `disabled`),合计 = `total`,与调度器同口径。**存活探针配 `/health`**;`/healthz` 用作就绪 / 告警。 -> 升级注意:`/healthz` 的 HTTP 状态从「恒 200」变为「ready=0 时 503」。只判 HTTP 200 的外部监控会在此后开始报警,这是预期行为;若你只想要计数、不想被状态码影响,改读 `/health` + 自行解析计数即可。 +> 升级注意:`/healthz` 从「恒 200」→「ready=0 时 503」。 ### Prometheus 指标(`GET /metrics`) -`GET /metrics` 输出 Prometheus text 格式(`text/plain; version=0.0.4`),供 Prometheus/Grafana 直接抓取。默认开启,**不鉴权**——与同样裸暴露的 `/healthz` 同口径;`METRICS_ENABLED=false`(可热更)时端点返回 404。 - -导出五个指标家族: +默认开启、默认**不鉴权**;`METRICS_ENABLED=false`(可热更)→ 404。设 `METRICS_TOKEN` 后抓取须带 `Authorization: Bearer `(不匹配 401)。 | 指标 | 类型 | 标签 | 数据源 | | --- | --- | --- | --- | -| `coding2api_requests_total` | counter | `provider` / `model` / `status`(ok\|error) | `usage_hourly`(永久,跨重启单调) | -| `coding2api_tokens_total` | counter | `provider` / `type`(input\|output\|reasoning\|cached) | `usage_hourly` | -| `coding2api_credentials` | gauge | `state`(ready\|cooling\|paused\|disabled) | 凭证池(与 `/healthz` 同口径) | -| `coding2api_upstream_errors_total` | counter | `provider` / `kind`(受控失败原因) | 进程内计数(重启归零) | +| `coding2api_requests_total` | counter | `provider` / `model` / `status` | `usage_hourly` | +| `coding2api_tokens_total` | counter | `provider` / `type` | `usage_hourly` | +| `coding2api_credentials` | gauge | `state` | 凭证池 | +| `coding2api_upstream_errors_total` | counter | `provider` / `kind` | 进程内计数(重启归零) | | `coding2api_task_failures_total` | counter | `task` | 进程内任务运行态(重启归零) | -**禁用高基数标签**:不含 `credential_id` / `api_key_id` / `username` —— 一个账号一条时间序列会让 Prometheus 侧内存与查询失控。`requests_total` / `tokens_total` 读**永久保留**的小时汇总(不是 90 天的明细),因此 scrape 时不做明细全表扫描、且计数跨重启单调;进程内计数(失败原因、任务失败)重启归零,由 Prometheus 的 counter reset 语义吸收。 - -> 已知偏离:扫描报告列了 `upstream_errors_total{provider,kind}` 且数据源写 `usage_hourly`,但小时汇总没有 `error_type` 维度,用 90 天明细当 `_total` 会随裁剪下降——故 `kind` 改由进程内计数器提供(重启归零),其余家族仍取小时汇总。 +不含 `credential_id` / `api_key_id` / `username`(高基数)。`requests_total` / `tokens_total` 读永久小时汇总,跨重启单调。汇总无 `error_type` 维度 → `kind` 由进程内计数器提供。 ## API Key 的绑定、白名单、来源 IP 与到期时间 -创建 Key 时可限定渠道、模型、来源 IP 与到期时间,适合「按出口分发 Key」。 - -- **渠道绑定**:选定渠道后该 Key 只在该渠道凭证里选号;模型属另一渠道时直接 400 并指出实际归属(不静默改道)。留空 = 自动(跨渠道选健康凭证)。`模型@渠道` 与绑定冲突时同样 400。 -- **来源 IP 白名单**:逗号分隔的 IP 或 CIDR(如 `203.0.113.9,10.0.0.0/8`),留空 = 不限制;写入时校验并规范化(`10.0.0.1` → `10.0.0.1/32`),来源不在白名单返回 403。**默认不采信 `X-Forwarded-For`**(客户端可写);仅 `TRUST_PROXY=true` 时按 XFF 判定、取**最后一个**条目,故只适用于「本服务前恰好一层受信反代」。 -- **模型白名单**:逗号分隔的模型名或 fnmatch glob(如 `glm-*,kimi-k3`),留空 = 不限制;匹配不区分大小写,`模型@渠道` 后缀不参与匹配,白名单外返回 400,`/v1/models` 也只列白名单内模型。 -- **到期时间**:epoch 秒,留空 = 永不过期,到期后立即 401(与「Key 不存在」统一文案,不泄露可枚举信息)。 -- **配额**:一个 JSON 字段,六项各自独立、`0` 或留空 = 不限制:`daily_requests`(当日请求数)、`daily_tokens`(当日 token)、`daily_cost_usd`(当日成本)、`rpm`(每分钟请求)、`tpm`(每分钟 token,用**预估**值判定——真实用量要等响应结束才知道)、`max_concurrency`(在途并发)。当日类按**北京时间自然日**统计(与凭证护栏同一口径),速率与并发是**进程内**滑动窗口(重启清零,不落库)。超限返回 429 + `{"error":{"type":"quota_exceeded",...}}`,`Retry-After` 指向可重试时刻(当日类 = 距次日 0 点;RPM/TPM = 最早一条滑出窗口;并发 = 1 秒)。**判定本身出异常时一律放行并记 warning**——配额是拒绝语义,但计量故障不能把正常请求打死。持 Key 的调用方可用 `GET /v1/usage?days=1|7|30` 自助查询自己的用量、剩余额度与当前速率占用(只读本地明细,不触发上游,不含凭证昵称或池级信息)。 - -**导入 CC Switch**:Key 列表每行的「导入 CC Switch」按钮打开对话框,粘贴创建时保存的完整 Key(列表只存脱敏 `preview`,服务端不再保留明文,故必须由用户提供),再选一个默认模型,即可生成 Claude Code / Codex CLI / OpenCode 三个 `ccswitch://` 导入深链;默认模型下拉与 Playground 同源(`/api/playground/models`),按渠道分组。粘贴的 Key 只留在对话框内存中,关闭即清除。创建 Key 成功后的对话框内也提供同样的导入按钮(那里直接用刚生成的明文 Key)。 +- **渠道绑定**:该 Key 只在该渠道凭证里选号;模型属另一渠道 → 400 并指出实际归属。留空 = 跨渠道自动。 +- **来源 IP 白名单**:逗号分隔 IP / CIDR;写入时规范化(`10.0.0.1` → `10.0.0.1/32`);名单外 → 403。默认不采信 `X-Forwarded-For`;仅 `TRUST_PROXY=true` 按 XFF 取最后一条。 +- **模型白名单**:逗号分隔模型名或 fnmatch glob(如 `glm-*,kimi-k3`);大小写不敏感;`模型@渠道` 后缀不参与匹配。名单外 400;`/v1/models` 也只列名单内模型。 +- **到期时间**:epoch 秒,留空 = 永不过期;到期后 401(与「Key 不存在」同文案)。 +- **配额**:六项独立,`0` / 留空 = 不限——`daily_requests` / `daily_tokens` / `daily_cost_usd` / `rpm` / `tpm`(按预估值)/ `max_concurrency`。当日类按北京时间自然日;速率 / 并发为进程内滑动窗口。超限 429 + `quota_exceeded`,`Retry-After` 指向可重试时刻。异常一律放行 + warning。 +- **自助查询**:`GET /v1/usage?days=1|7|30` 返回自己的用量、剩余额度、当前速率占用(只读本地明细)。 ## 凭证暂停 -凭证列表行内菜单的「暂停」把该凭证摘出**对话流量**,并让**每日签到 / token 预刷新**跳过它(这两类任务对 `disabled` 与 `enabled=0` 一视同仁)。其余后台任务(额度探测、成长中心、活跃上报)仍照常运行(只认系统硬禁用 `disabled`),适合「先不接聊天、但积分继续领」;「取消暂停」立即放回池子。与状态列的「已禁用」不同——那是渠道判定会话失效后的系统禁用,需重新登录后用「恢复」解除。同一开关也在 `POST /api/credentials/{id}/toggle`。 +「暂停」= 凭证摘出对话 + 签到 / token 预刷新跳过;其余后台任务(额度探测、成长中心、活跃上报)照常。适用「不接聊天、积分继续领」。「取消暂停」立即放回池子。≠「已禁用」——后者是渠道判定会话失效后的系统禁用,需重登后「恢复」。同一开关:`POST /api/credentials/{id}/toggle`。 -> OpenCode Zen / Kilo 是无凭证渠道,池里是一条虚拟占位行。**暂停它是永久停用的唯一方式**——删除后下次重启会被种子重新补上。暂停也会把该渠道从模型列表摘掉。 +> Zen / Kilo 是虚拟占位行,**暂停是永久停用的唯一方式**(删除后重启补回)。暂停也会把该渠道从模型列表摘掉。 ## 凭证批量运维 -池子一大,「逐个点签到 / 探测」就不现实。凭证页提供多选 + 批量操作条:每行一个 checkbox、组头一个「全选本渠道」,勾选后顶部出现操作条(探测额度 / 每日签到 / 暂停 / 取消暂停 / 恢复 / 取消选择)。 +凭证页支持多选 + 批量操作条(探测额度 / 每日签到 / 暂停 / 取消暂停 / 恢复)。 -- **后端批量端点**:`POST /api/credentials/batch`,body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?}`(`enabled` 仅 `toggle` 用)。一次请求最多 200 条,重复 id 自动去重。 -- **逐条隔离**:单条失败不影响其余条——凭证不存在、渠道不支持该能力、上游报错都只体现在该条结果里,其余条照常执行。响应为 `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`:`ok` 表示该条动作是否成功;`error` 表示这条根本没执行到上游(`credential not found` / `credential does not support ...` / `action_failed`),`detail` 是动作自身的返回体(probe 的剩余额度、checkin 的积分与状态)。 -- **暂停是显式状态而非取反**:`toggle` 的 `enabled` 由请求体显式给出,不会让「已启用 + 已暂停」的混选走向相反方向。因此操作条把 `toggle` 拆成**「暂停」(`enabled=false`)与「取消暂停」(`enabled=true`)两个按钮**——只给一个按钮的话,批量暂停后再无入口放回池子(`revive` 清的是硬禁用 `disabled`,不碰 `enabled`)。 -- **「恢复」与「取消暂停」是两件事**:前者是 `revive`(解除渠道判定会话失效后的系统硬禁用,需先重新登录),后者是 `toggle enabled=true`(把管理员手动暂停的号放回调度)。 -- **只收已有单条端点的动作**:`refresh`(token 预刷新)没有单条入口且由后台按到期窗口驱动,批量强刷会白耗一次性票据;`delete` 破坏性太强,需要单独的二次确认流,都不走这个通道。 -- **审计只记一条汇总**(`toggle` / `revive`):批量暂停 50 个号是一次操作、一个意图,逐条落库会把审计流水刷成噪声。`probe` / `checkin` 与单条端点同为只读语义,不写审计。`toggle` 的 detail 带方向(`批量 toggle(暂停):N/M 成功`),否则暂停与取消暂停在流水里无法区分。 -- **审计写失败不影响操作结果**:审计落库失败只记 warning,端点仍返回逐条结果。逐条变更此时已提交生效,因留痕失败返回 500 会让操作员误以为整批没跑。 +- **端点**:`POST /api/credentials/batch`,body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?, force?}`。一次 ≤200 条,重复 id 去重。`force` 只对 `revive` 有效(见下)。 +- **批量导入**:`POST /api/credentials/batch-import`,body `{items: [{provider, credential, nickname?, tags?}]}`。一次 ≤100 条;逐条校验,坏条目只进 `failed`(带 `index` 与原因);同渠道解密后内容完全一致视为重复,进 `skipped`。管理台凭证页「批量导入」支持粘贴 JSON 数组 / JSONL 或选文件。 +- **单条导出**:`GET /api/credentials/{id}/export` 返回与批量导入**同形**的 JSON(`provider` / `credential` / `nickname` / `tags`),可原样再导入。响应带 `Content-Disposition` 附件头与 `no-store`,内容是**明文凭证**——只对 admin/operator 开放,且落 `credential.export` 审计。管理台入口:凭证行「更多操作 → 导出凭证」。 +- **逐条隔离**:单条失败不影响其余。响应 `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`。 +- **签到失败是结果不是故障**:上游拒绝(账号被禁用 / 登录过期 / 限流)回 `ok=false` + 可读 `message`,不 500——否则管理台只弹「签到失败」,看不出真实原因。 +- **暂停是显式状态而非取反**:`toggle` 的 `enabled` 由请求体给出 → 操作条拆「暂停」/「取消暂停」两按钮。 +- **「恢复」≠「取消暂停」**:前者 `revive`(解系统硬禁用,需先重新登录);后者 `toggle enabled=true`。 +- **「恢复」默认保留上游点名的冷却**:`revive` 只解硬禁用、清本地冷却与模型级条目;冷却来源属于「上游点名恢复时刻」(限流 / 余额 / 权益 / 风控,即 `cooling_kind` ∈ `UPSTREAM_COOLDOWN_KINDS`)时**保留**——清掉只会立刻再撞一次上游(实测 2026-11-03 12:22–12:23 三次恢复 zen 凭证,每次都在恢复时顺手解掉刚写入的 429 冷却,下一请求再撞同一个 429)。要强制清除走 `force=true`(单条端点请求体 / 批量 `force`,前端菜单项「强制恢复」)。 +- 只收已有单条端点的动作:`refresh` / `delete` 不走此通道。 +- **审计只记一条汇总**(`toggle` / `revive`)。审计写失败只记 warning,不影响操作结果。 ## 凭证限额护栏 -凭证级**可选**护栏,给「这个号别用超」一个显式开关:行内菜单「设置限额」配置,四个护栏各自独立、全部留空 = 不受限(老库升级后行为不变)。判定在**选号前**完成,触顶的凭证不再参与对话流量;后台任务(签到 / 探测 / 成长中心)照常运行——限额只管「对话用量」,与「暂停」(连签到/预刷新一并停掉)不是一回事。 +凭证级可选护栏,行内菜单「设置限额」。四项独立,全留空 = 不受限。判定在选号前完成;后台任务照常跑。 -- **保留积分**:账户剩余积分 **≤** 该值时整号停用——给号留底,避免把额度跑空。与当日用量无关;余额未知(渠道不上报)时不猜、不停用。 -- **每日 token 上限**:当日累计 `输入 + 输出` token **达到**该值时整号停用。 -- **每日积分上限**:当日累计 credit 达到该值时整号停用。 -- **按模型 token 上限**:`{模型: 上限}`,某模型当日累计达到上限时**只停该模型**,其余模型照常可用。 -- **当日 = 北京时间自然日**(与签到、活跃上报同一时区),0 点刷新计数,避免与上游「每日重置」出现半天错位。 -- **无可用凭证 503**:当所有候选都因限额被跳过时,复用 `no_healthy_credential`(账号级原因,换个模型也未必能用);与「模型级冷却」的 `model_unavailable` 区分开。 -- 写入 `PUT /api/credentials/{id}/limits`(整体替换:只传要开的护栏,缺省字段 = 关闭该护栏;四个全关时删除配置行)。改动即时生效(写入后主动失效选号侧快照,不等 TTL)。审计动作 `credential.limits`。 - -> 实现注:判定需要「当日聚合用量」,那是每请求都变的动态数据,因此不进 `Candidate.is_selectable`(纯函数,只吃凭证自身状态),而在 executor 选号前用 `LimitGuard` 过滤;`LimitGuard` 对「限额表 + 当日用量」做进程内快照缓存(默认 15s),避免每次选号都查一次聚合。 +- **保留积分**:剩余 ≤ 该值 → 整号停用。余额未知不猜。 +- **每日 token 上限** / **每日积分上限**:当日累计达到即整号停用。 +- **按模型 token 上限**:`{模型: 上限}`,只停该模型。 +- **当日 = 北京时间自然日**,0 点刷新。 +- 候选全因限额跳过 → `no_healthy_credential` 503;模型级冷却 → `model_unavailable`;上游限流/额度类冷却 → 统计记 `rate_limit`(对外码仍是 `no_healthy_credential`)。 +- 写入 `PUT /api/credentials/{id}/limits`(整体替换;四个全关 → 删配置行)。即时生效。审计动作 `credential.limits`。 ## 凭证在途上限 -`MAX_INFLIGHT_PER_CREDENTIAL`(默认 `0` = 不限):同一凭证的**并发请求**达到该值后不再参与选号,避免把单账号打进上游限流 / 封号——多配几个号本就是为了分摊,把一个号打满不如把请求分出去。跨渠道全局生效(与 CodeArts 的渠道内 `CODEARTS_MAX_CONCURRENCY` 正交)。 - -- 计数是**进程内**的:单实例语义,多实例部署各自计数(与 `/api/tasks` 运行态同口径)。 -- 租约在请求结束时释放(含成功、轮换、异常、客户端断开);另有 **600s TTL 兜底**——漏释放的租约过期后被回收,宁可偶尔多放一个请求,也不能让号被静默占死。 -- 全部候选都占满时与限额护栏同口径报「无可用凭证」503。 -- 可在管理台「任务与配置」页热更,下一请求即生效(无快照缓存)。 +`MAX_INFLIGHT_PER_CREDENTIAL`(默认 0 = 不限):同一凭证并发达到该值后不再参与选号。 -**在途计数兼作负载键**(不限并发时也生效):在途数是调度器的「最少连接」排序键,插在「免费优先」之后、到期额度之前——并发请求自动摊到空闲号,避免全部压到排序确定的同一个号上(`Scheduler.select`)。上限为 `0`(不限)时 `blocked()` 恒 False,但租约**照常登记**,负载键照常可用;只有顺序请求(在途恒 0)才退回纯到期/健康度排序。 +- 计数进程内(单实例语义)。 +- 租约在请求结束时释放,另有 600s TTL 兜底。 +- 全部占满 → 与限额护栏同口径 503。 +- 可热更。 +- **在途计数兼作负载键**:插在「免费优先」之后、到期额度之前 → 并发自动摊到空闲号。 ## 凭证池的筛选与分组 -凭证多了以后,列表页把「找到某个号」这件事拆成三层,默认首屏只铺渠道级总览: - -- **筛选工具条**:搜索(昵称 / 渠道 / id,忽略大小写)× 渠道 × 状态 × 标签四件套。四者是 **AND** 关系,纯前端收敛,不改后端排序(后端仍按 `sort` / `order` 给全量)。命中时显示「筛选后 N / 总数」,并提供「清空」;无匹配给空态而非空白表格。渠道 / 状态 / 标签下拉里的计数来自当前全量,切换筛选不会自我缩窄。**标签下拉只在池里真的出现过标签时渲染**(没有标签时不留一个恒为「全部标签」的空控件);标签是结构化维度,**不参与搜索词匹配**——否则「查一个恰好叫『生产』的昵称」会变得不可能。 -- **标签**:每条凭证可贴任意个运维标签(逗号分隔,最多 16 个、单个 ≤ 24 字符;半角/全角逗号与分号都当分隔符),入口在行内「更多操作 → 设置标签」。标签**只用于列表筛选与展示**,不参与选号、不改变任何请求行为——把标签当策略用会让改名静默改变线上流量。行内最多列两个标签,多余收成 `+N` 并在悬浮里给全量。 -- **组排序**:单独一个控件,只重排「渠道组」,不动组内行序(行序仍由列头 / 后端 `sort` 决定)。默认**额度从高到低**,按组内额度剩余合计排名,让还能打最多的渠道排前面;可切回**渠道顺序**(固定,30s 轮询不会让组头乱跳)。无额度接口的免费层垫底(不当 `0` 参与比较),额度相同(含都为 null)时回落到渠道顺序。 -- **按渠道分组**:列表按渠道聚合,组头做成一张紧凑的「渠道仪表盘」——品牌图标 + 渠道名 + 账号数,加上**模型**(该渠道当前能提供的模型数量 + **最低倍率**,数据源与 Playground / `/v1/models` 同源,只算有可用凭证且过当前黑名单的渠道;**最低倍率只统计收费模型**——免费额度(0)不参与取最小,否则渠道里混着 1~2 个免费模型就会显示「最低 免」,读起来像整个渠道免费;整组都免费时才写「全免」;自定义别名不算独立模型(后端克隆目标条目,算进来会虚高);组头只给数量与最低倍率,hover 展开**卡片式浮层**——头部是渠道身份 + 条数胶囊,中部是限高内滚的逐条「模型名 + 该渠道倍率」清单,底部是倍率口径脚注。几十条模型铺开会把组头撑成十几行,而「几个模型、最便宜多少」正是判断这渠道值不值得展开的第一信息。倍率按渠道取(`by_provider` 优先,单渠道回退合并值),多渠道模型各渠道倍率不同,不能拿合并值冒充;`0` 显示成「免」并着色,未给倍率的显示 `—` 而不是当免费;模型列表未取到时整块不渲染,不显示「0 个」。模型块是**独立的可聚焦按钮**(放在折叠按钮外——button 套 button 会被 HTML 解析器拆开),键盘 Tab 到它同样能打开清单,不依赖鼠标 hover)、**状态分布**(图标 + 计数,只列出现过的态,口径与行内状态徽章同一套 `credentialState`)、**额度合计**(占比环 + 数字;环是纯图形仪表,精确百分比写在「额度合计 / 积分合计」标签行,对组内非 null 值求和,免费层 zen / kilo 无额度接口时显示 `—` 而不是 `0`,也不出环),以及**到期积分**(主窗口 1.5 天一块,次窗口 7 天仅在**比主窗口多**时再出一块——次窗口是主窗口的超集,数字相同说明没有超出 1.5 天的部分)。点组头折叠 / 展开该渠道,**默认全折叠**(首屏先看结构,要看具体账号再展开);筛选生效时自动展开命中渠道,清空筛选回到折叠总览。组顺序默认「额度从高到低」,也可用工具条切到固定的全站渠道顺序(CodeBuddy、TRAE 在前),不随行排序键变化。渠道名只出现在组头,行内不重复。**一个真实渠道一个组**:CodeArts 额度就是积分,组头额度合计标签写「积分合计」,其余渠道写「额度合计」。 -- **列头跟着展开区走**:没有全局表头,列头(含排序按钮与列说明)渲染在每个展开的渠道组内部,并在滚动容器里吸顶——折叠态下组头本身就是总览,列名没有对应内容,只有展开某组时才需要「这一列是什么」的锚点。同一时间可能有多个组展开,各组列头共用同一套排序状态(点任一处都写回同一个后端 `sort` 参数)。**「成长中心」列只在支持的渠道(CodeBuddy)出现**,其余渠道留一个空占位格——列数保持恒定,否则 auto 布局会重算列宽、下面展开的组整列错位。 -- **一个渠道一个组**:组头 label 就是渠道展示名,`ProviderGroup.key === provider`;CodeArts 不再拆「内置 / 福利」两组(福利链路已下线,额度就是积分账)。行内额度列读的是同一个渠道账本:CodeArts 是积分(`ledger=credits`),其余渠道是套餐 / 每日余额(`ledger=pool`),由 `providerLedger()` 派生。 -- **长列表可滚**:表格限高(70vh),滚到第 20 行时该组的吸顶列头与「操作」按钮仍在。 +- **筛选工具条**:搜索(昵称 / 渠道 / id)× 渠道 × 状态 × 标签,四者 AND,纯前端。显示「筛选后 N / 总数」+「清空」。标签下拉仅在池里真出现过标签时渲染;标签不参与搜索词匹配。 +- **标签**:逗号分隔,≤16 个、单个 ≤24 字符。仅筛选 / 展示,不参与选号。行内最多两个,多余收 `+N`。 +- **组排序**:只重排渠道组,不动组内行序。默认额度降序,可切回固定渠道顺序。无额度接口的免费层垫底。 +- **按渠道分组**:组头 = 紧凑仪表盘——图标 + 渠道名 + 账号数、模型(数量 + 最低倍率,仅收费模型;整组免费才写「全免」;自定义别名不算独立模型)、状态分布、额度合计、到期积分(主窗口 1.5 天一块;次窗口 7 天仅在更多时出)、今日签到(最右,模型前:已签 / 该签 + 失败数与最近时刻,取本地签到审计日志,不发上游请求;暂停 / 硬禁用不进分母,无签到能力的渠道不显示)。默认全折叠;筛选生效展开;点组头折叠 / 展开。 +- **列头跟展开区走**:列头在各展开组内部渲染并吸顶,各组共用一套排序状态。「成长中心」列仅 CodeBuddy 出现,其余渠道留空占位保列宽。 +- **长列表可滚**:表格限高 70vh,吸顶列头与「操作」常驻。 -额度列的数字带虚线下划线:悬浮显示**本行的周期口径**(本周期剩余 / 账户剩余 / 积分余额 / 免费层无额度接口),逐行的同款文案收进悬浮说明。 +额度列数字带虚线下划线,悬浮显示本行周期口径。 ## 模型列表加载 -`GET /v1/models` 与 Playground 的模型下拉只合并**当前有可用凭证**的渠道:该渠道至少有一条**未暂停、未会话失效**的记录。判定在缓存之前——没凭证的渠道不读缓存、不拉上游、不展示,故**从未接入**的渠道不出现「幽灵模型」;**全部暂停 / 凭证失效**时其模型暂时消失,恢复或补一条凭证后立刻回来;冷启动默认只有 `zen` / `kilo`(自带虚拟凭证)。冷却中的凭证仍算「有凭证」——只是暂时限流,列表不闪没。 +`GET /v1/models` 与 Playground 下拉只合并**当前有可用凭证**的渠道(至少一条未暂停、未失效);冷却中的仍算。顺序:CodeBuddy(国内 → 国际)、TRAE、Qoder、CodeArts、OfficeAce、小浣熊、灵犀,Zen / Kilo 垫底;组内按名字典序;多渠道模型归最高优先级渠道(`src/api/model_merge.py` 的 `_PROVIDER_RANK`)。 -**展示顺序**:CodeBuddy、TRAE、Qoder、CodeArts、OfficeAce 的模型依次排在前面,组内按模型名字典序;多渠道模型按最高优先级渠道归位。纯展示排序,不改变调度选号。 - -**请求路由只认这份模型缓存列表**:模型目录能证明某模型归属哪些渠道时,候选渠道就只有它们——登记了该模型的渠道全部不可用(都在冷却 / 都被暂停)时直接返回 503,不再把请求逐个渠道试过去(各上游只会回 11102 / 4001 / 401,还会给渠道写下数小时的「该后端无此模型」负缓存)。模型完全不在目录里时仍按全部候选渠道路由;直连指定(`模型@渠道`)与 Key 渠道绑定不受影响。 +**请求路由只认这份缓存**:目录能证明归属 → 候选只有那些渠道;全不可用 → 503,不逐渠道试。模型完全不在目录 → 仍按全部候选路由;直连指定与 Key 渠道绑定不受影响。 ### 模型目录落盘与启动即用 -模型表除进程内 TTL 缓存(300s)外,每次拉取成功还写快照到 `DATA_DIR/model_catalog.json` 并在**启动时同步读回**:重启那一刻「模型 → 渠道」归属表就已就绪,扁平名请求不会在预热(zen 免费模型探活要十几秒)跑完前扇出打错渠道。快照存未过滤原始表,损坏 / 超 7 天只丢缓存、不影响启动。另有 `MODEL_CATALOG_MINUTES` 后台任务兜底刷新。 +模型表除进程内 TTL 缓存(300s)外,每次拉取成功还写快照到 `DATA_DIR/model_catalog.json`,启动时同步读回 → 重启那刻归属表已就绪。快照损坏或超 7 天只丢缓存。另有 `MODEL_CATALOG_MINUTES` 任务兜底。 -Playground 与 `GET /v1/models` 走 **stale-while-revalidate**:有缓存立刻回旧列表,TTL 到期的渠道丢后台异步刷新;只有某渠道**一条缓存都没有**时才同步等一次。代价是列表最多滞后一个 TTL。拉取**连续失败**时重试间隔按 2^(n-1)×TTL 退避(封顶 2 小时,成功一次即恢复),日志只在首次失败打 WARNING、其后降 INFO——上游长期不可用(如 zen 免费层按出口 IP 拦截)时不会每 5 分钟重探十几秒再刷一条同样的告警。 +Playground 与 `/v1/models` 走 stale-while-revalidate:有缓存立刻回旧列表,TTL 到期后台刷新;某渠道零缓存才同步等。连续失败重试间隔按 2^(n-1)×TTL 退避(封顶 2 小时);日志只在首次失败打 WARNING。 ### 管理台「模型列表」页 -管理台「模型列表」页(`GET /api/model-catalog`,管理员 / 操作员可见)只读展示 OpenRouter 公开目录:刊例价(输入 / 输出 / 缓存读 / 缓存写,USD / 百万 token,可切人民币)、上下文·输出上限、输入→输出模态、能力(推理 / 工具调用 / 附件 / 结构化输出)、知识截止,以及 **Artificial Analysis 三项指数**(综合智能 / 编程 / 智能体,经 OpenRouter 公开接口,**非本服务实测**,不做排序)。目录缺失时页面显示空态,不影响聊天与成本。 - -OpenRouter 收录数百条,其中多数本服务没接。每行带 `available` 布尔(该模型是否**当前渠道真能提供**,口径与 `/v1/models` 一致),页面提供**默认关闭**的「只看当前渠道」开关,勾选后本地收窄到当前渠道获取到的模型(不重新请求)。 +(`GET /api/model-catalog`,管理员 / 操作员)只读展示 OpenRouter 公开目录:刊例价、上下文 / 输出上限、模态、能力、知识截止、Artificial Analysis 三项指数(非本服务实测,不排序)。每行带 `available`;页面有默认关闭的「只看当前渠道」开关。 -**疑似下线视图**:同页可切到「疑似下线」筛选——`GET /api/models/availability`(operator)把最近 N 天(默认 7、可调 1–30)`usage_events` 里 `error_type` 为 `upstream_blocked` / `invalid_request` 的失败按 `(渠道, 模型)` 聚合,给出失败次数、最近失败时刻与「是否已在黑名单」。行内「加入黑名单」把该模型**追加**到现行 `model_blocklist`(读现值再写,不覆盖既有项;已命中的行按钮置灰),写入走既有设置接口并落审计 `settings.update`,下一次 `/v1/models` 即生效。这个视图本身**只读聚合**(真发请求的那部分见下节「主动探活与人工接管」)。 +**疑似下线视图**:`GET /api/models/availability`(operator)把最近 N 天(默认 7,可调 1–30)`upstream_blocked` / `model_not_found` / `invalid_request` 失败按 `(渠道, 模型)` 聚合。行内「加入黑名单」→ 追加 `model_blocklist`,走既有设置接口 + 审计。 -「能力分」列头可点排序,按**综合智能指数**(智)排;无分数的行排末尾(不冒充 0 分)。只给这一个分项排序入口——表头一列对应一个入口,三项各排会把表头挤成一排按钮。 - -**浮动别名取同族最新快照的分**:OpenRouter 对同一个模型会同时挂**浮动别名**(`deepseek/deepseek-v4.1-flash`)与**带日期快照**(`deepseek/deepseek-v4.1-flash-1017`),并用 `canonical_slug` 声明同属一次发布。上游会把浮动别名钉在旧快照上、并对同一快照只发布一个评测变体(2026-10-19 实测:浮动别名标 `Non-reasoning` 24.7,`-1017` 标 `Max` 39.5)——分数看着「掉」了 15 分,其实只是换了变体。本项目转发时推理默认开着(渠道 `default_effort` 多为 `high`),与 Max 变体同口径,故按 `canonical_slug` 的日期取同族最新快照的分;显式带日期戳的 id(`deepseek-v4-pro-0813`)仍用自己的分。`source_model` 一律指向真正提供分数的上游 id。家族只从**带 `-YYYYMMDD` 的 canonical** 派生:`mistralai/mistral-large`(Feb '24)与 `mistralai/mistral-large-2512`(Mistral Large 3)削完日期同基却是两个模型,不互相借分。 +**浮动别名取同族最新快照的分**:OpenRouter 对同一模型同挂浮动别名与带日期快照,用 `canonical_slug` 声明同属一次发布。本项目转发推理默认开,与 Max 同口径 → 按 `canonical_slug` 日期取同族最新快照分;显式带日期戳的 id 仍用自身分。家族只从带 `-YYYYMMDD` 的 canonical 派生。 ### 主动探活与人工接管 -真实请求失败只能证明「这次没打通」,分不清「上游真没这个模型」与「额度耗尽 / 限流 / 网络抖动」。同页的**主动探活**面板(`GET /api/models/probes`,operator)只探**仍被 `blocked` 负缓存锁住**的 (渠道, 模型)——那是唯一会静默避让一个模型 6–24 小时的状态,也是唯一值得花额度去确认的目标;不遍历全量模型表。 +主动探活面板(`GET /api/models/probes`,operator)探两类 (渠道, 模型):**仍被 `blocked` 负缓存锁住**的,以及**已判死**的(`state='failed'`)。 -三态判定:**成功**(拿到内容或正常收尾)→ 立即清该渠道的 `blocked` 负缓存,模型马上回到池子,这是 TTL 走完之外的**唯一提前恢复入口**;**`failed`**(上游明确 `BLOCKED` / `INVALID`)→ 保留负缓存并累加连续失败;**`inconclusive`**(额度、限流、并发打满、网络、超时、空流、任何异常)→ **绝不禁用**,并把连续失败归零——一串无关故障不该凑成「连续失败」把好模型判死。 +三态判定: -探活与生产完全隔离:直连 `provider.stream_chat`,不经 executor,不写 `usage_events`、不动冷却表(除成功时清 `blocked`)、不改健康度、不参与任何出口判断;结论落独立的 `model_probes` 表,只供页面展示(探测失败 ≠ 上游拒绝)。`reason='model'`(6004 限流)不探——它有自己的重置墙钟。 +- **成功** → 立即清该渠道 `blocked` 负缓存 + 该模型回到列表(唯一恢复入口)。 +- **`failed`**(上游明确 `BLOCKED` / `INVALID`)→ 留负缓存 + 累加连续失败。 +- **`inconclusive`**(额度、限流、网络、超时、空流、任何异常)→ 绝不禁用,连续失败归零。 -面板**不列出命中 `MODEL_BLOCKLIST` 的模型**(口径同 `/v1/models`:原样名与归一键任一命中即滤,现读热更值):黑名单是管理员「这个模型我不要了」的显式决定,它已被排除在模型列表外,再在探活面板冒出来只会诱导管理员反复去救一个已主动弃用的模型。 +探活与生产完全隔离:直连 `provider.stream_chat`;不写 `usage_events`、不动冷却表、不改健康度。结论落独立 `model_probes` 表。面板不列命中 `MODEL_BLOCKLIST` 的模型。 -**判死的模型同时从模型列表里暂时摘掉**:`failed` 且该 (渠道, 模型) 仍被 `blocked` 负缓存锁着时,`/v1/models` 与别名表把**这一对**抽掉——只抽该渠道,别的渠道正常时同一模型照常展示(多渠道条目退化成单渠道条目)。到期口径与负缓存同一个 `cooling_until`(6–24h 起步的退避):负缓存一过期就自动回列表,因为此后没人再探它,只看 `failed` 会让它永久消失。与黑名单同口径——用户点名直连指定模型不受影响。 +**判死的模型同时从模型列表摘掉**:只抽该 (渠道, 模型),其它渠道正常时同一模型照常展示。**隐藏没有到期时间**——恢复只靠复探成功,不再挂在负缓存的 `cooling_until` 上:负缓存过期后没人再探,等于永久隐藏。后台周期确实会把判死行一起探,但周期 `MODEL_PROBE_MINUTES` 默认 `0`(关闭),默认部署下唯一的恢复入口是面板上的「立即探活」。 -**人工接管**(`POST /api/models/probes/manual`,`manual=on|off`):接管后自动探活整行跳过,避免后台与人工结论互相打架;接管**不**改负缓存——要立刻恢复请点「立即探活」(`POST /api/models/probes/probe`,不受接管标记限制)。后台周期由 `MODEL_PROBE_MINUTES` 控制(默认 `0` = 关闭,可热更)。 +**人工接管**(`POST /api/models/probes/manual`):接管后自动探活跳过该行,但不改负缓存。要立刻恢复 → 点「立即探活」。 ### 管理台「上游状态」页 -管理台「上游状态」页(`GET /api/upstream-status`,管理员 / 操作员可见)聚合各模型 / 网关提供方的**官方状态页**,回答「这个报错是上游整体挂了,还是我这边凭证 / 额度的问题」——本服务自己的 429/5xx 冷却只能回答「这个号现在不行」,官方状态页补上另一半。 +(`GET /api/upstream-status`,管理员 / 操作员)聚合各提供方官方状态页,回答「这报错是上游挂了,还是我这边凭证 / 额度问题」。 -- **来源**:OpenCode Zen(自定义 JSON,**逐模型 TTFT / tps**)、Kilo、Moonshot / Kimi、MiniMax、OpenAI、Anthropic(Statuspage v2)、Together AI(Better Stack JSON:API)、DeepSeek(Atom 事故史)。按 `scope` 分两组展示:`channel`(与本站渠道一一对应,如 zen / kilo)排在 `model`(经渠道间接使用)之前。 -- **归一化六档**:`operational` / `degraded`(性能下降)/ `partial`(部分中断)/ `major`(重大故障)/ `maintenance`(维护中)/ `unknown`(抓取失败)。Statuspage 的 `critical` 并入 `major`。整体档位取**最差**的源,但 `unknown` 排最后——一个源连不上不该压过其余源报出的真实故障。 -- **只做解释、不做决策**:官方状态页滞后于本服务的实时冷却(模型级冷却见 `src/provider/kilo/client.py`),把它并入选号 / 熔断只会让调度更慢更糊涂。 -- **只存内存、不落盘**:状态数据分钟级易变,重启后拿一份几小时前的「一切正常」比显示「尚未抓取」危险得多。抓取由后台任务(`UPSTREAM_STATUS_MINUTES`,默认 5 分钟)负责,单源失败只在该源那一行标错误原因,绝不让整轮抛异常。 -- **版式按「先结论、后明细」收敛**:顶部概况条给整体档位 + 档位分布 + 抓取运行态;下面按 `scope` 分两组、一行一个源(**异常源默认展开**,正常源折成一行摘要),另有「需要关注」条把非正常源提到最前面并锚点跳转。OpenCode Zen 逐模型展开 TTFT / tps,其余源给逐组件档位;单源组件明细超过 6 项时截断(**异常项排前面**,不会被折在「显示全部」后面),事故列表里进行中默认铺开、已恢复折进计数按钮。 -- **本页不覆盖**仅提供 HTML、无稳定机器可读端点的源(Groq、腾讯云、阿里云、华为云、SiliconFlow、Qoder、智谱、TRAE)——加源前先确认端点能稳定返回 JSON / XML,否则等于每天修一次 HTML 解析。 +- **来源**:OpenCode Zen(逐模型 TTFT / tps)、Kilo、Moonshot / Kimi、MiniMax、OpenAI、Anthropic、Together AI、DeepSeek。按 `scope` 两组:`channel` 先,`model` 后。 +- **归一化六档**:`operational` / `degraded` / `partial` / `major` / `maintenance` / `unknown`。整体取最差源,`unknown` 殿后。 +- **只解释、不决策**,不参与选号 / 熔断。 +- **只存内存、不落盘**;抓取由后台任务(`UPSTREAM_STATUS_MINUTES`,默认 5 分钟)负责,单源失败只标该行。 +- **先结论后明细**:顶部概况条 → 按 scope 两组,异常源默认展开;「需要关注」条提前非正常源。组件明细超 6 项截断(异常项排前)。 +- **不覆盖**纯 HTML 源(Groq、腾讯云、阿里云、华为云、SiliconFlow、Qoder、智谱、TRAE)。 ### token 到期展示 -凭证列表的 **token 剩余**列显示 access token 距到期还有多久,低于 `TOKEN_EXPIRY_WARNING_SECONDS`(默认 1 小时)标红。到期时间优先取上游 `expires_at`,缺失时回落 JWT `exp`;都取不到显示 `—`,**不猜本地 TTL**(否则是凭空捏造的预警)。 +凭证列表 **token 剩余**列显示 access token 距到期还有多久;低于 `TOKEN_EXPIRY_WARNING_SECONDS`(默认 1 小时)标红。优先 `expires_at`,缺失回落 JWT `exp`;都取不到 → `—`,不猜本地 TTL。 ## 侧栏数据提醒(导航角标) -管理台侧栏(`web/src/components/Sidebar.tsx`)在每个页面都在。多个**不点进去就发现不了**的待办态(告警未恢复 / 后台任务失败 / 凭证需重登)在这里显示成计数角标,由单个聚合端点 `GET /api/nav-badges` 一次返回,侧栏 60s 轮询(不挂 7 个页面级 hook,避免反复拉全量列表)。 +侧栏每页都在。不点进去发现不了的待办态 → 计数角标,由单个聚合端点 `GET /api/nav-badges` 返回,侧栏 60s 轮询。 | 导航项 | 门槛 | 角标口径 | |---|---|---| -| 凭证管理 | operator+ | `disabled`(需重新登录)**或** `health < 0`(额度耗尽)的凭证数 | -| 运维告警 | admin | `resolved_at IS NULL`(**仍在告警中**)的条数 | -| 任务与配置 | admin | `enabled` 且**上次执行失败**(`last_ok === false`)的任务数 | -| 上游状态 | operator+ | `summary.abnormal`(含维护中);色调随整体档位(维护=黄,部分/整体故障=红) | -| 用户管理 | admin | 有激活令牌(`activation_digest IS NOT NULL`)的账号数 | -| API Key | 全员(仅本人) | 已过期或 **7 天内**到期的 Key 数 | -| 模型列表 | operator+ | 快照陈旧标记(布尔文字「陈旧」,非计数) | - -- **按角色裁剪**:后端只下发当前角色有权看的键,**缺失 = 无权看**(不是 0),前端据此不渲染。凭证角标刻意不下发给 viewer——能看列表但无写权限,露出角标只会误导。 -- **与各页面数据不是同一口径**:角标只数「待办」。例如告警页 `total` 含已恢复历史,角标只数未恢复的;任务用「上次失败」(首次失败即提示)而非告警规则的「连续失败 ≥ 阈值」;`last_ok === null`(从未运行)与已关闭的任务都不计。 -- **凭证角标 ≠ `/healthz` 的可用数**:后者复用调度器 `is_selectable`,只看 disabled / enabled / cooling、**不看 health**,所以额度耗尽的号在 `/healthz` 里仍算 `ready`。两者不一致是刻意的。 -- **告警角标会自愈**:`AlertTask` 启动时从库里未恢复的行重建「告警中」集合,所以重启前落库、条件已消失的告警仍会被正常标记恢复;同一次故障期间的「提醒」行在恢复时一并收尾。详见 TECHNICAL.md 的「迟滞与恢复」。 -- **模型目录陈旧**:从未拉取(`price_saved_at` 为空)不算陈旧(新装部署不该一上来就报警);已拉过但超过 **2 倍** `OPENROUTER_CATALOG_MINUTES` 未刷新才算。 -- 角标窗口(API Key 7 天)是后端模块常量,不做热更;不点进去也能看到待办,点进去即是可行动页面。 +| 凭证管理 | operator+ | `disabled` 或 `health < 0` 的凭证数 | +| 运维告警 | admin | 仍在告警中的条数 | +| 任务与配置 | admin | 上次执行失败的任务数 | +| 上游状态 | operator+ | 异常源数(含维护中) | +| 用户管理 | admin | 有激活令牌的账号数 | +| API Key | 全员(仅本人) | 已过期或 7 天内到期的 Key 数 | +| 模型列表 | operator+ | 快照陈旧(布尔文字) | + +- **按角色裁剪**:后端只下发有权看的键;缺失 = 无权看(不是 0)。凭证角标不下发 viewer。 +- **只数「待办」**:告警页 `total` 含已恢复历史,角标只数未恢复;任务用「上次失败」,非「连续失败 ≥ 阈值」。 +- **凭证角标 ≠ `/healthz` 可用数**:后者不看 health → 额度耗尽的号仍算 `ready`。 +- **告警角标自愈**:`AlertTask` 启动时从库里未恢复行重建集合。 +- **目录陈旧**:从未拉取不算;超 2 倍 `OPENROUTER_CATALOG_MINUTES` 未刷新才算。 ## 统计 ### 口径 -时间范围默认**最近 24 小时**(另有 7 天 / 30 天 / 全部 / **自定义**):打开统计页的第一问题是「这个池子现在怎么样」,7 天会把当天的波动摊平。**自定义**给两个 `datetime-local` 输入(精确到分钟、按**浏览器本地时区**解析),两端可只填一边(另一边不限);起点不早于终点时整窗作废并提示,不静默退回全量。所有统计端点因此多接受一个 `until`(epoch 秒,半开上界):`[since, until)`,`until` 本身不计入——用户填的「结束时间」是瞬时右端点,两次相邻查询不重叠。 +默认**最近 24 小时**(另有 7 / 30 天、全部、自定义)。自定义 = 两个 `datetime-local`(浏览器时区),可只填一边;起点不早于终点 → 整窗作废 + 提示。端点全收 `until`(epoch 秒,半开 `[since, until)`)。 + +**失败类型** = 受控枚举(`CONTROLLED_ERROR_TYPES`):收尾行取**最后一次错误的真实归因**——模型级冷却耗尽 → `model_unavailable`;上游限流(SOFT / 429)→ `rate_limit`;链路层瞬态(连接超时 / 读超时 / TLS 中断)→ `upstream_timeout`;本机 DNS/连接失败(`ConnectError` / `gaierror`,连上游之前就失败)→ `network_error`(不冷却凭证);上下文超限(CodeBuddy 11115)→ `context_overflow`;模型不支持该请求形态(CodeArts 001001020 not multimodal)→ `model_capability`;上游明确说「没这个模型」→ `model_not_found`(与「请求无效」分开:前者是模型下线,后者是请求写错)。只有**一次都没选中凭证**(候选全在冷却 / 池子为空)才回落 `no_healthy_credential`。对外码相同,仅统计口径不同。 -**明细失败类型**是受控枚举(后端 `CONTROLLED_ERROR_TYPES`,界面只展示中文翻译):模型级冷却耗尽记「该模型暂时不可用」(`model_unavailable`,换个模型即可用),凭证池真耗尽才记「无可用凭证」(`no_healthy_credential`)——两者对外错误码相同(`no_healthy_credential` 503),仅统计口径区分。 +**请求 ID**:响应带 `X-Request-ID`。入站合法沿用,否则自生成;字符集 `[A-Za-z0-9._:-]`、长 1–128。落 `usage_events.request_id`,明细时间列悬停可见。**不透传上游**——各渠道出站头语义不同。 -**请求 ID(`X-Request-ID`)**:每个请求都在响应头带一个 `X-Request-ID`(任何响应都带,含被拒的 413 / 400)。入站 `X-Request-ID` 合法则沿用(便于与客户端日志对齐),否则自生成;合法字符集 `[A-Za-z0-9._:-]`、长度 1–128,越界即丢弃改用自生成(客户端可写,不校验会被塞入换行/超长串)。该 ID 随请求落进 `usage_events.request_id`,统计页「请求明细」的**时间列悬停**可见——用于把用户报的一个请求与这行明细对齐。老库历史行为 `NULL`(不回填)。**不透传到上游**:各渠道出站头语义不同(qoder 的 `X-Request-ID` 是 uid 派生的防重放值,trae/codearts 有指纹敏感头),统一改写会破坏既有契约。 +**Credit 消耗**:CodeBuddy = 上游真值;TRAE 按官方单价推算并标 `≈`(表 `src/provider/trae/pricing.py`;未收录不推算)。历史用 `scripts/backfill_trae_credit.py` 补。 -统计页 **Credit 消耗**列:CodeBuddy 是上游**真值**;TRAE 的 `token_usage` 帧只有 token 数,credit 按官方单价**推算**并标 `≈`。单价表集中在 `src/provider/trae/pricing.py`,未收录的模型不推算(显示 `—`)。历史明细可用 `scripts/backfill_trae_credit.py` 补齐。 +**成本(估算,≈)**:token × 公开刊例价,非上游真实扣费。单价取 OpenRouter 目录 → `(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价` 折 USD → 按写入时 `USD_CNY_RATE` 折 CNY。 -**成本(估算,≈)**:按 token × 公开刊例价估算,不是上游真实扣费。单价取自 **OpenRouter 公开模型目录**(`pricing.prompt` / `completion` / `input_cache_read`,USD / 百万 token),按 `(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价` 折算美元,再按**写入时生效的汇率**(`USD_CNY_RATE`)折人民币。 +- **写入时定值**:写明细时算好落库;历史行不随价表 / 汇率重算。上线前两列 `NULL` → `scripts/backfill_cost.py --apply` 补。 +- **匹配不到就不计**:本地名按 OpenRouter `id` / 展示名等值匹配(不唯一不配)→ 聚合 `null` 显示 `—` → 成本是下限。渠道分发代号按显式等价规则归到基础模型(只登记实测 id;不做通用剥日期 / 剥区域尾缀)。 -- **写入时定值**:成本在写明细时算好落库(`usage_events.cost_usd` / `cost_cny`),历史行**不随价表或汇率重算**;上线前历史两列为 `NULL`,可用 `scripts/backfill_cost.py --apply` 补齐(默认预览、写库前自动备份、幂等)。 -- **匹配不到就不计**:本地模型名按 OpenRouter 的 `id` / 展示名等值匹配(`src/model_match.py`,**命中不唯一就不配**),未匹配或未上报输入 token 的不计入,聚合值 `null` 显示 `—`(**不拿 0 冒充免费**),故成本是**下限**。渠道分发代号按显式等价规则归到基础模型:OfficeAce 的 `deepseek-v4.1-flash-0930`(快照日)与 CodeBuddy 国际站的 `deepseek-v4.1-flash-sg`(区域)都按 `deepseek-v4.1-flash` 的刊例价算;只登记实测过的 id,**不做通用「剥日期 / 剥区域尾缀」**——OpenRouter 的 `deepseek-v4.1-flash-1017` 是另一个价位的快照,剥尾会把两套价格混成一个。 -- **改价表后补历史**:等价规则改动只影响**新写入**的明细;已有明细两列不会自动重算,`scripts/backfill_cost.py --apply`(默认预览、写库前自动备份、幂等)可按当前价表补上。 +**缓存命中率**:命中 token ÷ 输入 token(1 位小数)。未上报或输入 0 → `—`。分组表 / 明细表把命中率与 token 并一格。 -**缓存命中率**:**Token 消耗**卡片在 token 分项后追加缓存命中率 = 命中 token ÷ 输入 token(保留 1 位小数);**未上报或输入为 0 时显示 `—`**。**分组统计**表的 Token 合并列与**请求明细**表的 Token 合并列都把命中率与 token 放同一格(「输入 / 输出 · 命中率」),同公式、同占位规则(派生列不参与排序)。 +**输出速度(tok/s)** = `token 总量 ÷ 生成时间总和`,非逐条比值再平均。两口径同分母、异分子:**正文速度** `output_tokens - reasoning_tokens`;**总产出速度**含思考 token;前端一格 `正文 / 总产出`。仅算经本服务路由、产出 ≥ 1 token 的成功请求。生成时间 = `latency_ms - ttfb_ms`;剔除窗口 < 200 ms 或单条 > 1000 tok/s。`ttfb_ms` = `NULL` 不进主口径;无样本 → 估算口径(端到端为分母,前置 `≈`);两口径皆无 → `—`。 -**输出速度(tok/s)**:**输出速度**卡片、分组表的「速度」合并列与**请求明细**表的速度列都按 `token 总量 ÷ 生成时间总和` 算,**不是逐条比值再平均**——短请求的逐条比值噪声大,逐条平均会把整体数字带歪;总量相除才是池级吞吐。聚合口径的两个分子**分母相同**,只是分子不同:**正文速度**的分子是 `output_tokens - reasoning_tokens`(贴近屏幕上看到的正文刷新速度),**总产出速度**含思考 token(思考成批快速吐出,数字会明显更高——实测思考占输出 50–67% 时两者约 190 与 63 tok/s);前端把它们并成一格显示成 `正文 / 总产出`(`88.2 / 198.8 tok/s`),列头下拉仍可分别排序。总览卡片给正文速度,总产出速度写在卡片 hint 里。**只算经本服务路由的成功请求,且该请求产出了至少 1 个 token**(**不设输出量下限**——短输出也是真实产出,剔掉会让短问答为主的渠道永远没有速度;输出为 0 / 未上报的请求没有可测产出,不算样本,不是「慢」)。生成时间 = 明细的 `latency_ms - ttfb_ms`(扣掉首字等待),并加两道物理合理性下限:**生成窗口 < 200 ms** 或**单条样本 > 1000 tok/s** 的按「生成时长不可测」剔除、不算样本——上游把整段内容攒成一批吐时首帧与末帧几乎同时到达,分母趋近 0 会把比值放大成 30 万 tok/s 这种天文数字(本地实测 901 token / 3 ms),它不是「很快」而是测不出来。`ttfb_ms` 为 `NULL`(非流式请求等)时没有可分离的生成时间,**该请求不进主口径**;若某行/某组在主口径下**一条样本都没有**,回落估算口径:拿端到端耗时当分母(**含首字等待**),样本门槛与主口径一致(同样只要「有输出 token」),同样套用上面两道下限,展示前置 `≈`——提醒它不是同一口径,别与不带 `≈` 的值直接比。两口径都没有样本(或输出全是思考 token,正文速度为 0)时该侧显示 `—`(不拿 0 冒充)。 -与分位列同源:聚合速度也只能从**逐请求明细**算(小时汇总没有单请求输出量、思考 token 数与逐请求耗时,无法按「单请求输出量 / 生成窗口」筛样本),故**只覆盖最近 90 天**;总览卡片同样如此(其余卡片读小时汇总)。**请求明细**给的是**每一条请求自己**的速度:同样按生成窗口(`latency - ttfb`)与那两道物理下限算,但**不回落估算口径**——逐行没有相邻样本摊平首字等待,拿端到端耗时除出来的单条比值只会误导,故无生成窗口的行直接显示 `—`(`tps` / `tps_visible` 字段)。分组表速度列与明细表速度列都可点表头排序(正文 `sort=tps_visible`、总产出 `sort=tps`,无样本的行/组按可空列语义排最后 / 升序时最前)。 +**图表指标**:请求次数 / Token / 成本 / 单请求成本 / 耗时 / 首字延迟 / 成功率 / 缓存命中率,切换看趋势。派生指标(成功率、缓存命中率、单请求成本)按**聚合后相除**算(`sum ÷ sum`),不是逐小时比率的平均;无样本给 `—`(断线),不画成 0。耗时 / 首字另配一张**分位图**(P50 中位数 + P95 上界,读明细表,仅 90 天)——均值会把长尾摊平。时间轴按小时补齐(空档保留位置,不压缩时间)。`metric` 参数取值域外显式 400(不静默回落到「请求次数」——否则拼错参数会拿到一份看起来正常的图)。自定义时间范围非法(起点 ≥ 终点)时**暂停取数**,不按「不限时间窗」发全量查询。 + +聚合速度只能从逐请求明细算 → 仅 90 天(总览卡片同;其余读小时汇总)。明细表给每条请求自身速度,不回落估算;无生成窗口 → `—`。速度列可点表头排序(`sort=tps_visible` / `sort=tps`)。 ### 积分记录 -凭证**额度**列数字后的下箭头展开「变动流水」,记录**两次额度探测之间的净变化**,不是动作归因:签到、成长、对话都会改余额而上游不打日志,探测只能看到区间净变化,界面一律写「净变化」而非「签到 +5」。首次探测只建基线;余额没变不记;余额变「未知」仍记但不填变化量;保留 90 天。 +额度数字后下箭头展开「变动流水」= **两次额度探测之间的净变化**,非动作归因:签到、成长、对话都改余额而上游不打日志 → 界面一律写「净变化」。首次探测只建基线;余额没变不记;变「未知」仍记但不填变化量;留 90 天。 + +**按账本分流**:流水带 `ledger`(`pool` / `credits`)。CodeArts 与小浣熊记 `credits`(两家额度都是积分),其余 `pool`。接口 `GET /api/credentials/{id}/credit-events?ledger=`(缺省 `pool`)。两本账量级 / 语义不同 → 分开读。 + +**签到记录**:凭证行「更多操作 → 签到记录」展开**签到审计日志**(每轮签到落一行:时间 / 结果 / 领取积分 / 上游业务码 / 文案 / 触发方式,定时与手动都记;保留 90 天)。接口 `GET /api/credentials/{id}/checkin-events?limit=`。与「变动流水」不同,这是**动作的逐次结果**(签到自己回的 code / credit),不依赖余额 diff,故「显示成功却没加积分」这类问题能直接对着上游答复定位。 -**按账本分流**:流水表带 `ledger` 列(`pool` / `credits`),每轮探测按凭证自己的渠道记一条——CodeArts 的额度来自积分接口,记 `credits`;其余渠道记 `pool`。CodeArts 行展开的是积分账流水(`ledger=credits`),其余渠道是套餐 / 每日余额流水(`ledger=pool`);接口 `GET /api/credentials/{id}/credit-events` 用 `ledger` 查询参数选账本(缺省 `pool`,未知取值回落 `pool`)。**必须分开读**:两本账数值量级与语义都不同,混在一个列表里会被当成同一本账的连续读数(老库里 CodeArts 的历史 `pool` 行是已下线的每日 token 池口径,与积分差一个数量级)。老库升级时历史流水一律归 `pool`——它们本就由当时的余额探测 diff 产生,不再展示。 +**今日签到汇总**:`GET /api/credentials` 每条凭证附 `checkin_today`(本地审计日志里**今天(北京时间)最近一条**结果,今日无记录为 `null`)。组头据此显示「已签 / 该签 + 失败数 + 最近时刻」——只读落库结论,30s 轮询不为几十个号各打一次签到场。`null` 是「还没有结论」(任务未轮到 / 暂停 / 渠道无签到),不是失败;渠道按 10:00 轮换签到窗口的(Qoder),窗口切换后到下一轮任务前会短暂沿用上一窗口的结果,时刻可见。 ### 分组维度与重新汇总 -**分组统计**面板用 tabs 切四个维度(按渠道 / 按模型 / 按用户 / 按凭证),共用同一张表(渠道 / 请求(请求数 · 成功率,悬停看成功数)/ Credit / 成本 / Token(输入 ÷ 输出 · 命中率,悬停看命中 token)/ 速度(正文 ÷ 总产出,tok/s,悬停看两口径)/ 平均延迟(耗时 ÷ 首字)/ P95 延迟(耗时 ÷ 首字)),共 8 列。对应端点 `GET /api/stats/by-provider|by-model|by-user|by-credential`。默认按请求数降序,点列头可切换排序;请求 / Token / 速度 / 平均延迟 / P95 延迟五个合并列点列头弹菜单选子列再排序(`sort=requests|ok_count|input_tokens|output_tokens|cached_tokens|avg_latency_ms|avg_ttfb_ms|p50_latency_ms|p90_latency_ms|p95_latency_ms|p99_latency_ms|p50_ttfb_ms|p90_ttfb_ms|p95_ttfb_ms|p99_ttfb_ms|tps_visible|tps`),成功率与命中率是派生只读值不参与排序。**延迟分位可切换**:P95 列的表头有 P50 / P90 / P95 / P99 四档切换(默认 P95,不铺开四列以免表头拥挤);四档由同一次窗口函数子查询算出,取整口径一致(nearest-rank,n 为**有值**样本数)。**按用户 / 按凭证**只对 admin 与 operator 展示。**口径差异**:渠道 / 模型 / 用户三维读**小时汇总**(永久保留,最近 5 分钟内的请求尚未计入);**凭证维度读明细表**(小时汇总主键里没有 `credential_id`),故只覆盖**最近 90 天**。 +tabs 切四维(渠道 / 模型 / 用户 / 凭证),共用一表(渠道 / 请求 / Credit / 成本 / Token / 速度 / 平均延迟 / P95 延迟)。端点 `GET /api/stats/by-provider|by-model|by-user|by-credential`。默认请求数降序;点列头切排序;合并列弹菜单选子列。**延迟分位 P50 / P90 / P95 / P99**(默认 P95,nearest-rank,n = 有值样本)。用户 / 凭证维度仅 admin / operator。 -**延迟两类口径**:**只统计成功请求**(失败请求的耗时会拉偏「典型耗时」)。**平均延迟列** = 平均耗时 / 平均首字 = `latency_sum ÷ 成功数`(读小时汇总,与总览卡片、图表同口径,永久保留);**P95 延迟列** = P95 耗时 / P95 首字 = 第 95 百分位,按 **nearest-rank**(第 `⌈0.95·n⌉` 小的值,n = 该组有值样本数)从**逐请求明细**算出——小时汇总只有和与计数,数学上还原不出分布,故分位列只覆盖**最近 90 天**。P50 / P90 / P99 与 P95 同一套 SQL(一次子查询算完四档),口径完全一致,只是百分位参数不同。`ttfb_ms` 对非流式 / 未产出首字的成功请求为 `NULL`,两列各自独立排名(只数有值样本)。无成功样本 / 未上报延迟时显示 `—`(不拿 0 冒充)。 +**口径差异**:渠道 / 模型 / 用户读小时汇总(永久;近 5 分钟未计入);凭证读明细表(仅 90 天)。 -**失败归因**:分组表下方另有「失败归因」面板,`GET /api/stats/errors`(operator+)按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 的明细,给出失败次数与最近失败时刻(按失败数降序,`limit` ≤ 5000)。点任意一行即**钻取**到下方明细并自动加上「只看失败」(`GET /api/stats/events?ok=0`,与 `provider`/`model`/`credential_id` 叠加);键盘 Enter / Space 同样触发。该面板只在 admin/operator 下渲染与请求。`/metrics` 的 `upstream_errors_total` 仍取进程内计数(重启归零),与这里的 90 天明细口径不同,两者不合并。 +**延迟两类口径**:只统计成功请求。平均 = `latency_sum ÷ 成功数`(小时汇总);分位从逐请求明细算(仅 90 天)。`ttfb_ms` = `NULL` 的两列各自独立排名。无样本 → `—`。 -**点行钻取**:点分组表任意一行,下方「请求明细」即按该行钻取(渠道 / 模型 / 凭证按同名列过滤,用户维度走作用域参数),条件叠加、可多个维度层层收窄,面板上方显示当前过滤标签并可一键清除。`GET /api/stats/events` 因此接受 `provider` / `model` / `credential_id` 三个过滤参数(`AND` 语义),另有 `ok=0` 只看失败。 +**失败归因**(`GET /api/stats/errors`,operator+):按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0`,返回失败次数 / **未救回数** / 最近失败时刻(降序,`limit` ≤ 5000)。**落行口径**:硬失败(`credential_unavailable` / `account_restricted` / `invalid_request` / `model_capability`)**每次尝试各一行**;限流 / 额度 / 超时 / 上下文超限等其余分类**每次请求只落收尾一行**(类型取最后一次错误的真实归因)——否则一次请求三行会把 `usage_hourly.requests` 与 Key 日请求额度一起刷高。同用户同 `request_id` 另有成功行 = 已救回;`未救回 = 失败数 − 救回数`(同一请求多次失败算多次,≠ 受损请求数)。几处特殊判据:凭证 401 落 `credential_unavailable`,**但额度耗尽证据成立时**(officeace 积分耗尽同一条 `401 APIG.1009`)改判 `CREDIT`——冷却到次日 04:00、不硬禁用;本地额度为正 / 未探测时先现场复探再判,仍为正或复探失败才保持 DEAD。风控限制(11140)落 `account_restricted`:2h 账号级冷却、当前请求不再换号,同渠道第二个凭证也中招才整渠道退避 24h。11102 / 11158 落 `model_not_found`(写 (账号, 模型) 负缓存,不冷却账号);11115 落 `context_overflow`、001001020 落 `model_capability`,两者都不惩罚凭证;一次凭证都没选中时明细落**候选渠道**而不是 `-`。完整分类与冷却时长见 TECHNICAL.md §3.2。点行钻取 +「只看失败」(`GET /api/stats/events?ok=0`);仅 admin / operator。`/metrics` 的 `upstream_errors_total` 取进程内计数,不合并。 -**明细表列合并**:请求明细把同类列并成一列,默认值一眼可读、悬停展开全部子项:**Token**(默认「输入 / 输出 · 命中率」,悬停含命中 token)、**延迟**(默认「耗时 / 首字」)、**速度**(默认「正文 / 总产出」,tok/s,悬停写全两口径)、**渠道 / 凭证**(凭证名在上、渠道 icon + 名在下,悬停看完整凭证 ID——同渠道常挂多个账号,先认账号)。**时间列拆成两行**(`20:17:09` / `2026-10-21`,**具体时刻优先**、日期次要色,等宽数字各自纵向对齐,窄屏不折行),请求 ID 提示仍挂在这一格。列数从 15 压到约 10,窄屏卡片也少一半字段。排序能力不丢:点合并列头弹出下拉选子列(Token → 输入 / 输出 / 命中 / 命中率,延迟 → 耗时 / 首字,速度 → 正文 / 总产出,渠道 / 凭证 → 渠道 / 凭证),选中即按该子列排序;**命中率**子列走后端 `sort=cache_rate`(命中 ÷ 输入,输入为 0 或未上报时为 `NULL`,与其它可空列同排序语义),速度子列走 `sort=tps_visible` / `sort=tps`,渠道子列走 `sort=provider` / `sort=credential`。 +**点行钻取**:点分组表任一行 → 明细按该行过滤(渠道 / 模型 / 凭证;用户维度走作用域参数),条件可叠加。`GET /api/stats/events` 接受 `provider` / `model` / `credential_id`(AND)+ `ok=0`。 -**导出 CSV**:明细面板右上「导出 CSV」(仅 admin / operator)按**当前时间范围 + 当前钻取过滤**导出逐请求明细,`GET /api/stats/events?format=csv`。列与页面明细表同口径,含**逐请求速度**(`正文速度 tok/s` / `总产出速度 tok/s`,与页面同规则:只算生成窗口可测的成功请求,不可测的行留空——空不是 0)。文件带 UTF-8 BOM(Excel 认编码)、`\r\n` 换行、时间列 ISO 8601(UTC),并对以 `= + - @`(及制表符/回车)开头的**文本**单元格加前导单引号防公式注入(数值含负数原样)。单次导出上限 10000 行,超出截断并在响应头 `X-Export-Truncated: 1` 标出,页面提示收窄条件。 +**明细表列合并**:Token、延迟、速度、渠道 / 凭证各并一列;默认值可读,悬停展开子项。时间列拆两行(时刻 / 日期)。点合并列头弹下拉选子列排序(`sort=cache_rate` / `tps_visible` / `tps` / `provider` / `credential`)。 -**实时流(SSE)**:明细面板右上「实时」开关打开后,`EventSource` 订阅 `GET /api/stats/events/stream`,展示**当前筛选范围内刚落库**的请求(与下方翻页的历史明细分开展示,避免混成一张会跳动的表)。事件在 `StatsCollector.record` **事务提交后**由进程内 `LiveEventHub` 广播(不轮询数据库),前端只保留最近 50 条、新→旧排列;关闭开关即断开连接。 +**导出 CSV**:`GET /api/stats/events?format=csv`(仅 admin / operator),按当前范围 + 钻取过滤导出。UTF-8 BOM、`\r\n`、时间 ISO 8601(UTC);`= + - @` 开头的单元格加前导单引号防公式注入。单次上限 10000 行;超出截断 + `X-Export-Truncated: 1`。 -- **作用域与过滤**:与 `/api/stats/events` 同口径——admin/operator 可传 `username`(不传 = 全量),viewer 恒回落到本人;`provider` / `model` / `credential_id` 三个过滤可叠加。换过滤会重建连接(`EventSource` 无法改查询串)。 -- **凭证昵称**:广播 payload 不含昵称(与明细端点一致,只给已脱敏字段 + `rowid`);仅 admin/operator 的连接在**服务端逐条按需补** `credential_name`,viewer 拿不到池里他人凭证的昵称。 -- **背压与上限**:每个订阅者一个**有界**缓冲(200 条),满了丢**最旧**的一条(实时流看的是「现在」);并发订阅上限 8,超出回 429 + `Retry-After`,不排队、不静默拖垮服务。 -- **保活与缓存**:15 秒一条 SSE 注释帧(`:` 开头,`EventSource` 忽略)防止反向代理断掉静默连接;响应带 `Cache-Control: no-store` 与 `X-Accel-Buffering: no`,避免中间层缓存或整段缓冲把「实时」变成批量。断线由浏览器自动重连,页面在浏览器放弃重连(如 401 / 429)时提示断开。 +**实时流(SSE)**:`GET /api/stats/events/stream` 展示当前筛选范围内刚落库的请求。`StatsCollector.record` 提交后由进程内 `LiveEventHub` 广播;前端留最近 50 条。 -若历史数据偏小(老库升级前落的明细没被覆盖、或补过明细后没重算),用 `python3 scripts/rollup_hourly.py --apply` 一次性重算(默认预览、**写库前自动备份**、幂等)。脚本先报告缺口与两侧时间跨度,并提示**明细已过保留期无法还原**的区间。 +- 作用域 / 过滤同 `/api/stats/events`;换过滤重建连接。 +- 广播 payload 不含凭证昵称;仅 admin / operator 连接由服务端按需补 `credential_name`。 +- 每订阅者有界缓冲 200 条(满则丢最旧);并发上限 8,超出 429。 +- 15 秒注释帧保活;`Cache-Control: no-store` + `X-Accel-Buffering: no`。 + +历史数据偏小 → `python3 scripts/rollup_hourly.py --apply` 重算(默认预览、写库前备份、幂等)。 ### 列表排序 -管理台各列表**列头可点排序**:点一次用该列的「首次点击方向」(数值列先降序、文本列先升序),再点同一列在升序 / 降序间切换。排序一律由**后端**完成——所有 `GET` 列表端点接受统一的 `sort`(白名单键)与 `order`(`asc` / `desc`)参数。**未知 `sort` 键或非法 `order` 值回落到默认排序,不报错**;`sort` 是白名单键而非 SQL 列名,注入面为零。 +列头可点排序:首次用自然方向(数值降序、文本升序),再点翻转。排序后端完成;端点接受 `sort`(白名单键)与 `order`(`asc` / `desc`)。未知 `sort` / 非法 `order` → 回落默认,不报错。 -适用端点:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`(逐请求明细:默认按 rowid 游标分页返回 `next_before`;按其它列排序或时间升序时改用 `LIMIT/OFFSET` 并返回 `total`)。各端点的白名单键与默认排序见 `src/api/`。 +适用:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。 ### 列表分页 -管理台各列表**统一分页**:共享同一套页码文案(`第 N / M 页`)、每页选项(`20 / 50 / 100`,默认 `50`)与上一页 / 下一页按钮。扁平列表一律由**后端**分页——`GET` 列表端点接受 `limit`(每页条数)+ `offset`(偏移)并返回 `total`(**筛选后**的总条数,供前端算页数);原列表键名不变,只新增 `total`,向后兼容。`/api/users`、`/api/api-keys`、`/api/alerts`、`/api/audit` 在 SQL 层 `LIMIT/OFFSET`;`/api/model-catalog` 因排序键含派生字段(能力分 / 可用性)先内存排序再切片。 - -`/api/model-catalog` 另接受两个筛选参数(服务端生效,前端搜索框去抖 250ms 后回第一页):`q`(大小写不敏感地匹配模型 `id` 与名称)与 `only_available`(`true` 时只留 `available=true` 的行)。`total` 是筛选后的条数,顶层 `count` 仍是目录全量(页头展示「命中 N / 共 M」)。 +统一分页:`第 N / M 页`、每页 10 / 20 / 50 / 100(默认 50)。扁平列表后端分页——端点接受 `limit` + `offset`,返回筛选后 `total`。`/api/model-catalog` 排序键含派生字段 → 内存排序再切片;另接受 `q` 与 `only_available`。 -**分组类表**(凭证页按渠道分组、用量统计分组表)不分页,保持一次性全量展示——它们的行数由渠道 / 分组维度决定,本就不受明细规模影响。 +分组类表(凭证页按渠道分组、用量统计分组表)不分页。 ## 后台任务 -额度探测、token 预刷新、每日签到、成长中心、活跃上报、明细清理、数据库备份、渠道模型列表刷新、模型目录刷新(OpenRouter)、运维告警、上游状态页聚合、模型主动探活共 12 类,由 `TaskRunner` 自动调度,失败互不影响。每类任务的上次执行时间、最近结果与错误在管理台**「任务与配置」页**查看(30 秒自动刷新),运行态只存在于**本次进程**内,重启归零。每个任务卡片带「立即运行」按钮(`POST /api/tasks/{key}/run`,admin + CSRF):**绕过时点窗口但绕过不了开关**——`due()` 未到点也照跑,而任务被开关关掉时如实回 `ran=false`(不把「关了」谎报成「跑过了」);同一任务已在跑时立刻回 `busy=true`、不排队、不并发打上游;同步等这一轮跑完再返回结果。 +12 类任务由 `TaskRunner` 调度,失败互不影响:额度探测、token 预刷新、签到、成长中心、活跃上报、明细清理、DB 备份、渠道模型列表刷新、模型目录刷新、运维告警、上游状态聚合、模型主动探活。「任务与配置」页看上次执行 / 结果 / 错误(30s 刷新);运行态只在本次进程。 + +每卡片带「立即运行」(admin + CSRF):绕过时点窗口,绕不过开关;已在跑 → `busy=true` 不排队。 -**渠道模型列表刷新**(`MODEL_CATALOG_MINUTES`,默认 30、下限 5)是兜底保鲜:模型表本身按「谁访问 `/v1/models` 或 Playground 谁刷新、TTL 300s」更新,这条只保证**没人访问时也会更新**。周期取 30 分钟是跟 zen 免费模型判活缓存对齐——再密也不会让 zen 多探活。 +**渠道模型列表刷新**(`MODEL_CATALOG_MINUTES`,默认 30)兜底保鲜,无人访问也更新。 -**成长中心**仅 CodeBuddy 有。Buddy 旅行 1–4 小时回来一次,故周期默认 60 分钟(`GROWTH_INTERVAL_MINUTES`)。抽奖 / 连登兑换 / 开 Buddy 盲盒 / 消耗补登卡属**不可逆动作**,`GROWTH_IRREVERSIBLE_ACTIONS=false` 可全部跳过。凭证列表「成长中心」列显示每个账号最近一轮结果,行内菜单可手动执行一次。 +**成长中心**仅 CodeBuddy。周期默认 60 分钟(`GROWTH_INTERVAL_MINUTES`)。 ### 活跃上报(可选,默认关闭) -CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端对话事件,账号长期只被本网关自动调用时会断连登。`ACTIVITY_REPORT_ENABLED=true` 时,后台任务在每天 `ACTIVITY_REPORT_HOUR`(默认 10 点)所在整点窗口为每个账号补发**一条** `chat_request_send` 事件续上连登——与积分、调度无关,上报失败不影响聊天请求。凭证行内菜单也可手动补报一次。适用于 CodeBuddy 与 CodeBuddy 国际。 +`ACTIVITY_REPORT_ENABLED=true` → 每天 `ACTIVITY_REPORT_HOUR`(默认 10 点)整点窗口为每账号补发一条 `chat_request_send` 续连登。与积分、调度无关。适用 CodeBuddy 与国际站。 -> ⚠️ **风险与限制**:官方活动条款禁止用模拟器 / 脚本篡改活动数据,处罚为**取消资格并追回已发礼品**。默认关闭,开启前请自行评估账号风险。事件名与请求形状依赖上游实现,**上游改版即失效**,不承诺任何收益,不作为可靠性功能。实测(2026-09-21):缺少 `userId` 时上游返回 HTTP 200 `{"code":0}` 却**静默丢弃**;OAuth 凭证的 `user_id`/`account_uid` 可能为空,此时回落取 bearer JWT 的 `sub`,仍缺失则跳过(不编造)。 +> **风险**:官方条款禁止脚本篡改活动数据;处罚 = 取消资格 + 追回礼品。默认关闭,开启前自行评估。上游改版即失效。缺 `userId` → 上游静默丢弃;OAuth `user_id` 为空 → 回落 JWT `sub`,仍缺则跳过。 ### 运维告警 -后台周期评估四类风险并落 `alert_events`(管理台「运维告警」页回看,admin-only): +后台周期评估九类风险 → `alert_events`(「运维告警」页,admin-only): | 规则 | 触发条件 | |---|---| -| `pool_empty` | 池里有凭证但可用数少于 `ALERT_POOL_READY_MIN` | -| `task_failed` | 某后台任务连续失败达到 `ALERT_TASK_FAILURES` 次(成功一轮清零) | -| `token_expiring` | 某凭证 token 剩余寿命落在 `ALERT_TOKEN_EXPIRY_HOURS` 内 | -| `error_rate` | 统计窗内上游失败占比达到 `ALERT_ERROR_RATE_THRESHOLD` 且样本足够 | - -- **静默窗**(`ALERT_SILENCE_MINUTES`):同一 `(规则, 对象)` 窗口内只落库 / 推送一次,避免周期性评估把记录刷屏。 -- **迟滞**(`ALERT_FOR_TICKS` / `ALERT_CLEAR_TICKS`):连续命中若干轮才真正告警,连续未命中若干轮才确认恢复。恢复时给该 `(规则, 对象)` **全部尚未恢复**的行写 `resolved_at`(同一次故障的「提醒」行一起收尾),并外推一条 `status="resolved"` 的 webhook;页面把 `resolved_at` 非空的行显示为「已恢复」。 -- **重启自愈**:迟滞**计数**进程内维护、重启归零重新累积;但「当前哪些告警未恢复」是已落库的事实,`AlertTask` 启动时从库里未恢复的行重建,所以重启前落库、条件已消失的告警仍会被正常标记恢复。 -- **人工处置**:页面每行未恢复的告警带「标记恢复」按钮(`POST /api/alerts/{id}/resolve`,admin + CSRF + 审计),用于规则已下线 / 误报等后台无法自动判定的情况。只标该行,不按 `(规则, 对象)` 批量——同组可能还有仍在告警的记录。 -- **保留期**:与请求明细一致(默认 90 天),由后台清理任务裁剪。 -- webhook 地址与各阈值见「任务与配置」页的「运维告警」卡片。 +| `pool_empty` | 可用数少于 `ALERT_POOL_READY_MIN` | +| `task_failed` | 某任务连续失败达到 `ALERT_TASK_FAILURES` 次 | +| `token_expiring` | token 剩余寿命落在 `ALERT_TOKEN_EXPIRY_HOURS` 内 | +| `error_rate` | 统计窗内失败占比达到 `ALERT_ERROR_RATE_THRESHOLD` 且样本足够 | +| `key_burst` | 单 Key 窗口内 token 用量达到 `ALERT_KEY_BURST_TOKENS`(滥用 / 泄露信号,不是错误) | +| `off_hours` | 工作时间 `[ALERT_OFF_HOURS_START, ALERT_OFF_HOURS_END)` 之外请求数达到 `ALERT_OFF_HOURS_MIN_REQUESTS` | +| `budget` | 当月成本达 `ALERT_BUDGET_CNY` 的某一 `ALERT_BUDGET_LEVELS` 档,只报最高档;100% 为 critical | +| `channel_dead` | 统计窗内某渠道请求数达到 `ALERT_CHANNEL_DEAD_MIN_REQUESTS` 且**一次都没成功**(抓「渠道整体挂了」:积分耗尽整批 401 会被 fallback 稀释掉错误率) | +| `checkin_failing` | 同一凭证最近连续 `ALERT_CHECKIN_FAIL_MIN_STREAK` 轮签到都失败(中间成功一次即清零;补「任务跑成功、上游业务返回失败」的盲区) | + +- **静默窗**:同一 `(规则, 对象)` 窗口内只落库 / 推送一次。 +- **迟滞**:连续命中 N 轮才告警;连续未命中 M 轮才确认恢复。恢复时给该 `(规则, 对象)` 全部未恢复行写 `resolved_at`,并外推 `status="resolved"` webhook。 +- **多出口**:通用 Webhook(完整 JSON + 签名)+ 飞书机器人(文本消息)+ Slack Incoming Webhook 可同时配置;任一渠道失败不影响其它渠道发送,但整体记为未投递成功。 +- **重启自愈**:迟滞计数进程内维护;「哪些未恢复」从库里重建。 +- **人工处置**:「标记恢复」(admin + CSRF + 审计)只标该行。 +- **保留期**:同请求明细(默认 90 天)。 ### 数据库备份与恢复 -**后台自动备份**(`BACKUP_INTERVAL_MINUTES`,默认 1440 分钟 = 每日一次)把主库一致性快照写进 `DATA_DIR/backups/`,并只保留最近 `BACKUP_KEEP` 份(默认 7)。 +**自动备份**(`BACKUP_INTERVAL_MINUTES`,默认每日)把一致性快照写进 `DATA_DIR/backups/`,只留最近 `BACKUP_KEEP` 份(默认 7)。 -- **用 `VACUUM INTO` 而不是复制文件**:WAL 模式下主库文件不含尚未 checkpoint 的已提交事务,直接 `cp` 会丢数据。`VACUUM INTO` 读一份一致性快照并重写成**紧凑、无 WAL 的独立库**,恢复时只需原子替换主库。 -- **落盘即校验**:写完立刻 `PRAGMA integrity_check`,不过则删掉半成品并记日志——一个写坏的备份比没有备份更危险。产物 `0600`、目录 `0700`(库内是凭证密文与明文用量/审计)。 -- **失败不影响服务**:备份是普通后台任务,异常只记日志、进任务卡片,不拖垮聊天请求。 +- 用 `VACUUM INTO`,不复制文件(WAL 下直接 `cp` 丢未 checkpoint 事务)。 +- 落盘即 `PRAGMA integrity_check`,不过 → 删半成品。产物 `0600`、目录 `0700`。 +- 失败只记日志,不影响服务。 -**手动备份**(升级/迁移前留一份): +手动备份: ```bash python3 scripts/backup_db.py # 备份到 DATA_DIR/backups/,保留 7 份 python3 scripts/backup_db.py --keep 14 # 保留 14 份(0 = 不清理) -python3 scripts/backup_db.py --out /path/dir # 指定备份目录 +python3 scripts/backup_db.py --out /path/dir ``` -**恢复**(**先停服**,脚本不做「服务是否在跑」探测——容器/权限都可能误判): +恢复(**先停服**): ```bash -python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 # 预览:只校验,不改动 +python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 # 预览:只校验 python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --apply # 校验 → 安全副本 → 原子替换 ``` -恢复流程:校验备份完整性(不过直接拒绝,绝不把坏文件换进生产库)→ 给现有主库做一份 `.pre-restore-<时间戳>` 安全副本(同样走 `VACUUM INTO`,不漏 WAL 中未 checkpoint 的事务)→ 原子替换主库 → 删除 `-wal`/`-shm` 残留(它们属于旧库,留着会损坏新库)。**恢复后重启服务。** +恢复流程:校验完整性 → 现有主库 `.pre-restore-<时间戳>` 副本 → 原子替换 → 删 `-wal`/`-shm` 残留。**恢复后重启服务。** + +管理台「备份与恢复」页(admin)提供同一能力的界面: + +- `GET /api/backups`:列出 `backups/*.sqlite3` + 当前待恢复文件(`pending`)。 +- `POST /api/backups`:立即备份一次。 +- `GET /api/backups/{name}` / `DELETE /api/backups/{name}`:下载 / 删除(只认目录内普通文件,拒绝路径与符号链接)。 +- `POST /api/backups/restore?filename=…`:原始字节上传 → 完整性 + 核心表 + schema 版本校验 → 暂存 `DATA_DIR/restore/pending.sqlite3`。**运行中不换库**,重启启动时在建连接之前原子替换;待恢复文件校验不过会留档 `.rejected-*` 并继续用当前库,不会把服务钉死在启动失败。 ## 配置 -常用项如下;完整的可配置项见 `src/config.py`(权威),且**每一个都已透传到 `docker-compose.yml`**。容器里改监听地址用 `HOST`/`PORT`(`PORT` 同时决定宿主机映射端口)。 +完整项见 `src/config.py`(权威),每项都透传 `docker-compose.yml`。容器内改监听地址用 `HOST`/`PORT`。 | 变量 | 默认 | 说明 | |---|---|---| -| `APP_SECRET` | **必填** | 凭证加密密钥,≥16 字符;丢失 = 凭证全部作废,无密钥轮换。启动时拒绝低熵值(全同字符 / 周期重复 / 字符集过小) | -| `ADMIN_USERNAMES` | 空 | **仅引导期**:逗号分隔的用户名,首次启动时被提权为 `admin`;DB 里已有角色后不再生效 | -| `USERS_FILE` | `secrets/users.txt` | **仅引导期**:老式用户文件路径,启动时一次性导入 SQLite(幂等)。账号唯一源是 `users` 表 | -| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | 浏览器可达地址;TRAE 登录回调(`+/authorize`)依赖它,scheme 为 https 时额外下发 HSTS。**不决定会话 Cookie 的 `Secure`**——那按请求实际协议动态判定(`X-Forwarded-Proto` 优先,无该头则看 Host),同一实例 LAN(http) 与隧道(https) 双入口才能都登录 | +| `APP_SECRET` | **必填** | 凭证加密密钥,≥16 字符;丢失 = 凭证全废。拒低熵值与已知占位值(`.env.example` 里那些)。**换密钥后旧凭证解不开**:先用 `scripts/rotate_app_secret.py` 重加密(停机 + 备份),否则单条探测回可行动的错误码、对话 / 后台任务只跳过坏凭证 | +| `ADMIN_USERNAMES` | 空 | 仅引导期(库里还没有活跃 admin):逗号分隔,首次启动提权 `admin` | +| `USERS_FILE` | `secrets/users.txt` | 仅引导期:老式用户文件,启动导入一次 | +| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | 浏览器可达地址;TRAE 登录回调用;https 下发 HSTS。不决定会话 Cookie 的 `Secure`。配回环 / `0.0.0.0` → 回调基址取当前请求 origin(见「部署注意」) | | `DATA_DIR` | `./data` | SQLite 与运行数据目录 | -| `DEFAULT_MODEL` | `deepseek-v4.1-flash` | 模型为空/`auto` 时的默认 | -| `QUOTA_PROBE_MINUTES` | `60` | 额度探测周期(下限 1 分钟) | -| `REFRESH_INTERVAL_MINUTES` | `30` | token 预刷新轮询周期(下限 5 分钟);短寿命渠道的预刷新窗口封顶必须**宽于**它 | -| `MODEL_CATALOG_MINUTES` | `30` | 渠道模型列表兜底刷新周期(下限 5 分钟) | -| `OPENROUTER_CATALOG_MINUTES` | `1440` | 模型目录刷新周期(下限 60 分钟):后台从 OpenRouter 公开接口一次抓取刊例价(成本估算)、明细元数据(「模型列表」页)与 Artificial Analysis 三项指数(Playground /「模型列表」页 / `/v1/models` 能力分)并落盘;上游变动少,默认每日一次 | -| `OPENROUTER_MODELS_URL` | `https://openrouter.ai/api/v1/models` | 模型目录来源地址(匿名可读、无需 key);改后需重启 | -| `USD_CNY_RATE` | `6.70` | 成本估算的美元兑人民币汇率;可热更,只影响之后写入的请求 | -| `GROWTH_INTERVAL_MINUTES` | `60` | 成长中心(仅 CodeBuddy)一轮领取的周期(下限 5 分钟) | -| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | 是否允许成长中心的不可逆动作:抽奖、连登兑换、开盲盒、消耗补登卡 | -| `ACTIVITY_REPORT_ENABLED` | `false` | 活跃上报(CodeBuddy 与国际):每天为账号补发一条对话事件续连登。**默认关闭**——条款禁止篡改活动数据,上游改版即失效 | -| `ACTIVITY_REPORT_HOUR` | `10` | 活跃上报的本地(北京)时间整点窗口;仅在开关开启时生效 | -| `CHECKIN_TIMES` | `""` | 每日签到执行时点:本地时间 `HH:MM`,逗号分隔(如 `09:37,21:10`)。命中时点才跑一轮;**留空 = 全天每 10 分钟检查一次**(旧行为,升级后不做任何配置也不变节奏)。坏段忽略并记 warning,全是坏段等同留空。上游签到幂等,多设几个时点只是多几次重试机会。配了时点后循环改为**每 60 秒**醒一次(否则抖动=0 时窗只有一分钟,必然整天漏签)。可热更 | -| `CHECKIN_JITTER_MINUTES` | `0` | 签到抖动(分钟):在时点**之前**提前一个 `0..N` 的随机偏移执行,把多部署摊开、避免同一分钟齐打上游。按「日期 + 时点」记去重键,抖动只改执行时刻,不会让同一时点跑两次。`0` = 不抖。可热更 | -| `LOG_BUFFER_LINES` | `500` | 管理台「服务端日志」页的进程内环形缓冲行数(只收本进程 `logging` 记录,不读文件)。`0` = 关闭该页(`GET /api/logs` 与 `/api/logs/stream` 均 404)。每行发出前过脱敏白名单,命中 `token` / `secret` / `authorization` / `cookie` / `password` / `api_key` 或 `sk-…` 形状的**整行丢弃**。可热更 | -| `ALERT_ENABLED` | `true` | 运维告警开关:后台周期评估四类风险(池耗尽 / 任务连续失败 / token 临近到期 / 上游错误率骤升),命中落 `alert_events` | -| `ALERT_WEBHOOK_URL` | 空 | 告警 Webhook:命中时 POST JSON(body 带 `status`:`firing` / `resolved`),多个用逗号分隔(≤16 个,仅 `http(s)`,拒内网/回环/链路本地/保留地址防 SSRF,投递前再按 DNS 解析复核一次、解析失败即跳过),并发投递、全部成功才算成功(总预算 15s);请求恒带 `X-Coding2API-Signature: sha256=` 供收端验真;留空 = 只留站内记录 | -| `ALERT_INTERVAL_MINUTES` | `5` | 运维告警评估周期(下限 1 分钟) | -| `ALERT_SILENCE_MINUTES` | `30` | 同一 `(规则, 对象)` 的静默窗:窗口内只落库 / 推送一次;`0` 关闭静默 | -| `ALERT_FOR_TICKS` | `1` | 进入迟滞:同一告警连续命中这么多轮才真正告警(滤掉单轮毛刺);`1` = 首轮即报 | -| `ALERT_CLEAR_TICKS` | `2` | 恢复迟滞:告警中的条目连续这么多轮不再命中才确认恢复(写 `resolved_at` 并外推 `status="resolved"` 通知),避免单轮抖动误撤 | -| `ALERT_POOL_READY_MIN` | `1` | 池耗尽阈值:可用凭证数少于该值时告警;`0` 关闭该规则 | -| `ALERT_TASK_FAILURES` | `3` | 后台任务连续失败达到该次数时告警(成功一轮清零);`0` 关闭 | -| `ALERT_TOKEN_EXPIRY_HOURS` | `24` | token 剩余时间少于该小时数时告警;`0` 关闭 | -| `ALERT_ERROR_RATE_THRESHOLD` | `0.5` | 上游错误率阈值:统计窗内失败占比达到该值且样本足够时告警;`0` 关闭 | -| `ALERT_ERROR_RATE_MIN_REQUESTS` | `20` | 错误率最小样本数:窗内请求数少于该值不判 | -| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | 错误率统计窗(分钟),数据源为 `usage_events` 明细 | -| `BACKUP_INTERVAL_MINUTES` | `1440` | 数据库备份后台任务周期(分钟),下限 60;`VACUUM INTO` 一致性快照落 `DATA_DIR/backups/` | -| `BACKUP_KEEP` | `7` | 备份保留份数:只留最近这么多份,更旧的按时间删除;`0` 表示不清理 | -| `UPSTREAM_STATUS_ENABLED` | `true` | 上游状态页聚合开关:后台并发抓各模型 / 网关提供方的官方状态接口并归一化,管理台「上游状态」页只读展示。**只做解释不做决策**;快照只存内存、重启归零 | -| `UPSTREAM_STATUS_MINUTES` | `5` | 上游状态页抓取周期(下限 1 分钟) | -| `UPSTREAM_STATUS_TIMEOUT_SECONDS` | `10.0` | 单源 HTTP 超时(秒):8 个源并发抓,慢的那个不该拖住整轮 | -| `MODEL_PROBE_MINUTES` | `0` | 模型主动探活周期(下限 1 分钟,`0` = 关闭)。只探仍被 `blocked` 负缓存(11102「该后端无此模型」)锁住的 (渠道, 模型),用一条最小对话区分「模型真没了」与「额度/限流/网络」:成功即清该渠道的负缓存(唯一的提前恢复入口),上游明确说没这个模型才算失败,其余一律「无法判定」且**绝不禁用**。探活直连 provider、不写 `usage_events`、不动冷却、不参与选号;结论落独立的 `model_probes` 表。开启会真发上游请求、消耗额度,按余量决定;负缓存本身 6h 起步,实际建议 ≥60 分钟。可热更,也可在「模型列表」页对单个模型点「立即探活」 | -| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | 主到期排序窗口:把距到期 ≤ 该秒数的积分加总,多的账号先用;CodeBuddy 与 TRAE 都按包独立到期、都参与;`≤0` 关闭整套到期排序 | -| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | 次到期排序窗口:主窗口打平时才比较;`≤0` 关闭该级 | -| `CONVERSATION_STICKY_SECONDS` | `3600` | 会话粘性 TTL:优先级 **请求体显式会话标识**(`conversation_id` / `prompt_cache_key`,`metadata` 优先)> **请求头会话标识**(`x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id`)> **消息前缀指纹**兜底;手动 pin 优先于全部。头与指纹值都掺 `username` 再哈希,两用户同值不串号;头必须过字符白名单(`[A-Za-z0-9._:-]`,≤128),非法则丢弃退回兜底。带 `user_id` 时不派生前缀兜底键(头照常生效);`≤0` 关闭 | -| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,seed-code-pro-*,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,minimax-m2.5,deepseek-v3-*,hunyuan-*,hy3*,deepseek-v4-*,qwen3.7-*,auto,nemotron-3-*,glm-5.2*,nemotron-3*,lfm2*,north-mini-code,openrouter-models-router,fast,balanced,deep,primary,hy4-preview,inkling-small,minimax-m2.7,kimi-k2.*,minimax-m3,gpt-5.4,gpt-5.5,laguna*,qwen3-vl-235b,seed-2.1*,xing4.0-29b-a4,glyph-cluster,qwen3.8-flash,seed-evolving` | 模型列表黑名单(fnmatch,仅影响列表展示);按**归一后的对外写法**匹配(原代号、归一键、展示名、展示名归一键四种任一命中即滤) | -| `MODEL_FALLBACK_GROUPS` | `""` | 跨渠道 fallback 兼容组:格式 `组名=成员1,成员2;组名2=成员3`;成员可带 `@渠道`。请求任一成员时该成员置首、组内其余依次回退;主渠道全不可用才回退,**仅在本次尝试尚未产出任何响应帧前**切换。留空 = 不启用 | +| `DEFAULT_MODEL` | `deepseek-v4.1-flash` | 模型为空 / `auto` 时默认 | +| `QUOTA_PROBE_MINUTES` | `60` | 额度探测周期(下限 1) | +| `REFRESH_INTERVAL_MINUTES` | `30` | token 预刷新轮询周期(下限 5) | +| `MODEL_CATALOG_MINUTES` | `30` | 渠道模型列表兜底刷新周期(下限 5) | +| `SHORT_COOLDOWN_WAIT_SECONDS` | `60` | 全池都在短冷却时原地等最短剩余冷却(≤ 该值),等到的号解禁后重取一次,而不是直接 503;轮换试过的号同样参与。等待期不占在途租约。`0` = 关闭。可热更;非流式请求建议同时开 `NONSTREAM_KEEPALIVE_SECONDS=15` 免得中间盒先掐连接 | +| `OPENROUTER_CATALOG_MINUTES` | `1440` | 模型目录刷新周期(下限 60) | +| `OPENROUTER_MODELS_URL` | `https://openrouter.ai/api/v1/models` | 目录来源(匿名可读);改后重启 | +| `TOOL_OUTPUT_COMPRESS_ENABLED` | `false` | 压缩 `role=tool` 的超长纯文本(去 ANSI + 折叠重复行);可热更 | +| `TOOL_OUTPUT_COMPRESS_MAX_CHARS` | `8000` | 触发压缩的字符阈值,≤0 视为关闭;可热更 | +| `USD_CNY_RATE` | `6.70` | 成本估算汇率;可热更,只影响之后写入 | +| `GROWTH_INTERVAL_MINUTES` | `60` | 成长中心周期(下限 5) | +| `GROWTH_IRREVERSIBLE_ACTIONS` | `true` | 允许抽奖 / 连登兑换 / 开盲盒 / 消耗补登卡 | +| `ACTIVITY_REPORT_ENABLED` | `false` | 活跃上报,默认关闭 | +| `ACTIVITY_REPORT_HOUR` | `10` | 活跃上报本地整点窗口 | +| `CHECKIN_TIMES` | `""` | 签到时点 `HH:MM` 逗号分隔;留空 = 全天每 10 分钟查。可热更 | +| `CHECKIN_JITTER_MINUTES` | `0` | 签到抖动(分钟),时点前随机提前。可热更 | +| `CHECKIN_SKIP_PROVIDERS` | `""` | 跳过签到的渠道(逗号分隔,如 `raccoon`):只停签到,聊天不受影响。可热更 | +| `LOG_BUFFER_LINES` | `500` | 服务端日志页缓冲行数;`0` 关该页。可热更 | +| `ALERT_ENABLED` | `true` | 运维告警开关 | +| `ALERT_WEBHOOK_URL` | 空 | 通用 Webhook,逗号分隔(≤16,仅 http(s),拒内网 / 回环防 SSRF),带 `X-Coding2API-Signature` | +| `ALERT_FEISHU_WEBHOOK_URL` | 空 | 飞书群机器人地址;按 `msg_type=text` 发送,URL 校验同通用 Webhook | +| `ALERT_SLACK_WEBHOOK_URL` | 空 | Slack Incoming Webhook 地址;按 `{text}` 发送,URL 校验同通用 Webhook | +| `ALERT_INTERVAL_MINUTES` | `5` | 告警评估周期 | +| `ALERT_SILENCE_MINUTES` | `30` | 同 `(规则, 对象)` 静默窗;`0` 关闭 | +| `ALERT_FOR_TICKS` | `1` | 进入迟滞轮数 | +| `ALERT_CLEAR_TICKS` | `2` | 恢复迟滞轮数 | +| `ALERT_POOL_READY_MIN` | `1` | 池耗尽阈值;`0` 关闭 | +| `ALERT_TASK_FAILURES` | `3` | 任务连续失败次数阈值;`0` 关闭 | +| `ALERT_TOKEN_EXPIRY_HOURS` | `24` | token 剩余小时阈值;`0` 关闭 | +| `ALERT_ERROR_RATE_THRESHOLD` | `0.5` | 错误率阈值;`0` 关闭 | +| `ALERT_ERROR_RATE_MIN_REQUESTS` | `20` | 错误率最小样本 | +| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | 错误率统计窗 | +| `ALERT_KEY_BURST_TOKENS` | `0` | 单 Key 用量暴涨阈值(token);`0` 关闭 | +| `ALERT_KEY_BURST_WINDOW_MINUTES` | `15` | 单 Key 用量统计窗;`0` 关闭 | +| `ALERT_CHANNEL_DEAD_MIN_REQUESTS` | `20` | 渠道零成功告警的最小样本(窗口内请求数);`0` 关闭 | +| `ALERT_CHANNEL_DEAD_WINDOW_MINUTES` | `60` | 渠道零成功统计窗(分钟) | +| `ALERT_CHECKIN_FAIL_MIN_STREAK` | `3` | 签到连续失败轮数阈值;`0` 关闭 | +| `ALERT_CHECKIN_FAIL_WINDOW_HOURS` | `24` | 签到失败回看窗(小时) | +| `ALERT_OFF_HOURS_START` / `_END` | `0` / `0` | 工作时间 `[start, end)`(本地时区,可跨零点);相等 = 关闭 | +| `ALERT_OFF_HOURS_MIN_REQUESTS` | `0` | 非工作时段请求数阈值;`0` 关闭 | +| `ALERT_BUDGET_CNY` | `0` | 当月成本预算(元);`0` 关闭。仅计可定价明细 | +| `ALERT_BUDGET_LEVELS` | `50,80,95,100` | 预算分层档位;只报当前最高档,100% 为 critical | +| `BACKUP_INTERVAL_MINUTES` | `1440` | 备份周期(下限 60) | +| `BACKUP_KEEP` | `7` | 备份保留份数;`0` = 不清理 | +| `UPSTREAM_STATUS_ENABLED` | `true` | 上游状态页开关 | +| `UPSTREAM_STATUS_MINUTES` | `5` | 状态抓取周期(下限 1) | +| `UPSTREAM_STATUS_TIMEOUT_SECONDS` | `10.0` | 单源 HTTP 超时 | +| `MODEL_PROBE_MINUTES` | `0` | 主动探活周期(`0` = 关)。探仍被 `blocked` 负缓存锁住与已判死的 (渠道, 模型);成功即恢复展示并清负缓存,其余绝不禁用。可热更 | +| `QUOTA_EXPIRY_WINDOW_SECONDS` | `129600` | 主到期排序窗口;`≤0` 关闭 | +| `QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS` | `604800` | 次到期窗口;`≤0` 关闭 | +| `CONVERSATION_STICKY_SECONDS` | `3600` | 会话粘性 TTL。优先级:请求体会话标识 > 请求头 > 消息前缀指纹;pin 最高。`≤0` 关 | +| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,fast,balanced,deep,primary,auto,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,glm-5.2*,glm.5.2*,kimi-k2.*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,kimi-k3,minimax-m2.5,minimax-m2.7,minimax-m3,deepseek-v3-*,deepseek-v4-*,qwen3.7-*,qwen3-*,qwen3.6*,qwen3-vl-235b,gpt-5.4,gpt-5.5,gpt-5.6*,hy3*,hy4-preview,hunyuan-*,seed-2.1*,seed-2-1-turbo,doubao-seed-2.1-turbo,seed-code-pro-*,seed-evolving,space-bunny,nemotron-3-*,nemotron-3*,lfm2*,laguna*,north-mini-code,inkling-small,xing4.0-29b-a4,glyph-cluster,openrouter-models-router,deepseek-v4.1-flash-0930,deepseek-v4.1-flash-bnt,glm-5.3-test,glm-5.3-zx,kimi-k3-test-1009,kimi-k3-1019` | 列表黑名单(fnmatch,仅影响展示),按归一后写法匹配 | +| `MODEL_FALLBACK_GROUPS` | `""` | 跨渠道 fallback 组:`组名=成员1,成员2;…`。留空不启用 | | `ALLOWED_HOSTS` | 空 | Host 白名单,防 DNS rebinding | -| `ALLOW_TEST_HOST` | `false` | 额外放行 FastAPI TestClient 的默认 Host `testserver`。**仅测试用**,生产保持 `false` | -| `TRUST_PROXY` | `false` | 是否采信 `X-Forwarded-For` 判定来源 IP(白名单、登录限流与审计共用)。仅「本服务前恰好一层受信反代」时开启 | -| `ENABLE_DOCS` | `false` | 是否暴露 `/docs` 与 `/openapi.json`(默认关闭) | -| `DUMP_REQUEST_BODIES` | `false` | 诊断:把 `/v1` 原始请求体落盘到 `data/dumps/`(**含对话内容**,仅排查用) | -| `LOG_LEVEL` | `INFO` | 日志级别;审计日志是 INFO 级,调到 `WARNING` 会一并关掉 | -| `HOST` / `PORT` | `127.0.0.1` / `8000` | 监听地址与端口(compose 默认 `0.0.0.0`)。compose 里 `PORT` 同时决定**容器内监听端口与宿主机映射端口**(两侧一致,改一个即可) | -| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | CodeBuddy 上游地址;改动时必须同时加入 `CODEBUDDY_ALLOWED_ENDPOINTS` | -| `CODEBUDDY_ALLOWED_ENDPOINTS` | 官方两站(见 compose) | 上游端点白名单,真实 Token 只发往白名单内地址 | -| `CODEBUDDY_INTL_API_ENDPOINT` | `https://www.codebuddy.ai` | CodeBuddy 国际上游地址;改动时必须同时加入 `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | -| `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | `https://www.codebuddy.ai` | 国际站白名单,**刻意不含**国内站主机(跨站发送即凭证泄露,填错启动失败) | -| `ZEN_API_ENDPOINT` | `https://opencode.ai` | OpenCode Zen 上游地址;改动时必须同时加入 `ZEN_ALLOWED_ENDPOINTS` | -| `ZEN_ALLOWED_ENDPOINTS` | `https://opencode.ai` | Zen 端点白名单 | -| `ZEN_OPENCODE_VERSION` | `1.18.0` | 门禁伪装用的 `opencode/` UA 版本(低于阈值会被 426 拒绝) | -| `KILO_API_ENDPOINT` | `https://api.kilo.ai/api/gateway` | Kilo 上游地址;改动时必须同时加入 `KILO_ALLOWED_ENDPOINTS` | -| `KILO_ALLOWED_ENDPOINTS` | `https://api.kilo.ai/api/gateway` | Kilo 端点白名单 | -| `QODER_API_ENDPOINT` | `https://openapi.qoder.com.cn` | Qoder openapi 地址(额度/登录);国际站设为 `https://openapi.qoder.sh` | -| `QODER_GATEWAY_ENDPOINT` | `https://gateway.qoder.com.cn` | Qoder 推理网关地址(聊天/模型发现);国际站设为 `https://api1.qoder.sh` | -| `QODER_ALLOWED_ENDPOINTS` | 国内 openapi+gateway + 国际站(见 compose) | Qoder 端点白名单,带 COSY 签名的请求只发往白名单内地址 | -| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap 引擎地址;改动时必须同时加入 `CODEARTS_ALLOWED_ENDPOINTS` | -| `CODEARTS_ALLOWED_ENDPOINTS` | snap 引擎 + STS + 门户(见 compose) | CodeArts 端点白名单,AK/SK 签名请求只发往白名单内地址 | -| `OFFICEACE_API_ENDPOINT` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce 控制面地址(OAuth / 开通校验);改动时必须同时加入 `OFFICEACE_ALLOWED_ENDPOINTS` | -| `OFFICEACE_ALLOWED_ENDPOINTS` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce 端点白名单。**只需控制面**:模型网关每账号不同,由上游下发并存在凭证里 | -| `OFFICEACE_CHAT_MIN_INTERVAL` | `0` | OfficeAce 聊天最小间隔(秒),独立节流器;`0` 关闭 | -| `PROVIDER_PROXIES` | `""` | **按渠道出站代理**(启动期项,改后需重启);留空 = 全部直连。容器内 `127.0.0.1` 指容器自身,代理跑在宿主时须写 `host.docker.internal` | -| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB 聊天节流器最小间隔(秒):按凭证分桶、**桶内允许并发**,只在同凭证顺序连发时补足间隔;`0` 关闭 | -| `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` | `5` | CB 国际聊天最小间隔(秒),**独立**节流器;`0` 关闭 | -| `ZEN_CHAT_MIN_INTERVAL` | `0` | Zen 聊天最小间隔(秒),独立节流器,默认关闭 | -| `KILO_CHAT_MIN_INTERVAL` | `0` | Kilo 聊天最小间隔(秒),独立节流器,默认关闭 | -| `QODER_CHAT_MIN_INTERVAL` | `5` | Qoder 聊天最小间隔(秒),独立节流器;`0` 关闭 | -| `CODEARTS_CHAT_MIN_INTERVAL` | `5` | CodeArts 聊天最小间隔(秒),独立节流器;`0` 关闭 | -| `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts 每账号**在途并发上限**:上升沿满额时请求挂起直到有请求结束;`0` 关闭 | -| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts **账号滑动窗口**(秒):与上一项组合成「每账号每 60s 最多启动 3 次」;`0` 关闭窗口口径 | -| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | 出站 `system`/`assistant` 正文命中「伪装其他厂商官方客户端」指纹串时替换为占位符(上游 11128 内容风控),只改出站副本;`false` 关闭 | -| `REFRESH_SKEW_HOURS` | `24` | token 到期前该小时数窗口内预刷新。短寿命渠道自行封顶(CodeArts STS 只有 2h,封顶 45min) | -| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | 管理台 token 到期预警阈值;`≤0` 关闭预警。纯展示,不参与调度 | -| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 全局节流器随机等待区间(秒)。区间相等时(聊天节流器的 `*_CHAT_MIN_INTERVAL` 形态)额外**向上**抖动 ±25%,避免多实例精确同拍;下限不击穿 | -| `AUTO_CONTINUE_MAX` | `10` | 上游以 `finish_reason=length` 截断时同凭证自动续写的最多次数;`0` 关闭 | -| `CONTEXT_COMPRESS_ENABLED` | `true` | 按模型目录里的输入上限裁剪过长对话,避免撞上游硬限制;目录里查不到上限的模型不受影响 | -| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | 压缩预算里为模型回复预留的输出 token 数 | +| `ALLOW_TEST_HOST` | `false` | 放行 `testserver`,仅测试用 | +| `TRUST_PROXY` | `false` | 是否采信 `X-Forwarded-For`;仅「前恰好一层受信反代」时开 | +| `ENABLE_DOCS` | `false` | 暴露 `/docs` 与 `/openapi.json` | +| `DUMP_REQUEST_BODIES` | `false` | `/v1` 原始请求体落盘(含对话,仅排查)。**可热更**(管理台「可观测性」),目录 0700 / 文件 0600 / 保留 200 份且不超 24h;改动进审计 `settings.update` | +| `GATEWAY_PAUSED` | `false` | 全站暂停(kill switch):所有 API Key 鉴权的 `/v1` 端点返 503 (`code=service_paused`),管理台与登录照常可用。**可热更**,一键止血;改动进审计并单独记 WARNING | +| `GATEWAY_PAUSE_REASON` | 空 | 暂停原因,只进日志与审计(不回显给调用方);留空按「未填原因」记 | +| `LOG_LEVEL` | `INFO` | 日志级别;审计日志是 INFO,调到 WARNING 一并关 | +| `HOST` / `PORT` | `127.0.0.1` / `8000` | 监听地址与端口(compose 默认 `0.0.0.0`);`PORT` 同时决定宿主映射 | +| `CODEBUDDY_API_ENDPOINT` | `https://copilot.tencent.com` | 上游地址;改动须同步白名单 | +| `CODEBUDDY_ALLOWED_ENDPOINTS` | 官方两站(见 compose) | 端点白名单;真实 Token 只发名单内 | +| `CODEBUDDY_INTL_API_ENDPOINT` | `https://www.codebuddy.ai` | 国际站地址 | +| `CODEBUDDY_INTL_ALLOWED_ENDPOINTS` | `https://www.codebuddy.ai` | 国际站白名单,刻意不含国内站 | +| `ZEN_API_ENDPOINT` | `https://opencode.ai` | Zen 上游地址 | +| `ZEN_ALLOWED_ENDPOINTS` | `https://opencode.ai` | Zen 白名单 | +| `ZEN_OPENCODE_VERSION` | `1.18.0` | 门禁伪装 UA 版本(低于阈值 → 426) | +| `ZEN_API_ROOT` | `/zen/v1` | 上游 API 根;可切 `/inference/v1`(两路径当前都返回 200) | +| `ZEN_GATE_TOOLS` | `bash,read` | 门禁 tools 集合;上游收紧时可切 `bash,glob,grep,read` | +| `ZEN_USER_AGENT` | 空 | UA 模板(`{version}` 代入版本号);空 = `opencode/` | +| `KILO_API_ENDPOINT` | `https://api.kilo.ai/api/gateway` | Kilo 上游地址 | +| `KILO_ALLOWED_ENDPOINTS` | `https://api.kilo.ai/api/gateway` | Kilo 白名单 | +| `QODER_API_ENDPOINT` | `https://openapi.qoder.com.cn` | Qoder openapi(额度 / 登录);国际站 `https://openapi.qoder.sh` | +| `QODER_GATEWAY_ENDPOINT` | `https://gateway.qoder.com.cn` | Qoder 推理网关;国际站 `https://api1.qoder.sh` | +| `QODER_ALLOWED_ENDPOINTS` | 国内 openapi+gateway + 国际站(见 compose) | Qoder 白名单 | +| `CODEARTS_API_ENDPOINT` | `https://snap-access.cn-north-4.myhuaweicloud.com` | CodeArts snap 引擎 | +| `CODEARTS_ALLOWED_ENDPOINTS` | snap 引擎 + STS + 门户(见 compose) | CodeArts 白名单 | +| `OFFICEACE_API_ENDPOINT` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | OfficeAce 控制面 | +| `OFFICEACE_ALLOWED_ENDPOINTS` | `https://agentarts.cn-southwest-2.myhuaweicloud.com` | 只需控制面(模型网关由上游下发) | +| `OFFICEACE_CHAT_MIN_INTERVAL` | `0` | OfficeAce 聊天最小间隔;可热更 | +| `PROVIDER_PROXIES` | `""` | 按渠道出站代理;启动期项 | +| `CREDENTIAL_PROXY_POOL` | `""` | 出口 IP 粘性:全局代理池(分号分隔),按凭证 id 稳定哈希分配;启动期项 | +| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB 聊天节流(按凭证分桶);可热更 | +| `CODEBUDDY_INTL_CHAT_MIN_INTERVAL` | `5` | CB 国际节流,独立;可热更 | +| `ZEN_CHAT_MIN_INTERVAL` | `0` | Zen 节流;可热更 | +| `KILO_CHAT_MIN_INTERVAL` | `0` | Kilo 节流;可热更 | +| `RACCOON_API_ENDPOINT` | `https://xiaohuanxiong.com` | 小浣熊上游地址 | +| `RACCOON_ALLOWED_ENDPOINTS` | `https://xiaohuanxiong.com` | 小浣熊白名单 | +| `RACCOON_CHAT_MIN_INTERVAL` | `0` | 小浣熊聊天最小间隔;可热更 | +| `RACCOON_MAX_CONCURRENCY` | `2` | 小浣熊每账号在途并发上限;`0` 关闭;可热更 | +| `LINGXI_API_ENDPOINT` | `https://lingxi.wps.cn` | 灵犀上游地址 | +| `LINGXI_ALLOWED_ENDPOINTS` | `https://lingxi.wps.cn,https://lingxi.kdocs.cn` | 灵犀白名单(对话面按两域顺序回落) | +| `LINGXI_CHAT_MIN_INTERVAL` | `0` | 灵犀聊天最小间隔;可热更 | +| `QODER_CHAT_MIN_INTERVAL` | `5` | Qoder 节流;可热更 | +| `CODEARTS_CHAT_MIN_INTERVAL` | `5` | CodeArts 节流;可热更 | +| `CODEARTS_MAX_CONCURRENCY` | `3` | CodeArts 每账号在途并发上限;`0` 关闭;可热更 | +| `CODEARTS_REQUEST_WINDOW_SECONDS` | `60` | CodeArts 账号滑动窗口;`0` 关闭;可热更 | +| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | 出站正文命中「伪装官方客户端」指纹 → 替换(上游 11128 风控) | +| `REFRESH_SKEW_HOURS` | `24` | token 到期前该窗口预刷新;短寿命渠道自封顶 | +| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | 到期预警阈值;`≤0` 关。纯展示 | +| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 全局节流随机等待区间 | +| `AUTO_CONTINUE_MAX` | `10` | `finish_reason=length` 时同凭证自动续写次数;`0` 关闭 | +| `CONTEXT_COMPRESS_ENABLED` | `true` | 按模型输入上限裁剪过长对话 | +| `CONTEXT_COMPRESS_RESERVE_TOKENS` | `4096` | 为回复预留的输出 token | | `CONTEXT_COMPRESS_MIN_KEEP_MESSAGES` | `4` | 无论多长都保留的最近消息条数 | -| `CONTEXT_COMPRESS_SAFETY_RATIO` | `0.95` | 按模型上限的百分比计算压缩预算,给 token 估算误差留余量 | -| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | 非流式聚合整体超时(秒):超时按瞬态错误换号重试;`≤0` 关闭 | -| `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | 流式**首事件**超时(秒):上游接了连接却长时间不吐第一个数据帧时,在**出帧前**换号重试(此时切换安全,客户端没收到任何内容)。注释帧 / 心跳不重置该计时。`0` = 关闭。**默认必须保持关闭**——实测慢上下文首字可达 40–71s,默认值会误杀正常请求;要用请按部署实际填(推荐 `60`)。可热更 | -| `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | 流式**空闲**超时(秒):**已出帧**后上游长时间不再吐数据时断流,只发一帧错误收尾——半截输出不可回滚,换号会把两个账号的内容拼给客户端。`0` = 关闭(推荐 `300`)。可热更 | -| `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | 短冷却「等待续上」(秒):全池都在短冷却、没有任何立即可用候选时,最短剩余冷却不超过该值则等待后重取一次,而不是直接 503。等待期间不占在途租约、不计入轮换预算。`0` = 关闭(快速失败,旧行为)。可热更 | -| `MODEL_ALIASES` | `""` | 自定义模型别名:`对外名=模型@渠道;对外名2=模型`,`@渠道` 省略则自动选。列表与请求解析用同一份;与真实 id / 自动别名撞车、目标不存在、渠道不持有该模型时该段跳过并记 ERROR。留空 = 不启用。可热更 | -| `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | 单账号在途请求上限(跨渠道全局):并发占满的号不再参与选号;`0` = 不限。租约请求结束释放,另有 600s TTL 兜底漏释放 | -| `METRICS_ENABLED` | `true` | 是否暴露 `GET /metrics`(Prometheus text 格式,**不鉴权**,与 `/healthz` 同口径);`false` 关闭(返回 404)。可热更 | -| `REQUEST_LOG_ENABLED` | `true` | 请求级结构化日志(logfmt):每个业务请求(`/api/*`、`/v1/*`、`/authorize`)一行 `event=request request_id=… method=… route=… status=… duration_ms=…`。路由用模板(`/api/credentials/{credential_id}`)不用实际 ID;**不写 query string、请求体、用户名**,靠 `request_id` 关联 `usage_events`。5xx 记 WARNING、其余 INFO;静态资源与探针不记(uvicorn 自带 access log 已关闭)。`false` 关闭请求级日志。可热更 | -| `FORCE_DISABLE_THINKING` | `false` | 出站剥离思考内容:开启后响应里不再有 `reasoning_content` / Anthropic `thinking` 块 / Responses reasoning item,**只过滤响应、不改请求体**(上游仍照常思考)。三个出口、流式与非流式一致生效。可热更 | -| `REASONING_EFFORT_MAP` | `""` | 出站 `reasoning_effort` 档位映射:格式 `渠道=from:to,from:to;*=from:to`(渠道取 `KNOWN_PROVIDERS`,`*` 兜底);目标写 `none` = 删除该键、走渠道默认。只改写客户端**显式**给的档位,渠道自身 `setdefault` 补的默认档不受影响。留空 = 原样透传。可热更 | -| `ANTHROPIC_THINKING_EFFORTS` | `""` | Anthropic 入站 `thinking.budget_tokens` → 上游 `reasoning_effort`:格式 `预算:档位,预算:档位,disabled:档位`(`disabled` 匹配 `thinking.type=="disabled"`;其余取「阈值 ≤ budget_tokens 中最大的那一档」,低于最小阈值不映射)。留空 = 完全不映射(`thinking` 照旧忽略)。可热更 | +| `CONTEXT_COMPRESS_SAFETY_RATIO` | `0.95` | 压缩预算占模型上限比例 | +| `CONTEXT_COMPRESS_STICKY_ENABLED` | `true` | 粘性裁剪:一次裁到预算 60% 并锁定窗口起点,跨轮前缀不变以命中上游缓存 | +| `PREFIX_DEBUG_ENABLED` | `false` | 前缀缓存诊断:每请求一行载荷指纹(只记摘要),定位命中率异常的分叉点。可热更 | +| `UPSTREAM_COMPLETE_TIMEOUT_SECONDS` | `600` | 非流式聚合整体超时;`≤0` 关闭 | +| `STREAM_FIRST_EVENT_TIMEOUT_SECONDS` | `0` | 首事件超时(出帧前可换号)。**默认必须保持关闭**,推荐 60。可热更 | +| `STREAM_IDLE_TIMEOUT_SECONDS` | `0` | 流内空闲超时(已出帧只断流不换号),推荐 300。可热更 | +| `SHORT_COOLDOWN_WAIT_SECONDS` | `0` | 全池短冷却时等待续上;`0` = 快速失败。可热更 | +| `NONSTREAM_KEEPALIVE_SECONDS` | `0` | 非流式出口 N 秒未完成 → 转 chunked 吐空白心跳(JSON 前导空白合法);`0` = 关,仅 CDN 后需要,推荐 15。可热更 | +| `MODEL_ALIASES` | `""` | 自定义别名;留空不启用。可热更 | +| `MAX_INFLIGHT_PER_CREDENTIAL` | `0` | 单账号在途上限;`0` = 不限。600s TTL 兜底 | +| `METRICS_ENABLED` | `true` | 暴露 `/metrics`。可热更 | +| `METRICS_TOKEN` | 空 | 设置后 `/metrics` 要求 `Authorization: Bearer `;空 = 公开(与 `/healthz` 同口径)。启动期项 | +| `REQUEST_LOG_ENABLED` | `true` | 请求级结构化日志(logfmt);不写 query / 请求体 / 用户名。可热更 | +| `FORCE_DISABLE_THINKING` | `false` | 出站剥离思考内容;只过滤响应,不改请求。可热更 | +| `REASONING_EFFORT_MAP` | `""` | 出站档位映射 `渠道=from:to;*=from:to`(渠道取 `KNOWN_PROVIDERS`);`none` = 删键。可热更 | +| `ANTHROPIC_THINKING_EFFORTS` | `""` | Anthropic `thinking.budget_tokens` → 上游 `reasoning_effort`。可热更 | ### 管理台热更(「任务与配置」页) -上表中带「可热更」语义的 **60 项**可不改 `.env`、不重启,直接在管理台「任务与配置」页修改: - -`DEFAULT_MODEL`、`MODEL_BLOCKLIST`、`MODEL_ALIASES`、`CONTEXT_COMPRESS_ENABLED`、`CONTEXT_COMPRESS_RESERVE_TOKENS`、`CONTEXT_COMPRESS_MIN_KEEP_MESSAGES`、`CONTEXT_COMPRESS_SAFETY_RATIO`、`MODEL_FALLBACK_GROUPS`、`STREAM_FIRST_EVENT_TIMEOUT_SECONDS`、`STREAM_IDLE_TIMEOUT_SECONDS`、`SHORT_COOLDOWN_WAIT_SECONDS`、`QUOTA_EXPIRY_WINDOW_SECONDS`、`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`、`CONVERSATION_STICKY_SECONDS`、`MAX_INFLIGHT_PER_CREDENTIAL`、`GROWTH_IRREVERSIBLE_ACTIONS`、`GROWTH_INTERVAL_MINUTES`、`REFRESH_INTERVAL_MINUTES`、`QUOTA_PROBE_MINUTES`、`MODEL_CATALOG_MINUTES`、`OPENROUTER_CATALOG_MINUTES`、`USD_CNY_RATE`、`CODEBUDDY_CHAT_MIN_INTERVAL`、`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`、`ZEN_CHAT_MIN_INTERVAL`、`KILO_CHAT_MIN_INTERVAL`、`QODER_CHAT_MIN_INTERVAL`、`CODEARTS_CHAT_MIN_INTERVAL`、`CODEARTS_MAX_CONCURRENCY`、`CODEARTS_REQUEST_WINDOW_SECONDS`、`PACER_MIN_SECONDS`、`PACER_MAX_SECONDS`、`ACTIVITY_REPORT_ENABLED`、`ACTIVITY_REPORT_HOUR`、`ALERT_ENABLED`、`ALERT_WEBHOOK_URL`、`ALERT_INTERVAL_MINUTES`、`ALERT_SILENCE_MINUTES`、`ALERT_FOR_TICKS`、`ALERT_CLEAR_TICKS`、`ALERT_POOL_READY_MIN`、`ALERT_TASK_FAILURES`、`ALERT_TOKEN_EXPIRY_HOURS`、`ALERT_ERROR_RATE_THRESHOLD`、`ALERT_ERROR_RATE_MIN_REQUESTS`、`ALERT_ERROR_RATE_WINDOW_MINUTES`、`BACKUP_INTERVAL_MINUTES`、`BACKUP_KEEP`、`UPSTREAM_STATUS_ENABLED`、`UPSTREAM_STATUS_MINUTES`、`MODEL_PROBE_MINUTES`、`METRICS_ENABLED`、`REQUEST_LOG_ENABLED`、`FORCE_DISABLE_THINKING`、`REASONING_EFFORT_MAP`、`ANTHROPIC_THINKING_EFFORTS`、`CHECKIN_TIMES`、`CHECKIN_JITTER_MINUTES`、`LOG_BUFFER_LINES`。 +上表只给常用项标了「可热更」;管理台共 **85 项**(`src/runtime_settings.py` 的 `HOT_SETTINGS`)可不改 `.env`、不重启,直接在「任务与配置」页改。 -要点:**优先级 `DB 覆盖值 > .env`**——改过后 .env 对该项不再生效,页面标「DB 覆盖」,点「恢复默认」删掉覆盖行才回落 .env。值存 `runtime_settings` 表(纯 key/value),新增可热更项无需迁移;白名单外的 key、非法类型 / 越界值写入前即拒。启动期项(`APP_SECRET` / `PORT` / `DATA_DIR` / `USERS_FILE` / `OPENROUTER_MODELS_URL` / 上游端点白名单)**不在**白名单。接口 `GET /api/settings` 读快照、`PUT /api/settings` 写(admin + CSRF),传 `null` 恢复默认。该页同时展示**后台任务运行态**,页面按 tab 组织;运行态是**进程内**的(`GET /api/tasks`,admin,页面每 30 秒刷新),重启归零。 +**优先级 `DB 覆盖值 > .env`**:覆盖后 .env 失效,页面标「DB 覆盖」;点「恢复默认」删覆盖行才回落 .env。值存 `runtime_settings` → 新增项免迁移。白名单外 key、非法类型 / 越界值写入前即拒。启动期项不在白名单:`APP_SECRET` / `PORT` / `DATA_DIR` / `USERS_FILE` / `OPENROUTER_MODELS_URL` / 上游端点白名单。`GET /api/settings` 读快照;`PUT /api/settings` 写(admin + CSRF),`null` 恢复默认。同页展示任务态(`GET /api/tasks`,30s 刷新)。 ## 部署注意 -- **挂载目录属主**:容器内以 uid 1001(`appuser`)运行,`./data` 与 `./secrets` 必须可写/可读,否则 SQLite 打不开: +- **挂载属主**:容器内 uid 1001(`appuser`)→ `./data` 与 `./secrets` 必须可写 / 可读: ```bash mkdir -p data secrets && sudo chown -R 1001:1001 data secrets ``` -- **时区**:镜像默认 `TZ=Asia/Shanghai`,`.env` 里的 `TZ` 会透传进容器(compose 已声明该变量)。签到 / 额度周期按「今天 9 点」判断,务必与宿主一致;基础镜像已装 `tzdata`,否则 `TZ` 会被静默忽略、退回 UTC。 +- **时区**:镜像默认 `TZ=Asia/Shanghai`;`.env` 的 `TZ` 透传。签到 / 额度周期按「今天 9 点」判断 → 必须与宿主一致。 +- **TRAE 回调地址**:默认 `PUBLIC_BASE_URL=http://127.0.0.1:8000` 只在本机可用。远程 / 容器部署改成浏览器能开的地址。**忘改也不会静默失败**:基址落回环 / `0.0.0.0` → 回调改用当前请求 origin(LAN IP 或隧道域名);「添加凭证」显示实际地址;授权超时点名查 `PUBLIC_BASE_URL`。**回调地址在浏览器侧打不开时**(NAS / 远程部署常见 TRAE 跳转「网络错误」,refreshToken 只留在地址栏):「添加凭证」里的 TRAE 粘贴框把地址栏整条链接粘回即可完成登录,不依赖浏览器能否访问本服务。 +- **端口**:compose 里 `PORT` 同时决定容器内监听端口与宿主映射端口。 +- **改代码后重建镜像**:`docker compose up -d` 不带 `--build` 用本地已有镜像跑旧代码。改 `src/` 或前端后必须 `docker compose up -d --build`。 -- **端口**:compose 里 `PORT` 同时决定容器内监听端口与宿主机映射端口,改一个即可(如 `PORT=9000` → 宿主 9000、容器内也监听 9000)。健康检查同步跟随,无需手工改。 +### 轮换 APP_SECRET(仅在怀疑密钥外泄时) -- **改代码后重建镜像**:compose 同时声明 `build: .` 与 `image:`,`docker compose up -d` **不带 `--build`** 会直接用本地已存在的 `ghcr.io/robbsluo/coding2api:latest`,跑的是旧代码。改 `src/` 或前端后务必: +`APP_SECRET` 是 Fernet 密钥的唯一来源(无盐 SHA-256),**直接换值会让已存凭证全部不可解**。要换必须重加密: - ```bash - docker compose up -d --build - ``` +```bash +python3 scripts/backup_db.py # 1. 先留一致性备份 +sudo systemctl stop coding2api # 2. 停机(运行中的进程会用旧密钥写库) +python3 scripts/rotate_app_secret.py \ + --db data/coding2api.sqlite3 \ + --old-secret-file /path/to/old.env \ + --new-secret-file /path/to/new.env # 3. 全解 → 校验 → 单事务重写 +# 4. .env 换新值、chmod 600 .env、重启 +``` -### 升级后必须重启后端 +任一条解不开就整体回滚,库保持原样。脚本接受 `.env` 或只含密钥的单行文件;不要把密钥写在命令行(会进 history 与 `ps`)。 -**改动 `src/` 后必须重启进程,否则出现「新前端 + 旧后端」错配。** 典型症状:管理台页面能打开(前端产物本就是静态文件),但调用新端点全部失败——旧后端没有该路由,未匹配的 `/api/*` 按约定返回 JSON `404`,前端当成通用失败,弹出与真实原因无关的提示。前端不需重启(后端每次请求现读 `web/dist`),**后端不是**——进程管理器只在进程**退出**时重新拉起,不监听源码变化。 +### 升级后必须重启后端 +**改 `src/` 后必须重启进程**,否则「新前端 + 旧后端」错配:页面能开,新端点全 404。前端不需重启(后端每次请求现读 `web/dist`);后端不是。 ```bash docker compose up -d --force-recreate # Docker / compose sudo systemctl restart coding2api # systemd ``` -**确认升级已生效**(先查版本,再查路由): +确认升级已生效: ```bash -# 1) schema 版本已迁移(期望 29)且账号已导入 sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, enabled FROM users;" -# 2) 路由存在:期望 401(未登录),404 = 旧后端进程 -curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users # 期望 401;404 = 旧后端 ``` ### 前端产物与后端在同一端口 -后端会把前端产物放在 `/` 直接服务(`src/webapp/static.py` 每次请求现读磁盘),因此**只改前端时构建完刷新即可,无需重启后端**;`localhost:5173` 是 Vite 开发态(HMR),两者可并存。**这条只对前端成立**——改了 `src/` 下后端代码必须重启进程。 - -`scripts/build-web.sh` 是构建入口,会处理「依赖未装」与「node 不在 PATH」两类常见情况: +后端把前端产物放 `/` 直接服务(每次请求现读磁盘)→ **只改前端时构建完刷新即可**。`localhost:5173` 是 Vite 开发态,两者可并存。 ```bash ./scripts/build-web.sh # 仅在产物过期时构建 ./scripts/build-web.sh --force # 无条件重建 ``` -> **`web/.npmrc` 不能删,也不能只在本地改**:它把 `auto-install-peers` 关掉了。`@lobehub/icons` 把 `@lobehub/ui` / `antd` 声明为 peer,自动安装会拖进 400+ 个用不到的包(本项目只 import 它的静态图标组件,不引用 `@lobehub/ui`);关掉后 lock 里少一半包,`pnpm audit --prod` 也是 0 漏洞。代价是**真正需要的 peer 必须显式声明**(`@testing-library/dom` 就是这么加进 `devDependencies` 的)。该设置与 lockfile 里的 `settings.autoInstallPeers` 强耦合,所以 `Dockerfile` 也必须 `COPY web/.npmrc`,否则 `--frozen-lockfile` 直接报 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`。 +> **`web/.npmrc` 不能删**:它关掉 `auto-install-peers`(否则 `@lobehub/icons` 拖进 `@lobehub/ui` / `antd`)。该设置与 lockfile 的 `settings.autoInstallPeers` 强耦合;`Dockerfile` 必须 `COPY web/.npmrc`,否则 `--frozen-lockfile` 报 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`。 + +> **部署操作见本地文档**:进程管理、路径、工具版本 → `docs/local-environment.md`(已 gitignore)。 + +## 公网部署(Docker + Caddy 自动 HTTPS) -> **部署相关操作记在本地文档**:当前开发机的进程管理方式、路径与工具版本等见 `docs/local-environment.md`。该目录已在 `.gitignore` 中,**克隆仓库的人不会有这个文件**。 +有公网 IP 的 VPS 用这条:一条 compose 起「Caddy 入口 + 应用」,证书自动申请 / 续期,应用端口不外露。 + +``` +客户端 ──443/TLS──> Caddy(终止 TLS)──compose 内网──> coding2api:8000 +``` + +前提:Docker Compose ≥ 2.24(覆盖文件用了 `!override`)、站点地址(域名,或见下「只有公网 IP、没有域名」)的 A / AAAA 记录已指向本机、80 与 443(tcp + udp)空闲(80 要留给 ACME HTTP-01)。 + +`.env` 补两项(其余同「快速开始」): + +```bash +PUBLIC_DOMAIN=ai-api.example.com # 必填:证书按它签发;没有域名时填公网 IP(见下) +ACME_EMAIL=ops@example.com # 域名模式必填:Let's Encrypt 到期通知 +# TLS_MODE= # 留空 = 自动申请证书;公网 IP 自签填 internal(见下) +# ALLOWED_HOSTS 留空即可:空 = 放行本地回环 + PUBLIC_BASE_URL 的主机名 +``` + +启动并看证书签发: + +```bash +docker compose -f docker-compose.yml -f docker-compose.public.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.public.yml logs -f caddy +curl -fsS https://ai-api.example.com/health +``` + +覆盖文件(`docker-compose.public.yml`)改了什么: + +| 改动 | 为什么 | +|---|---| +| 应用端口改绑 `127.0.0.1:${PORT}` | 不给明文 http 留旁路:入口只能走 Caddy | +| `TRUST_PROXY=true` | 前面恰好一层 Caddy(且应用端口只在回环,无法直连绕过),Key 的 `allowed_ips` 才看得到真实来源 IP | +| `ALLOWED_HOSTS` 补域名 + `127.0.0.1` | 非空即「完全替换」语义:.env 里已有的值会顶掉域名(全站 400);漏掉回环会让容器 healthcheck 判 400 → 永远 unhealthy | +| `HOST=0.0.0.0` | `.env.example` 里的 `HOST=127.0.0.1` 会让容器只监听回环,Caddy 连不上 | +| 新增 Caddy 服务(`deploy/caddy/`) | 自动 HTTPS + HTTP→HTTPS 308;`flush_interval -1` 保流式逐帧;`X-Forwarded-For` 覆盖式写入,客户端自带伪造无效;反代不设超时上限(耗时口径交给应用自己的 `STREAM_*` / `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`) | +| `TLS_MODE` 选证书来源 | `acme`(默认,域名)自动申请 Let's Encrypt;`internal`(公网 IP)用 Caddy 自带 CA 自签 | +| `/metrics` 返 404 | 该端点默认无鉴权(可设 `METRICS_TOKEN` 收紧),公网入口直接摘掉;需要时在宿主 `curl 127.0.0.1:${PORT}/metrics` | + +### 只有公网 IP、没有域名 + +Caddy 的自动申请(ACME)只认域名,不给裸 IP 签发。两条路自选: + +1. **要浏览器直接认的证书** → 用 sslip.io / nip.io 这类公共通配 DNS 名当域名:`203-0-113-10.sslip.io`(或 `203.0.113.10.sslip.io`、`.nip.io` 两种写法)会自动解析到 `203.0.113.10`。`PUBLIC_DOMAIN` 填它、`ACME_EMAIL` 照填,走的就是常规 ACME。代价:证书按「注册域」限流,sslip.io / nip.io 是两个被**所有使用者共享**的域名(2026-02 被打满过一次,各自申请过配额上调)——签不出来就换另一个,或退回自签。 +2. **只给 API 客户端用、不介意证书不被信任** → `PUBLIC_DOMAIN=<公网 IP>` + `TLS_MODE=internal`。Caddy 用自带 CA 自签:链路照样加密,但证书不在公共信任库里——浏览器告警,`curl` 要 `-k`,或把根证书装进客户端信任(根证书在 `caddy_data` 卷里:`docker compose -f docker-compose.yml -f docker-compose.public.yml exec caddy cat /data/pki/authorities/local/root.crt`)。 + +> 自签模式依赖 Caddyfile 里的 `default_sni {$PUBLIC_DOMAIN}`:浏览器与 curl 对 **IP 字面量不发 SNI**(RFC 6066 只允许主机名),没有这行 Caddy 选不出证书,客户端只看到 `tlsv1 alert internal error`。已默认写入,不用手改。 + +注意: + +- **证书落具名卷 `caddy_data`**:别 `docker compose ... down -v` —— 会重新签发,撞 Let's Encrypt 每域名每周 5 次的下发限额。 +- **改 `src/` 后必须重建应用容器**:`... up -d --build`;只重启 Caddy 无效(重启后端本身见上文「升级后必须重启后端」)。 +- 家宽 CGNAT 无入站地址走不了这条 → 用下一节的 frp 隧道方案。 ## 公网入口(VPS + frp 反向隧道) -家宽在 CGNAT 后没有可入站地址,公网入口只能由内网侧**主动连出**建隧道。 -Cloudflare Tunnel 免费版会把大陆流量绕到洛杉矶边缘(实测端到端 1.1–2.4s、丢包约 60%), -换成「境外 VPS 做入口 + frp 隧道回源」后,端到端降到 80–150ms。**只改入站链路**: -出站到上游仍走本机 `PROVIDER_PROXIES`,不受影响。 +家宽 CGNAT 后无入站地址 → 公网入口只能内网侧主动连出。Cloudflare Tunnel 免费版把大陆流量绕洛杉矶(实测 1.1–2.4s、丢包约 60%);「境外 VPS 入口 + frp 隧道回源」降到 80–150ms。只改入站;出站仍走 `PROVIDER_PROXIES`。 ``` 客户端 ──443/TLS──> VPS(nginx) ──frp 隧道──> 家中 Mac:8000 ``` -产物在 `deploy/frp/`(frps/frpc 配置)、`deploy/nginx/`(反代 + TLS)、 -`deploy/systemd/frps.service`(VPS 侧)、`deploy/launchd/com.coding2api.frpc.plist` -(Mac 侧)。配置格式为 frp v0.71.1 的 TOML。 +产物:`deploy/frp/`、`deploy/nginx/`、`deploy/systemd/frps.service`、`deploy/launchd/com.coding2api.frpc.plist`(frp v0.71.1 TOML)。 -### VPS 侧(Linux + systemd) +VPS 侧: ```bash sudo apt install -y nginx certbot python3-certbot-nginx -# 从 https://github.com/fatedier/frp/releases 取 linux_amd64 包,解出 frps -sudo install -m 755 frps /usr/local/bin/frps - +sudo install -m 755 frps /usr/local/bin/frps # 从 frp release 解出 sudo useradd --system --no-create-home --shell /usr/sbin/nologin frps -sudo install -d -m 755 /etc/frp -sudo install -m 644 deploy/frp/frps.toml /etc/frp/frps.toml -# 两侧共用的 token(客户端安装时用同一个值) +sudo install -d -m 755 /etc/frp && sudo install -m 644 deploy/frp/frps.toml /etc/frp/ openssl rand -hex 32 | sudo tee /etc/frp/frps-token >/dev/null sudo chmod 600 /etc/frp/frps-token && sudo chown frps:frps /etc/frp/frps-token - sudo install -m 644 deploy/systemd/frps.service /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now frps - -# nginx:把 __TUNNEL_DOMAIN__ 换成实际域名后安装 sed 's/__TUNNEL_DOMAIN__/ai-api.example.com/g' deploy/nginx/coding2api.conf \ | sudo tee /etc/nginx/sites-available/coding2api.conf >/dev/null sudo ln -sf /etc/nginx/sites-available/coding2api.conf /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx -sudo certbot --nginx -d ai-api.example.com # 签发证书并自动配置续期 +sudo certbot --nginx -d ai-api.example.com ``` -DNS 侧把域名 A 记录指向 VPS 公网 IP;境外 VPS 无需 ICP 备案。 +DNS 把域名 A 记录指向 VPS 公网 IP;境外 VPS 无需 ICP 备案。 -### 家中 Mac 侧(launchd) +家中 Mac 侧: ```bash -brew install frp # 或自行下载并设置 FRPC_BIN +brew install frp ./scripts/install-frpc.sh --server --token <与 VPS 一致的 token> launchctl print gui/$(id -u)/com.coding2api.frpc | grep -E 'state|pid' -./scripts/install-frpc.sh --uninstall # 卸载 +./scripts/install-frpc.sh --uninstall ``` -`deploy/frp/frpc.toml` 用 frp 原生的 `{{ .Envs.FRP_SERVER_ADDR }}` 模板取值, -仓库里不含任何机器特定值;地址与 token 文件路径由 plist 注入,token 落在 -`secrets/frpc-token`(600,已在 `.gitignore` 中),不进 plist——`launchctl print` -会把 plist 原样打印出来。 +`frpc.toml` 用 `{{ .Envs.FRP_SERVER_ADDR }}` 模板取值;地址与 token 由 plist 注入,token 落 `secrets/frpc-token`(600,已 gitignore)。 -### `.env` 要同步的三处 +`.env` 同步三处: | 变量 | 值 | 漏了会怎样 | |---|---|---| -| `ALLOWED_HOSTS` | 追加隧道域名 | 隧道侧所有请求 400 `invalid host header` | -| `PUBLIC_BASE_URL` | `https://<隧道域名>` | TRAE 登录回调基址错、https 下不下发 HSTS | -| `HOST` | 保持 `127.0.0.1` | 隧道回源到本机回环即可,**不必**绑 `0.0.0.0` | +| `ALLOWED_HOSTS` | 追加隧道域名 | 隧道侧全部 400 `invalid host header` | +| `PUBLIC_BASE_URL` | `https://<隧道域名>` | TRAE 登录回调基址错、不下发 HSTS | +| `HOST` | 保持 `127.0.0.1` | 隧道回源到本机回环即可 | -`TRUST_PROXY`:VPS 上恰好一层 nginx,可置 `true`,让 API Key 的 `allowed_ips` -看到真实客户端 IP(nginx 已转发 `X-Forwarded-For`)。若仍需保留局域网直连入口, -保持 `false`——否则同网段可伪造 XFF 绕过白名单。 - -> 改完 `.env` 必须重启服务才算生效(见上「部署注意」)。 -> frpc 与 frps 的日志都走 stdout → Mac 侧 `logs/frpc.*.log`(newsyslog 轮转, -> `deploy/newsyslog/coding2api.conf` 已含两条规则)/ VPS 侧 journald。 +`TRUST_PROXY`:VPS 上恰好一层 nginx 可置 `true` → Key 的 `allowed_ips` 看到真实 IP。仍需局域网直连入口则保持 `false`。 ## 日志 -应用只写 **stdout / stderr**,不写文件、不自行轮转:部署形态与采集方式各异,都靠这两个 fd 对接,轮转交给平台工具。因此**日志不会自行停止增长**——按下面对应形态配一次即可。 +应用只写 **stdout / stderr**,不写文件、不轮转。 -| 部署形态 | 日志到哪 | 轮转机制 | 需做什么 | +| 部署形态 | 日志到哪 | 轮转 | 需做什么 | |---|---|---|---| -| **Docker / compose** | `docker logs coding2api` | json-file 驱动(compose 配好 `10m × 5`) | **无需操作** | -| **Linux(systemd)** | `journalctl -u coding2api` | journald 自带 | **无需操作**(模板见 `deploy/systemd/`) | -| **Linux(非 systemd)** | 重定向到 `/var/log/coding2api/*.log` | `logrotate` | `sudo cp deploy/logrotate/coding2api /etc/logrotate.d/` | -| **裸跑** | 终端 stderr | 无 | 自己重定向并自备轮转 | +| Docker / compose | `docker logs coding2api` | json-file(10m × 5) | 无需操作 | +| Linux(systemd) | `journalctl -u coding2api` | journald 自带 | 无需操作 | +| Linux(非 systemd) | 重定向到文件 | `logrotate` | `sudo cp deploy/logrotate/coding2api /etc/logrotate.d/` | +| macOS(launchd) | `logs/launchd.{out,err}.log` | `scripts/rotate-logs.sh` | `./scripts/install-logrotate.sh` | +| 裸跑 | 终端 stderr | 无 | 自行重定向并轮转 | + +> **macOS 不要用 newsyslog**:它只做 rename 式轮转,而 launchd 给进程的 fd 仍指向旧 inode —— 轮转后进程继续往已归档的文件里写,新路径的文件直到下次重启才出现,中间日志全丢(本机实测丢过 21.7h)。`rotate-logs.sh` 走「拷贝 → 压缩 → 截断」,与 Linux 侧 `copytruncate` 同语义。 -级别用 `LOG_LEVEL`(默认 `INFO`)。**审计日志是 INFO 级**,调到 `WARNING` 会把它一并关掉。 +级别用 `LOG_LEVEL`(默认 `INFO`)。**审计日志 = INFO 级**,调到 `WARNING` 一并关掉。 ### 请求级结构化日志 -除 uvicorn 自带的裸 access log 外,每个业务请求(`/api/*`、`/v1/*`、`/authorize`)额外写一行 logfmt: +每个业务请求(`/api/*`、`/v1/*`、`/authorize`)额外一行 logfmt: ``` event=request request_id=9f2c… method=POST route=/v1/chat/completions status=200 duration_ms=1234 ``` -- **`route` 是路由模板**(`/api/credentials/{credential_id}`),不是实际路径——资源 ID 不进日志,日志也不会按 ID 无限分叉;未匹配到路由(404、被安全中间件拒掉)时回落为清洗过的原始路径(白名单字符 + 120 字符截断)。 -- **不写 query string、请求体、响应体、请求头、用户名**。query 里可能被客户端塞 token;身份靠 `request_id` 去 `usage_events.request_id` 关联(那里已有 `username` 列),日志系统保留更久、权限更宽,能少放就少放。 -- `request_id` 与响应头 `X-Request-ID` 同值,可双向对照。 -- **级别**:`5xx` 记 WARNING(服务端自己的问题,值得抓),`4xx` 与成功记 INFO(否则 401 探测、拼错路径会把告警日志淹没)。 -- **不记**静态资源(`assets/*.js`)与探针(`/health`、`/healthz`、`/metrics`):探针已有 `/healthz` 计数与 metrics 覆盖,静态资源量级大且无排障价值,记下来只会把业务行挤出视野。 -- **uvicorn 自带 access log 已关闭**(`run()` 传 `access_log=False`):它没有时间戳、没有 request_id、带完整 query string,与上面这层完全重复,只是把同样的请求再写满另一个轮转文件。排查探针/静态资源请用 `/healthz` 计数与 metrics。 -- 开关 `REQUEST_LOG_ENABLED`(默认 `true`,可热更),关掉后不再有请求级日志。 +- `route` = 路由模板(`/api/credentials/{credential_id}`),资源 ID 不进日志。未匹配路由 → 回落清洗过的原始路径。 +- 不写 query string、请求体、响应体、请求头、用户名。身份靠 `request_id` 关联 `usage_events`。 +- `request_id` 与响应头 `X-Request-ID` 同值。 +- 级别:`5xx` → WARNING,其余 INFO。 +- 不记静态资源与探针(`/health`、`/healthz`、`/metrics`)。 +- uvicorn access log 已关。 +- 开关 `REQUEST_LOG_ENABLED`(默认 `true`,可热更)。 -> 容器里的 `data/dumps/`(诊断开关 `DUMP_REQUEST_BODIES=true` 写入)不在 docker 日志体系内,由应用自行保持最多 200 份。 +> 容器里 `data/dumps/`(`DUMP_REQUEST_BODIES=true` 写入)不在 docker 日志体系;应用自行保持最多 200 份**且不超过 24 小时**(按 mtime),目录 0700、文件 0600——这是唯一把对话写进磁盘的开关,排查完请立刻关闭。 ## 开发 @@ -911,16 +1034,25 @@ pnpm exec vitest run pnpm build ``` +参与贡献(提 issue / PR 门槛 / schema 约束)见 [`CONTRIBUTING.md`](CONTRIBUTING.md)。 + ## 文档 | 文档 | 内容 | |---|---| -| [`PROPOSAL.md`](PROPOSAL.md) | 目标与非目标、上游可行性核实、架构、数据模型、安全边界 | +| [`PROPOSAL.md`](PROPOSAL.md) | 目标与非目标、上游可行性、架构、数据模型、安全边界 | | [`TECHNICAL.md`](TECHNICAL.md) | 技术栈、模块规格、Provider 协议、请求时序、测试策略 | -| [`diagrams/coding2api-architecture.html`](diagrams/coding2api-architecture.html) | 系统架构图(浏览器打开) | -| [`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html) | 请求主链路时序图(浏览器打开) | -| [`diagrams/coding2api-credential-lifecycle.html`](diagrams/coding2api-credential-lifecycle.html) | 凭证调度状态机(浏览器打开) | +| [`diagrams/coding2api-architecture.html`](diagrams/coding2api-architecture.html) | 系统架构图 | +| [`diagrams/coding2api-request-flow.html`](diagrams/coding2api-request-flow.html) | 请求主链路(/v1/responses、/v1/messages 共用主链) | +| [`diagrams/coding2api-account-flow.html`](diagrams/coding2api-account-flow.html) | 账号与 API Key 生命周期 | +| [`diagrams/coding2api-data-model.html`](diagrams/coding2api-data-model.html) | 数据模型(图示 13 张表 + 归属关系;`checkin_events` 尚未画入,以 `src/db/schema.sql` 的 14 张为准) | +| [`diagrams/coding2api-permission-matrix.html`](diagrams/coding2api-permission-matrix.html) | 三角色权限矩阵 | +| [`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html) | 请求主链路时序图 | +| [`diagrams/coding2api-credential-lifecycle.html`](diagrams/coding2api-credential-lifecycle.html) | 凭证调度状态机 | +| [`CONTRIBUTING.md`](CONTRIBUTING.md) | 提 issue / PR 的流程与门槛 | +| [`SECURITY.md`](SECURITY.md) | 漏洞报告渠道与部署方信任边界 | +| [`CITATION.cff`](CITATION.cff) | 引用信息(GitHub「Cite this repository」) | ## 授权协议 -MIT,见 [LICENSE](LICENSE)。借鉴的上游项目署名见 [NOTICE](NOTICE)。 +MIT,见 [LICENSE](LICENSE)。上游署名见 [NOTICE](NOTICE)。 diff --git a/SECURITY.md b/SECURITY.md index 06dd1a24..2be5c015 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,34 +2,29 @@ ## 报告漏洞 -请**不要**为安全问题开公开 Issue。用 GitHub 的 +安全问题**不要**开公开 Issue。走 GitHub [私密漏洞报告](https://github.com/RobbsLuo/Coding2API/security/advisories/new) -提交,或直接邮件联系维护者(见仓库提交历史 / LICENSE 中的作者)。 +,或邮件联系维护者(见仓库提交历史 / LICENSE 作者)。 -请附上: +请附: -- 受影响的版本或 commit(`git rev-parse HEAD`) +- 受影响版本或 commit(`git rev-parse HEAD`) - 复现步骤与最小 PoC -- 影响面(能否未授权访问、能否读写凭证、能否打内网) -- 你希望公开的时间点(如有) +- 影响面(能否未授权访问、读写凭证、打内网) +- 希望公开的时间点(如有) ## 响应 -单人维护项目,尽力而为:3 个工作日内确认收到,能修的尽快修,修复随常规 -release 发布并在 commit message 与 GitHub Release 说明里注明。 +单人维护,尽力而为:3 个工作日内确认收到,能修尽快修;修复随常规 release 发布,commit message 与 GitHub Release 说明注明。 ## 部署方须知(本项目的信任边界) -这个服务把上游账号凭证加密存在本地,并暴露管理台。部署时请确保: - -- `APP_SECRET` 是随机且保密的:它既是会话签名密钥,也是凭证加密密钥的派生源 - (见 `src/db/crypto.py`)。泄露等同于凭证泄露。 -- 管理台不要裸暴露到公网。若必须暴露,前面套一层带鉴权/限流的反向代理, - 并确保 `PUBLIC_BASE_URL` 与 `ALLOWED_HOSTS` 配置正确(见 README「配置」)。 -- `data/`(SQLite,含凭证密文与审计)、`secrets/users.txt`(口令哈希)不得进入 - 备份之外的任何地方;服务启动会把 `data/` 目录权限收到 `0700`。 -- 告警 webhook 地址由 admin 配置:写入期与**投递前**都会拒绝指向内网/回环/ - 链路本地(云元数据)的地址,解析失败按阻断处理。 -- 安全扫描在 CI 中执行:bandit(中危+)、pip-audit、`pnpm audit --prod`。 +服务把上游账号凭证加密存本地,并暴露管理台。部署须确保: + +- `APP_SECRET` 随机且保密:会话签名密钥 + 凭证加密密钥派生源(见 `src/db/crypto.py`)。泄露等同凭证泄露。 +- 管理台不要裸暴露公网。必须暴露时前置带鉴权/限流的反向代理,并确保 `PUBLIC_BASE_URL` 与 `ALLOWED_HOSTS` 正确(见 README「配置」)。 +- `data/`(SQLite,含凭证密文与审计)、`secrets/users.txt`(口令哈希)不得进备份之外任何地方;服务启动把 `data/` 权限收到 `0700`。 +- 告警 webhook 地址由 admin 配置:写入期与**投递前**都拒绝内网/回环/链路本地(云元数据)地址,解析失败按阻断处理。 +- 安全扫描在 CI 执行:bandit(中危+)、pip-audit、`pnpm audit --prod`。 已知与已评估的非问题(扫描器误报口径)见 `PROPOSAL.md` 的安全审查章节。 diff --git a/TECHNICAL.md b/TECHNICAL.md index 460d7847..aef53b05 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -1,54 +1,57 @@ # Coding2API 技术方案 -PROPOSAL.md 定方向,本文档定实现。描述**当前代码的现状**,不含历史沿革。 -## 1. 技术栈定稿 +PROPOSAL.md 定方向,本文定实现。只写**当前代码现状**。 + +## 1. 技术栈 | 层 | 选型 | 版本 | 说明 | |---|---|---|---| -| 运行时 | Python | 3.12(`requires-python >=3.12`;CI 3.12,Dockerfile 运行时基础镜像 3.14-slim) | | -| 包管理 | uv(venv + pyproject.toml + uv.lock) | 锁定版本见 `uv.lock` | | -| Web | FastAPI + Uvicorn | 0.143.3 / 0.54.0 | | -| 配置 | pydantic-settings | 2.16.0 | `Settings` 为 frozen | -| HTTP | httpx 双客户端(流式/短请求分离);`httpx[socks]` 提供按渠道 SOCKS5 代理 | 0.28.1 / socksio | 共享 `trust_env=False` | +| 运行时 | Python | 3.12(`requires-python >=3.12`;`.python-version` 3.12,Dockerfile 3.14-slim) | | +| 包管理 | uv(venv + pyproject.toml + uv.lock) | 见 `uv.lock` | | +| Web | FastAPI + Uvicorn | 0.162.0 / 0.54.0 | | +| 配置 | pydantic-settings | 2.16.1 | `Settings` 为 frozen | +| HTTP | httpx 双客户端(流式/短请求分离);`httpx[socks]` 提供按渠道 SOCKS5 | 0.28.1 / socksio | 共享 `trust_env=False` | | 数据库 | 标准库 sqlite3(WAL)+ 手写 SQL | 内置 | | -| 加密 | cryptography Fernet(凭证列) | 50.0.2 | | -| 测试 | pytest + pytest-cov + respx + 文件化 fixture | pytest 9.1.1 / pytest-cov 7.1.0 / respx 0.23.1 | | -| 前端 | React 19 + Tailwind 4 + shadcn/ui + @lobehub/icons + Vite 8 + TypeScript 7 | 实例版本见 `web/package.json` | | +| 加密 | cryptography Fernet | 50.0.2 | | +| 测试 | pytest + pytest-cov + respx + 文件化 fixture | 9.1.1 / 7.1.0 / 0.23.1 | | +| 前端 | React 19 + Tailwind 4 + shadcn/ui + @lobehub/icons + Vite 8 + TypeScript 7 | 见 `web/package.json` | | -> 上表是选型定稿;小版本随依赖漂移,实际以 `pyproject.toml` / `uv.lock` / `web/package.json` 为准;仅主版本或兼容边界(`requires-python`、Node 主版本)变化时回改本表。 +> 小版本随依赖漂移,以 `pyproject.toml` / `uv.lock` / `web/package.json` 为准;仅主版本或兼容边界变化时回改本表。 -## 2. 目录结构与模块规格 -按目录分组,行内列出该目录全部文件;描述按「/」与文件顺序对应。 +## 2. 目录结构与模块 +行内列出该目录全部文件;职责按「/」与文件顺序对应。 | 路径 | 文件 | 职责 | |---|---|---| | `pyproject.toml` | — | uv 项目;`[tool.pytest.ini_options]` 设 coverage 目标 | -| 仓库根 | `Dockerfile`、`docker-compose.yml` | compose build context 依赖根目录 | -| `src/` | `main.py` `config.py` `runtime_settings.py` `version.py` | FastAPI 组装·lifespan·路由挂载(只做接线)/pydantic-settings 全部 env + `live()` 归一化标量·取值器/运行时配置覆盖层(DB 覆盖 > env + 白名单校验 + snapshot)/版本号(`pyproject.toml` 唯一真源,读不到回落常量) | -| `src/webapp/` | `limits.py` `security.py` `request_id.py` `access_log.py` `log_buffer.py` `handlers.py` `logging.py` `static.py` | 请求体上限 ASGI 中间件(登录 8KB / 其余 16MB)/Host 白名单 + 安全响应头(CSP·nosniff)/请求 ID ASGI 中间件(最外层,任意响应含 413/400 都带 `X-Request-ID`,并设置会话标识 contextvar)/请求级 logfmt 日志/日志环形缓冲 + 脱敏白名单 `redact_or_drop`(整行丢弃)+ root handler 挂/摘/异常 → HTTP 响应(稳定机器可读错误码)/root logger 配置/前端产物定位 + SPA catch-all(排除 `/api`、`/v1`) | -| `src/db/` | `schema.sql` `conn.py` `migrate.py` `repo.py` `limits.py` `crypto.py` `backup.py` | DDL 定稿(只加不改)/线程本地连接 + WAL + busy_timeout + `transaction()`/启动执行 `schema.sql` + `_MIGRATION_COLUMNS`·`_MIGRATION_DROPS`·`_MIGRATION_INDEXES`(**补列之后**才建索引,否则老库 `no such column`)/凭证·API Key·用户·审计·告警·积分流水持久化(手写 SQL)/凭证限额护栏配置 CRUD + 当日用量聚合/Fernet 加解密/备份·恢复核心(`VACUUM INTO` 一致性快照 + `integrity_check` + 0600 + 保留 N 份,任务与 `scripts/` 共用) | -| `src/auth/` | `users.py` `bootstrap.py` `session.py` `api_key.py` `csrf.py` `throttle.py` `rbac.py` `access.py` | DbUserStore + UsersFileStore(users.txt 导入)/启动引导(导入 → `ADMIN_USERNAMES` 提权 → 无活跃 admin 报错)/会话 Cookie 签发校验(手写 HMAC,payload 带 `session_epoch`)/`sk-` 生成 + SHA-256 摘要 + 常量时间校验/写操作 CSRF/登录限流(三级窗口 + PBKDF2 并发上限)/三角色 `admin`·`operator`·`viewer`/来源 IP 与模型白名单(纯函数) | -| `src/provider/` | `base.py` `proxy.py` `naming.py` `token_expiry.py` `openai_chunk.py` `fixtures/` | Provider 协议·Event·ErrKind·Quota·HealthScore·异常族/按渠道出站代理(解析 `PROVIDER_PROXIES` + `build_client`)/模型名归一(八渠道三字段)/到期提取(`expires_at` → JWT `exp`)/标准 OpenAI chunk 共享原语(choices 校验 + 空 tool_call 判定)/真实样本(`codebuddy/*.sse`、`trae/*.sse`) | -| `src/provider/codebuddy/` | `client.py` `intl.py` `events.py` `credential.py` `headers.py` `oauth.py` `checkin.py` `activity.py` `growth.py` `growth_runner.py` `refresh.py` | 上游 HTTP + SSE 流 + 额度探测 + 内容风控指纹清洗/国际版子类(仅覆渠道 id 与能力 None)/OpenAI 风格 SSE → Event + 状态与业务码分类/凭证类型与解析/上游技术常量与请求头/设备码轮询/签到 + 连续天数/活跃上报协议层/成长中心协议层(15 端点)/成长中心编排(7 类领取的顺序与失败判定)/token 刷新 + 多账号切换 | -| `src/provider/trae/` | `client.py` `events.py` `credential.py` `pricing.py` `backfill.py` `callback.py` | SOLO 上游 + 额度探测/自定义 SSE → Event/凭证解析(嵌套与扁平)+ 原子写回 + 签到设备号/单请求积分推算(上游 `token_usage` 不给积分)/历史明细推算积分回填与重算/登录 URL 构造 + 回调解析 | -| `src/provider/zen/` | `client.py` `events.py` | 上游 + 免费层门禁伪装 + 注入工具回包过滤 + `ZenProvider`/标准 OpenAI SSE → Event(无私有信封) | -| `src/provider/kilo/` | `client.py` `events.py` | 上游(标准 OpenAI,无门禁)+ `isFree` 过滤(不探活)/标准 OpenAI SSE → Event(`delta.reasoning` 思考通道) | -| `src/provider/qoder/` | `cosy.py` `client.py` `events.py` `credential.py` `auth.py` | COSY(自定义 Base64、AES/RSA、签名与会话头缓存)/上游 + 信封 SSE 解包 + 额度·签到/信封 SSE → 内层 chunk → Event + 状态与业务码分类/凭证解析(realm·组织·到期)/设备码 PKCE 登录(start/poll) | -| `src/provider/codearts/` | `signer.py` `dpop.py` `client.py` `events.py` `credential.py` `auth.py` `oauth.py` | 华为云 SDK-HMAC-SHA256(纯函数,便于固定向量测试)/ES256 P-256 DPoP JWS(低 S 归一化)/上游 + 私有 SSE 还原 + 额度(积分)探测 + 模型目录/SSE → 增量 Event(OpenAI chunk 与累计全文双形状)/凭证解析(AK/SK/STS/DPoP/refresh)/OAuth2 PKCE 登录/回调链接粘贴换 token 适配 | -| `src/provider/officeace/` | `signer.py` `client.py` `events.py` `credential.py` `oauth.py` | 华为云双签名(V11 派生密钥 + SDK-HMAC,纯函数便于固定向量测试)/上游 + 标准 OpenAI SSE + 积分·签到·费率(V11 签名)/错误分类(伪 200 · 81004/81009 模型级 · 81111/81114 模型级限流)+ 订阅积分解析 + `max_tokens` 预算改写/两套凭据(不过期 Basic app key / 约 2h 临时 AK-SK)与一次性 refresh 轮转/云端授权码轮询登录(PKCE + DPoP + 开通校验) | -| `src/engine/` | `scheduler.py` `executor.py` `limits.py` `inflight.py` `affinity.py` `continuation.py` `compress.py` `model_resolver.py` `sse.py` | 选号 + 冷却状态机 + pin(纯逻辑,操作 Candidate)/执行 + 模型目录收窄 + 轮换重试 + fallback 链 + 统计埋点/凭证限额护栏判定 + 选号前快照缓存/凭证在途租约(进程内计数 + TTL 兜底 + 选号前过滤)/会话粘性/截断续写/上下文压缩裁剪(纯函数)/`"glm-5.2"`·`"glm-5.2@trae"`·`auto` → 候选集 + fallback 组解析/SSE 帧解析 + 行长上限 | -| `src/compat/` | `openai/{request,response,errors}.py` `responses/{request,response}.py` `anthropic/{request,response}.py` | ChatRequest 校验·chunk 聚合·OpenAI error shape/Responses 出口(Codex CLI 子集)/Anthropic Messages 出口(Claude Code 子集) | -| `src/tasks/` | `pacer.py` `quota_probe.py` `checkin.py` `growth.py` `activity.py` `refresh.py` `retention.py` `backup.py` `alerting.py` `upstream_status.py` `model_probe.py` `status.py` `runner.py` | 全局节流器(随机区间 + 按桶并发上限 + 滑动窗口)/启动立即一轮 + 每 `QUOTA_PROBE_MINUTES`/签到:`CHECKIN_TIMES` 留空时全天每 10 分钟一轮(旧行为),配了时点则按「时点 − 抖动」窗口判定、轮询周期自动收到 60 秒,成功即封账(支持渠道回填 `seal_until`)/成长中心(仅 CB)落 `growth_events`/活跃上报(CB 与 CB 国际版,默认关闭)/每 `REFRESH_INTERVAL_MINUTES` 预刷新/每 5 分钟小时汇总重算 + 90 天前明细清理/每 `BACKUP_INTERVAL_MINUTES` 备份主库(`VACUUM INTO`,落线程池)/运维告警(四类规则 + webhook + 落库)/上游状态页聚合(并发抓各源 → 原子替换内存快照,单源失败只记 `error`、整轮永不抛)/模型主动探活(只探 blocked 负缓存,三态判定,成功即清负缓存)/任务清单 `TASK_SPECS` + 进程内运行态/后台任务调度,接入应用生命周期 | -| `src/stats/` | `backfill.py` `collector.py` `live.py` `query.py` `csv.py` | 历史成本一次性回填与重算(`scripts/backfill_cost.py` 复用)/`usage_events` 写入(脱敏)+ 小时汇总双写·重算 + 事务提交后 `on_record` 广播/进程内实时广播中心(有界缓冲、订阅上限)/`overview`·`by-*`·`timeline`·`model-timeline`·`events`(含钻取过滤与导出取数)/CSV 导出纯函数(列定义 + 公式注入防护 + 行编码) | -| `src/` 顶层 | `sorting.py` `benchmarks.py` `pricing.py` `model_match.py` `request_context.py` `upstream_status.py` | 列表排序 `sort`/`order` 白名单解析(db 与 api 共用)/OpenRouter 单一源:刊例价 + 明细元数据 + 能力指数 + 落盘快照/单请求成本估算(USD→CNY)/本地模型名 ↔ OpenRouter 条目匹配(仅 benchmarks·pricing 用)/请求 ID 的 contextvar + 白名单校验 + 自生成(中间件设置、统计写入现读)/上游状态页聚合(`SOURCE_SPECS` + 四种解析器 + 并发抓取 + 内存快照 store) | -| `src/api/` | `deps.py` `endpoints.py` `chat.py` `responses.py` `messages.py` `context.py` `models.py` `model_merge.py` `model_catalog.py` `balance.py` `authorize.py` `admin_credentials.py` `admin_keys.py` `admin_settings.py` `admin_users.py` `admin_audit.py` `admin_alerts.py` `admin_badges.py` `admin_upstream.py` `admin_model_catalog.py` `admin_model_availability.py` `logs.py` `activate.py` `admin_stats.py` `admin_auth.py` `playground.py` `streaming.py` `metrics.py` | Services 容器 + 鉴权·会话·CSRF 依赖/三个 /v1 出口共用的 `dispatch_compat` 骨架/`POST /v1/chat/completions`·`/v1/responses`·`/v1/messages`/请求入口的上下文压缩接线/`GET /v1/models`(按凭证加载 + SWR + 缓存·TTL + 落盘 + 黑名单 + 白名单)/模型归一·合并·命名·对外 id 消歧·别名表(纯函数)/模型目录落盘快照 + 别名预热/`GET /v1/user/balance`/`GET /authorize`(TRAE 回调落点)/凭证 CRUD·toggle·pin·probe·checkin·成长·账号切换/API Key CRUD(渠道绑定 + 双白名单 + 到期)/`GET`/`PUT /api/settings` + `GET /api/tasks` + `POST /api/tasks/{key}/run`/用户管理/审计查询/告警回看 + 人工标记恢复(`GET /api/alerts`、`POST /api/alerts/{id}/resolve`)/侧栏角标聚合(`GET /api/nav-badges`,按角色裁剪 payload)/上游状态聚合只读回看(`GET /api/upstream-status`)/模型目录只读查看 + 模型级失败聚合 + 探活结论·立即探活·人工接管/一次性令牌激活流/统计查询/登录·登出·会话·自助改密 + 上游登录 start·poll·complete·cancel/会话调试端点(无需 API Key)/SSE 流包装(长空隙插心跳帧)/`GET /metrics`(Prometheus text 格式) | -| `src/audit/actions.py` | — | 审计动作常量 + 中文标签(含 `alert.resolve`) | -| `web/` | — | React 前端。**`.npmrc` 的 `auto-install-peers=false` 与 `pnpm-lock.yaml` 的 `settings.autoInstallPeers` 强耦合**:关掉是为了不装 `@lobehub/icons` 声明的 `@lobehub/ui`/`antd` peer 子树(400+ 包,本项目只深引用静态图标组件,不引用 `@lobehub/ui`);代价是真实需要的 peer 必须显式进 `devDependencies`(如 `@testing-library/dom`)。`Dockerfile` 必须 `COPY web/.npmrc`,否则 `--frozen-lockfile` 报 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` | -| `deploy/` | — | 各部署形态模板(launchd / newsyslog / logrotate / systemd) | -| `diagrams/` | — | 架构图 / 请求时序图 / 调度状态机(HTML 成品 + 源 JSON) | -| `scripts/` | — | hash_password / create_user / 回填与运维脚本 | -| `tests/` | — | 按 `src/` 子包镜像 | - -## 3. 核心类型定义 +| 仓库根 | `Dockerfile`、`docker-compose.yml`、`docker-compose.public.yml` | compose build context 依赖根目录/公网部署覆盖文件(叠 Caddy 入口) | +| `src/` | `main.py` `config.py` `runtime_settings.py` `version.py`(其余顶层模块见下「`src/` 顶层」) | 组装·lifespan·路由挂载/pydantic-settings 全部 env + `live()` 归一化/运行时配置覆盖层(DB > env + 白名单 + snapshot)/版本号(`pyproject.toml` 真源) | +| `src/webapp/` | `limits.py` `security.py` `request_id.py` `access_log.py` `log_buffer.py` `handlers.py` `logging.py` `static.py` | 请求体上限 ASGI(登录 8KB / 备份恢复 512MB / 其余 16MB)/Host 白名单 + CSP·nosniff/请求 ID(最外层,含 413/400)+ 会话 contextvar/请求级 logfmt/日志环形缓冲 + `redact_or_drop`/异常 → 稳定错误码/root logger/SPA catch-all(排除 `/api`、`/v1`) | +| `src/db/` | `schema.sql` `conn.py` `migrate.py` `repo.py` `limits.py` `key_usage.py` `paid_models.py` `crypto.py` `backup.py` | DDL(只加不改)/线程本地连接 + WAL + busy_timeout + `transaction()`/迁移(**补列之后**建索引)/持久化手写 SQL/限额 CRUD + 当日聚合/按 `api_key_id` 聚合明细(Key 配额与 `/v1/usage` 唯一数据源)/x0 实测扣费校准(§6.1)/Fernet/备份恢复(`VACUUM INTO` + integrity_check + 0600,§8) | +| `src/auth/` | `users.py` `bootstrap.py` `session.py` `api_key.py` `csrf.py` `throttle.py` `rbac.py` `access.py` | DbUserStore + users.txt 导入/引导(导入 → `ADMIN_USERNAMES` 提权 → 无活跃 admin 报错)/会话 HMAC(含 `session_epoch`)/`sk-` 摘要 + 常量时间校验/CSRF/登录三级限流 + PBKDF2 并发上限/三角色/IP 与模型白名单(纯函数) | +| `src/provider/` | `base.py` `proxy.py` `naming.py` `token_expiry.py` `openai_chunk.py` `auth_state.py` `credential_fields.py` `lazy_clients.py` `login_identity.py` `tos.py` `fixtures/` | Provider 协议·Event·ErrKind·Quota·HealthScore/渠道代理(`PROVIDER_PROXIES`)+ 出口粘性/模型名归一/到期提取(`expires_at` → JWT `exp`)/OpenAI chunk 原语/OAuth pending 登记基类(owner·consume·cancel·cleanup 一份)/华为云凭证字段解析/懒建连接池(按出口分池)/重登合并键(渠道 → 稳定上游身份)/ToS 评级(仅展示)/真实样本 | +| `src/provider/codebuddy/` | `client.py` `intl.py` `events.py` `credential.py` `headers.py` `oauth.py` `checkin.py` `activity.py` `growth.py` `growth_runner.py` `model_access_store.py` `refresh.py` | 上游 + SSE + 额度探测 + 风控指纹清洗/国际版子类(覆渠道 id 与能力 None)/SSE → Event + 分类/凭证解析/常量与头/设备码/签到/活跃上报/成长中心(15 端点)/成长编排(7 类领取)/探活结果落盘(TTL 6h)/刷新 + 多账号切换 | +| `src/provider/trae/` | `client.py` `events.py` `credential.py` `pricing.py` `backfill.py` `callback.py` | 上游 + 额度/自定义 SSE → Event/凭证解析 + 原子写回 + 签到设备号/单请求积分推算/历史回填/登录 URL + 回调解析 | +| `src/provider/zen/` | `client.py` `events.py` | 上游 + 门禁伪装 + 注入工具回包过滤/标准 SSE → Event | +| `src/provider/kilo/` | `client.py` `events.py` | 上游(标准 OpenAI)+ `isFree` 过滤(不探活)/SSE → Event(`delta.reasoning`) | +| `src/provider/qoder/` | `cosy.py` `client.py` `events.py` `credential.py` `auth.py` | COSY(自定义 Base64、AES/RSA、签名与会话头缓存)/上游 + 信封解包 + 额度·签到(网页 Cookie 时逐包到期)/信封 → chunk → Event/凭证解析/设备码 PKCE | +| `src/provider/codearts/` | `signer.py` `dpop.py` `client.py` `events.py` `credential.py` `auth.py` `oauth.py` | SDK-HMAC-SHA256(纯函数)/ES256 DPoP(低 S)/上游 + 私有 SSE 还原 + 积分额度/SSE → 增量 Event(双形状)/凭证解析/OAuth2 PKCE/回调粘贴换 token | +| `src/provider/officeace/` | `signer.py` `client.py` `events.py` `credential.py` `oauth.py` | 双签名(V11 + SDK-HMAC,纯函数)/上游 + 积分·签到·费率/错误分类 + `max_tokens` 改写/两套凭据 + 一次性 refresh/云端授权轮询(PKCE + DPoP + 开通校验) | +| `src/provider/raccoon/` | `client.py` `events.py` `credential.py` `activity.py` | 标准 OpenAI 上游 + 积分余额·流水·心跳/SSE → Event + 分类(`user_model_rate_limited` → CONCURRENCY)/会话 JWT 归一 + claims 提取 + 稳定设备 id/页面访问 + 流水核对签到 + 四池明细 + 到期阶梯 | +| `src/provider/lingxi/` | `client.py` `events.py` `credential.py` | 上游 + 私有 SSE(`choices[].text`、无 `[DONE]`)+ 智点额度·签到/SSE → Event + 错误分类/wps_sid Cookie 归一 + uid / sid 哈希身份 | +| `src/engine/` | `scheduler.py` `executor.py` `limits.py` `inflight.py` `half_open.py` `affinity.py` `continuation.py` `compress.py` `sticky_trim.py` `tool_output.py` `model_resolver.py` `reasoning.py` `prefix_debug.py` `sse.py` | 选号 + 冷却状态机 + pin/执行 + 目录收窄 + 轮换 + fallback + 统计埋点/限额判定 + 快照缓存/在途租约/冷却半开探测/会话粘性/截断续写/上下文压缩/粘性裁剪/tool 输出压缩/模型名解析/档位映射解析/前缀缓存诊断/SSE 帧解析 | +| `src/compat/` | `openai/{request,response,errors,dsml}.py` `responses/{request,response}.py` `anthropic/{request,response}.py` | Chat 校验·聚合·error shape·DSML 泄漏回读/Responses 出口/Anthropic Messages 出口 | +| `src/tasks/` | `pacer.py` `quota_probe.py` `checkin.py` `growth.py` `activity.py` `refresh.py` `retention.py` `backup.py` `alerting.py` `upstream_status.py` `model_probe.py` `status.py` `runner.py` | 全局节流器/额度探测/签到(`CHECKIN_TIMES` 空则每 10 分钟)/成长(仅 CB)/活跃上报/预刷新/小时汇总 + 90 天清理/备份/告警(九类规则 + webhook)/上游状态聚合/模型探活/`TASK_SPECS` + 运行态/调度接入 lifespan | +| `src/stats/` | `backfill.py` `collector.py` `live.py` `query.py` `csv.py` | 历史回填/明细写入 + 小时双写 + 提交后广播/进程内实时广播/各类统计查询/CSV(公式注入防护) | +| `src/` 顶层 | `sorting.py` `benchmarks.py` `pricing.py` `model_match.py` `model_alias.py` `key_quota.py` `tags.py` `request_context.py` `upstream_status.py` `error_text.py` `instance_lock.py` | `sort`/`order` 白名单/OpenRouter 目录/成本估算(USD→CNY)/模型名匹配/`MODEL_ALIASES` 解析/Key 级配额(六项)/凭证标签纯函数/请求 ID + 凭证 id contextvar/状态页聚合/异常 → 带类名的文本(出口过脱敏)/`DATA_DIR` 单实例 `flock` | +| `src/api/` | `deps.py` `endpoints.py` `chat.py` `responses.py` `messages.py` `context.py` `models.py` `model_merge.py` `model_catalog.py` `balance.py` `authorize.py` `usage.py` `credential_login.py` `admin_credentials.py` `admin_keys.py` `admin_settings.py` `admin_users.py` `admin_audit.py` `admin_alerts.py` `admin_badges.py` `admin_upstream.py` `admin_model_catalog.py` `admin_model_availability.py` `admin_backups.py` `admin_billing.py` `logs.py` `activate.py` `admin_stats.py` `admin_auth.py` `playground.py` `streaming.py` `metrics.py` | Services 容器 + 鉴权依赖/`dispatch_compat` 骨架/三个 /v1 出口/压缩接线/`GET /v1/models`(SWR + 白名单)/归一·合并·别名/目录快照/余额/TRAE 回调/`/v1/usage`/OAuth 落库合并/凭证运维/Key CRUD/配置与任务/用户/审计/告警/角标/上游状态/模型目录 + 探活/备份/月度账单/日志页/激活/统计/登录/Playground/SSE 包装/`/metrics` | +| `src/audit/actions.py` | — | 审计动作常量 + 中文标签 | +| `web/` | — | React 前端。**`.npmrc` 的 `auto-install-peers=false` 与 lock 的 `settings.autoInstallPeers` 强耦合**(避免拉 `@lobehub/ui`/`antd` 子树);`Dockerfile` 必须 `COPY web/.npmrc`,否则 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`。`shadcn` CLI **不是依赖**(组件源码已入库):样式层 `src/styles/shadcn-tailwind.css` 是 `shadcn@4.22.1/dist/tailwind.css` 的逐字节副本,加组件用 `pnpm dlx shadcn@latest add `——把 CLI 留在 devDependencies 会拖进 ts-morph/minimatch/deepmerge 整棵依赖树,其中两条有 Dependabot 告警且上游无修复路径 | +| `deploy/` | — | launchd / logrotate / systemd 模板;`caddy/` 为公网 Docker 部署的入口配置(`Caddyfile` + 按 `TLS_MODE` import 的 `tls_acme` / `tls_internal` 两个 TLS 片段) | +| `diagrams/` | — | 架构图 / 时序图 / 状态机(HTML + 源 JSON) | +| `scripts/` | — | 建号、回填、运维脚本 | +| `tests/` | — | 按 `src/` 子包镜像,外加 `test_key_quota.py` / `test_model_alias.py` / `test_upstream_status.py` 这类顶层模块对应用例 | + +## 3. 核心类型 ### 3.1 中立事件层(`provider/base.py`) ```python class EventKind(StrEnum): @@ -69,6 +72,8 @@ class Event: error_code: int | None = None error_message: str | None = None error_kind: ErrKind | None = None # 流内错误的分类,executor 直接消费 + retry_after_seconds: float | None = None # 上游点名「过多久再来」(仅排队信号带) + model: str | None = None # 上游响应里的模型名,只用于「静默替换」观测 @dataclass(slots=True) class Usage: @@ -80,25 +85,65 @@ class Usage: credit_estimated: bool = False # credit 是否为本服务推算(True 时展示标 ≈) ``` -**事件名过滤**:TRAE 上游会发 `metadata` / `timing_cost` / `extra_info` / `progress_notice` 等无下游语义事件,`events.py` 的 `KNOWN_EVENT_NAMES` 之外的名字直接跳过。「解析失败不静默」针对的是**已知事件的畸形数据**:归一集内 JSON 非对象 / 字段类型不对仍抛 `UpstreamProtocolViolation`,不退回默认值。**tool_call 分片续块必须保留**:TRAE 上游工具调用按 `index` 分片,首片带 `function_call.name`、后续片只有 `arguments` 增量且**没有 name**。归一为 OpenAI 形状时只丢「无 name **且** arguments 为空」的噪声(`_is_blank_solo_tool_call`);带实际 arguments 的续片保留,交下游 `ToolIndexState` 按 index 合并。若按「无 name 即丢弃」过滤,续片全被吃掉 → 客户端拼出截断 JSON → 工具报错 → 原样重试同轮 → 死循环。CodeBuddy/Zen/Kilo 的 `is_blank_tool_call` 同一判据(`provider/openai_chunk.py`)。 -### 3.2 错误分类(决定冷却时长与作用域) +- **事件名过滤**:TRAE `metadata` / `timing_cost` / `extra_info` / `progress_notice` 无语义 → 不在 `KNOWN_EVENT_NAMES` → 跳过。 +- **解析失败不静默**:已知事件 JSON 非对象 / 字段类型错 → `UpstreamProtocolViolation`,不退回默认值。 +- **tool_call 续块须保留**:TRAE 按 `index` 分片;首片带 `function_call.name`,续片只有 `arguments`。只丢「无 name **且** arguments 空」噪声(`_is_blank_solo_tool_call`)。「无 name 即丢」→ 吃光续片 → 截断 JSON → 死循环。`provider/openai_chunk.py` 的 `is_blank_tool_call` 同判据。 + +### 3.2 错误分类(定冷却时长与作用域) ```python class ErrKind(StrEnum): PLAN = "plan" # 权益耗尽(1005)→ 冷却 12h CREDIT = "credit" # 余额不足(402/14018)→ 冷却到次日 04:00(等签到恢复) SOFT = "soft" # 限流/404 → 冷却 60s,不累计错误数 - DEAD = "dead" # session 失效 → 硬禁用 disabled=1 + DEAD = "dead" # session 失效(重登可恢复)→ 硬禁用 disabled=1; + # 额度耗尽的证据成立时 executor 先降级为 CREDIT + # (证据陈旧会先现场复探,见 _credit_if_exhausted) + RESTRICTED = "restricted" # 账号被上游风控限制(11140)→ 账号级 2h 冷却,当前请求不再换号 OTHER = "other" # 其他 4xx/5xx → 累计,连续 3 次 → 冷却 10m - INVALID = "invalid" # 请求无效(模型不存在等)→ 不冷却凭证,跳过该上游;全拒 → 400 + INVALID = "invalid" # 请求无效(不存在等)→ 不冷却凭证,跳过该上游;全拒 → 400 MODEL = "model" # 该模型用量超限(429+6004)→ 只冷却 (凭证, 模型),10m 起翻倍封顶 2h CONCURRENCY = "concurrency" # 并发会话打满 → 固定 60s 模型级短冷却,不翻倍 BLOCKED = "blocked" # 该账号无此模型(400/404+11102)→ (凭证, 模型) 负缓存,6h 起翻倍封顶 24h - REQUEST = "request" # 请求级错误(11101/11115/11128/11135)→ 零动作:不冷却、不累计,仅换号 + REQUEST = "request" # 请求级错误(11101/11128/11135)→ 零动作:不冷却、不累计,仅换号 + CONTEXT = "context" # 上下文超限(11115 `prompt is too long`)→ 同 REQUEST 处置,统计独立成类 + CAPABILITY = "capability" # 模型能力拒绝(CodeArts 001001020)→ 同 INVALID 处置,统计独立成类 + NETWORK = "network" # 本机网络/DNS 失败(ConnectError / gaierror)→ 零动作,不惩罚凭证 + QUEUE = "queue" # 排队中(isQueued / RESOURCE_LIMIT)→ 零动作:等 retryAfterSeconds 后原号重开 MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY}) +NO_PENALTY_KINDS = frozenset({REQUEST, CONTEXT, NETWORK}) ``` -`MODEL_SCOPED_KINDS` 只写 `credential_model_cooldowns`(见 §6.3),不碰账号级 `cooling_until`,故同账号其他模型仍可选——这是两层冷却的核心:单个模型打满不能连累同账号其他模型。反之账号级冷却出现时会清空该凭证的模型级条目,否则「切模型」能绕过账号级限流。 +`MODEL_SCOPED_KINDS` 只写 `credential_model_cooldowns`(§6.3),不碰账号级 `cooling_until`。账号级冷却出现 → 清该凭证模型级条目;不清 → 切模型绕过账号级限流。 + +**账号被上游风控限制(RESTRICTED,CodeBuddy 11140)**:`403 request illegal` 字面像「请求非法」,实际是上游按账号(多号同源时按出口 IP/设备)拉黑——换模型、换请求形态、换代理都无效(techysy/10router、yetone/magpie、maiphucgiang/codebuddy2api 三项目结论一致)。 + +- **必须拦在 `401/403 → DEAD` 之前**:判 DEAD 写不可逆 `disabled=1`,而这类限制上游会自行解除,写成禁用就变成「必须有人点恢复」,还诱使运维反复 revive 去撞。 +- **账号级指数冷却**(`RESTRICTED_COOLDOWN_SECONDS` 2h 起按该凭证命中次数翻倍,封顶 `RESTRICTED_COOLDOWN_MAX_SECONDS` 7 天);上游 `Retry-After` 采用并夹在 `[now + 本次时长, now + 24h]`(更短的值回落本次时长,防 `Retry-After: 0` 让号在冷却刚写下即可选),到期自动回候选。固定 2h 会让整批同源账号每 2h 被原样重试,重复撞击正是上游判「自动化」的证据(2026-10-27 实测一天撞 4 轮)。`save_success` 清 `err_count`。 +- **当前请求不再换号**:`executor` 命中后 `_skip_provider` 跳过该渠道剩余凭证(实测一次请求连打 3 个号全 403,新号加入 3 分钟即被封)。 +- **窗口内第二个不同凭证也命中 → 渠道级暂停 24h**(`_pause_provider_on_burst` + `cool_provider`):只冷却、不写 `disabled`;判据是窗口内**不同凭证个数**而非命中次数——按次数升级会让单凭证的两个连续请求把整渠道停掉。 + +**硬禁用的可操作原因(`disabled_reason`)**:真 session 失效(401 / token 过期)仍是 DEAD,`disabled_reason` 写「session dead」或 provider 识别的具体原因(CodeBuddy `disabled_reason()` 识别 11140 文案,作非 RESTRICTED 入口兜底)。管理台 `CredentialsPage` 原样展示,写错会把人引向错误修复动作;只回受控文案,不拼上游正文。 + +**排队语义(QUEUE)**:上游回排队中 + 建议等待秒数 = 号满,等 N 秒重打,非账号故障、非请求错误。 + +- **不冷却**:`scheduler.note_error` 对 QUEUE 零动作(同 REQUEST)。 +- **不换号**:`executor` 用 `pinned` 记住被点名凭证,等完原号重开。 +- **不占轮换预算**:排队轮次不计 `attempts`;计则三次排队吃满 `max_rotate` → 503。 +- **有界**:`QUEUE_MAX_ROUNDS = 3` 轮、单轮封顶 `QUEUE_MAX_WAIT_SECONDS = 60s`。 +- **已出帧不重开**:半截输出不可回滚 → 降级 SOFT 收尾。 + +**短冷却原地等(A4)**:没有任何**立即可用**候选、但最短剩余冷却不超过 `SHORT_COOLDOWN_WAIT_SECONDS`(默认 60)时,在原连接上等它到期后重取一次,而不是直接 503。 + +- **有可用号绝不等待**——等冷却中的号等于白降吞吐。 +- **本轮 `tried` 里的号同样参与等待**(B1):旧口径按 `tried` 排除,导致「轮换把最后一个号打进软冷却」这个最常见的耗尽场景直接 503。DEAD / INVALID / 额度耗尽属长冷却,剩余时间远超上限,由 `remaining > limit` 自然挡掉,不按错误类型特判。 +- **等到的号必须先 `tried.discard`** 再重取,否则选号仍按「本轮试过」排除它,白等一轮再 503。 +- **只重取一次**:睡醒后名额被别的请求抢走就照常 503,不做第二轮排队。 +- 等待期**不占在途租约**、**不计 `attempts`**;`0` = 关闭(快速失败)。非流式请求建议同时开 `NONSTREAM_KEEPALIVE_SECONDS`,否则中间盒可能先掐连接。 + +识别 `provider.base.parse_queue_hint`(信封可嵌套,最多下钻 3 层):`isQueued: true` / `subCode ∈ {RESOURCE_LIMIT, RESOURCE_LIMIT_EXCEEDED}` 任一成立即认定;**宁可漏判不可误判**,识别不出走原分类。Qoder `_decode_frame` 须**先于** `classify_error_code` 拦下——上游把排队包成 403,按状态码分类判 DEAD 会硬禁用凭证。`Event.retry_after_seconds` 承载上游点名秒数。 + +**排队标记可能藏在序列化字符串里**:上游常把内层报文再 `json.dumps`(Qoder 排队报文串两层:`{"code":"403","message":"{\"code\":\"10605\",\"message\":\"{…isQueued…}\"}"}`)。只认 dict/list 会漏判 → 403 落 DEAD → 可自愈凭证被硬禁用。故 `parse_queue_hint` 下钻字符串;串里套串**不额外消耗深度预算**(深度只按 dict/list 协议层级计)。判据同时放进 `classify_error_code`,任何入口都不漏判。 | 上游信号 | CB | TRAE | Kilo | ErrKind | |---|---|---|---|---| @@ -107,21 +152,29 @@ MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY}) | 模型级限流 | 429 + `code=6004` | 429 + `code=6004` | 429 / 502/503/504(点名模型) | MODEL | | 并发打满 | — | — | — | CONCURRENCY(CodeArts `TM.00001041`) | | 该账号无此模型 | 400/404 + `code=11102` | 400/404 + `code=11102` | — | BLOCKED | -| 请求级错误 | 400 + 11101/`Unmarshal chat params failed`/11115/11128/11135 | — | 403 | REQUEST | +| 请求级错误 | 400 + 11101/`Unmarshal chat params failed`/11128/11135 | — | 403 | REQUEST | +| 上下文超限 | 400 + 11115 `prompt is too long` | — | — | CONTEXT(零动作,统计独立成类) | | 限流 | 429(无 6004) | 429(无 6004) | — | SOFT | | 不存在 | 404 | 404 | — | SOFT(CodeArts 404 亦 SOFT) | | 会话失效 | 401/403 | 401 | — | DEAD | | 服务端错误 | 5xx | 5xx | 500 | OTHER | | 请求自身无效 | 400 | 400(含 4001) | 400/404/422 | INVALID | -| 流内错误事件 | SSE error 事件 | `event:error` 业务码 | `{"error":{...}}` 帧 | 同上映射(单一来源:provider 解析时写 `Event.error_kind`,executor 直接消费;缺失回落 OTHER) | +| 模型能力拒绝(带图→非多模态) | — | — | — | CAPABILITY(详见下) | +| 排队中 | — | — | — | QUEUE(Qoder `isQueued` / `RESOURCE_LIMIT`) | +| 流内错误事件 | SSE error 事件 | `event:error` 业务码 | `{"error":{...}}` 帧 | 同上映射(单一来源 `Event.error_kind`,缺失回落 OTHER) | -> **zen / kilo 的 401 例外**:两者都无凭证,`401 Missing API key.` 只说明该模型需要付费 key,故 `classify_status(401)` 归 `INVALID` 而非 `DEAD`,避免一次强制付费模型请求把整条免费渠道硬禁用。 +- **zen / kilo 401 → `INVALID`**:两者无凭证;`401 Missing API key.` 只说明该模型要付费 key;归 `DEAD` 会因一次强制付费请求硬禁用整条免费渠道。 +- **模型能力拒绝 → `CAPABILITY`(处置同 `INVALID`),不是 `REQUEST`**:CodeArts `InferHub.001001020.406 The request model is not multimodal` 是「这个模型收不了图片」,同渠道换任何号都回同一错误。归 `REQUEST`(换号重试)会让同渠道 N 条凭证各撞一次,`MAX_ROTATE = 3` 被吃满,请求轮不到 `supports_images=True` 的渠道——2026-11-01 22:18–22:20 四个带图请求就这样全部 400 收尾(明细 4 条 `invalid_request`,`rescued=0`)。归 `CAPABILITY` 一次尝试即 `_skip_provider` 跳过整条渠道;HTTP 400 与流内同判。 +- **额度耗尽写成凭据失效 → 分类层仍 `DEAD`,引擎按新鲜额度证据收口为 `CREDIT`**:officeace 积分耗尽回 `401 APIG.1009 AppKey or AppSecret is invalid`(与 AppKey 真坏同文案,模型列表接口也出现;实测 2026-11-02 三条凭证 `quota_remaining = 0 / quota_total = 4100`)。分类层放宽会让真坏 AppKey 静默成「等签到」,故照判 `DEAD`,由 `executor._credit_if_exhausted` 收口:`health == -1` 或 `quota_remaining <= 0` 改判 `CREDIT`(冷却到次日 04:00,不硬禁用、不出网);证据陈旧(正数 / 未探测)先 `_probe_quota_now` 复探再判(周期探额 60 分钟一轮,401 常早于下次探测——实测 2026-11-03:三条凭证 07:39/08:00/08:25 被硬禁用,401 前最后一次探额 56.66 / 100 / 51.37,复探立刻为 0)。复探仍正数 / 失败 / 无探测能力 / 凭证已删除 → 保持 `DEAD`(`probe_failed` 不入库,免得网络抖动把正数抹成未知);同号并发 401 在 60s 窗共用一次复探(`DEAD_QUOTA_PROBE_TTL_SECONDS`)。收尾行(`_stream_error_type` / 轮换耗尽行)与尝试行同判据。旧口径代价(硬禁用 + 落 `credential_unavailable` → revive 5 次 + OAuth 重登 1 次,401 照旧)也说明必须按证据收口。判据只看额度证据、不看渠道名。 +- **kilo 429 与 502/503/504 → `MODEL`**:免费池 = OpenRouter 共享池,报错点名具体模型(`limit_source: upstream_provider_shared_pool`);其他免费模型仍可用。 +- **CodeArts 临时凭证过期 → `SOFT`**:`HTTP 400` + `APIG.0602 security token has expired`,识别先于 `401/403 → DEAD`(`_STS_EXPIRED_MARKERS`);判 `DEAD` 会让 `RefreshTask` 跳过该凭证、断唯一自愈路径。 +- **Qoder 401 与 403 不对称**:`401` 一律 `DEAD`(实测只有凭据/会话失效一种形态 `TOKEN_EXPIRE`;放宽等于让被吊销 token 在 60s 轮换里反复撞上游);`403` 只在带会话失效标记时 `DEAD`,其余 `SOFT`。排队信号优先于两者。`classify_status` 只是 `classify_error_code` 转发,判据只写一处,HTTP 与流内信封不漂移。 +- **TLS 中断(`ssl.SSLError`)→ `SOFT`**:`executor._classify` 与 `httpx.TransportError` 并列兜住。anyio 只把 `SSLEOFError` 转 `BrokenResourceError`,其余(`record layer failure`、证书链握手失败)原样上抛,既非 `httpx.TransportError` 也不在「只认具体类」白名单。不单列会逃到 `stream_guarded` 出一帧 `internal_error`:不换号、不冷却、`usage_events` 零痕迹。不放开到 `OSError` 整体(磁盘满、句柄耗尽会误冷却健康凭证)。 -> **kilo 的 429 与 502/503/504 归 `MODEL`**:免费池是 OpenRouter 共享池转发,429 报错点名具体模型(`limit_source: upstream_provider_shared_pool`),429 消退后同一模型转 503 `no endpoints available`,两种情况下同一时刻其他免费模型仍可用,属模型级而非账号级。 +**业务码只从 `"code": N` 键值形态提取**(`provider.base.business_codes`):裸搜数字会让 `"code":111020` 命中 `11102`。判定顺序即优先级:`429+6004` 先于 `429`,`402/14018` 先于状态码兜底。 -> **CodeArts 的临时凭证过期归 SOFT**:`HTTP 400` + `APIG.0602 security token has expired`,识别在 `401/403 → DEAD` 之前(标记 `_STS_EXPIRED_MARKERS`),判 `SOFT`(账号级可恢复故障:下一轮预刷新换上新 STS 即自愈)。不能判 `DEAD`——硬禁用会让 `RefreshTask` 跳过该凭证,反而断掉唯一自愈路径。 +**内容风控指纹(11128)**:`400 + 11128 Illegal API invocation from an unapproved channel` → REQUEST。主因 `system`/`assistant` 正文含「伪装其他厂商官方客户端」指纹串(身份声明行、billing 头字段名、git 上下文行):与凭证无关、确定性复现、只在两角色 `content` 命中(`tool_calls` 与 reasoning 不触发),写进历史则后续每轮都 11128。处置:出站前 `sanitize_channel_markers` 替换占位符(`CODEBUDDY_SANITIZE_CHANNEL_MARKERS=false` 关闭),客户端历史不受影响,文本块列表逐块处理。清单在 `provider/codebuddy/client.py::CHANNEL_MARKERS`——刻意不写原文,以免污染读本文件的会话。 -**业务码一律从 `"code": N` 键值形态提取**(`provider.base.business_codes`),不搜裸数字:`"code":111020` 含 `11102` 子串,裸匹配会把无关响应判成模型错误。判定顺序即优先级,`429+6004` 必须先于 `429`、`402/14018` 必须先于状态码兜底。**内容风控指纹(11128)**:`400 + 11128 Illegal API invocation from an unapproved channel` 归 REQUEST,主因是**内容风控**——`system`/`assistant` 消息正文出现「伪装其他厂商官方客户端」的指纹串(如 Claude Code 系统提示身份声明行、billing 头字段名、git 上下文行)时整单拒绝。特征:与凭证无关(换号无效)、确定性复现、仅这两个角色的 `content` 命中(`tool_calls` 参数与 reasoning 均不触发);会话一旦把指纹写进历史,后续每轮(含压缩请求本身)都持续 11128。处理:出站前 `sanitize_channel_markers` 把它替换为占位符(`CODEBUDDY_SANITIZE_CHANNEL_MARKERS=false` 关闭),客户端历史不受影响;`content` 为文本块列表时逐块处理。指纹清单在 `provider/codebuddy/client.py::CHANNEL_MARKERS`,此处刻意不写原文以免污染阅读本文件的 agent 会话。 ### 3.3 三态健康度 ```python @dataclass(slots=True) @@ -146,86 +199,145 @@ def health_score(q: Quota | None) -> int | None: return max(0, min(100, round((q.remaining or 0.0) / q.total * 100))) ``` -调度排序:`known DESC > unknown(中性参与)> exhausted(-1)`。`probe_failed` 与「探测到 0」必须区分——Zen/Kilo 虚拟凭证恒返回 `probe_failed=True`(未知,**不是耗尽**)。**CB `remaining` 的取值口径**:个人版额度探测一律取逐包 `CycleCapacityRemainPrecise` 之和,**不得**用响应顶层的 `TotalDosage`——后者是上周期口径(本周期消耗记在 `CycleCapacityUsed`),采用它会出现 `remaining == total`、健康度恒 100、并与到期阶梯 / 额度明细自相矛盾。企业版仍走 `limitNum - credit`(另一接口,无此问题)。 +排序 `known DESC > unknown > exhausted(-1)`(完整排序键见 §6.1)。`probe_failed` 与「探测到 0」须分开——Zen/Kilo 虚拟凭证恒 `probe_failed=True`。**CB 个人版 `remaining` = 逐包 `CycleCapacityRemainPrecise` 之和,不用顶层 `TotalDosage`**:后者上周期口径 → `remaining == total`、健康度恒 100、与到期阶梯矛盾。企业版走 `limitNum - credit`。 + ### 3.4 截断续写 -上游以 `finish_reason == "length"` 结束本轮流时,同凭证自动续写,最多 `AUTO_CONTINUE_MAX`(默认 10,0 关闭)。实现在 `src/engine/continuation.py` 的 `ContinuationStream`:包装上游事件流,截断则追加「已产出正文(含 reasoning)+ 续写指令」重发,并把输出上限两键归零(否则在同一处再次截断),跨轮累计 usage,末端补发一条累计 usage + 最后一轮真实 `finish_reason`(上游整轮未报 usage 时不补发,避免出口把「未上报」误报成 0)。做成事件流包装器而非 executor 内重跑:凭证固定,轮换 / 记账 / 统计零改动。**只实现 `length` 一个判据**:「仅 reasoning 无正文」只在客户端下发 `max_tokens` 时出现,而生产路径只发 `reasoning_effort`、本网关也不做 max 键映射,故该形态不可达;「空正文无工具调用」「代码块未闭合」无证据支撑,均不做——凭空加启发式会把「模型就是想空回」误判成截断。**输出上限键行为**:`max_completion_tokens` 被 CB 上游**完全忽略**,`max_tokens` 才生效(精确截断 + `length`);两键同发时后者胜出;TRAE 对两个键**都不生效**;`enable_thinking: false` 亦被上游忽略(仍产 reasoning 且计入 `max_tokens`)。**`reasoning_effort` 缺失时补 `medium`**:不带该字段时 CB 上游把整段思考以「可见推演」写进 `delta.content`(`delta.reasoning_content` 恒空、`usage.reasoning_tokens` 恒 0),正文里混着自我质疑;带上任意档位即刻恢复独立思考通道。官方 CLI 的 dump 都带该字段,非官方 CLI 客户端不发,是唯一触发面。`stream_chat` 用 `setdefault` 补缺省、**不覆盖客户端显式值**。 +`finish_reason == "length"` → 同凭证续写,最多 `AUTO_CONTINUE_MAX`(默认 10,0 关闭)。实现 `engine/continuation.py::ContinuationStream`:追加「已产出正文 + 续写指令」重发;输出上限两键归零;跨轮累计 usage;末端补一条累计 usage + 最后一轮真实 `finish_reason`(上游未报 usage 不补,避免误报 0)。事件流包装器而非 executor 重跑:凭证固定 → 轮换 / 记账 / 统计零改动。 + +只做 `length` 一个判据:「仅 reasoning 无正文」只在客户端下发 `max_tokens` 时出现,本网关不做 max 键映射 → 不可达;「空正文」「代码块未闭合」无证据 → 不做。 + +**输出上限键**:CB 忽略 `max_completion_tokens`、只认 `max_tokens`(精确截断 + `length`);两键同发后者胜。TRAE 两键都不生效。`enable_thinking:false` 亦被 CB 忽略。 + +**`reasoning_effort` 缺失补 `medium`**:不发该字段 → CB 把思考写进 `delta.content`(`reasoning_content` 恒空、`reasoning_tokens` 恒 0)。`stream_chat` 用 `setdefault`,不覆盖显式值。 + ### 3.5 会话粘性键 -(`src/engine/affinity.py`)键优先级 **① 请求体显式键** `conversation_id` > `conversationId` > `prompt_cache_key`(同键名先 `metadata` 对象再请求体顶层)→ **② 请求头会话标识**(`x-session-id` / `x-opencode-session` / `x-session-affinity` / `conversation-id` / `conversation_id`,按此顺序取第一个合法值,须匹配 `[A-Za-z0-9._:-]{1,128}`)→ **③ 消息增量前缀指纹**兜底;头来源经 `request_context` 的 contextvar 传入(`RequestIdMiddleware` 在入口设置、请求结束复原),不污染中间层签名,键带 `header:` 前缀避免与 body 同值撞键。头排在 body 之后是因为 body 显式键最精确、头次之(客户端声明的会话身份仍强于指纹猜测),排在指纹之前是因为指纹只是启发式。带 `user_id`(顶层或 `metadata` 内)时不派生**前缀兜底**键(头照常生效)(否则同用户的并行对话前缀可能相同,会被误钉到同一凭证)。`prompt_cache_key` 是 OpenAI 官方顶层参数、`metadata.user_id` 是 Anthropic Messages API 官方字段;`conversation_id`/`conversationId`/顶层 `user_id` 非标准键,作兼容探测接受(命中即用、未命中无害)。键里掺入用户名防止不同 API 用户串号(**头值同样掺 username**——头是客户端可写的,不掺就等于允许A用户靠猜头值占用B用户的账号);TTL(`CONVERSATION_STICKY_SECONDS`)内存态,重启后丢粘性只影响一轮选号,条目数上限 512 超量先淘汰过期再按最旧淘汰。 -### 3.6 模型元数据与黑名单 -**模型元数据**:两边上游的模型配置接口都直接给出推理元数据,网关透传为 `/v1/models` 的 OpenAI 额外字段: +(`engine/affinity.py`)优先级:① 请求体 `conversation_id` > `conversationId` > `prompt_cache_key` > `session_id`(同键名先 `metadata` 再顶层)→ ② 请求头 `x-session-id` / `x-opencode-session` / `x-session-affinity` / `thread-id` / `session-id` / `conversation-id` / `conversation_id`(首个合法值,须匹配 `[A-Za-z0-9._:-]{1,128}`;`thread-id`/`session-id` 是 Codex 自 2026 起的现行写法,见 openai/codex#22193)→ ③ 消息增量前缀指纹。 + +- 头经 `request_context` contextvar 传入(`RequestIdMiddleware` 设置、请求结束复原);键带 `header:` 前缀避免与 body 撞键。 +- 带 `user_id`(顶层或 `metadata`)**不派生前缀兜底键**(头照常生效);否则同用户并行对话误钉同一凭证。 +- 键掺用户名(**头值同样掺**):头是客户端可写输入;不掺就能靠猜占用他人账号。 +- TTL `CONVERSATION_STICKY_SECONDS` 内存态,重启只影响一轮选号;条目上限 512,超量先淘汰过期再按最旧。 +### 3.6 模型元数据与黑名单 | 字段 | CB 来源 | TRAE 来源 | 取值 | |---|---|---|---| | `supports_reasoning` | `supportsReasoning` | `display_config.model_capability`(`reasoning_model`→true、`chat_model`→false),缺失回落 `reasoning_effort_config.support_thinking` | true / false / null | | `default_effort` | `reasoning.effort` | 无对应字段 | CB: `high` / `medium`;TRAE 恒 null | -`supported_efforts`(可接受档位集合)**不实现**:上游只给「默认档位」不给档位清单,凭空枚举 ChatGPT 三档属编造。CB 的 `onlyReasoning`/`canDisableThinking` 同样**不透传**(语义未经核实)。**唯一例外是 Playground 的入参控件**(`web/src/lib/playgroundParams.ts`):它只帮用户拼请求体,不对外声明能力——档位候选固定为 OpenAI 标准三档(`low`/`medium`/`high`),上游声明的 `default_effort` 若不在其中则追加进候选;控件在 `supports_reasoning !== true` 且无 `default_effort` 时**不渲染**,默认选中「不发送」(渠道各自补默认,如 CB 补 `medium`)。三个高级参数(`temperature` / `max_tokens` / `reasoning_effort`)均**留空即不发送该键**,前端校验边界与 `compat/openai/request.py` 对齐(`temperature` ≤ 1000、`max_tokens` 为 1–4e6 的正整数),避免前端放过、后端 400。逐字段补缺(多上游同名模型先到先填、后到只补 None)。**黑名单**(`MODEL_BLOCKLIST`,fnmatch glob,只影响列表展示、直连不受影响)。默认口径是**只展示当前在售的新模型**,三类一律滤掉:① 上游内部/代理模型——`custom_model_*` / `*sub*agent*` / `summary` / `browser_use_*` / `file_search_agent`,以及各渠道的「自动路由」占位 `default` / `default-model` / `fast-model` / `balanced-model` / `primary-model` / `deep-model` / `auto`;② 已被新一代取代的老模型——`glm-4.*` / `glm-5` / `glm-5.0*` / `glm-5.1` / `glm-5.2*` / `glm-5v-*` / `glm-5-turbo`、`kimi-k2.*`、`minimax-m2.5`/`m2.7`/`m3`、`deepseek-v3-*` / `deepseek-v4-*`(`v4.1` 不受影响)、`qwen3.7-*`、`gpt-5.4`/`gpt-5.5`、`hy3*` / `hy4-preview`、`hunyuan-*`、`seed-2.1*` / `seed-code-pro-*` / `seed-evolving`、`qwen3-vl-235b`、`nemotron-3*` / `lfm2*` / `laguna*` / `north-mini-code` / `inkling-small` / `xing4.0-29b-a4` / `openrouter-models-router`;③ 实测不可用的噪音——`glyph-cluster`(Kilo 两次 121s 后 408)、`qwen3.8-flash`(Qoder `qfmodel` 400「Execution failed: null」,CB/TRAE 的 `qwen3.8-max` 不受影响)。在售主力刻意不加:`deepseek-v4.1-flash` / `kimi-k3` / `glm-5.3*` / `mimo-v2.6*` / `qwen3.8-max` / `openpangu-2.0-*`。**按归一后的对外写法匹配**(`_block_names`):原代号、原代号归一键、展示名、展示名归一键四种写法取并集,用户照列表里看到的 `kimi-k3` / `Kimi K3` 写也能生效;保留原代号是因为默认规则带下划线(`custom_model_*`),归一键把下划线换成连字符后不命中;清洗后退化为空的名字不参与匹配。**非模型级问题不进黑名单**:CodeArts `deepseek-v4-*` / `glm-5.3-flash` 的 `InferHub.4291.200 insufficient quota` 与 `TM.00001041` 是账号级,Kilo `poolside/laguna-xs-2.1` / `nemotron-3-nano-omni` 的 429·502 与 `thinkingmachines/inkling-small` 的 `limit_rpd` 日配额都会自行恢复。**两条存疑未加**:Qoder `mmodel`(MiniMax-M2.7)回 inner body `null` 触发协议异常,但与 CB 正常的同名模型合并成一条,滤掉会连带隐藏可用渠道;zen `ling-3.1-flash-free` 两次 429,同样与 Kilo 可用的同名模型合并。**黑名单热更与列表缓存**:`MODEL_BLOCKLIST` 是热更项,但 `list_models` 的缓存只存**未过滤原始表**,过滤在每个出口现做(`_visible()`,命中缓存 / 成功拉取 / 失败兜底三条路径都过);前端保存后显式 `invalidateQueries(["playground-models"])`,黑名单与列表两条缓存都立即生效,不依赖 TTL。**失败也进 TTL(负缓存)**:`model_list_fetched_at` 记**上次尝试**时间(成功或失败都刷新),TTL(300s)内不再打上游——有缓存用缓存,没缓存跳过该渠道。 +- `supported_efforts` **不实现**:上游只给默认档不给清单。CB `onlyReasoning` / `canDisableThinking` 同样不透传(语义未核实)。 +- **唯一例外 Playground 入参控件**(`web/src/lib/playgroundParams.ts`):档位候选固定 OpenAI 三档(`low`/`medium`/`high`),上游 `default_effort` 不在其中则追加;`supports_reasoning !== true` 且无 `default_effort` 不渲染,默认「不发送」。三个高级参数留空即不发键;前端校验边界与 `compat/openai/request.py` 对齐(`temperature` ≤ 1000、`max_tokens` 1–4e6)。 +- 逐字段补缺:多上游同名模型先到先填,后到只补 None。 +- **黑名单**(`MODEL_BLOCKLIST`,fnmatch glob,只影响展示,直连不受影响)。四类滤掉:① 上游内部/代理模型——`custom_model_*` / `*sub*agent*` / `summary` / `browser_use_*` / `file_search_agent`,及自动路由占位 `default` / `default-model` / `fast-model` / `balanced-model` / `primary-model` / `deep-model` / `auto`;② 已被取代的老模型——`glm-4.*` / `glm-5` / `glm-5.0*` / `glm-5.1` / `glm-5.2*` / `glm-5v-*` / `glm-5-turbo`、`kimi-k2.*` / `kimi-k3`(→ `kimi-k3.1-pro`)、`minimax-m2.5`/`m2.7`/`m3`、`deepseek-v3-*` / `deepseek-v4-*`(`v4.1` 不受影响)、`qwen3.7-*` / `qwen3-*` / `qwen3.6*`、`gpt-5.4`/`5.5`/`5.6*`、`hy3*` / `hy4-preview`、`hunyuan-*`、`seed-2.1*` / `seed-code-pro-*` / `seed-evolving`、`qwen3-vl-235b`;③ 已下架的免费池与开源小模型——`space-bunny`、`nemotron-3*` / `lfm2*` / `laguna*` / `north-mini-code` / `inkling-small` / `xing4.0-29b-a4` / `openrouter-models-router`、`glyph-cluster`(121s 后 408);④ 测试号与区域/日期快照代号——`deepseek-v4.1-flash-0930` / `-bnt`、`glm-5.3-test` / `-zx`、`kimi-k3-test-1009` / `-1019`(逐条登记,不用通用剥尾;`deepseek-v4.1-flash-1017` 是另一价位快照,仍在售)。在售主力刻意不加:`deepseek-v4.1-flash` / `kimi-k3.1-pro` / `glm-5.3*`(含 flashx)/ `mimo-v2.6*` / `qwen3.8-max` / `qwen3.8-flash` / `openpangu-2.0-*` / `gpt-6-*` / `grok-4.7` / `gemini-3.8-flash`。`qwen3.8-flash` 2026-10-27 实测 chat 成功,原「400 Execution failed」判断失效故移出。 +- **按归一后的对外写法匹配**(`_block_names`):原代号、原代号归一键、展示名、展示名归一键四种取并集(默认规则带下划线;归一键把下划线换成连字符后不命中)。清洗后为空的名字不参与。 +- **非模型级问题不进黑名单**:CodeArts `InferHub.4291.200 insufficient quota` 与 `TM.00001041` 是账号级;Kilo `poolside/laguna-xs-2.1` / `nemotron-3-nano-omni` 的 429·502 与 `thinkingmachines/inkling-small` 的 `limit_rpd` 自行恢复。 + ### 3.7 Responses 出口(Codex CLI) -`POST /v1/responses`:`src/api/responses.py`(端点)+ `src/compat/responses/request.py`(入站映射)+ `src/compat/responses/response.py`(出站翻译)。与 `/v1/chat/completions` **共用同一个 executor**,选号 / 冷却 / 轮换 / 记账 / 会话粘性全部零改动;引擎侧唯一改动是 `Executor.stream(..., translator=...)` 注入缝——出口形状由 translator 决定,引擎不再硬编码 chat 形状的 SSE 帧。字段形状全部取自官方 `openai` Python SDK(3.x)由 OpenAPI 生成的类型定义,并用该 SDK 作客户端做端到端契约验证。**入站映射**(Responses → 内部 chat 载荷): +`src/api/responses.py` + `compat/responses/{request,response}.py`。与 chat **共用同一 executor**,唯一改动 = `Executor.stream(..., translator=...)` 注入缝。字段形状取自官方 `openai` SDK(3.x),并用该 SDK 做端到端契约验证。 | Responses | chat | |---|---| -| `instructions` | 首条 `system` 消息 | +| `instructions` | 首条 `system` | | `input` 字符串 | `user` 消息 | -| `input[].type=message`(`input_text`/`output_text` part) | `messages[]`;`developer` 角色改写为 `system` | -| `input[].content[].type=input_image` | chat 的 `image_url` part(`image_url` 直传,`detail` 丢弃);纯文本 part 折成字符串,含图时保留数组 | -| `input[].type=function_call` | assistant 消息的 `tool_calls[]` | -| `input[].type=function_call_output` | `tool` 消息(part 数组可含 `input_image`) | -| `input[].type=reasoning` | assistant 消息的 `reasoning_content`(仅明文部分) | -| `tools[].type=function`(**扁平** `{name, description, parameters}`) | 嵌套 `{type:function, function:{...}}` | -| `tool_choice` 字符串 / `{type:function,name}` | 同名 / 嵌套形式 | -| `max_output_tokens` | `max_tokens`(CB 上游只认这个键) | +| `input[].type=message`(`input_text`/`output_text`) | `messages[]`;`developer` 改写为 `system` | +| `input[].content[].type=input_image` | `image_url` part(`detail` 丢弃);纯文本折成字符串 | +| `input[].type=function_call` | assistant `tool_calls[]` | +| `input[].type=function_call_output` | `tool` 消息(part 可含 `input_image`) | +| `input[].type=reasoning` | assistant `reasoning_content`(仅明文) | +| `tools[].type=function`(扁平) | 嵌套 `{type:function, function:{...}}` | +| `tools[].type=namespace`(默认 `functions` 组) | 成员直译为 `function`;**非默认命名空间丢弃**(客户端按 `(namespace,name)` 查登记表,chat 带不回 namespace) | +| `tools[].type=web_search/tool_search/custom` | **跳过**(托管 / freeform,本网关无对应能力;400 会打死整轮) | +| `tool_choice` 字符串 / `{type:function,name}` | 同名 / 嵌套 | +| `max_output_tokens` | `max_tokens` | | `reasoning.effort` | `reasoning_effort` | -| `text.format`(`{type,name,description?,schema,strict?}` 扁平) | `response_format`(`text`/`json_object` 直通;`json_schema` 展开成嵌套;缺 name/schema、未知 type 显式 400)——校验委托 `compat/openai/request.py: normalize_response_format`(chat 入站共用同一段,`prefix="text.format"` 只改报错路径) | +| `text.format` | `response_format`(委托 `normalize_response_format`,`prefix="text.format"` 只改报错路径) | | `temperature` / `top_p` / `parallel_tool_calls` / `prompt_cache_key` / `text.verbosity` | 同名透传 | -**出站事件序列**(流式):`response.created` → `response.output_item.added`(message / reasoning / function_call)→ `response.content_part.added`(output_text)→ `response.output_text.delta × N` → `response.output_text.done` → `response.content_part.done` → `response.output_item.done` → `response.completed | response.incomplete`。思考内容用独立 `reasoning` item + `response.reasoning_summary_text.delta/.done`(不混进正文);工具调用用 `function_call` item + `response.function_call_arguments.delta/.done`。**Responses 协议没有 `[DONE]` 哨兵**,终止事件本身就是流结束(`done_sent` 与之对齐,供 executor 区分「正常收尾」与「中途断开」)。流式已完成后再来的 FINISH/ERROR 一律忽略。`finish_reason=length` → `response.incomplete`(`incomplete_details.reason=max_output_tokens`),`content_filter` 同理;其余为 `completed`。流内错误以 `response.failed` 表达,错误码映射成 Codex CLI 认识的取值(`no_healthy_credential` → `server_is_overloaded`)。**刻意接受的取舍**:`include=["reasoning.encrypted_content"]`(Codex CLI 每轮必带)**接受并忽略**——400 会直接打死主客户端;其他 `include` 值才 400。`previous_response_id`(Codex 不发该字段)与 `store=true` 保持 400(本网关无状态)。Codex CLI 按 SSE 的 `event:` 行分派事件,故 `event:` 与 `data.type` 两个字段都必须发;`response.completed.response` 在它那边是强类型解析(`id` 必填、`usage` 含三个 token 字段),故按官方必填字段完整发出。Codex 回传历史里的 `reasoning`/`compaction` 只有密文、没有 chat 等价物,**有意无损丢弃**;其余 Responses 私有 item 类型(`local_shell_call`/`custom_tool_call` 等)显式 400。**验证状态**:以官方 `openai` SDK(流式 / 非流式 / 工具调用)作权威客户端跑通全部契约,并对真实 CB 上游冒烟;**未经真实 Codex CLI 端到端验证**(开发环境无 CLI)。 +**出站序列**(流式):`response.created` → `output_item.added`(message / reasoning / function_call)→ `content_part.added` → `output_text.delta × N` → `output_text.done` → `content_part.done` → `output_item.done` → `response.completed | incomplete`。思考为独立 `reasoning` item(`response.reasoning_summary_text.delta/.done`),工具为 `function_call` item(`response.function_call_arguments.delta/.done`)。**无 `[DONE]` 哨兵**,终止事件即流结束(`done_sent` 对齐)。`finish_reason=length` → `response.incomplete`(`max_output_tokens`),`content_filter` 同理;流内错误 → `response.failed`(`no_healthy_credential` → `server_is_overloaded`)。 + +取舍:`include=["reasoning.encrypted_content"]` **接受并忽略**(400 打死 Codex CLI),其他 `include` 值 400。`previous_response_id` 与 `store=true` 保持 400(无状态)。`event:` 与 `data.type` 双发。回传历史 `reasoning`/`compaction` 只有密文 → **有意丢弃**;其余私有 item(`local_shell_call`/`custom_tool_call`)显式 400。**未经真实 Codex CLI 端到端验证**(开发环境无 CLI)。 + ### 3.8 Anthropic Messages 出口(Claude Code) -`POST /v1/messages`(流式 + 非流式)与 `POST /v1/messages/count_tokens`,供只走 Anthropic 协议的客户端接入。与 `/v1/responses` 同一模式:新增 `compat/anthropic/{request,response}.py` + `api/messages.py`,**复用同一 executor**。**入站映射**(`request.py`,映射成内部 `ChatRequest.raw`,只实现 chat 子集): -- `system`(字符串或 text 块数组)→ 一条 `system` 消息,置于最前;空则丢弃。 -- `messages[].content` 为字符串时按角色直传;为块数组时:`text` → 文本、`tool_result` → `tool` 消息(`tool_use_id` → `tool_call_id`;其 `content` 内的图片同样归一)、`image` → chat 的 `image_url` part(`source.type=base64` 拼成 `data:{media_type};base64,{data}`、`source.type=url` 直传)、`document` / `source.type=file` → 显式 400(chat 上游无对应表达)。user 的连续块按类型**拆成多条**消息(文本与 tool_result 交替),assistant 的块**合并成一条**(`thinking` → `reasoning_content`、`tool_use` → `tool_calls`、`redacted_thinking` 丢弃)——与上游 chat 的消息模型对齐。**纯文本内容仍折成字符串**(只有含图时才输出内容块数组),故无图请求的映射结果与加图片支持前**逐字节一致**,不会因本次改动击穿会话粘性指纹与上游前缀缓存。 -- `tools[].input_schema` → `function.parameters`;`tool_choice`:`auto`/`none` 直传,`any` → `required`,`tool` → `{"type":"function",...}`;`stop_sequences` → `stop`;`max_tokens`/`temperature`/`top_p` 直传,`top_k` 丢弃(chat 无等价物)。 -- **`cache_control` 原样透传**(默认开启、无开关;落点见 `compat/anthropic/request.py` 的 `_cache_control`):`system` 内容块、user/assistant 内容块、`tool_result`(挂 tool 消息**顶层**,避免造一个空 text 块进上下文)、assistant `tool_calls` 元素顶层、`tools` 元素顶层。**只校验结构**(对象 + 非空字符串 `type`;`ttl` 若给必须字符串),取值不解释——本网关是管道,未知 `type` 交给上游裁决,结构不合法显式 400 而非静默丢弃。**无标记时映射结果逐字节不变**:`_merge_parts` / `_system_content` / `_assistant_message` 只在「全 text 且无 `cache_control`」时折字符串,故会话粘性指纹与上游前缀缓存不受影响。八渠道实测 6 个接受(codebuddy / codebuddy_intl / trae / qoder / codearts / kilo),zen 上游 `FreeTierError` 403 无法验证、OfficeAce 未跑探针(`--plain` 对照同样失败);探针 `scripts/probe_cache_control.py`。 +`POST /v1/messages` + `/v1/messages/count_tokens`,同模式复用 executor。 + +- `system`(字符串或 text 块)→ 首条 `system`;空则丢。 +- `messages[].content` 字符串直传;块数组:`text` → 文本;`tool_result` → `tool` 消息(`tool_use_id` → `tool_call_id`,内含图片同样归一);`image` → `image_url`(`base64` 拼 `data:{media_type};base64,{data}`,`url` 直传);`document` / `source.type=file` → 显式 400。user 连续块按类型**拆多条**;assistant 块**合并一条**(`thinking` → `reasoning_content`、`tool_use` → `tool_calls`、`redacted_thinking` 丢弃)。**纯文本仍折成字符串**——无图请求映射结果与加图片支持前逐字节一致。 +- `tools[].input_schema` → `function.parameters`;`tool_choice` `auto`/`none` 直传、`any` → `required`、`tool` → 嵌套;`stop_sequences` → `stop`;`max_tokens`/`temperature`/`top_p` 直传;`top_k` 丢弃。 +- **`cache_control` 原样透传**(默认开、无开关):`system` 块、user/assistant 块、`tool_result`(挂 tool 消息顶层)、assistant `tool_calls` 元素、`tools` 元素。只校验结构(对象 + 非空 `type`,`ttl` 若给须字符串),取值不解释;无标记 → 映射逐字节不变。实测 6 家接受(codebuddy / codebuddy_intl / trae / qoder / codearts / kilo);zen 因 `FreeTierError` 不能验证、OfficeAce 未跑探针,小浣熊与灵犀(11 月新增)未验证(`scripts/probe_cache_control.py`)。 + +**出口翻译**(`response.py`,实现 `StreamSink`):`message_start` → `content_block_start`/`delta`/`stop`(`text_delta` / `thinking_delta` / `input_json_delta`)→ `message_delta`(`stop_reason` + usage)→ `message_stop`。无 `[DONE]`。thinking 块在 `content_block_stop` 前补占位 `signature_delta`(本网关不产真签名)。错误走 `event: error`。 + +**心跳**:`StreamSink.keepalive()` 各出口自实现,`dispatch_compat` 交 `with_keepalive` 作空闲填充帧。Anthropic 用具名事件 `event: ping` + `data: {"type": "ping"}`(官方规范;只解析 `event:` 行的客户端会忽略注释帧);chat / Responses 用 SSE 注释帧 `: keepalive`——Responses **不能**改 ping:协议无此事件类型,严格解析器遇未知事件直接报错。 + +**非流式保活**(`NONSTREAM_KEEPALIVE_SECONDS`,默认 0 = 关闭):`stream=false` 聚合完成前一个字节都不发 → CDN / 反代空闲超时切连接。`api/streaming.py::respond_with_keepalive` 用**宽限期**:先等 N 秒。 + +- 期间完成 → 普通 `JSONResponse`:状态码 / `Content-Length` / 字节内容与不开保活**全一致**;异常照常抛给异常处理器(400/503 不变)。 +- 超时 → 才提交 200 + chunked,先吐 `b"\n"` 再补正文。 + +空白字节合法:RFC 8259 §2 `JSON-text = ws value ws`,值前允许任意空白 → `"\n\n{...}"` 仍是合法 JSON(`json.loads` / `JSON.parse` 实测接受)。**不用 `StreamingResponse` 起手**:200 会在聚合前发出 → 之后失败改不回来;宽限期把该窗口压到只有真超时请求。代价:提交 200 后才失败只能把错误写进正文(`LATE_FAILURE_PAYLOAD`)→ **默认关闭**。本部署 nginx `proxy_read_timeout 3600s` + frp TCP,无短空闲超时;30 天实测 >60s 成功请求 1741 个全成功。 + +**usage 缓存字段**:内部 `input_tokens` 是 OpenAI 口径(含缓存命中),Anthropic 语义互斥。命中时 `input_tokens = max(0, prompt_tokens − cached_tokens)` 并另记 `cache_read_input_tokens`;命中为 0/None 不补占位。 + +**鉴权**:先 `x-api-key`(`ANTHROPIC_API_KEY`),空则回落 `Authorization: Bearer`(`ANTHROPIC_AUTH_TOKEN`),共用 `_api_key_principal`。Base URL 填到根,SDK 自拼 `/v1/messages`。`count_tokens` 本地启发式估算,不转发。 + +**CC Switch 深链**(`web/src/lib/ccSwitch.ts`):`ccswitch://v1/import?resource=provider&…`,`app` = `claude`/`codex`/`opencode`。`endpoint` 随 `apiFormat`:`anthropic` 填根不带 `/v1`;`openai_chat`/`openai_responses` 填 `/v1`。深链含 Key 明文 → 只在用户主动操作时生成(创建成功对话框 / 每行「导入 CC Switch」,后者需粘贴完整 Key,仅存内存)。需 CC Switch v4.0.10+。无 Gemini 出口。 -**出口翻译**(`response.py`,实现 executor 的 `StreamSink` 协议):`message_start` → `content_block_start`/`content_block_delta`/`content_block_stop`(文本 `text_delta`、思考 `thinking_delta`、工具 `input_json_delta`)→ `message_delta`(`stop_reason` + usage)→ `message_stop`。Anthropic 协议**没有 `[DONE]` 哨兵**,`message_stop` 即流结束;thinking 块在 `content_block_stop` 前补一个占位 `signature_delta`(客户端要求,本网关不产真签名)。错误用 `event: error`(`_error_type_for` 把上游错误码映射成 Anthropic 错误类型)。**usage 缓存字段**:内部 `Usage.input_tokens` 是 OpenAI 口径的 `prompt_tokens`(**含**缓存命中),而 Anthropic 语义里 `input_tokens` 与 `cache_read_input_tokens` / `cache_creation_input_tokens` 互斥(`input_tokens` 只计未命中)。故 `_usage_payload` 与 `completion_to_message` 统一拆分:命中时 `input_tokens = max(0, prompt_tokens − cached_tokens)` 并另记 `cache_read_input_tokens = cached_tokens`,不减会让 Claude Code 重复计费;命中为 0/None 时不补占位字段。**鉴权**:`deps.api_key_user_anthropic` 先读 `x-api-key`(Anthropic SDK 的 `ANTHROPIC_API_KEY`),为空再回落 `Authorization: Bearer`(`ANTHROPIC_AUTH_TOKEN`),两者共用 `_api_key_principal`。客户端 Base URL 填到根,SDK 自行拼 `/v1/messages`。`count_tokens` 用与上下文压缩同一套启发式估算本地计数(不转发上游)。**CC Switch 一键导入深链**(前端 `web/src/lib/ccSwitch.ts`):在「API Key 管理」页生成 `ccswitch://v1/import?resource=provider&…` 链接(`app` = `claude`/`codex`/`opencode`,对应三个出口协议),由用户点击触发桌面端导入确认。**endpoint 形态随 `apiFormat` 变**:`anthropic`(Claude Code)填到根、不带 `/v1`(与上面的约定一致),`openai_chat`/`openai_responses` 填到 `/v1`。深链含 Key 明文,故按钮只在用户主动打开、且已给出 Key 时生成:一处是**创建成功后的对话框**内(`CcSwitchImportButtons`,与「明文只显示一次」同生命周期),另一处是 **Key 列表每行的「导入 CC Switch」对话框**(列表只存脱敏 `preview`,需用户粘贴完整 Key,仅存于对话框内存)。两处的「默认模型」下拉与 Playground 同源(`/api/playground/models`,经 `usePlaygroundModels` 共用缓存键)。本网关无 Gemini 出口,故不提供 `app=gemini`。协议与取值依官方文档(CC Switch 用户手册「Deep Link Protocol」),需 v4.0.10+。 ### 3.9 上下文压缩 -**动机**:上游对输入长度有硬限制(CodeBuddy `11115 prompt is too long: … tokens > … maximum`)。反代若原样转发,长会话必然撞墙,客户端只看到裸的 400,而换号无意义(每个号上限一样)。**只做确定性的「估算 → 裁剪」**,不做「超限后压缩再重试」的放大路径。实现分两层:纯函数 `engine/compress.py` + 装配闭包 `api/context.py`(`build_context_compressor`,由 main.py 注入 `ExecutorDeps.context_compress`)。 +动机:上游输入硬限(CB `11115 prompt is too long`),换号无意义。只做确定性「估算 → 裁剪」,不做超限后压缩重试。纯函数 `engine/compress.py` + 装配闭包 `api/context.py`。 -- **token 估算**:中文 0.55 tok/字、数字 0.33、其他 0.25。刻意不用「3 字符 ≈ 1 token」的英文口径——它会把中文低估约 1.6 倍,于是「以为装得下、其实装不下」,压缩根本不触发。**图片 part 按 URL/base64 正文长度计入**:早期实现对非文本 part 一律记 100 字符,而一张 1 MB 的图 base64 后约 1.4 M 字符,按 100 估等于「图片会话永不触发压缩」,会一路撞上游输入上限。 -- **预算**:`模型上限 × safety_ratio − reserve_for_output`。 -- **裁剪**:`system` 永久保留(丢了会改变模型行为);`assistant.tool_calls` 与其后连续的 `tool` 结果**同组同生共死**(只删一半会让上游报 tool_call_id 找不到);至少保留最近 `min_keep_messages` 条;其外从最新往最老贪心回填。裁剪后仍超限则截断最长的**非 system** 消息内容(留头尾 + 标记)。 -- **未知上限不压缩**:模型目录里查不到 `max_input_tokens` 时直接跳过——宁可不裁剪,也不拿一个猜的数字去砍用户上下文。故本功能对未知模型**零副作用**。 -- **声明值 ≠ 实际可用量**:目录里的 `max_input_tokens` 是模型自报的声明值,个别渠道严重低估。TRAE 声明 200000/232768,实测 1,450,000 字符(约 1,035,544 token)仍能正确读到开头,1,470,000 字符才报 `4022`/`4026`(针在开头、问题在结尾、中间填随机 ASCII,答对暗号即证明窗口横跨整段)。按声明值算压缩预算只有 217K,用户发 30 万 token 时上游明明能吃却被本地静默压掉。故 `api/context.py` 的 `_CHANNEL_WINDOW_FLOOR` 把压缩预算按渠道抬到实测下限(TRAE `1_048_576`),**只抬不压**(声明值更大时保留声明值),且**只影响压缩预算**——`/v1/models` 对外宣告仍用声明值,客户端保持保守认知,不会主动发超限请求。 -- **多渠道按实际渠道取值**:同名模型可能挂多个渠道、各自上限不同(qoder 180K vs codebuddy 1M)。压缩从 API 入口移到 **executor 选号之后**(`ExecutorDeps.context_compress`),按实际服务渠道的上限裁剪——路由前只能取 min 的口径会把大窗口渠道的会话反复误裁,前缀不稳定击穿上游前缀缓存。压缩实现无副作用:每轮从原文重压(换号/回退可能落到不同窗口的渠道),affinity 指纹读未压缩原文。 +- **估算**:中文 0.55 tok/字、数字 0.33、其他 0.25(「3 字符 ≈ 1 token」把中文低估 1.6 倍)。**图片 part 按 URL/base64 长度计入**(按 100 字符估 → 图片会话永不触发压缩)。 +- **预算** = `模型上限 × safety_ratio − reserve_for_output`。 +- **裁剪**:`system` 永久保留;`assistant.tool_calls` 与其后连续 `tool` 结果**同生共死**;至少留最近 `min_keep_messages` 条;其外从新到老回填,**只保留连续后缀**——装不下就停。跳着留会在保留集合留洞,下一轮洞的位置一变,发往上游的前缀整段失效(缓存只剩 system 段)。仍超限 → 截断最长**非 system** 消息(留头尾 + 标记)。 +- **未知上限不压缩**:目录查不到 `max_input_tokens` → 直接跳过,零副作用。 +- **声明值 ≠ 可用量**:TRAE 声明 200000/232768,实测 1,450,000 字符(约 1,035,544 token)仍可读,1,470,000 才报 `4022`/`4026`。故 `_CHANNEL_WINDOW_FLOOR` 按渠道抬到实测下限(TRAE `1_048_576`),**只抬不压**,只影响压缩预算(`/v1/models` 仍宣告声明值)。TRAE `context_window_tokens` 有 `{dev, max}` 两档,`max > dev` 时取 `max`;否则 1M 上下文模型被报成 200K。 +- **按实际渠道取值**:压缩在 executor **选号之后**执行(`ExecutorDeps.context_compress`);路由前只能取 min 口径 → 反复误裁大窗口渠道。每轮从原文重压;affinity 指纹读未压缩原文。 -热更项 `context_compress_enabled`(默认 true)/ `context_compress_reserve_tokens`(4096)/ `context_compress_min_keep_messages`(4)/ `context_compress_safety_ratio`(0.95)。 -### 3.10 API Key 的绑定、白名单、来源 IP 与到期时间 -在 Key 级策略上叠加四项(**不做配额**),`api_keys` 增四列: +热更:`CONTEXT_COMPRESS_ENABLED`(true)/ `CONTEXT_COMPRESS_RESERVE_TOKENS`(4096)/ `CONTEXT_COMPRESS_MIN_KEEP_MESSAGES`(4)/ `CONTEXT_COMPRESS_SAFETY_RATIO`(0.95)/ `CONTEXT_COMPRESS_STICKY_ENABLED`(true)。 +**tool 输出压缩**(`engine/tool_output.py`,默认关):只压 `role=tool` 且超 `TOOL_OUTPUT_COMPRESS_MAX_CHARS`(8000)的纯文本 content——去 ANSI 转义、折叠连续重复行(`xxx (×N)`)、超长时保留「头 40 行 + 尾 40 行 + 命中错误关键字的行」并标注 `[... 省略 K 行 ...]`。错误行永不丢(避免把「为什么失败」压没)。**确定性**:同输入同输出,客户端每轮重发历史时压出的前缀逐字相同,不破坏上游前缀缓存;不做模型化摘要。与窗口无关,窗口未知/未超限也生效;关闭时载荷逐字节不变。每次真正压缩写一行 INFO(`tool 输出压缩 provider=… 字符 A→B`)。 + +**粘性裁剪**(`engine/sticky_trim.py`,默认开):上面「每轮按当轮预算重算最近 N 条」有个致命副作用——客户端每轮**重发完整历史**时每轮多一条消息,保留窗口起点前移一条,发往上游的前缀从第 2 条起就变了,上游前缀缓存只剩 `system` 段可命中(实测 qoder 单号 32 条请求里 21 条 `reusable=1`,命中率 25%)。 + +改为:只在超预算时裁,一次裁到 `budget × 0.6`,把保留窗口起点(正文绝对序号)与该条消息摘要记进进程内 LRU(按「渠道 + 模型 + 会话身份」)。之后每轮只要起点消息没变、仍是消息组边界、裁后总量仍在预算内,就沿同一起点——前缀逐字相同,缓存正常命中;直到新增消息顶回预算才再裁一次。历史被改写(摘要对不上)或分组变了就丢弃记忆重裁,宁可多裁一次也不发错上下文。关闭开关即回到旧行为。前提是保留集合**连续**(见上「裁剪」)。 + +实测(qoder / deepseek-v4.1-flash,客户端每轮重发 1.88M token 全量历史):修复后每次裁到 97K token(预算的 58%),后续每轮 `reusable=257/259`,上游 `cached_tokens/input_tokens` 稳定 **99%+**,单号聚合从 63% 升到 99%。日志 `reason=no_memory|digest_changed|not_group_start|over_budget` 直接看出为什么没复用。 + +### 3.9.1 前缀缓存诊断 + +命中率按**单请求**看才有意义:`cached_tokens / input_tokens` 聚合后,一条「每轮都裁掉一条老消息、只命中 system 段」的长会话就能把单号拉低一半以上(实测 qoder 单号 25.4%,同时刻同渠道其他号 62%)。网关只落 `input_tokens` / `cached_tokens` 两个数字,看不到前缀在哪条消息分叉,所以提供按需诊断(`PREFIX_DEBUG_ENABLED`,默认关)。 + +- **只记摘要**:逐条消息 `sha256` 前 16 位、system/tools 摘要、`lcp`(与**同凭证同模型**上一条请求连续相同的消息条数)、`reusable`、`split`(分叉点消息摘要)。不落正文,避免把用户代码写进日志。 +- **`reusable` 与 `lcp` 分开**:tools 定义排在 system 之后,`tools` 变了等于正文全部失效,故 `reusable = min(lcp, 1)`。 +- **首条没有基准**:`lcp` / `reusable` / `split` 记 `None`,不把「这条是第一条」误报成「前缀完全变了」。 +- **压缩会留证据**:`api/context.py` 裁剪成功时另写一行 `前缀诊断·压缩 … messages=N→M 估算=A→B sticky=… reason=…`;同一条流连续多轮都在裁 ⇒ 就是它把命中打没了,`reason` 直接给出为什么没复用上一次的窗口起点(`no_memory` / `digest_changed` / `not_group_start` / `over_budget`)。 +- **开销**:关时每请求只多一次属性读取;开时 INFO 级、每请求一行,状态是进程内 LRU(64 个 `凭证+模型` 键)。 + +### 3.10 API Key 策略 | 列 | 取值 | 空值语义 | |---|---|---| -| `provider_binding` | 见 `KNOWN_PROVIDERS`(八渠道) | 空 = 自动(跨渠道选健康凭证) | -| `allowed_ips` | 逗号分隔 IP/CIDR | 空 = 不限制来源 IP | -| `allowed_models` | fnmatch glob、逗号分隔 | 空 = 不限制 | +| `provider_binding` | 见 `KNOWN_PROVIDERS` | 空 = 跨渠道 | +| `allowed_ips` | 逗号分隔 IP/CIDR | 空 = 不限 | +| `allowed_models` | fnmatch glob、逗号分隔 | 空 = 不限 | +| `quota` | JSON 文本,六项(`daily_requests`/`daily_tokens`/`daily_cost_usd`/`rpm`/`tpm`/`max_concurrency`) | 空 / 0 = 该项关闭 | | `expires_at` | epoch 秒 | NULL = 永不过期 | -- **渠道绑定**:选定渠道后该 Key 只在该渠道凭证里选号;模型属另一渠道时直接 400 并指出实际归属(不静默改道)。`模型@渠道` 与绑定冲突时同样 400。执行在 `Executor._apply_binding`(`resolve_target` 内),与 `@provider` 强制指定共用收窄路径;绑定把候选固定为该渠道并置 `forced`(跳过模型目录收窄)。目录未就绪时不做归属判断,保守放行。 -- **来源 IP 判定**在 `deps.api_key_user`(鉴权当场判掉):**默认不采信 `X-Forwarded-For`**(客户端可写);仅 `TRUST_PROXY=true` 时按 XFF 判定、取**最后一个**条目。策略是纯函数(`auth/access.py`):写入时 `normalize_allowed_ips` 校验规范化(`10.0.0.1` → `10.0.0.1/32`)、非法值 400;读取宽松解析、脏条目丢弃,整份白名单一条都解析不出则**拒绝**(fail closed)。命中失败返回 **403**(与 401「凭证无效」区分)。 -- **模型白名单**:三个 /v1 出口(chat / responses / messages)解析后校验;省略模型名(空 / `auto`)时按 `default_model` 判定,**不能借空名绕过**;匹配大小写不敏感,`模型@渠道` 后缀不参与匹配;`/v1/models` 也按白名单过滤展示。写入时 `normalize_allowed_models` 校验。 -- **到期时间**:`deps._api_key_principal` 在鉴权当场判过期(过期与「Key 不存在」**统一 401 文案**,不泄露可枚举信息)。 +- **渠道绑定**:只在绑定渠道选号;模型属他渠道 → 400 并指出归属;`模型@渠道` 冲突同样 400。执行 `Executor._apply_binding`,与 `@provider` 共用收窄路径并置 `forced`(跳过目录收窄)。目录未就绪 → 保守放行。 +- **来源 IP**:`deps.api_key_user` 当场判。**默认不采信 `X-Forwarded-For`**;仅 `TRUST_PROXY=true` 取**最后一个**条目。写入 `normalize_allowed_ips` 规范化(`10.0.0.1` → `10.0.0.1/32`);读取宽松,整份白名单解析不出 → **拒绝**(fail closed)。失败 403。 +- **模型白名单**:三个 /v1 出口解析后校验;空名 / `auto` 按 `default_model` 判定(不能借空名绕过);大小写不敏感,`@渠道` 后缀不参与;`/v1/models` 同样过滤。 +- **到期**:过期与「Key 不存在」**统一 401 文案**。 +- **Key 级配额**(`key_quota.py`):六项独立;当日类按北京自然日从 `usage_events` 聚合,速率 / 并发是进程内滑动窗口(重启清零,多副本各算各的)。超限 429 + `quota_exceeded`;**判定异常一律放行 + warning**(配额是旁路治理,安全边界在鉴权与 IP 白名单那边 fail closed)。自助查询见 `GET /v1/usage?days=1|7|30`(只读自己的明细,不暴露池结构)。 -`ApiKeyPrincipal` 返回 `(username, key_id, provider_binding, allowed_models)`;`ApiKeyRepository.verify` 保留为只回用户名的薄封装。 +### 3.11 reasoning 控制 +只给机制不给策略:三项默认关 / 留空。内置默认矩阵(哪个渠道认哪些档位)一律不做(未实测即编造)。 -### 3.11 reasoning 控制(档位映射 + 思考内容剥离) -**定位**:客户端与上游对「思考」的表达方式不一致——Anthropic 用 `thinking.budget_tokens`,OpenAI 系用 `reasoning_effort`,各上游对档位的接受度也不同。这里只提供**机制**,不给策略:三个热更项默认全关 / 留空,生效形态完全由使用者配置决定。内置默认矩阵(「哪个渠道认哪些档位」「多少预算算高档」)**一律不做**——未逐一实测上游接受性,写了就是编造(与 `MODEL_FALLBACK_GROUPS`、`supported_efforts` 同一取舍)。 +**① `FORCE_DISABLE_THINKING`**(默认 false):响应侧剥离。过滤点 = executor 消费事件的循环(`_stream_loop` 与 `_complete_model` 聚合前),三个出口一致生效。**刻意不放进 `_stream_source`**:`ContinuationStream` 在那层内,续写靠 reasoning 原文 → 剥在外层才既保质量又对客户端隐藏。只过滤响应不改请求体(CB `enable_thinking:false` 被忽略;不发 `reasoning_effort` 反退化成可见推演)。`usage.reasoning_tokens` 不受影响。 -**① `force_disable_thinking`(bool,默认 false)**:出站**响应侧**剥离思考内容。过滤点是 executor 消费上游事件的循环(`_stream_loop` 的 `async for event in iterator` 与 `_complete_model` 的聚合前),丢掉 `EventKind.REASONING` 事件——三个出口(chat / Anthropic / Responses)、流式与非流式因此一致生效。**刻意不放在 `_stream_source` 里**:截断续写包装器 `ContinuationStream` 在那一层内,续写要靠 reasoning 原文拼上下文,剥在包装器外层才能既保住续写质量又对客户端隐藏思考。**只过滤响应、不改写请求体**:实测 CB `enable_thinking:false` 被上游忽略(仍产 reasoning 且计入 `max_tokens`),而**不发** `reasoning_effort` 反而退化成「可见推演」(思考混进 `delta.content`);其余渠道的请求侧接受性未核实,写请求体就是凭猜测改客户端意图。`usage.reasoning_tokens` 不受影响(思考照常计费,只是不展示)。 +**② `REASONING_EFFORT_MAP`**(默认空):格式 `渠道=from:to,from:to;*=from:to`,目标写 `none` 即删键。应用点在选号之后(`_map_effort`,`provider_id` 已知)。**只动客户端显式值**(渠道 `setdefault` 在 provider 侧、晚于此处)。需改写时返回**新 dict**,不改 `request.raw`(后者是粘性指纹输入)。 -**② `reasoning_effort_map`(str,默认空)**:出站把客户端**显式**给的 `reasoning_effort` 改写成渠道期望的档位。格式 `渠道=from:to,from:to;*=from:to`(渠道取 `KNOWN_PROVIDERS`,`*` 兜底;目标写 `none` = 删除该键、走渠道默认)。应用点在 executor **选号之后**(`_map_effort`,`provider_id` 已知)——同名模型跨渠道接受的档位不同,路由前无从判断。**只动客户端显式值**:渠道各自的 `setdefault` 补默认档(如 CB 补 `medium`)发生在 provider 侧、晚于这里,故不受影响;客户端没给档位时一个字段都不加。需要改写时返回**新 dict**,不改 `request.raw`——后者是会话粘性指纹的输入,原地改会污染指纹链、也会把改写泄漏到下一轮轮换。 +**③ `ANTHROPIC_THINKING_EFFORTS`**(默认空):`budget_tokens` → `reasoning_effort`,格式 `预算:档位,…,disabled:档位`;取「阈值 ≤ budget 的最大档」,低于最小阈值不映射。**留空 = 完全不映射**。Anthropic 只规定 `budget_tokens ≥ 1024`、未给对应表 → 阈值须显式配置。未知 `thinking.type` 与非法预算不映射。结果写入 `ChatRequest.raw` → 再经 ② 映射。 -**③ `anthropic_thinking_efforts`(str,默认空)**:Anthropic 入站 `thinking.budget_tokens` → 上游 `reasoning_effort`。格式 `预算:档位,预算:档位,disabled:档位`:`disabled` 匹配 `thinking.type == "disabled"`;其余取「阈值 ≤ `budget_tokens` 中最大的那一档」,低于最小阈值则不映射。**留空 = 完全不映射**,`thinking` 与改动前一样被忽略(`parse_messages_request` 一个字段都不写)。Anthropic 官方只规定 `budget_tokens ≥ 1024`、**未给「预算 ↔ 档位」对应表**,故阈值表必须由使用者显式给出。未知 `thinking.type`(如 `adaptive`)与非法 `budget_tokens` 一律不映射——猜错会把客户端「不要思考」的意图翻成「高预算思考」。映射结果写进 `ChatRequest.raw["reasoning_effort"]`,因此**会**再经过 ② 的渠道映射(两个机制可组合)。 +解析宽容:`engine/reasoning.py` 忽略空段 / 缺分隔符 / 非整数阈值 → 一行笔误不连累其他条目,不抛异常。 -**解析宽容**:`engine/reasoning.py` 的三个解析函数忽略空段 / 缺分隔符 / 非整数阈值的坏段,一行笔误只丢自己、不连累同配置里的其他合法条目,也不抛异常——坏配置不能让整个网关起不来。 +**④ 档位自适应降级**(默认行为,无需配置):上游明确拒收 `reasoning_effort`(HTTP 400 或流内 error 事件,文案命中 `reasoning_effort` / `effort level` / `budget_tokens` / 「推理档位」等标记)时,在**原号**上降一档重试一次(`xhigh→high→medium→low→minimal`),不换号、不冷却、不计入轮换预算。降级后的档位作为本次请求剩余尝试的上限(`_clamp_effort`)。只降一档:第二次仍被拒或已是最低档/未知档位 → 走既有 INVALID 路径(换上游,全拒才 400)。已出帧(`emitted`)后不重试(半截输出不可回滚)。 ## 4. Provider 协议 ```python @@ -250,83 +362,170 @@ class Provider(Protocol): async def aclose(self) -> None: ... ``` -> 上表与 `src/provider/base.py` 的 `Provider` 协议严格一致;两处定义必须同步(改任一处必须同步另一处)。 +> 与 `src/provider/base.py` 严格一致,改一处须同步另一处。 -约定: -- 凭证在引擎与 provider 之间以 dict 传递(解密后的 `data_enc`);`credential_from` 等能力把 dict 还原为 provider 私有 dataclass,provider 不接触 sqlite。 -- `stream_chat` 只产出 `Event`,产出前先做 HTTP 状态码检查;`classify` 由 executor 调用。 -- 可选能力**不实现即不定义**(不是抛 `NotImplementedError`):调用方用 `getattr`/`hasattr` 探测,缺失时返回 400「该凭证不支持此操作」,而不是 500。当前可选集:`start_auth`/`poll_auth`(仅支持 poll 的 provider)、`complete_callback`(仅 TRAE)、`list_accounts`/`switch_account`(仅 CodeBuddy)、`credential_from`/`checkin_scope`(刷新与签到任务的能力探测)、`checkin`/`checkin_status`(签到)、`growth`(成长中心,仅 CodeBuddy)、`host`(展示用)。 -- 八条渠道中 CodeBuddy / TRAE / Zen / Kilo / Qoder / OfficeAce 共用 `engine/sse.py` 的帧解析器(SSE 规范层),事件语义各自映射;CodeArts 的私有「累计全文」形状无法用 `FrameAssembler` 表达,自实现 `iter_data_lines`,只复用 `engine/sse` 的 `MAX_SSE_LINE_BYTES` / `SSEFrameTooLarge`。 -- Zen 与 Kilo 是最「薄」的两个 provider:标准 OpenAI SSE,`events.py` 无需私有信封解析。两者都**只实现** `import_credential`/`classify`/`probe_quota`/`list_models`/`stream_chat`/`aclose`,签到 / 成长 / 刷新一律靠「方法缺失」自然跳过。 +- 凭证以 dict 传递(解密后的 `data_enc`);`credential_from` 等能力还原为私有 dataclass,provider 不接触 sqlite。 +- `stream_chat` 只产 `Event`,产出前先查 HTTP 状态码;`classify` 由 executor 调用。 +- 可选能力**不实现即不定义**(不抛 `NotImplementedError`):调用方 `getattr` 探测,缺失 → 400「该凭证不支持此操作」。当前可选集:`start_auth`/`poll_auth`、`complete_callback`(仅 TRAE)、`list_accounts`/`switch_account`(仅 CB)、`credential_from`/`checkin_scope`、`checkin`/`checkin_status`、`growth`(仅 CB)、`host`。 +- CodeBuddy / TRAE / Zen / Kilo / Qoder / OfficeAce 共用 `engine/sse.py` 帧解析;CodeArts 累计全文形状自实现 `iter_data_lines`,只复用 `MAX_SSE_LINE_BYTES` / `SSEFrameTooLarge`。 +- Zen 与 Kilo 最薄:只实现 `import_credential`/`classify`/`probe_quota`/`list_models`/`stream_chat`/`aclose`;签到 / 成长 / 刷新靠方法缺失自然跳过。 ## 5. 各渠道协议实现 -### 5.1 OpenCode Zen 免费层(`zen`) -Zen 免费层是**标准 OpenAI 流式协议**(`chat.completion.chunk` + `data: [DONE]`),`provider/zen/events.py` 很薄:`content` / `reasoning_content` / `tool_calls` / `usage` / `finish_reason` / 流内 `error` 各一条映射。唯一要留意的是**最后一段正文与 `finish_reason` 会同帧**,故 `parse_all_events` 必须能在同一帧里同时产出 CONTENT 与 FINISH。上游 `[DONE]` 之后还跟一帧 `{"choices": [], "cost": "0"}`,无下游语义,跳过。**渠道私有的只有门禁伪装**(集中在 `zen/client.py`): +### 5.1 OpenCode Zen(`zen`) +标准 OpenAI 流式(`chat.completion.chunk` + `[DONE]`)。`events.py` 很薄,唯一要点:**末段正文与 `finish_reason` 同帧** → `parse_all_events` 同帧产出 CONTENT 与 FINISH。`[DONE]` 后 `{"choices": [], "cost": "0"}` 跳过。 + +渠道私有只有门禁伪装(`zen/client.py`): | 条件 | 要求 | 违反后果 | |---|---|---| | `User-Agent` | `opencode/`,version ≥ 1.18.0 | 低于阈值 → 426;无版本号 → 403 | -| `x-opencode-session` | `^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$`,且**前 12 位 hex 须解码回近期时间戳** | 403 | +| `x-opencode-session` | `^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$`,**前 12 位 hex 须解码回近期时间戳** | 403 | | body `stream` | 必须 `true` | 403 | -| body `tools` | 必须**同时**含 name 为 `bash` 与 `read` 的工具(参数可为空壳) | 403 | -| `Authorization` | `Bearer public` 可带可不带(匿名也通) | 带伪造 key → 401 | +| body `tools` | 必须同时含 `bash` 与 `read`(参数可空壳) | 403 | +| `Authorization` | `Bearer public` 可带可不带 | 带伪造 key → 401 | -> **会话前缀必须编码近期时间**:门禁不只校验 session 的**形态**,还要求 12 位 hex 前缀 -> 解码回**近期时间**,否则一律 `403 FreeTierError`(随机 hex 形态合规但语义不对)。 -> 编码照官方 `Identifier.create`(`sst/opencode` `src/id/id.ts`):把毫秒时间戳左移 -> 12 位(`ms * 0x1000`)再 `+1`,**按位取反**,取低 48 位写成 12 位小写十六进制; -> 后 14 位内容自由。复刻见 `new_session_id` / `_session_time_prefix`。**门禁在鉴权之前**: -> 带真实 `oc_sk_*` key 反而 401,`Bearer public` 才 200,故本渠道始终不带用户 Token。 +会话前缀编码照官方 `Identifier.create`(`sst/opencode` `src/id/id.ts`):`ms * 0x1000 + 1`,按位取反,取低 48 位写 12 位小写 hex;后 14 位自由。复刻见 `new_session_id` / `_session_time_prefix`。**门禁在鉴权之前**:带真实 `oc_sk_*` 反而 401。 -UA 版本走 `ZEN_OPENCODE_VERSION`(上游改阈值改 env,不硬编码);`ZEN_API_ENDPOINT` 走端点白名单(`ZEN_ALLOWED_ENDPOINTS`)。Zen **不携带任何用户 Token**,白名单只为防误配。**403 FreeTierError 抖动在客户端内重试一次**:上游偶发对同一请求回 `403 {"type":"error","error":{"type":"FreeTierError"}}`(门禁四项每次都满足,数秒后的同请求即 200)。403 归 `ErrKind.REQUEST` → 不冷却也不跳过渠道,但 zen 只有**一条虚拟凭证**,轮换候选随即耗尽 → 对外 503。故 `stream_chat` 在客户端内部先消化抖动:退避 `GATE_403_RETRY_DELAY`(1s)后换新 session id、关旧连接重发一次(`GATE_403_RETRIES=1`),重试仍 403 才上抛。只对 403 重试,其余状态码仍一次上抛。**注入空壳 + 改写/过滤回包**:门禁要求 tools 含 `bash`/`read`,但这两个可能是**我们伪造的**、客户端从没声明过(如 OpenCode 的执行工具叫 `shell`)。模型偏好调 `bash` 这个名字,直接吞掉会让客户端收到「空正文 + stop」提前结束回合。故两级处理:① 客户端声明了同义工具(`TOOL_SYNONYMS`,`bash`→`shell`/`execute`、`read`→`read_file`)→ 把注入名的 tool_call 函数名**改写**成同义工具原样透传,注入时还复制同义工具的 description/parameters;② 没有同义工具 → 才整块丢弃并记 WARNING。实现只在**缺失时**补骨架工具(用户自带同名工具 → 不注入、不改写、不过滤,绝不误伤);处理按 `index` 跟踪流式 tool_call 分片;若整条流无任何保留的真 tool_call,则把 `finish_reason=tool_calls` 收敛为 `stop`。**免费模型清单完全动态(现拉现探)**:上游 `/zen/v1/models` 免鉴权,但返回**全部**模型(含 70 多个付费模型),且**不带任何免费/付费标记**(`owned_by` 恒 `opencode`;亦无 cost 字段)。唯一权威信号是**匿名可用性**:付费模型恒 401 `Missing API key.`,免费模型永不 401。故两步过滤:① 按 `-free` 后缀收窄候选;② 对候选并发探活,**只保留 2xx**(已下线 400、区域限制 403、上游故障 5xx、超时都剔除)。无静态白名单,上游增删免费模型自动跟随;探活结果为空时抛协议错,让 `/v1/models` 用上次成功的缓存兜底。**判活结果缓存 30 分钟**(`MODELS_CACHE_TTL_SECONDS`,比服务层 `MODEL_LIST_TTL_SECONDS` 300s 长一档):探活是列表链路里最贵的一步(逐个真发一次推理,总耗时等于最慢那个),服务层每 5 分钟到期重拉列表时直接复用。**免费模型显式 x0 倍率**:`fetch_models` 给每个存活候选 `credit_rate=0.0`(而非 `None`)。 +- UA 走 `ZEN_OPENCODE_VERSION`,端点走 `ZEN_API_ENDPOINT`(受 `ZEN_ALLOWED_ENDPOINTS` 校验)。Zen **不携带任何用户 Token**,白名单只为防误配。 +- **403 FreeTierError 抖动客户端内消化**:403 归 `REQUEST`(不冷却不跳渠道),但 zen 只有一条虚拟凭证 → 轮换立刻耗尽 → 503。故 `stream_chat` 退避 `GATE_403_RETRY_DELAY`(1s)后换新 session id 重发一次(`GATE_403_RETRIES=1`),仍 403 才上抛;其余状态码不重试。 +- **门禁三处可配**(移动靶,改完重启即换、随时回退):UA 模板 `ZEN_USER_AGENT`(`{version}` 代入 `ZEN_OPENCODE_VERSION`,空 = `opencode/`)、API 根 `ZEN_API_ROOT`(默认 `/zen/v1`,可切 `/inference/v1`;2026-10-28 实测两路径 `GET /models` 均 200)、工具集合 `ZEN_GATE_TOOLS`(默认 `bash,read`,可切第三方实测的 `bash,glob,grep,read`)。默认值 = 本仓实测通过的形态;tools 逐字节 hash 上游是否校验**未核实**,故不实现假 hash。 +- **注入空壳 + 改写/过滤回包**:门禁要 `bash`/`read`(客户端工具叫 `shell`,注入可能伪造)。① 有同义工具(`TOOL_SYNONYMS`:`bash`→`shell`/`execute`、`read`→`read_file`)→ 注入名 tool_call **改写**成同义工具透传,注入时复制其 description/parameters;② 无同义工具 → 整块丢弃 + WARNING。只在**缺失时**补骨架;按 `index` 跟踪分片;整条流无真 tool_call → `finish_reason=tool_calls` 收敛为 `stop`。 +- **免费清单全动态**:`/zen/v1/models` 免鉴权但返回全部模型且**无免费标记**(`owned_by` 恒 `opencode`,无 cost);唯一权威信号 = 匿名可用性(付费恒 401 `Missing API key.`)。两步过滤:`-free` 后缀收窄 → 并发探活,**只剔除确定不可用**(`PROBE_DEAD_STATUSES` = 400/401/403/404:付费/下线/区域限制);`429`(匿名免费额度耗尽)/ 5xx / 连接超时按「暂不可判定」**保留**(实测 2026-11-03 匿名探测全线 `429 FreeUsageLimitError`,旧口径要求 2xx 会让 Zen 显示「无模型」),与 CodeBuddy 国际同取向。无静态白名单;候选全被确定判死才抛协议错,列表用缓存兜底。**判活缓存 30 分钟**(`MODELS_CACHE_TTL_SECONDS`,比服务层 300s 长一档)。免费模型标 `credit_rate=0.0`(非 `None`)。 -**虚拟凭证行**:Zen 无凭证、无额度接口。池里种一条空凭证(`credential_data={}`,`added_by="system"`),复用现有调度 / 冷却 / 统计 / 会话粘性。`probe_quota` 恒返回 `Quota(probe_failed=True)` → `health_score` 返回 `None`(**未知**,不是 `EXHAUSTED=-1`)——这点很关键:若返回 `total=None`,`health_score` 会判定为「已耗尽」而把这条免费渠道错误降级。种子幂等(已有 zen 凭证则不补),**用户删除后重启会复活**,永久停用请用「暂停」。只为默认装配路径种子(测试注入自定义 registry 时不多出凭证行)。 -### 5.2 Kilo 免费层(`kilo`) -Kilo(`api.kilo.ai/api/gateway`)对外是**标准 OpenAI 兼容协议**,既无私有信封,也**没有 Zen 那种门禁伪装**。`provider/kilo/events.py` 与 Zen 一样薄,唯一区别是**思考字段在 `delta.reasoning`**(Zen 是 `reasoning_content`),另带 `reasoning_details` 结构,忽略即可。`parse_all_events` 同样处理同帧 finish,并额外补 usage(Kilo 的 `usage` 常与收尾 `choices` 同帧)。**免费模型有权威标记、故不做探活(与 Zen 相反)**:`/models` 每个条目带 `isFree` 布尔(含 `kilo-auto/free`、`stealth/space-bunny-alpha` 等**无 `:free` 后缀**者)。故直接按 `isFree is True` 过滤,**不做探活**——探活会白耗本就极小的免费配额(网关级约 200 req/h/IP),且结果随上游免费池(实为 OpenRouter 免费池转发)波动不稳定。无静态白名单;过滤结果为空时抛协议错,让 `/v1/models` 用上次成功的缓存兜底。元数据(`name` / `context_length` / `top_provider.max_completion_tokens` / `supported_parameters` 含 `tools` / `architecture.input_modalities` 含 `image`)透传为中立 `Model`;免费模型显式标 **x0 倍率**。**虚拟凭证行**(同 Zen):`credential_data={}`、`added_by="system"`,`probe_quota` 恒 `Quota(probe_failed=True)` → `None`。种子幂等、**删除后重启复活**、永久停用请「暂停」。**错误分类**:401→`INVALID`(无凭证,401 只表示该模型需要付费 key/BYOK)、400/404/422→`INVALID`、403→`REQUEST`、**429 与 502/503/504→`MODEL`**(见 §3.2)。限流交引擎处理,本包不自建熔断。**独立 pacer**:`KILO_CHAT_MIN_INTERVAL`(热更项,默认 0 = 不节流)走独立 `Pacer`,与 zen / CB / TRAE 互不排队;`stream_chat` 在 `finally` 里 `pacer.release("kilo")` 归还并发名额。 -### 5.3 Qoder(`qoder`) -**与 zen/kilo 的本质区别**:Qoder 是**真实账号渠道**,凭证必须登录获得、加密入库,走与 CB 相同的设备码轮询登录轨道,但**不种虚拟凭证**——没有 token 就是不可用。**私有 COSY 协议**:推理 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`。请求体不是 JSON,而是**自定义 Base64 变体**(三段轮转 + 自定义字母表 + `=`→`$`);认证不是标准 Bearer,而是 `Authorization: Bearer COSY..` 加上整套 `cosy-*` 头。签名串为 `md5(payload_b64 + "\n" + cosy_key + "\n" + date + "\n" + body + "\n" + path)`(`path` 去掉 `/algo` 前缀,query 不参与签名);`payload` 是键排序紧凑 JSON `{cosyVersion,ideVersion,info,requestId,version}`;`cosy_key` 由临时 AES 密钥经服务端 RSA 公钥加密得到,`info` 是该临时密钥 AES-128-CBC 加密的身份 JSON。密码学复用项目已有 `cryptography`(AES-128-CBC + RSA PKCS1v15)。`cosy-machineid`/`cosy-machinetoken` 按 `uid` 稳定派生(同一账号长期同一虚拟设备,避免随机机器码触发风控)。**信封式 SSE**:响应每行是 `data:{"headers":…,"body":"<内层 OpenAI chunk>","statusCodeValue":200}`——即**外层信封包内层 chunk**。解析器逐帧解出 `body`,`body=="[DONE]"` 结束,`statusCodeValue != 200` 判上游错误(HTTP 仍可能 200)。内层仍是标准 OpenAI chunk,事件映射与 kilo 近似(`clean_delta` 顺带剔除网关补的空噪声 delta:`extra_fields`/`refusal`/空 `reasoning_content`/空 `tool_calls`)。**模型发现**:`GET {gateway}/algo/api/v2/model/list?Encode=1`,**必须带整套 COSY 签名头**(签名 body 为 `qoder_encode("")`)。裸 GET(无 COSY 头)会 403;方法固定 GET(带头用 POST/PUT 会被上游 400「Request method ... not supported」拒绝)。清单条目带 `key`(内部代号,如 `qmodel_38max`)与 `display_name`(可读名,如 `Qwen3.8-Max`):`id` 用 `key`、`name` 用 `display_name`。**`price_factor` 即「Credit 消耗倍率」**:来自服务端模型列表的 `price_factor`,与全站 `credit_rate` 同义(越小越省),直接映射;免费模型上游给 `0.0`。注意它是**相对倍率**,不是每次任务的固定积分。 +**虚拟凭证行**:`credential_data={}`、`added_by="system"`,复用调度 / 冷却 / 统计 / 粘性。`probe_quota` 恒 `Quota(probe_failed=True)` → health `None`(**未知**,非 `EXHAUSTED`)。种子幂等,**删除后重启复活**,永久停用用「暂停」。只在默认装配路径种子。 + +### 5.2 Kilo(`kilo`) +标准 OpenAI 兼容,无私有信封、无门禁。`events.py` 与 Zen 同薄,区别:**思考在 `delta.reasoning`**(另带 `reasoning_details`,忽略)。同帧 finish 处理,补 usage(常与收尾 `choices` 同帧)。 -**签到(活动制)**:`GET /sash/api/v1/me/campaigns`(**必须带 `Cosy-ClientType: 10`**,否则上游静默返回 `campaigns:[]`)→ 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"`(`VIEW_DETAILS` 等展示位也是 `CLAIMABLE`,不筛 actionType 会领错活动)→ `POST /sash/api/v1/me/campaigns/{campaignId}/claim`(同样带 `Cosy-ClientType: 10`)。claim 响应 `replayed:true` 是幂等重放、`status=="BLOCKED" && failureCode=="SAME_PERSON_ALREADY_CLAIMED"` 是同一自然人已领,两者都归一为 `already_checked_in=True`;其它 `BLOCKED` 才是真失败。**新协议不提供连续天数**,`streak_days` 恒为 `None`。仅当活动制接口本身 404/405/410 时才整体回退旧的 `daily-check-in` 流程;活动制与旧接口都不可用才算「本区域无此接口」,记为 skipped(`ok=False` 但不算失败)——**国际版该端点 404**。**签到窗口 ≠ 自然日,故按窗口封账**:上游签到活动每日 **10:00(UTC+8)** 才轮换 `campaignId`,当前窗口 `startAt 10:00 → endAt 次日 09:59`。按自然日封账会在凌晨把上一轮窗口的 `CLAIMED` 判成「今日已签到」而封掉整天,故 `CheckinResult.seal_until`(epoch 秒,缺省 None = 当天结束)由 Qoder 的活动制路径回填 `checkin_window_end(payload)` = 签到类活动里**最晚**的 `endAt`(展示位 `VIEW_DETAILS` 的 `endAt` 不采信,缺失/非数值/0 → None 回落原口径)。`CheckinTask._done_scopes` 为 `dict[done_key → 截止时刻]`,跳过判据 `_sealed()`(`until > now`);旧 `daily-check-in` 回退路径与其它渠道仍走自然日封账。**额度**:`GET {openapi}/api/v2/quota/usage` 的 `userQuota` + `addOnQuota` 合成 `remaining`/`total`,`cycle_end` 取最早 `expiresAt`。 +- **免费有权威标记故不探活**:`/models` 每条带 `isFree`(含无 `:free` 后缀的 `kilo-auto/free`、`stealth/space-bunny-alpha`)。探活白耗极小配额(网关级约 200 req/h/IP)且结果波动。过滤为空抛协议错,列表用缓存兜底。 +- 元数据透传:`name` / `context_length` / `top_provider.max_completion_tokens` / `supported_parameters` / `architecture.input_modalities`。免费模型标 x0。 +- 虚拟凭证行同 Zen。**错误分类**:401 / 400 / 404 / 422 → `INVALID`(401 只表示该模型需付费 key / BYOK);403 → `REQUEST`;**429 与 502/503/504 → `MODEL`**。限流交引擎,本包不自建熔断。 +- 独立 pacer `KILO_CHAT_MIN_INTERVAL`(热更,默认 0),`finally` 里 `pacer.release("kilo")`。 + +### 5.3 Qoder(`qoder`) +真实账号渠道,凭证须登录获得、加密入库,不种虚拟凭证。**私有 COSY 协议**: + +- 推理 `POST {gateway}/algo/api/v2/service/pro/sse/agent_chat_generation`。请求体 = **自定义 Base64 变体**(三段轮转 + 自定义字母表 + `=`→`$`);认证 `Authorization: Bearer COSY..` + 整套 `cosy-*` 头。 +- 签名 `md5(payload_b64 + "\n" + cosy_key + "\n" + date + "\n" + body + "\n" + path)`(`path` 去 `/algo`,query 不参与)。`payload` = 键排序紧凑 JSON `{cosyVersion,ideVersion,info,requestId,version}`;`cosy_key` = 临时 AES 密钥经服务端 RSA 公钥加密;`info` = AES-128-CBC 加密的身份 JSON。复用 `cryptography`。 +- `cosy-machineid`/`cosy-machinetoken` 按 `uid` 稳定派生(随机机器码触发风控)。 +- **信封式 SSE**:`data:{"headers":…,"body":"<内层 OpenAI chunk>","statusCodeValue":200}`。逐帧解 `body`;`body=="[DONE]"` 结束;`statusCodeValue != 200` 判上游错误(HTTP 仍可能 200)。`clean_delta` 剔除空噪声 delta。 +- **保活帧 `body:"null"` 必须跳过**:上游在流中发 `{"headers":…,"body":"null","statusCodeValue":200,"statusCode":"OK"}` 保活(实测 2026-10-26)。它不含内容,早先当「内层不是对象」→ 抛 `UpstreamProtocolViolation` → 整条流判失败(16:29-16:53 连续 13 次「流式响应失败: Qoder inner chunk is not an object」),客户端只看到回复被截断。判据用**结构**(`json.loads` 后是 `None` / 空 dict / 空 list)而非字符串前缀,见 `events.is_keepalive_body`。 +- **模型发现** `GET {gateway}/algo/api/v2/model/list?Encode=1`,**须带整套 COSY 头**(签名 body = `qoder_encode("")`);裸 GET 403,方法固定 GET(POST/PUT 400)。`id` 用 `key`(如 `qmodel_38max`)、`name` 用 `display_name`。`price_factor` = credit 消耗倍率(相对值,免费给 `0.0`)。 +- **签到(活动制)**:`GET /sash/api/v1/me/campaigns`(**须带 `Cosy-ClientType: 10`**,否则静默空列表)→ 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"` → `POST .../{campaignId}/claim`(同样带头)。`replayed:true` 与 `BLOCKED`+`SAME_PERSON_ALREADY_CLAIMED` 归一为已签;其它 `BLOCKED` 才是失败。新协议无连续天数(`streak_days` 恒 None)。仅活动制接口 404/405/410 时回退旧 `daily-check-in`;两者都不可用记 skipped(`ok=False` 但不算失败)。**国际版该端点 404**。 +- **签到窗口 ≠ 自然日**:上游每日 **10:00(UTC+8)** 轮换 `campaignId`,窗口 `10:00 → 次日 09:59`。按自然日封账会在凌晨封掉整天 → `CheckinResult.seal_until` 回填 `checkin_window_end(payload)`(签到类活动里**最晚**的 `endAt`,不采信 `VIEW_DETAILS`)。`CheckinTask._done_scopes` = `dict[done_key → 截止时刻]`,判据 `_sealed()`(`until > now`)。 +- **额度** `GET {openapi}/api/v2/quota/usage`:`userQuota` + `addOnQuota` 合成 `remaining`/`total`,`cycle_end` 取最早 `expiresAt`。 +- **节点故障归类**:Qoder 把自身节点故障包成 400(`{"code":"400","message":"[FAIL]node:… msg:Execution failed: null"}`,HTTP 与 `statusCodeValue` 都是 400)。`classify_error_code` 对 400/404/422 加响应体判据:命中 `NODE_FAILURE_MARKERS = ("[FAIL]node:", "Execution failed")` → `ErrKind.MODEL`;否则 `INVALID`。乱码模型名不带该标记 → 不误伤。 +- **403 判定统一(HTTP 与流内信封同一判据)**:`statusCodeValue` 403 早先由 `_decode_frame` 直接调 `classify_error_code` 判 DEAD(硬禁用),HTTP 403 却走 `classify_status` 判 SOFT——同码两后果。现信封也走 `classify_status`,优先级:排队 → QUEUE;`DAILY_QUOTA_MARKERS`(`Billing daily count exceeded`,`code 110`「每日计费次数用尽」)→ CREDIT(冷却到次日 04:00);`SESSION_DEAD_MARKERS`(`TOKEN_EXPIRE` / `12153` / `Offline user session not found`)→ DEAD;其余 → SOFT。实测 2026-10-27 15:39 `{"code":"110","message":"Billing daily count exceeded"}` 被流内路径判死,qoder `nick5080166916` 硬禁用。 +- **`content: null` 须归一为空串**:Qoder 拒收带 `tool_calls` 的 assistant 把 `content` 置 `null`(OpenAI 允许)。上游把该消息当「无正文」跳过 → 紧随的 `role:"tool"` 成孤儿,报文却报 `Messages with role 'tool' must be a response to a preceding message with 'tool_calls'`——指向 tool 配对,真因在 assistant 的 null content。出站前 `build_chat_body._normalize_null_content` 把 assistant/tool 的 `null` 改成 `""`(语义等价)。2026-10-23 实测:同一请求体只把 `null` 换成 `""` 即 400 → 200,与 `reasoning_content` / 并行调用 / 轮数无关。codebuddy_intl、trae、officeace 不敏感。 +- 白名单 `QODER_ALLOWED_ENDPOINTS` 含国内 openapi+gateway 与国际版,启动时校验;`QODER_CHAT_MIN_INTERVAL`(热更,默认 5s)独立 pacer。 -**上游节点故障归类**:Qoder 把**自身推理节点故障**包成 400——`{"code":"400","message":"[FAIL]node:… msg:Execution failed: null"}`(HTTP 与信封 `statusCodeValue` 都是 400),而同批其他模型正常。这与「模型不存在 / 请求无效」的 400 语义不同:换凭证不解决但会自愈。故 `classify_error_code` 对 400/404/422 增加响应体判据:命中 `NODE_FAILURE_MARKERS = ("[FAIL]node:", "Execution failed")` → `ErrKind.MODEL`(模型级瞬时冷却),否则维持 `INVALID`。安全前提:探测未知模型名(乱码/空串)上游既不返回 ERROR 也不带该标记,故不会误伤。耗尽轮换且**全部候选都因该模型处于模型级冷却**时,503 文案用 `model 'x' temporarily unavailable on upstream`(可附相近模型建议)而非 `all credentials unavailable`。**节点白名单**:`QODER_ALLOWED_ENDPOINTS` 同时含国内 openapi+gateway 与国际版;`_qoder_endpoint` 启动时校验。`QODER_CHAT_MIN_INTERVAL`(热更项,默认 5s)走独立 pacer。 ### 5.4 CodeArts(`codearts`) -**华为云签名体系 + 私有 SSE 帧**。**SDK-HMAC-SHA256 签名**:鉴权用 AK/SK + `X-Security-Token`,`Authorization: SDK-HMAC-SHA256 Access=, SignedHeaders=…, Signature=…`。三处必须逐字节对齐:signedHeaders 是**请求全部头**(小写、字典序);CanonicalURI **每段单独 percent-encode 且末尾补 `/`**(漏尾斜杠是 APIG.0301 的常见原因);payload hash 取 `X-Sdk-Content-Sha256`。`signer.py` 写成纯函数(不含「当前时间」)以便固定向量测试。**DPoP 令牌刷新(刚性约束)**:`POST {sts}/v1/oauth2/tokens` `grant_type=refresh_token` 需带 **DPoP(ES256/P-256) JWS**。refresh_token 与 `client_id=codearts-agent` + DPoP 私钥**三者绑定、一次性**——刷新成功必须把新 `refresh_token` 回写凭证(`RefreshTask` 先落库再同步账号)。ES256 用 `cryptography`(`ec.SECP256R1` + `ECDSA(SHA256)`)并**低 S 归一化**(`s > n/2` 时取 `n - s`)——华为 STS 校验要求低 S,而 `cryptography` 不保证(实测 2000 次签名有 991 次 high-S)。私钥必须随 refresh_token 一起持久化并原样复用(STS 把 refresh_token 绑定在首次换取时的 DPoP 公钥上)。**刷新节流**:`REFRESH_SKEW_HOURS` 默认 24h ≫ STS 寿命 2h,而 `needs_refresh` 语义是「离到期还剩 skew 秒就刷」,故给 `CodeArtsCredential` 加 `refresh_skew_cap_seconds = 2700`,`needs_refresh` 取 `min(skew, cap)`:把「一进窗口每轮都刷」降成「离到期 45min 内才刷」。**窗口必须宽于轮询周期**(`REFRESH_INTERVAL_MINUTES`,默认 30min):窗口 ≤ 周期时轮询点整轮落在窗口之外,凭证拖到到期才刷(实测封顶 30min、周期 60min 时在距到期约 26s 才刷新,上游回 `APIG.0602`)。封顶 45min > 30min 周期留余量;轮询粒度下实际在**剩余约 30min** 处刷新。 +**SDK-HMAC-SHA256**:AK/SK + `X-Security-Token`,`Authorization: SDK-HMAC-SHA256 Access=, SignedHeaders=…, Signature=…`。三处逐字节对齐:signedHeaders = **全部头**(小写、字典序);CanonicalURI **每段 percent-encode 且末尾补 `/`**(漏尾斜杠 = `APIG.0301` 常见原因);payload hash 取 `X-Sdk-Content-Sha256`。`signer.py` 纯函数(不含当前时间)以便固定向量测试。 + +**DPoP 刷新(刚性)**:`POST {sts}/v1/oauth2/tokens` `grant_type=refresh_token` 需带 ES256/P-256 JWS。refresh_token 与 `client_id=codearts-agent` + DPoP 私钥**三者绑定、一次性**——刷后须回写新 `refresh_token`(`RefreshTask` 先落库再同步账号)。ES256 用 `cryptography`(`ec.SECP256R1` + `ECDSA(SHA256)`)并需**低 S 归一化**(`s > n/2` 取 `n - s`;实测 2000 次有 991 次 high-S)。私钥随 refresh_token 持久化复用。 + +**刷新节流**:`REFRESH_SKEW_HOURS` 默认 24h ≫ STS 寿命 2h → `CodeArtsCredential.refresh_skew_cap_seconds = 2700`,`needs_refresh` 取 `min(skew, cap)`。**窗口须宽于轮询周期**(`REFRESH_INTERVAL_MINUTES` 默认 30):窗口 ≤ 周期 → 整轮落在窗口外,拖到到期才刷(实测 30min 封顶 + 60min 周期 → 距到期 26s 才刷,上游回 `APIG.0602`)。45min 封顶 > 30min 周期 → 实际在剩余约 30min 处刷。 + +**SSE 双形状**:逐行 `data:` JSON(也有裸 JSON 行),`data:[DONE]` 结束。v2 `/api/v2/chat/completions` = 标准 OpenAI chunk(增量在 `delta`;收尾帧 `choices:[]` + `usage`;`tool_stream:true` 时分片在 `delta.tool_calls`)。旧形状 `{"text":"<累计全文>"}` = 替换语义 → `TextSnapshot` 做差(新文本以旧为前缀则只发后缀);结束帧 `{"text":"[DONE]","error_code":"0"}`。错误两路:HTTP 非 2xx,或流内 `error_code`(`ChatAgent.*` / `TM.00001041`,HTTP 仍 200)。业务码是字符串,`Event.error_code` 只有 int 槽位 → 原始码进 `error_message`、分类进 `error_kind`。 -**SSE 帧(两种形状同时兼容,按字段分派)**:逐行 `data:` JSON(行间有空行;也有不带 `data:` 前缀的裸 JSON 行),最后由 `data:[DONE]` 结束。**v2 `/api/v2/chat/completions` 是标准 OpenAI chunk**:`{"choices":[{"delta":{"content":…,"reasoning_content":…,"tool_calls":…},"finish_reason":…}]}`,增量在 `delta`,收尾帧 `choices:[]` + `usage` 单独给 token 数;带 `tool_stream:true` 时工具调用分片在 `delta.tool_calls`。旧形状则是 `{"text":"<累计全文>"}`(**替换语义**,用 `TextSnapshot` 做差:新文本以旧文本为前缀 → 只发新增后缀;否则整段重发)+ 结束帧 `{"text":"[DONE]","error_code":"0"}`。错误有两条路:HTTP 非 2xx,或流内 `error_code`(形如 `ChatAgent.*` / `TM.00001041`,HTTP 仍 200)。业务码是**字符串**,中立 `Event.error_code` 只有 int 槽位,故原始码进 `error_message`、分类进 `error_kind`。**每日签到**:三个端点都挂 **snap 引擎根上、不带 `snap-manager` 前缀**(`/v1/statistics/plugin`、`/v1/current/user` 反而必须带——两套前缀混用容易扫不到): +**签到**:三个端点挂 snap 引擎根、**不带 `snap-manager`**(`/v1/statistics/plugin`、`/v1/current/user` 反之须带): | 用途 | 端点 | 备注 | |---|---|---| | 查活动 | `GET {snap}/v1/ops/delivery?channel=IDE` | 回 `data.items[]` | -| 领取 | `POST {snap}/v1/ops/claim` | body `{campaignId:1, idempotentKey:"claim_1_", channel:"IDE"}` | -| 确认 | `POST {snap}/v1/ops/confirm` | body `{campaignId:1}` | +| 领取 | `POST {snap}/v1/ops/claim` | body `{campaignId:<当轮活动 ID>, idempotentKey:"claim__", channel:"IDE"}` | +| 确认 | `POST {snap}/v1/ops/confirm` | body `{campaignId:<当轮活动 ID>}` | -`idempotentKey` 格式由前端 JS 定死(`claim__`);请求头 `agent-type: PromptCenter`。**四种 status 必须区分**:`ELIGIBLE`(可领)→ claim + confirm;**`CLAIMED`(已领未确认,非终态)→ 只补 confirm**;`CONFIRMED` / `CONSUMED` → 已签,不碰写接口。**claim 时积分即到账**,confirm 只推进状态,故 confirm 失败不丢积分、但 `checkin()` 仍返回 `ok=False` 让任务下一轮补。活动不存在 / 已过期一律 `ok=True`(不是错误);`ELIGIBLE` 但 `claimable=false` 或未知 status 归 `ok=False` 重试。业务码走 HTTP 200(如 `40001 尚未到达权益刷新时间`),不能只看状态码。`seal_until` 留空(上游只给活动总 `endTime`、无每日刷新窗口)。`checkin_scope` 用 `uid` 隔离,身份未知回落空串。**签到之外的活动操作未接入**。**额度=积分**(`parse_credits`):`GET {snap}/snap-manager/v1/statistics/plugin` 回 `metrics[]`,其中 `usageTotalPackageCredit` 是合计、其余(Basic/OnDemand/Bonus)是分项包,各含 `package_credit_amount/used/remain`;数值**本身即积分**(内置模型 GLM-5.2 / OpenPangu 等按积分计费,这是本渠道唯一的额度来源),**不做任何单位折算**。合计项直接进 `Quota.remaining/total`(健康度由它派生),分项包写进 `Quota.packages`(展示),顶层 `end_date`(实测每账号不同,如 `2026-11-02`)是套餐到期日、登记进 `Quota.expiry_ladder`(到期点=`end_date`)——积分用不完同样作废,故调度「快过期的先用」也会先烧积分。`metrics` 里取不到合计项、或合计项无有效数值时判**未探测**(`probe_failed=True`,绝不填 `remaining=0` 被误判「已耗尽」);积分接口失败只记 warning、不把整次探测判失败。**前缀必须带 `snap-manager`**(不带回 APIG.0101,与 `/v1/ops/*` 相反),头 `Agent-Type: PromptCenter`。官方只给「模型积分消耗系数」(GLM-5.2 0.7 / OpenPangu-2.0-Flash 0.32),**没给公式**,无法从 `input_price`/`ratio` 归约 → **不猜**,逐请求 `credit` 保持 `None`,真实消耗以积分余额为准。「保活」**只由 `RefreshTask` 承担**(先落库再同步):`refresh_token` 一次性,额度探测等旁路若也顺手刷新,同一 token 会被两处各消费一次、且旁路结果不落库 → 库里 token 被烧成废票(实测由此把渠道打成 `APIG.0602`)。`probe_quota` 因此为**只读余额**,不做保活刷新。 +**活动 ID 必须取当轮响应,不能写死**:2026-11 上游把每日签到活动从 `campaignId=1`(「每日签到领1000 积分」)换成 **34**(「每日签到领100积分」),面额同时从 1000 降到 100。旧实现按 ID 1 精确挑活动,换 ID 后 `daily_credit_campaign` 恒返回 `None` → `checkin()` 回「官方签到活动未开放」并按成功封账 → 表现为**签到任务每天成功、web 端任务仍在、积分不增加**(线上 `checkin_events` 全是「官方签到活动未开放」)。现按 `type == "USER_LOGIN"`(等价 `extra.triggerEvent == user.login`)挑活动,`campaignId` 1 只作无 `type` 字段的极老响应兜底;claim/confirm 的 `campaignId` 一律取当轮条目。 -**倍率(`credit_rate`)**:模型目录 `GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`)的每条目带 `credit[]`,取首档 `ratio_display`(如 `"0.7x"`)。模型列表**只走该内置目录**:空列表按协议违规上抛(`UpstreamProtocolViolation`,对外「上游无可用模型」),HTTP / 协议错误直接上抛由调用方退避。**登录**:OAuth2 PKCE → `POST {snap-manager}/v1/oauth2/tokens`(authorization_code)换 `{access_key_id, secret_access_key, security_token, expiration, refresh_token}`,DPoP 私钥随 credential 一起生成并加密入库。门户把授权码 302 回 `http://127.0.0.1:{port}/oauth/callback`——用户本机地址,服务端监听不到;故本渠道不用 poll 轨道,而走「paste 轨道」:前端展示授权页后让用户把浏览器地址栏里那条打不开的回调链接粘回,走 `POST /api/auth/upstream/complete` 由服务端用 code + 登录时登记的 PKCE `code_verifier`/DPoP 私钥换 token。登录后补身份:`CodeArtsOAuth._finish` 用签名身份接口(`caller_identity`,按区域不可用时回落 `current_user`)尽力补 `uid`/`user_name`/`nickname`(只补缺失项);失败不阻断登录。 +`idempotentKey` 格式 `claim__`;头 `agent-type: PromptCenter`。**四态**:`ELIGIBLE` → claim + confirm;**`CLAIMED`(已领未确认)→ 只补 confirm**;`CONFIRMED`/`CONSUMED` → 已签。**claim 时积分即到账**,confirm 只推进状态 → confirm 失败不丢积分但仍返回 `ok=False` 让下轮补。活动不存在 / 已过期 `ok=True`;`ELIGIBLE` 但 `claimable=false` 或未知 status → `ok=False`。业务码走 HTTP 200(如 `40001`)。`seal_until` 留空。`checkin_scope` 用 `uid`。**签到之外的活动操作未接入**。 + +**额度=积分**(`parse_credits`):`GET {snap}/snap-manager/v1/statistics/plugin`(**须带 `snap-manager` 前缀**,头 `Agent-Type: PromptCenter`)回 `metrics[]`;`usageTotalPackageCredit` = 合计,其余(Basic/OnDemand/Bonus)= 分项包。数值即积分,**不做单位折算**;合计进 `Quota.remaining/total`,分项进 `Quota.packages`,顶层 `end_date`(如 `2026-11-02`)只进 `cycle_end`/`packages[].end`,**不进阶梯**(它不是逐笔作废时刻)。取不到合计项或数值无效 → `probe_failed=True`(绝不填 0)。 + +**逐笔明细(`parse_web_credits`)**:有 `web_cookie` 时优先 portal `overview` + `campaign/{id}` 分页,福利包按笔展开(`total/used/end/expiring`),`expiry_ladder` 用真实 `expireAt`;`expiredAmount`/`status` 会滞后,判死只看 `expireAt <= now`;已过期条目不进 `packages`。portal 未登录(3xx/401/403/HTML/空 body/业务码 401|403)→ `Quota.web_cookie_state="expired"` 落 `credentials.web_cookie_state` 供管理台提示,其他失败保持 NULL/旧状态;重贴 Cookie 时 API 复位为 NULL。无 Cookie 或 portal 失败 → 回退 statistics 的 7 天窗口桶,**不把探测判失败**。portal 只认控制台会话 Cookie,AK/SK 签名打不通,且必须带 `X-Requested-With: XMLHttpRequest`。官方只给积分系数(GLM-5.2 0.7 / OpenPangu-2.0-Flash 0.32)无公式 → **不猜**,逐请求 `credit` 保 `None`。「保活」只由 `RefreshTask` 承担(一次性 token 两处消费会烧成废票),`probe_quota` 只读余额。 + +**倍率**:`GET {snap}/v1/model/builtin`(头 `Agent-Type: PromptCenter`)每条带 `credit[]`,取首档 `ratio_display`(如 `"0.7x"`)。空列表按协议违规上抛。 + +**登录**:OAuth2 PKCE → `POST {snap-manager}/v1/oauth2/tokens`(authorization_code)换 `{access_key_id, secret_access_key, security_token, expiration, refresh_token}`;DPoP 私钥随 credential 生成并加密入库。回调 302 到 `http://127.0.0.1:{port}/oauth/callback`(服务端监听不到)→ **paste 轨道**:用户把打不开的回调链接粘回,`POST /api/auth/upstream/complete` 用 code + `code_verifier` + DPoP 私钥换 token。登录后用 `caller_identity`(不可用回落 `current_user`)尽力补 `uid`/`user_name`/`nickname`;失败不阻断。 + +**重新登录合并**:三条 OAuth 落库路径(poll / 粘贴回调 / `/authorize`)统一走 `api/credential_login.py`。provider 声明稳定身份(CodeArts `uid`,兜底 `domain_id|user_name`;OfficeAce `account_id`/`principal_id`)→ 命中同渠道既有凭证就地更新(整份凭证 + token 时间)、解除硬禁用、清 `err_count` 与模型级负缓存。**账号级冷却按来源决定**:`UPSTREAM_COOLDOWN_KINDS`(限流/余额/权益/风控)保留,与 `revive(force=False)` 同口径——重登换的是凭证不是配额;其余(本地 OTHER / `cooling_kind` 为 NULL 的老库)照旧清除。`enabled` / `pinned` / `tags` / `added_by` / `created_at` 保留;多行命中优先 `disabled`(本次要救的那条)→ `pinned` → 最早创建。**身份按别名集合取交集**(不只首选键):身份补全偶发失败时新登录只有兜底键,单键比较会与老行首选键错开而新增重复行。身份缺失/未命中 → 新增。查→合并→新增整段持渠道级登录锁,并发登录不产生重复行。 + +**一次性票终态**:`client.refresh_token` 捕获 `TokenEndpointError` 后经 `events.relogin_required(body)` 判定,只认 `_RELOGIN_MARKERS`(`STS5.1806 the refresh token has been used` / `invalid client id` / `InvalidDPoPHeader` / `invalid_grant`)。网络 / 5xx / 限流 / 普通 4xx 仍可重试(误判会让偶发故障永久废掉好凭证)。`RefreshTask` 捕获后 `mark_relogin_required`:写可操作 `disabled_reason`、硬禁用、清模型级冷却。 + +**并发上限与滑动窗口**:上游每账号每约 60s 最多 3 个会话;超出 `HTTP 400` + `TM.00001041 并发会话数已达上限(3个)`(会话在 HTTP 结束后仍滞留,实测约 68s 恢复)。`Pacer` 按桶在途上限 `max_concurrency`(满则挂起等 `release`)+ 滑动窗口 `window_seconds`(同桶最近 N 秒最多放行 M 次)。装配 `lambda: runtime.codearts_max_concurrency`(默认 3)与 `lambda: runtime.codearts_request_window_seconds`(默认 60s,`0` 关闭窗口)。窗口模式配 `max_concurrency=0` 不生效。 + +**名额归还须显式关流**:`async for ... break` 不关闭 async generator(只在耗尽 / `aclose()` / GC 时跑 `finally`)。executor 遇 `ERROR` 的 `break` 让名额推迟归还 → `_inflight` 单调累积 → 表现「用了三次就限制」。故 `provider.base.aclose_stream` 统一关闭;`stream_guarded` / `stream` / `_stream_loop` / `complete` / `ContinuationStream.__aiter__` 五处提前结束处全部显式关闭。 + +白名单 `CODEARTS_ALLOWED_ENDPOINTS` 含 snap 引擎、STS、门户;`CODEARTS_CHAT_MIN_INTERVAL`(热更,默认 5s)独立 pacer。 -**一次性票终态(`UpstreamReloginRequired`)**:`refresh_token` 被消费 / 绑定项不符后,每次重试都是同一张废票。CodeArts 令牌端点在 `client.refresh_token` 捕获 `TokenEndpointError` 后经 `events.relogin_required(body)` 判定——只认 `_RELOGIN_MARKERS`(`STS5.1806 the refresh token has been used` / `invalid client id` / `InvalidDPoPHeader` / `invalid_grant`)这类**上游点名**终态;网络 / 5xx / 限流 / 绑定项齐全的普通 4xx 仍走可重试路径(误判终态会让一次偶发故障永久废掉一条好凭证)。`RefreshTask` 捕获后调 `mark_relogin_required`:写可操作的 `disabled_reason`(「刷新令牌已失效,需重新登录」)、硬禁用、清模型级冷却;下轮 `candidates()` 计入 skipped 不再重试。**并发上限与滑动窗口**:上游对**每账号并发会话数**有硬限(实测 3),超出直接 `HTTP 400` + `TM.00001041 并发会话数已达上限(3个)`。`Pacer` 按桶在途上限 `max_concurrency`(满则挂起等 `release` 让位;0/None 保持旧行为),CodeArts pacer 装配 `lambda: runtime.codearts_max_concurrency`(热更项,默认 3)。但上游限制的实为「**每账号每约 60s 最多 3 个会话**」(会话在 HTTP 流结束后仍滞留数十秒,实测约 68s 才恢复),纯在途上限挡不住突发,故 `Pacer` 再加**滑动窗口**口径:`window_seconds > 0` 时与 `max_concurrency` 组合成「同桶最近 `window_seconds` 秒内最多放行 N 次启动」,窗口内满额则挂起到最早一次启动滑出窗口。CodeArts pacer 装配 `window_seconds=lambda: runtime.codearts_request_window_seconds`(默认 60s;`0` 关闭窗口口径退回纯在途上限)。窗口准入**同时**受窗口与在途两个闸门约束;窗口模式配 `max_concurrency=0` 时不生效。**名额归还必须显式关闭上游流**:`async for ... break` **不关闭** async generator(CPython 只在耗尽 / 显式 `aclose()` / GC 的 asyncgen finalizer 时才跑 `finally`)。executor 遇 `EventKind.ERROR` 的 `break` 于是让节流名额推迟归还;轮换重试每次重新 `wait_turn`,`_inflight` 单调累积,满 `max_concurrency` 后新请求在 `wait_turn` 无限阻塞——表现为「用了三次就限制」而非「并发三」。故 `provider.base.aclose_stream` 统一关闭,`stream_guarded` / `stream` / `_stream_loop` / `complete` / `ContinuationStream.__aiter__` 五处提前结束消费处全部显式关闭上游流。**节点白名单**:`CODEARTS_ALLOWED_ENDPOINTS` 含 snap 引擎、STS、门户三个主机;AK/SK 签名请求只发往白名单。`CODEARTS_CHAT_MIN_INTERVAL`(热更项,默认 5s)独立 pacer。 ### 5.5 CodeBuddy 国际(`codebuddy_intl`) -**独立渠道而非「改 `CODEBUDDY_API_ENDPOINT` 切站点」**:endpoint 在本项目是**进程级单值**(`Settings` 单字段,装配时注入一个 client)。切过去会让所有存量国内站凭证立刻打到国际站——两站是各自注册的账号体系,额度与模型清单都不通用,表现为额度错配或 403。国际站作为并列渠道存在,两站凭证可同时在池里,各走各的端点、pacer 与登录流程。**协议零复制**:两站是**同一套后端**——同一 Keycloak realm(`copilot`)、同一组路径(`/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*`、`/billing/meter/*`)。故 `CodeBuddyIntlProvider` 是 `CodeBuddyProvider` 的**数据类子类**(`src/provider/codebuddy/intl.py`),只覆盖渠道 id 与三项能力;协议解析、凭证规范化、事件分类、签到、刷新全部共用国内站代码。**路由存在性的判据**:国际站网关对已注册路径回 `401`(openresty/APISIX 认证插件),对未注册路径回应用层 JSON `{"error_msg":"404 Route Not Found"}`,两者可区分。**但前缀级挂认证时 401 不携带信息**(`/v2/activity/*` 整个前缀挂认证,不存在的子路径也返回 401),此时必须另找同前缀下的对照路径。**唯一的行为差异:首条 system 强制注入**。国际站网关要求 `messages[0]` 是 system prompt,否则整单 `400` + `{"code":11128,"msg":"first message is not system prompt"}`。国内站无此要求。处置是 client 构造参数 `require_leading_system`,**只在国际站装配时开**:`ensure_leading_system` 仅在首条非 system 时补一条 `DEFAULT_LEADING_SYSTEM = "You are a helpful assistant."`。三条约束:① 判首条必须在 `developer → system` 归一**之后**(PI 等客户端把 system 转成 developer,归一已满足要求);② 客户端自带 system 时不注入、不覆盖、不重排;③ 正文取中性最小句且不带任何厂商指纹,否则撞 11128 内容风控。注入只改 deepcopy 出的出站副本,不穿透回 `request.raw`(会话粘性按原文算指纹)。**能力边界**: -- **签到在渠道侧就不存在**:路径存在(`/billing/meter/checkin-activity-status` 回 `code=0` 而非 404),但活动本身没有(`active: false`,`season`/`theme_name`/`start_time`/`end_time`/`activity_name` 全空;CLAIM 回 `{"code":10001,"msg":"签到活动未开启或已过期"}`)。全空是关键判据——这是**渠道级缺席**。处置:`checkin` / `checkin_scope` / `checkin_status` 三项置 `None`(三个探测点都要关)。**为什么必须关**:保留只会让后台任务每 10 分钟失败一次,连续失败触发 `task_failed` 噪音告警,把真实告警淹掉。 -- **成长中心未验证**:整个 `/v2/activity/*` 前缀挂在认证插件后(不存在的子路径也返回 401 而非 404),401 无法证明子路由存在;而成长中心含抽奖 / 连登兑换 / 盲盒等**不可逆**动作,在未验证的端点上盲跑会真实消耗账号权益。处置:`growth` 置 `None`——调用方一律用 `getattr(provider, "growth", None)` 探测,置 `None` 即自动跳过,无需在任务层加渠道分支;管理台的菜单项本就按 `credential.provider === "codebuddy"` 判定,对本渠道天然不出现。 -- **活跃上报启用**:上报打的端点是 `/v2/report`,与成长中心的 `/v2/activity/*` **不是同一路径**——国际站 `/v2/report` 带凭证回 `200 {"code":0}`,同批探针里 `/v2/report-nope` 回应用层 `404 Route Not Found`(两者可区分)。`activity_scope` 含 endpoint(`https://www.codebuddy.ai|`),故两站不会互相占位。 +**独立渠道而非切 `CODEBUDDY_API_ENDPOINT`**:endpoint 是进程级单值(`Settings` 单字段);切过去 → 存量国内凭证立刻打到国际站(两站独立账号体系,额度与模型清单不通用)。 + +**协议零复制**:同一套后端(同一 Keycloak realm `copilot`、同一组路径 `/v2/chat/completions`、`/v3/config`、`/v2/plugin/auth/*`、`/v2/billing/*`、`/billing/meter/*`)→ `CodeBuddyIntlProvider` = `CodeBuddyProvider` 的数据类子类(`provider/codebuddy/intl.py`),只覆渠道 id 与三项能力。**路由存在性判据**:已注册路径回 401(openresty/APISIX);未注册回 `{"error_msg":"404 Route Not Found"}`。前缀级挂认证时 401 不带信息(`/v2/activity/*` 整前缀挂认证)→ 须另找同前缀对照路径。 + +**唯一行为差异:首条 system 强制注入**。国际站要求 `messages[0]` 是 system,否则 `400` + `{"code":11128,"msg":"first message is not system prompt"}`。client 构造参数 `require_leading_system` 只在国际站开;`ensure_leading_system` 仅在首条非 system 时补 `DEFAULT_LEADING_SYSTEM = "You are a helpful assistant."`。三约束:① 判首条须在 `developer → system` 归一**之后**;② 客户端自带 system 不注入、不覆盖、不重排;③ 正文不带厂商指纹(否则撞 11128)。只改 deepcopy 的出站副本,不穿透回 `request.raw`。 + +**国际站指纹(2026-10-29)**:国际站自 2026-10-15 加渠道校验后,`X-Conversation-*` 三件套只是**入场券**(缺任一 → `400/11128`,补齐 → `200`,decola/9router #5005 的 A/B);账号是否被判自动化看的是**指纹可信度**。`CodeBuddyClient(intl=True)` 只作用于国际站: + +- **会话 id 稳定**:`X-Conversation-ID` 由 `conversation_seed`(显式会话键 / 会话头 / 首条非 system 消息哈希,见 `client.conversation_seed`)派生 **UUID v1**,同一对话每轮同一 id、换号重试也沿用;逐请求随机等于「每问一句开一个新对话」,是最显眼的自动化特征。 +- **版本常量对齐官方现行 CLI**:`INTL_CLI_VERSION` / `INTL_OPENAI_JS_PACKAGE_VERSION` / `INTL_NODE_RUNTIME_VERSION`(UA、`X-IDE-Version`、`X-Product-Version`、`x-stainless-*` 同源)。国内站继续用 2.107.0 那套。 +- **补官方头**:`X-Session-ID`(= 会话 id)、`X-Model-ID`(当前模型)、`X-Trace-ID`(= `X-Request-ID`)、`X-Product-Version`;值不明的 `X-Agent-Type` / `X-Data-Tag` / `X-Root-Request-ID` **不发**——发官方从不发的头本身就是信号。 +- **`X-User-Id` 回退 JWT `sub`**:本地 intl 凭证的 `user_id` / `account_uid` 都是空串,而官方 CLI 每请求都发该头(上游据此关联账号);只在国际站回退。 +- **请求体 gzip**:官方 CLI 用 `Content-Encoding: gzip`(`gzip_body=True`,仅 intl 装配)。 +- **探活结果落盘**:`filter_available_models` 的 20 连发结果除进程内缓存外写 `{data_dir}/codebuddy-model-access.json`(`model_access_store.py`,原子写、TTL 6h、坏文件降级为空)。否则每次重启对每个账号重放一轮「同 IP、秒级窗口、并发 4」的探活,正是风控眼里的自动化(2026-10-27 当天 11 次重启 ≈ 1320 次)。 + +**11140 无客户端解**:账号一旦被定性,换头、换模型、换体形态都无效(多实现一致结论;`SUBMIT_FEEDBACK` 申诉是唯一路径)。2026-10-27 六个国际站账号全中(含一个零聊天记录、余额未动的新号),止损见 `docs/codebuddy-intl-analysis-2026-10-29.md`。 + +**能力边界**: +- **签到渠道级缺席**:路径存在(`/billing/meter/checkin-activity-status` 回 `code=0`),活动没有(`active: false`,`season`/`theme_name`/`start_time`/`end_time`/`activity_name` 全空;CLAIM 回 `{"code":10001,"msg":"签到活动未开启或已过期"}`)→ `checkin` / `checkin_scope` / `checkin_status` 三项置 `None`(保留只会每 10 分钟失败一次,触发 `task_failed` 噪音)。 +- **成长中心未验证**:`/v2/activity/*` 整前缀挂认证,401 无法证明子路由存在;成长含不可逆动作 → `growth` 置 `None`,调用方 `getattr(..., None)` 自动跳过。管理台菜单按 `credential.provider === "codebuddy"` 判定,天然不出现。 +- **活跃上报启用**:`/v2/report` 与 `/v2/activity/*` 不同路径,国际站回 `200 {"code":0}`(`/v2/report-nope` 回 404)。`activity_scope` 含 endpoint,两站不互相占位。 -**模型合并:修渠道内重名导致的伪渠道后缀**:上游把多个模型共用一个展示名是常态(CB 的 `hy4-preview` / `hy4-preview-x` 都叫「Hy4 preview」),`_merge_key` 一遇到渠道内展示名重名就退回**渠道限定键**,代价是这些模型**在本渠道内**就断了跨渠道合并。改判据:重名时若本渠道**存在某个 id 归一后等于该 label key 的模型**,就用这个归一键当合并键(重名者 id 不同故自然分开,且不算「按名入键」故不登记歧义别名);没有 id 自洽候选时(TRAE 的 `sagitta` / `aquila` 组)仍退回限定键。注意 `-x` / `-sg` 是**真实存在的不同上游模型**(id 不同、`max_input_tokens` 有差异),不能被合并掉。**白名单互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有 `https://www.codebuddy.ai`,**不含**国内站主机。两站 Token 由不同网关签发,跨站发送即凭证泄露,故把国内站地址填进 `CODEBUDDY_INTL_API_ENDPOINT` 会在**启动时**抛 `ValueError`。**pacer 与 OAuth 各自独立**:`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`(热更项,默认 5s)装一个独立 `Pacer`——共用会让两站请求排进同一条队列互相空等最小间隔。`_upstream_auth` 对两个渠道各建一套 `CodeBuddyOAuth`(登录域名不同)。`_pacer_key` 带渠道 id 构造,即便将来共用 Pacer 也不会互相占位。 +**模型合并:修渠道内重名导致的伪渠道后缀**:上游常共用展示名(`hy4-preview` / `hy4-preview-x` 都叫「Hy4 preview」)。重名时若本渠道**存在 id 归一后等于该 label key 的模型** → 用该归一键当合并键;无 id 自洽候选时(TRAE 的 `sagitta`/`aquila`)仍退回限定键。`-x` / `-sg` 是**真实不同模型**,不可合并。 -### 5.6 OfficeAce(`officeace`,华为云果办 / AgentArts) -**两套凭据、两个面、两种签名**——这是本渠道唯一的复杂度来源。聊天面(`{gateway}/v2/chat/completions`、`/v2/models`)用**永不过期的** `Authorization: Basic :`;控制面(`/v1/subscription*`、`/v1/model-token-rates`、`/v1/claw/*`)用**约 2h 有效期**的临时 AK/SK + `X-Security-Token`。故凭证结构里两组字段并存,`has_control_plane` 判定控制面能力(只有 app key 的凭证能聊天但不能签到 / 探额度,`probe_quota` 回 `probe_failed` 而非 0,否则会被调度器当耗尽)。**两条签名轨道不能互换**:控制面 `/v1/claw/*` 是华为云标准 `SDK-HMAC-SHA256`(复用 `codearts.signer`);模型网关的 `/v1/subscription*` 与 `/v1/model-token-rates` 只认 **`V11-HMAC-SHA256`**(多一层 CredentialScope `YYYYMMDD/{region}/apic`,且签名密钥要派生)。V11 三处必须原样:① 派生外层 key=**AK**、msg=**SK**(与直觉相反);② `HMAC(HMAC(AK,SK), scope+0x01)[:32]` 截断后转 **hex 文本**当下一轮密钥(不是原始字节);③ **不要**加 `X-Sdk-Content-Sha256` 头。`CanonicalURI` 每段 percent-encode 且末尾补 `/`,**query 必须参与签名**(`/v1/subscription/usage?sku_attr_code=…`),`X-Project-ID` 有则计入 SignedHeaders。写错任一处的回包都是恒 401 `verify ak sk signature fail` / `APIG.0301`,不告诉你哪一步错。 +**白名单互不含**:`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` 默认只有国际站;填国内站地址在**启动时**抛 `ValueError`(跨站发送即凭证泄露)。**pacer 与 OAuth 各自独立**:`CODEBUDDY_INTL_CHAT_MIN_INTERVAL`(热更,默认 5s)独立 `Pacer`,`_pacer_key` 带渠道 id;两渠道各建一套 `CodeBuddyOAuth`。 -**网关地址归一**:`client-permission-validate` 下发的 `api_base` 带 `/v2`(`https://modelgw-XXXX.…/v2`),而 `models.json` 里存的不带。`_gateway_of()` 统一去掉尾部 `/v2` 再拼端点,否则聊天路径拼出 `/v2/v2/chat/completions`。**每账号一个网关主机**,故它不进 `OFFICEACE_ALLOWED_ENDPOINTS`(白名单只管控制面,带临时 AK/SK 签名的请求只发往白名单主机;网关是上游签发并存在凭证里的值,不是本服务能配错的东西)。 +### 5.6 OfficeAce(`officeace`) +**两套凭据、两个面、两种签名**。聊天面(`{gateway}/v2/chat/completions`、`/v2/models`)用**永不过期**的 `Authorization: Basic :`;控制面(`/v1/subscription*`、`/v1/model-token-rates`、`/v1/claw/*`)用**约 2h** 临时 AK/SK + `X-Security-Token`。`has_control_plane` 判控制面能力(只有 app key 的凭证能聊天,但不能签到 / 探额度 → `probe_quota` 回 `probe_failed` 而非 0)。 -**错误分类**:限流走**伪 200**(HTTP 200 + `data:{"error":{...}}`),且有第二种形态——首帧正常、第二帧才 error,故不能在首帧成功后停止检查。`81004`(无该模型权限)/ `81009`(模型名不认)→ `ErrKind.BLOCKED`,登记**模型级**负缓存(同渠道同模型的其余凭证一并跳过,成功一次即撤销);`81111`/`81112`/`81114` → 限流;`14018` → `ErrKind.CREDIT`。**不做主动模型探活**(探活消耗积分);模型目录只按 `/v2/models` 列表 + 用户实际请求的成败收敛。 +**两条签名轨道不能互换**:控制面 `/v1/claw/*` 用 `SDK-HMAC-SHA256`(复用 `codearts.signer`);模型网关的 `/v1/subscription*` 与 `/v1/model-token-rates` 只认 **`V11-HMAC-SHA256`**(多一层 CredentialScope `YYYYMMDD/{region}/apic`,密钥要派生)。V11 三处须原样:① 派生外层 key=**AK**、msg=**SK**;② `HMAC(HMAC(AK,SK), scope+0x01)[:32]` 转 **hex 文本**当下一轮密钥;③ **不加** `X-Sdk-Content-Sha256` 头。`CanonicalURI` 每段 percent-encode 且末尾补 `/`,**query 参与签名**,`X-Project-ID` 有则计入 SignedHeaders。写错恒 401 `verify ak sk signature fail` / `APIG.0301`。 -**`max_tokens` 改写**(`prepare_body` → `clamp_max_tokens`):上游对推理模型有「思考预算不进 `max_tokens`」的行为,客户端给 64 会被思考吃光、回空正文。故 `>= 16` 一律追加 1024 headroom 再夹到模型上限;`< 16` 原样透传(用户明确要极短);未给不补。`max_completion_tokens` 归一成 `max_tokens`(上游只认后者)。上限表:`glm-5*` 131072、`qwen3*` 65536、其余 393216。 +**网关地址归一**:`client-permission-validate` 下发的 `api_base` 带 `/v2` → `_gateway_of()` 去尾部 `/v2` 再拼端点(否则 `/v2/v2/chat/completions`)。每账号一个网关主机 → 不进 `OFFICEACE_ALLOWED_ENDPOINTS`(白名单只管控制面)。 -**积分即主账**:额度单位 `officeace_points`,`Quota.remaining`/`total` 直接是积分(没有并行的 token 池)。来源两笔:新用户一次性 4000 + **每日签到 1000**,各自独立到期,故口径写「逐笔到期」而非本周期。**不做 credit 估算**:上游给了 `cost_coefficient` 与 `tokens_per_point`,但二者的叠乘关系官方未说明,猜错会让跨渠道额度排序整体失真。签到 `POST {gateway}/v1/subscription/bonus/claim` 是**写操作**(调用即真发积分),body 必须是空 JSON 对象 {} + `Content-Type: application/json`——官方调用点是无 body 的 POST,但实测那样回 500 `OfficeAce.11020001`。上游对当日重复领取幂等。收获只能按 `bonus_skus` 的**剩余量增量**算:`points` 是活动累计发放量,由多个渠道(桌面端签到、活动页手动领)共同贡献,拿它当本次收获会虚高。`seal_until` = 下个北京时间 0 点,次日新活动出现即自动补签。`checkin_scope` 用 `account_id`,身份未知返回**空串**(返回 `officeace|` 会让同渠道所有未知身份凭证算同一 scope,任务只跑第一个)。 +**错误分类**:限流走**伪 200**(HTTP 200 + `data:{"error":{...}}`),且有「首帧正常、第二帧才 error」形态 → 不能首帧成功后停检查。`81004`(无模型权限)/ `81009`(模型名不认)→ `BLOCKED` 模型级负缓存;`81111`/`81112`/`81114` → 限流;`14018` → `CREDIT`。不做主动探活(耗积分),模型目录按 `/v2/models` + 实际请求成败收敛。**唯一例外是 `APIG.1009` 的 DEAD 收口**:积分耗尽与 AppKey 真坏同文案,见 §3.2「额度耗尽写成凭据失效」(复探 `GET /v1/subscription`:只读、不耗积分、不刷新凭据)。 -**登录走 poll 轨道**:授权回调由**云端**承接(`/v1/claw/auth/callback`),服务端不需要开本地端口、也不需要用户粘链接——先在 `POST /v1/claw/auth/state` 要 state(**本地造的不被认**,回 `AgentArts.11000004`),再把 PKCE challenge + DPoP 公钥拼成 authorize URL,套一层 `{auth_host}/authui/login.html?service=`;随后轮询 `GET /v1/claw/auth/code?state=`,202(`AgentArts.11000009`)表示用户还没点完,拿到 code 后走 `POST /v1/oauth2/tokens`(authorization_code + DPoP)换临时 AK/SK,再调 `client-permission-validate` 校验是否**真的开通了模型网关**(未开通回 `subscription: null`,此时明确报错而不是存一条永远打不通的凭证),最后取 `model_app_key`/`model_app_secret` 与网关地址一并入库。`AuthStateStore` 按 **username** 分键做 reservation(`begin`/`owner`/`session_entry`/`touch`/`consume`/`cancel`/`cleanup`,带 TTL 与 pending 上限):单槽全局态会让两个管理员的并发登录互相覆盖,把 A 的凭证记到 B 名下。并发轮询同一 state 时 `consume` 只有一路成功,另一路抛错——否则同一账号会出现两条重复凭证。`force_login` 走 `/authui/logout?service=…`(浏览器里还有华为云会话时云端会直接放行当前账号,用户看不到登录页);默认 False,管理台「新增」不传。 +**`max_tokens` 改写**(`clamp_max_tokens`):`>= 16` 追加 1024 headroom 再夹到模型上限;`< 16` 原样;未给不补。`max_completion_tokens` 归一成 `max_tokens`。上限表:`glm-5*` 131072、`qwen3*` 65536、其余 393216。 -**配置**:`OFFICEACE_API_ENDPOINT` / `OFFICEACE_ALLOWED_ENDPOINTS` 只管控制面;`OFFICEACE_CHAT_MIN_INTERVAL`(热更,默认 0——上游没有 CodeArts 那种硬并发限)装独立 `Pacer`,与其余渠道互不排队。登录流量(每次登录几十次短轮询)与聊天流量用**独立连接池**,但共用同一个出站代理设置。**单账号单用户**:官方《套餐管理》写明「个人版面向个人用户,限制单账号单用户使用」,故不做多人共用。 +**积分即主账**:单位 `officeace_points`;`Quota.remaining`/`total` 直接是积分。来源:新用户一次性 4000 + 每日签到 1000,各自独立到期(口径「逐笔到期」)。**不做 credit 估算**:`cost_coefficient` 与 `tokens_per_point` 的叠乘关系官方未说明。签到 `POST {gateway}/v1/subscription/bonus/claim` 是**写操作**;body 必须是 `{}` 且带 `Content-Type: application/json`(无 body 回 500 `OfficeAce.11020001`);当日重复领取幂等。收获按 `bonus_skus` 的**剩余量增量**算(`points` 是累计发放量,会虚高)。`seal_until` = 下个北京时间 0 点。`checkin_scope` 用 `account_id`;身份未知返回**空串**。 + +**重新登录合并**:与 CodeArts 同一套 `credential_login` 策略(§5.4),合并键 `account_id`(缺省 `principal_id`)——同账号重登更新既有行、解除硬禁用,账号级冷却按来源保留,不新增重复凭证。 + +**登录走 poll 轨道**:回调由**云端**承接(`/v1/claw/auth/callback`),无需本地端口或粘链接。`POST /v1/claw/auth/state` 要 state(本地造的回 `AgentArts.11000004`)→ PKCE challenge + DPoP 公钥拼 authorize URL,套 `{auth_host}/authui/login.html?service=`;轮询 `GET /v1/claw/auth/code?state=`(202 = 未完成);拿 code 后 `POST /v1/oauth2/tokens`(authorization_code + DPoP)换临时 AK/SK;再调 `client-permission-validate` 校验真开通(`subscription: null` 报错);存 `model_app_key`/`model_app_secret` 与网关地址。`AuthStateStore` 按 **username** 分键 reservation(TTL + pending 上限):单槽全局态会让两管理员并发登录互相覆盖,并发轮询同一 state 只有一路 `consume` 成功。`force_login` 走 `/authui/logout?service=…`,默认 False。 + +**配置**:`OFFICEACE_API_ENDPOINT` / `OFFICEACE_ALLOWED_ENDPOINTS` 只管控制面;`OFFICEACE_CHAT_MIN_INTERVAL`(热更,默认 0)独立 pacer。登录流量与聊天用**独立连接池**,共用出站代理。**单账号单用户**(官方《套餐管理》限制)。 + +### 5.7 小浣熊(`raccoon`) +**标准 OpenAI 协议 + 网页会话 JWT**。三处渠道特例,其余与 Zen/Kilo 同构: + +- **凭据归一**(`credential.py`):入参接受裸 JWT / `{"access_token":"…"}` / `{"token":…}` / `{"xhx_token":…}` / 整段 cookie(`xhx_token=…; …`)。`uid`、`expires_at` 从 JWT claims 解出(`sub`/`uid`、`exp`),**不验签**(验签是上游的事;这里只当元数据)。无登录轨道,只能 JSON 导入。 +- **未知模型防护**:上游对不认识的模型名静默回落默认模型并回 200(实测 `definitely-not-a-model` → `model=raccoon-090dd3`)。两层拦截:① `stream_chat` 前按最近一次目录(`known_models`,小写集合)校验,不在目录里直接抛 404/INVALID(不发上游);② 流首帧比对上游回显的 `model`,不一致抛 `UpstreamProtocolViolation`。目录未拉过(集合为空)时放行,真实保护靠第 ② 层。 +- **额度口径**:`available_points` 即积分余额,`Quota.total = remaining`(上游没有总量口径,不编造),账本记 `credits`(与 CodeArts 同账)。`billing_effective_multiplier` 直接当 `credit_rate`,0 = 限免,调度器优先选。**按请求计费**(每笔 `points=-1`,与 token 量无关),故不做 token 折算。 +- **错误分类**:`user identity could not be verified` / 401 → `DEAD`(会话 JWT 过期,只能重取);积分耗尽类文案 → `CREDIT`;`429 user_model_rate_limited` → `CONCURRENCY`(固定短冷却,模型级);其余 429 → `SOFT`;400/404/422 → `INVALID`。 +- **并发**:实测 5 并发放行、第 6 个 429 → `RACCOON_MAX_CONCURRENCY`(热更,默认 2)+ 独立 pacer(**不**与其他渠道共享)。 +- **签到**(`activity.py`):上游无领取接口,每日 300 积分由服务端 00:00 自动发放;当天登录过才延期到 7 天,否则当日 24:00 清零。`checkin` = `visit`(`user_info`,页面级访问)→ `fetch_quota` → `fetch_bills` → `checkin_result()`;只认流水、不看 200。四态:本次触发(created_at 在 90s 窗口内)/ 今天早些时候已发 / 今天无新发放但仍有未过期 `daily_grant`(视为已签,凌晨封账到 01:30 再确认)/ 完全无可用积分(失败,重试推到 01:30)。 +- **活跃上报**:`POST /api/web/auth/v1/devices_current_heartbeat`;头族 `x-client-device-id`(必填,缺了 `100002 params_invalid_error`)/ `x-client-platform: web` / `x-client-version` / `x-raccoon-language`。device_id 用 `derive_device_id(uid)` 稳定派生。**心跳不算登录**(只刷 `last_seen_at`),故 `activity` 先做一次 `GET /api/web/auth/v1/user_info` 页面访问再发心跳;访问失败按活跃失败返回,让任务在同一小时窗口内重试。 +- **额度明细**:`balance` 的四个池 → `Quota.packages`(0 的池不出条目;`topup_frozen` 时标 `usable=False`);`daily_grant.expire_at` → `Quota.expiry_ladder`(已被 `daily_expire` 消费的不计)。 + +### 5.8 灵犀(`lingxi`) +**WPS 账号 cookie + 一次对话三条请求**,差异集中在 `client.py`: + +- **凭据**(`credential.py`):接受整段 Cookie / 裸 `wps_sid`;`uid` 从 Cookie 段取,重登合并首选 `uid`、兜底 `sid-`(与 `login_identity` 同口径)。**没有 refresh 接口**,过期只能重贴;判定只看上游 401,不按时长猜(猜错会把还能用的号摘掉)。 +- **三条请求**:`POST /api/aioffice/v1/cowork/sessions`(建 cowork 会话)→ `POST /api/public/v1/credits/runs`(领 run)→ `POST /api/public/v1/agent-runtime/chat/completions`。上游**按 run 计费**(一个 run 内多次调用只扣一次智点)→ `ensure_run` 按「账号 × 模型」缓存 1h(**不滑动续期**)+ 并发单飞;`SQLNoRows` /「会话不存在」丢弃缓存重建一次。 +- **伪装桌面端**:`X-Client-Type: lingxi_claw` + `x-kso-app-name/app-version/device-id`(固定可复现值)。端点默认 `https://lingxi.wps.cn`;账号面按主域 → `lingxi.kdocs.cn` 顺序回落(kdocs 账号的集群),白名单必须两者都含,漏收会让回落直接 404。 +- **SSE**(`events.py`):正文在 `choices[].text`(非 `delta.content`)、**不发 `[DONE]`**(靠 `finish_reason` 收尾);`usage.total_tokens` 是**当前上下文大小** → 自算 `prompt+completion`;`cached_tokens` / `reasoning_tokens` 是扁平字段。上游每帧是单行 `data:{…}`,不能用「空行分帧」的通用解析器(会吞帧)。 +- **工具 schema 压平**(`client.sanitize_schema`):`anyOf` / `oneOf` / `$ref` / 数组型 `type` 会让整轮回「AI服务异常」;`max_tokens` 注入 `base_llm_arguments`,`reasoning_effort` 顶层与 `llm_arguments` 各一份。 +- **错误分类**:401 → `DEAD`;402 / 额度文案 → `CREDIT`;403 → `SOFT`;404/422 → `INVALID`;429 → `MODEL`(点名模型);审核拦截(`AuditFailed`)→ `INVALID`(换号无用);run 过期 → `SOFT` + 重建一次。 +- **额度 / 签到**:`total_balance` 即智点余额,赠送批次 `bonus_credits[].expire_time` → 到期阶梯(15 天内窗口);账本记 `credits`,目录 `multiplier` 直接当 `credit_rate`。签到走任务中心 `daily_check_in`(`status=claimed` 即已签),奖励按**领前/领后余额差**算,差 0 时读任务状态——上游对重复领取回 200 假成功(`result=ok` 而余额不动),绝不看 HTTP 200。 ## 6. 调度器 ### 6.1 选号规格 @@ -344,61 +543,67 @@ class Scheduler: ``` 排序键(`_free_first_rank` 起,字典序): - -1. **免费优先档**:候选中存在 `credit_rate == 0` 的渠道时,免费渠道整体排到所有付费渠道之前(`None` = 未知不算免费)。仅当本次候选带有倍率数据(executor 从模型目录现查注入)才生效;候选池无免费渠道时该档恒 0。 -2. **到期额度排序(两级字典序)**:`expiry_ladder` 中「距到期 ≤ 主窗口」(`QUOTA_EXPIRY_WINDOW_SECONDS`,默认 36h)的额度加总,多的先用;打平再比次窗口(默认 7 天);渠道无到期信息(如 CB 企业版)计 0;主窗口 ≤0 时次窗口一并失效(`expiry_windows()` 统一折算)。CodeBuddy、TRAE(按包独立到期)与 CodeArts(积分包按套餐 `end_date` 到期)均落阶梯参与此排序。 +1. **免费优先档**:候选含 `credit_rate == 0` → 免费渠道整体排前(`None` 不算免费),仅候选带倍率数据生效。**x0 会被实测扣费校准**:目录说 0、但最近 24h `usage_events` 里有该 (渠道, 归一键) 的 `credit > 0 AND credit_estimated = 0`(上游真值)时注入实测均值(`db/paid_models.py`,快照 TTL 600s)。上游改计费不改目录时,目录的 x0 会永久占住免费档、压过所有到期积分(实测 2026-11-03 起 intl `deepseek-v4.1-flash` 目录写 x0、每次实扣 ~0.14,officeace 15.8 小时内到期的 100 积分包整夜闲置)。无观测行时仍信目录(夜里没人用不是免费的证据);目录给正数或 `None` 时一律不校准。 +2. **到期额度(两级字典序)**:`expiry_ladder` 中「距到期 ≤ 主窗口」(`QUOTA_EXPIRY_WINDOW_SECONDS`,默认 36h)额度加总,多者先用;打平比次窗口(默认 7 天);无到期信息计 0;主窗口 ≤0 → 次窗口失效。CB / TRAE / CodeArts(积分包按 `end_date`)均落阶梯。 3. **健康度三态**:`known(0-100) DESC > unknown > exhausted(-1)`。 -4. **账户剩余积分**(`quota_remaining`)多者优先——只作健康度的**打平键**,不会越级压过低健康度的高余额号。 +4. **账户剩余积分**多者优先——只作健康度**打平键**。 5. 仍同分按 `credential_id` 稳定。 -**手动 pin 优先**:存在 `pinned` 凭证时只在 pinned 之间排序(管理员显式指定优先于其余排序)。到期积分只算一处:`expiring_credits()`。选号走 `Candidate.expiry_credits()`(主/次两级各调一次),管理台列表走 `GET /api/credentials` 的 `quota_expiring_credits` / `quota_expiring_credits_secondary`(两个窗口值随响应返回),两处共用同一实现与同一个 `expiry_windows()` 折算,界面数字与选号顺序不会漂移。渠道无到期信息时返回 `null`(不显示);窗口关闭或确实无积分临近过期时返回 `0`(同样不显示)。CodeArts 的到期积分来自它自己的 `expiry_ladder`(积分包按套餐 `end_date` 到期),与其它渠道同一口径,`quota_expiring_credits` / `_secondary` 即两个窗口值。 +**管理员权重**(`credentials.routing_weight`,`-1000..1000`,NULL/0 = 默认)不参与上表的键比较,而是在排完序后再做一次**同健康档内的稳定重排**:取当前第一名所在的 `_rank` 档,档内按 `-weight` 重排,再接回其余档。因此权重能压过免费优先 / 在途 / 到期额度,但永远不能把 exhausted 或 unknown 的号越过 known 档(权重是偏好,不是准入条件);全 0 时排序与补列前逐字节一致。 + +**手动 pin 优先**:有 `pinned` → 只在 pinned 间排序。到期积分只算一处 `expiring_credits()`:选号走 `Candidate.expiry_credits()`;列表走 `GET /api/credentials` 的 `quota_expiring_credits` / `_secondary`;共用 `expiry_windows()`。无到期信息 → `null`;窗口关闭或确无临近过期 → `0`(均不显示)。 + +> **TRAE 也落 `quota_expiry_ladder`**(权益包各自独立到期),两列同时填,口径「未过期 + 有余额」。`quota_cycle_end` 仍 `NULL`(各包未必共享重置点)。 -> **TRAE 也落 `quota_expiry_ladder`**:权益包各自独立到期(每月登录积分按月、签到奖励各有到期日),与 CodeBuddy 同构,两列同时填、口径统一为「未过期 + 有余额」。`quota_cycle_end`(单值「最早到期」)TRAE 仍为 `NULL`:各包未必共享一个重置点,阶梯已表达「哪些包何时到期」。 ### 6.2 候选收窄 -`Executor._narrow_providers`:**候选只信模型目录缓存列表**。目录能证明某模型归属哪些渠道时,候选渠道就只有它们——登记了该模型的渠道全部不可用(都在冷却 / 都被暂停)时直接返回 503,不再把请求逐个渠道试过去(各上游只会回 11102 / 4001 / 401,还会给渠道写下数小时的「该后端无此模型」负缓存)。目录未就绪 / 模型完全不在目录里时仍按全部候选渠道路由(保守);`模型@渠道` 强制指定与 Key 渠道绑定不受影响。新鲜度上限是模型列表 TTL 300s + 后台兜底刷新——新渠道最坏 300s 后进候选,不存在「漏试真正持有它的新渠道」。 +`Executor._narrow_providers`:**候选只信模型目录缓存**。目录能证明归属 → 候选渠道只有它们;全不可用直接 503,不再逐渠道试(各上游只回 11102 / 4001 / 401,还会写下数小时负缓存)。目录未就绪或模型不在目录 → 按全部候选路由;`模型@渠道` 与 Key 绑定不受影响。新鲜度 TTL 300s(`MODEL_LIST_TTL_SECONDS`)+ 后台兜底刷新。 + +**「目录登记」的口径含探活判死的渠道**(§9.1.1):判死只把它从**展示**里摘掉,别名登记保留——否则「没登记」回退成全部渠道扇出,正是收窄要防的那件事。 + ### 6.3 模型级冷却 -`credential_model_cooldowns(credential_id, model, cooling_until, hits, reason)` 按 **(凭证, 模型)** 独立建表——账号级 `cooling_until` 放不下「同账号其他模型仍可用」这层语义。`model_probes(provider, model, state, failures, last_probe_at, last_ok_at, last_error, manual, updated_at)` 是**观测记录**,与冷却表严格分工:冷却表是调度权威状态(真实请求的失败驱动,直接决定选号),探活表只供管理台展示——把探测结论写进冷却表会让一次探测直接改变选号结果,而「探测失败」未必是模型本身的问题(额度、限流、网络都会失败)。 - -- 登记的名字是**该凭证所属上游的原始模型名**(`_upstream_model(provider_id, model)`,未知则原样),因为 CB 与 TRAE 的注册名大小写变体不同,用归一名会漏判。 -- 选号路径:`executor._select` 逐凭证过滤 `is_selectable(now, 该凭证的模型名)` 并把模型目录里当前模型的倍率注入候选快照(`Candidate.credit_rate`),再交给 `Scheduler.select`;同时完成模型收窄、粘性命中与排序(`_sticky(...) or scheduler.select(...)`)。 -- 退避:`MODEL` 基数 10m 起翻倍、封顶 2h;`BLOCKED` 6h 起翻倍、封顶 24h;`CONCURRENCY` 固定 60s 短冷却、不翻倍(打满是瞬态信号)。换 reason 重新计数(基数与封顶不同,沿用对方 hits 会得到错误的第三种时长)。 -- 清除:`save_success(..., model=)` 只删 `reason='blocked'`(模型限流按上游重置,成功一次不代表限制解除);`revive` / 删除凭证 / 账号级冷却出现都清模型条目。 -- 回流:留存任务每轮 `purge_expired_model_cooldowns()` 回收过期行;管理台列表只下发未过期条目。 -- 无模型名可归因时(流内事件未带 model)退化为账号级 SOFT,不写孤儿记录。 -- **进入模型级冷却的来源不止「模型限流」**:Qoder 把自己节点故障包成 400(见 §5.3)也归 `MODEL`,同样只锁该模型。耗尽轮换时若**所有候选都只因该模型的模型级冷却被排除**(`_all_model_cooled`),503 文案用 `model 'x' temporarily unavailable on upstream` 而非 `all credentials unavailable`——避免把「换个模型就好」误导成「整池凭证挂了」。**统计同口径分流**(`_exhaust_error_type`,与文案同一判据):该情形落 `error_type=model_unavailable`(界面「该模型暂时不可用」),其余耗尽仍 `no_healthy_credential`(「无可用凭证」);**对外错误码不变**(仍 `no_healthy_credential`,Anthropic/Responses 出口的错误码映射依赖它)。 +`credential_model_cooldowns(credential_id, model, cooling_until, hits, reason)` 按 **(凭证, 模型)** 建表——账号级 `cooling_until` 放不下「同账号其他模型仍可用」。`model_probes(...)` 是**观测记录**:冷却表为调度权威(真实失败驱动);探活表只供展示(探测失败未必是模型问题)。 + +- 登记名 = **该凭证所属上游的原始模型名**(`_upstream_model`);CB 与 TRAE 大小写变体不同,归一名漏判。 +- 选号路径:`executor._select` 逐凭证过滤 `is_selectable(now, 模型名)` + 注入 `Candidate.credit_rate` → 交 `Scheduler.select`;同时完成收窄、粘性命中与排序(`_sticky(...) or scheduler.select(...)`)。 +- **冷却到期先半开**(`engine/half_open.py`,`(凭证, 作用域)` 粒度;作用域空串 = 账号级、非空 = 该模型):到期后只放行**一个**探测请求,其余请求见占用即换号(唯一候选时按无可用返回)。成功记「已探通」120s 期内直放;失败由上游写新冷却、探测位立刻释放。探测位 TTL 600s 兜 GC 泄漏。理由:到期即全量回池会把刚解冻的号再打进限流(锯齿)。进程内集合,重启只让每个号多探一次。 +- 退避:`MODEL` 10m 起翻倍封顶 2h;`BLOCKED` 6h 起翻倍封顶 24h;`CONCURRENCY` 固定 60s 不翻倍。换 reason 重新计数。 +- **上游点名恢复时刻优先**:`Retry-After` → `parse_retry_after`;头缺失时 CB 走 `parse_reset_at` 读正文 `reset at … UTC+8`(`retry_hints(headers, body=raw)`,仅 CodeBuddy 传 body)。两者经 `clamp_retry_at` 钳在 `[now + 常量, now + 24h]` → `MODEL` 的 600s 只是下限,上游说 22:34 恢复即冷却到 22:34。日志按 `_scope_label` 打作用域:模型级「模型 <名>」,账号级「作用域 账号」——账号级分类的调用方同样传模型名,一律打 `模型` 会让 zen 429 读起来像模型级限流。 +- 清除:`save_success(..., model=)` 只删 `reason='blocked'`;删除凭证 / 账号级冷却出现都清模型条目,`revive` 亦然。留存任务每轮 `purge_expired_model_cooldowns()`(删行)+ `purge_expired_account_cooldowns()`(`cooling_until` 与 `cooling_kind` 一起置 NULL);列表只下发未过期。账号级那条是**卫生清理**非调度依赖——选号按 `cooling_until > now` 判断,但陈旧值让管理台「冷却」排序失真。 +- **账号级冷却带来源标签** `credentials.cooling_kind`(ErrKind 值):`revive` 对 `UPSTREAM_COOLDOWN_KINDS`(soft/credit/plan/restricted)**保留**冷却,只解硬禁用与本地冷却;`force=True` 才清。此前一律清除,导致「恢复」后立刻再撞同一个 429(2026-11-03 12:22–12:23 实测)。老库补列后为 NULL = 来源未知,按旧行为清除。 +- 无模型名可归因 → 退化为账号级 SOFT。 + +**轮换耗尽与错误归因**:模型级冷却来源不止模型限流(Qoder 节点故障 §5.3 也归 `MODEL`)。全部候选只因该模型被排除(`_all_model_cooled`)→ 503 文案 `model 'x' temporarily unavailable on upstream`,统计落 `error_type=model_unavailable`;其余耗尽按最后一次错误的真实归因落统计(§9.1.3),只有一次都没选中凭证才 `no_healthy_credential`;对外错误码不变。 + ### 6.4 跨渠道 fallback 兼容组 -**配置**(热更项 `MODEL_FALLBACK_GROUPS`,默认 `""` 关闭):`组名=成员1,成员2;组名2=成员3`。 +热更 `MODEL_FALLBACK_GROUPS`(默认 `""` 关闭):`组名=成员1,成员2;组名2=成员3`。 -- **纯函数层**(`model_resolver.py`):`parse_fallback_groups` 宽容解析(空段 / 缺 `=` / 空组名 / 空成员一律跳过,一行坏配置不会让网关起不来);`ordered_fallback_chain(model, groups)` 只做**映射**不校验模型是否存在:组名是**入口别名、不进链**,请求成员时该成员置首、组内其余成员按配置顺序跟随(大小写不敏感)。 -- **执行层**(`executor.py`):`_fallback_chain(target)` 把组解析成 `ModelTarget` 元组。 - - `@渠道` 与 API Key 渠道绑定(`target.forced`)**不参与回退**。 - - 目录可用时**剔除不在任何候选渠道登记的回退成员**(「兼容组白名单」);目录未就绪则全部放行。成员按名去重。 - - 链首就是用户请求的那个模型时无论目录是否认识都保留。 -- **非流式** `complete` 逐链项走完整的选号 / 冷却 / 轮换 / 会话粘性 / 统计,捕获 `NoHealthyCredential` / `InvalidRequest` 继续下一项,整条链耗尽才以最后一项的错误抛出。 -- **流式** `stream` 逐链项跑 `_stream_loop`,**只有在本链项尚未产出任何响应帧之前**才允许切换——已出帧后换模型会让客户端看到两个模型的混合输出。用私有信号 `_ModelExhausted` 在链内传递「本项未出帧即用尽」;整条链都用尽时用**最后链项**的 `translator` 产出终帧。 -- `preflight` 按**整条链**判断是否有注册上游。 +- **纯函数层**(`model_resolver.py`):`parse_fallback_groups` 宽容解析(空段 / 缺 `=` / 空组名 / 空成员跳过);`ordered_fallback_chain` 只映射不校验存在性:组名是**入口别名、不进链**;请求成员置首,其余按配置顺序跟随(大小写不敏感)。 +- **执行层**:`@渠道` 与 Key 绑定(`target.forced`)不参与回退;目录可用时剔除不在任何候选渠道登记的回退成员,未就绪全放行;成员按名去重;链首即请求模型无条件保留。 +- **非流式** `complete` 逐链项走完整选号 / 冷却 / 轮换 / 粘性 / 统计,捕获 `NoHealthyCredential` / `InvalidRequest` 续,整链耗尽才抛最后一项错误。 +- **流式** `stream` 逐链项跑 `_stream_loop`,**只有本项尚未产出任何响应帧前**才允许切换(已出帧会混两模型输出)。`_ModelExhausted` 传「未出帧即用尽」;整链用尽用**最后链项**的 `translator` 产终帧。 +- `preflight` 按整条链判断是否有注册上游。 ### 6.5 凭证限额护栏 -`credential_limits(credential_id PK, keep_credits, daily_tokens, daily_credits, per_model_tokens JSON, updated_at, updated_by)`——**缺行 = 不受限**(老库升级后行为不变)。 +`credential_limits(credential_id PK, keep_credits, daily_tokens, daily_credits, per_model_tokens JSON, updated_at, updated_by)`——**缺行 = 不受限**。 -- **判定**(`src/engine/limits.py::evaluate`,纯函数):`keep_credits` 用 `quota_remaining ≤ keep_credits` 停用整号(与当日用量无关,余额未知不猜);`daily_tokens` / `daily_credits` 用当日累计 `≥ 上限` 停用整号;`per_model_tokens` 命中的模型只停该模型(`blocked_models`)。`None` / `≤0` = 该护栏关闭。 -- **当日口径**:北京自然日 0 点(`day_start`,`ZoneInfo("Asia/Shanghai")`),与签到 / 活跃上报同一时区。 -- **用量聚合**(`src/db/limits.py::day_usage`):按 `credential_id` 聚合 `usage_events`(`input+output` token、`credit`、按模型 token)。**不用 `usage_hourly`**:小时汇总主键没有 `credential_id`,无法还原到凭证维度。失败请求也计入。 -- **挂钩点**:`Executor._selectable` 在 `is_selectable` 之后按 `deps.limits.blocked(candidate, target.model)` 二次过滤。**刻意不写进 `Candidate.is_selectable`**:判定依赖每请求都变的「当日聚合用量」,而 `is_selectable` 是纯函数(只吃凭证自身状态);塞进去会让 `pool_counts` / 池健康计数也去查一遍用量。按模型上限比对的是**归一名**(`usage_events.model` 就是归一名),不是上游原始名。 -- **快照缓存**:`LimitGuard` 对「限额表 + 当日用量」做进程内快照(TTL 15s);无任何限额配置时**不查**用量聚合。管理台写入后调用 `invalidate()` 立即生效,不等 TTL。 -- **错误语义**:全部候选都因限额被跳过时走「无可用凭证」`no_healthy_credential`(账号级原因)——`_all_model_cooled` 判据不含限额,故不会被误判成 `model_unavailable`。 +- **判定**(`engine/limits.py::evaluate`,纯函数):`keep_credits` 用 `quota_remaining ≤ keep_credits` 停整号(余额未知不猜);`daily_tokens` / `daily_credits` 用当日累计 `≥ 上限` 停整号;`per_model_tokens` 只停该模型。`None` / `≤0` = 关闭。 +- **当日口径**:北京自然日 0 点(`ZoneInfo("Asia/Shanghai")`)。 +- **用量聚合**(`db/limits.py::day_usage`):按 `credential_id` 聚合 `usage_events`(token、credit、按模型 token)。**不用 `usage_hourly`**(主键无 `credential_id`)。失败请求也计入。 +- **挂钩点**:`Executor._selectable` 在 `is_selectable` 后按 `deps.limits.blocked(...)` 二次过滤。**不写进 `Candidate.is_selectable`**(后者纯函数,塞进去会让 `pool_counts` 也查用量)。按模型比对**归一名**。 +- **快照缓存**:`LimitGuard` 对「限额表 + 当日用量」做进程内快照(TTL 15s);无配置不查用量。写入后 `invalidate()` 立即生效。 +- **错误语义**:全部因限额跳过 → `no_healthy_credential`(`_all_model_cooled` 判据不含限额)。 ### 6.6 凭证在途租约 -`MAX_INFLIGHT_PER_CREDENTIAL`(全局热更,默认 `0` = 不限):同一凭证的并发请求达到上限后不再参与选号,避免把单账号打进上游限流 / 封号(同类项目的 `pool.max_in_flight` / `QODER_MAX_CONCURRENCY`)。**在途计数兼作负载键**:`Executor._selectable` 把在途数注入 `Candidate.inflight`,调度器按「最少连接」排(在 `Scheduler.select` 排序链里,免费档之后、到期额度之前),并发请求自动摊到空闲号;上限为 0(不限)时 `blocked()` 恒 False 但租约照常登记,故不限并发时负载键仍可用。 +`MAX_INFLIGHT_PER_CREDENTIAL`(全局热更,默认 `0` 不限):达上限后该凭证不再参与选号。**在途计数兼作负载键**:`Executor._selectable` 注入 `Candidate.inflight`,调度器按「最少连接」排(免费档之后、到期额度之前)。上限 0 时 `blocked()` 恒 False 但租约照常登记。 -- **实现**(`src/engine/inflight.py`):`InflightTracker` 是 `credential_id → 活跃租约开始时刻列表` 的**进程内**计数;`InflightGuard` 包一层热更上限。租约只记开始时刻、不绑请求标识——同一凭证的多个租约彼此等价,释放任意一个即可(先入先出),调用方在请求两侧各调一次,不必在轮换 / fallback 的多层 `finally` 里传递句柄。 -- **为什么同步(不用 `asyncio.Lock`)**:`acquire` / `release` / `count` 只在 executor 的事件循环里调用,且「选号 → 登记」之间没有任何 `await`(选号、压缩、创建上游迭代器都是同步的),单线程下不会交错。加异步锁反而会要求 `_selectable` 变成 async,把一个纯过滤函数染上异步。 -- **TTL 兜底**(`LEASE_TTL_SECONDS = 600`):正常路径由 `finally` 释放;生成器被 GC、异常路径漏走释放时租约会滞留并慢慢占满号。超过 TTL 的租约在计数 / 登记时视为过期并回收——宁可偶尔多放一个请求,也不能让号被永久占住。 -- **挂钩点**:`Executor._selectable` 在 `is_selectable` / 限额之后按 `deps.inflight.blocked(credential_id)` 过滤;`_stream_loop` 与 `_complete_model` 在创建上游迭代器后 `_lease`、统一在 `finally` 里 `_release`(覆盖成功、轮换、异常、客户端断开)。全部候选都占满时与限额同口径报 `no_healthy_credential`。 -- **与 `tasks/pacer.py` 的 `max_concurrency` 的区别**:pacer 的桶按渠道分、只覆盖走 pacer 的渠道;这里是**跨渠道的凭证级**上限,语义是「同一个账号别被我方并发压垮」,与渠道节流正交。 +- **实现**(`engine/inflight.py`):`InflightTracker` = `credential_id → 活跃租约开始时刻列表` 的进程内计数;`InflightGuard` 包一层热更上限。租约只记开始时刻、不绑请求标识,先入先出,释放任一即可。 +- **为什么同步**:`acquire` / `release` / `count` 只在事件循环调用,且「选号 → 登记」间无 `await`;加异步锁会让 `_selectable` 变 async。 +- **TTL 兜底**(`LEASE_TTL_SECONDS = 600`):正常由 `finally` 释放;漏走时租约滞留,超 TTL 在计数 / 登记时回收。 +- **挂钩点**:`_selectable` 在 `is_selectable` / 限额之后过滤;`_stream_loop` 与 `_complete_model` 创建上游迭代器后 `_lease`,`finally` 里 `_release`。全满时与限额同口径 `no_healthy_credential`。 +- **与 pacer `max_concurrency` 的区别**:pacer 桶按渠道分、只覆盖走 pacer 的渠道;这里是**跨渠道的凭证级**上限,与渠道节流正交。 ## 7. 请求时序(chat completions 主链路) -> 可视化版本:[`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html)。`POST /v1/responses` 与主链路同:先把请求体映射成等价的 `ChatRequest`,其后选号 / 轮换 / 记账 / 粘性完全一致,出口形状由注入的 translator 决定。 +> 可视化:[`diagrams/coding2api-request-sequence.html`](diagrams/coding2api-request-sequence.html)。`POST /v1/responses` 同链路:先映射成 `ChatRequest`,其后全一致,出口形状由 translator 决定。 ``` 客户端 → POST /v1/chat/completions (Bearer sk-) @@ -421,7 +626,9 @@ class Scheduler: 全拒时 400 invalid_request(未知模型名 ≡ 无上游提供,不走 503) - REQUEST 类换号但不落库:既不冷却也不累计,否则会顺手把已有的 cooling_until 写成 NULL 5. response.py:Event → OpenAI chunk(流式)或聚合(非流式) - - 首块补 role:assistant;上游无 index 的 tool_calls 补稳定 index + - 首块补 role:assistant;上游无 index 的 tool_calls 补稳定 index, + 无 `id` 的补稳定 `id`(`call_{index}_{seq}` 记忆化)——OpenAI 客户端按 + `id` 聚合流式分片,缺 id 的分片会被丢弃,表现为工具调用静默消失 - 收尾帧序列:finish chunk → usage 帧(choices: [])→ [DONE];上游未报 usage 则不补该帧(不发 0 占位)。非流式/流式收尾共用 usage_payload() - 直连 CodeArts v2 每 chunk 都带 usage,取收尾帧真值(取第一个会记成 0/空) @@ -429,39 +636,45 @@ class Scheduler: 7. scheduler.note_success:清 err_count,并把本对话重新粘到实际服务的凭证 ``` -客户端断连:生成器被关闭/取消时统计 `error_type=client_disconnect`,关闭上游流;例外:`[DONE]` 已产出后的收尾断开(客户端拿到回调即关连接,框架在结束帧 `more_body=False` 前有一拍竞态会把它判为断开)按成功记账。 +客户端断连:生成器关闭 / 取消 → 统计 `error_type=client_disconnect` + 关闭上游流;例外:`[DONE]` 已产出后的收尾断开(框架在结束帧 `more_body=False` 前有一拍竞态)按成功记账。 + +流截断:上游 HTTP 200 正常关闭、但本次尝试从未产出终态事件(`EventKind.FINISH`)→ 响应不完整。凭证照常 `save_success`(上游没报错,冷却只会误伤健康号),统计记 `ok=False` + `error_type=truncated_stream`,客户端仍收到收尾帧与 `[DONE]`。 + +上游模型观测:`Event.model`(协议里有 `model` 就填)与请求模型按尝试比较;双向都不构成前缀关系(`glm-5.2` vs `glm-5.2-20261001` 视为一致)时只写 WARNING「疑似静默替换」,不改响应、不冷却凭证、不写 `error_type`(与用量对账 `_check_usage_consistency` 同口径:观测信号不进失败归因)。占位名(`auto` / `default` / `unknown` / `none`)不算证据。**TRAE 的真实模型只在元数据帧回显**(`extra_info.model`、`timing_cost.provider_model_name`),这些帧无下游语义,故 `TraeClient` 先在流循环取提示再贴到后续事件(output/done/usage 帧都不带模型)。**Qoder 实测只有 `model:"auto"`**(信封 headers 与 chunk 均无模型名),天然是观测盲区。 ## 8. 数据库 -DDL 以 `src/db/schema.sql` 为准。当前 `SCHEMA_VERSION = 29`,共 13 张表:`api_keys` / `credentials` / `credential_limits` / `users` / `audit_events` / `usage_events` / `usage_hourly` / `growth_events` / `credit_events` / `credential_model_cooldowns` / `model_probes` / `runtime_settings` / `alert_events`。 +DDL 以 `src/db/schema.sql` 为准。`SCHEMA_VERSION = 32`(v29 删了福利链路的积分三列,v30 加用户级策略两列,v31 加 `checkin_events` 签到审计日志表,v32 加 `credentials.cooling_kind` 冷却来源),14 张表:`api_keys` / `credentials` / `credential_limits` / `users` / `audit_events` / `usage_events` / `usage_hourly` / `growth_events` / `checkin_events` / `credit_events` / `credential_model_cooldowns` / `model_probes` / `runtime_settings` / `alert_events`。 ```sql -- conn.py 打开时执行 PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; -PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_keys 仍在应用层校验) +PRAGMA foreign_keys = ON; -- 库里没有任何外键:users ↔ api_keys 等关系全在应用层校验 ``` -- 连接:`threading.local()` 每线程一个 `sqlite3.Connection(row_factory=sqlite3.Row)`,引擎与 FastAPI 线程池各持自己的连接。写入统一走 `Database.transaction()`(正常提交、异常回滚),无应用层写锁,并发由 SQLite 串行化(WAL + `busy_timeout=5000` 下短写足够)。 -- 加密:`Fernet(base64.urlsafe_b64encode(sha256(APP_SECRET).digest()))`;APP_SECRET 丢失 = 凭证全部不可解,只能重录。启动时按熵拒掉弱密钥(长度 ≥16、不同字符数 ≥8,另拒重复字符 / 短周期重复串)。 -- 运行时配置覆盖层 `runtime_settings(key, value, updated_at)`:纯 key/value,只存「管理台改过」的 key,未出现的 key 回落 env。键集合随版本演进(新增可热更项无需迁移);值的类型与范围由 `HOT_SETTINGS` 白名单校验,白名单外/类型非法的行在读取时被忽略并记日志。 -- 模型列表与模型目录不占表:模型列表是「进程内 TTL 缓存 + `DATA_DIR/model_catalog.json` 落盘快照」,OpenRouter 目录是 `DATA_DIR/openrouter_catalog.json`(见 §9.4);签到去重由 `CheckinTask` 的当日作用域集合实现。 +- 连接:`threading.local()` 每线程一个 `sqlite3.Connection(row_factory=sqlite3.Row)`。写入统一走 `Database.transaction()`,无应用层写锁,并发由 SQLite 串行化。 +- 加密:`Fernet(base64.urlsafe_b64encode(sha256(APP_SECRET).digest()))`;APP_SECRET 丢失 = 凭证全部不可解。启动按熵拒弱密钥(长度 ≥16、不同字符数 ≥8,拒重复字符 / 短周期重复串)。 +- **解密失败按「这条凭证用不了」处理,不得连累整批**:早先 `credential_data` 抛 `CredentialDecryptError` 直达顶层 → 一条坏凭证就让整个聊天请求 500、整轮后台任务(额度探测 / 预刷新 / 签到 / 成长中心 / 活跃上报 / 模型探活)挂掉、整页模型列表 500(实测 2026-10-26 14:48)。现按路径分两种口径:**批处理路径**(选号、模型列表、后台任务、即时探测)捕获后**跳过该凭证**(记 skipped + 一条 ERROR 日志),其余照常;**单条探测端点**照旧如实报可行动的 500(`credential_decrypt_failed`,管理员该去处理这个号)。 +- `runtime_settings(key, value, updated_at)`:纯 key/value,只存管理台改过的 key,未出现回落 env;值由 `HOT_SETTINGS` 白名单校验,非法行读取时忽略并记日志。 +- 模型列表与目录不占表:进程内 TTL 缓存 + `DATA_DIR/model_catalog.json` / `openrouter_catalog.json`(§9.4);签到去重由 `CheckinTask` 当日作用域集合实现。 -**备份与恢复**(`src/db/backup.py`,任务与 `scripts/` 共用):`VACUUM INTO` 把主库写成一致性快照(WAL 下直接 `cp` 会丢未 checkpoint 的事务),落盘后立即 `PRAGMA integrity_check`(不过则删半成品并抛错),产物 `0600` 目录 `0700`;后台任务每 `BACKUP_INTERVAL_MINUTES` 备份一次、按 mtime 保留最近 `BACKUP_KEEP` 份。恢复 `scripts/restore_db.py`:先校验备份 → 给现有主库做 `.pre-restore-` 一致性安全副本 → 同目录临时文件 `os.replace` 原子替换 → 删 `-wal`/`-shm` 残留(属旧库,留着会损坏新库);默认预览,`--apply` 才写。**不做「服务在跑」探测**(容器/权限都可能误判),文档要求恢复前先停服、恢复后重启。 +**备份与恢复**(`db/backup.py`,任务 / 管理台 / `scripts/` 共用):`VACUUM INTO` 写一致性快照(WAL 下直接 `cp` 丢未 checkpoint 事务);落盘即 `PRAGMA integrity_check`(不过则删半成品并抛错);产物 `0600` 目录 `0700`;每 `BACKUP_INTERVAL_MINUTES` 备份、按 mtime 保留 `BACKUP_KEEP` 份。恢复 `scripts/restore_db.py`:校验 → `.pre-restore-` 安全副本 → `os.replace` → 删 `-wal`/`-shm` 残留;默认预览,`--apply` 才写。**不做「服务在跑」探测**:要求恢复前停服、恢复后重启。端点与「上传后重启才换库」语义见 README「数据库备份与恢复」,此处不重复。 -**凭证标签**(`credentials.tags`,v22 新增列;纯函数在 `src/tags.py`):逗号分隔文本,`MAX_TAGS=16` / `MAX_TAG_LENGTH=24`。**写入口校验、读路径容错**——`parse_tags` 对超长/超量/非字符串项抛 `ValueError`(端点转成 400),`tags_value` 对库里手工改坏的越界项只丢弃、不抛错,避免一条脏标签让整页凭证列表失败。去重按**大小写不敏感**但保留首次写法。半角/全角逗号与分号都当分隔符(中文输入法极易打出全角逗号);含分隔符的**列表项**在写入前被拒(存进去会被读回时拆成两条,读写不等价)。**标签不参与调度与鉴权**——它是给人看的收敛维度,一旦影响选号,改名就会静默改变线上流量。端点 `PUT /api/credentials/{id}/tags`(整体替换,空数组 = 清空,`require_operator`),审计动作 `credential.tags`;导入 `POST /api/credentials` 也可选带 `tags`。列表响应里 `tags` 是**数组**(不是存储文本),前端筛选与徽章直接用。 +**凭证标签**(`credentials.tags`,v22;纯函数 `src/tags.py`):逗号分隔,`MAX_TAGS=16` / `MAX_TAG_LENGTH=24`。**写入校验、读取容错**——`parse_tags` 对越界项抛 `ValueError`(端点转 400);`tags_value` 只丢弃坏项不抛错。去重大小写不敏感、保留首次写法。半角/全角逗号与分号都当分隔符;含分隔符的列表项写入前被拒。**标签不参与调度与鉴权**。端点 `PUT /api/credentials/{credential_id}/tags`(整体替换,`require_operator`),审计 `credential.tags`。列表响应 `tags` 是数组。 **迁移纪律**(`migrate.py`): -- 启动时读 `schema.sql` 逐条 `CREATE TABLE IF NOT EXISTS`(只加不改)。 -- 新增**列**写进 `migrate._MIGRATION_COLUMNS`,走 `ALTER TABLE ... ADD COLUMN`(列已存在时忽略,老库幂等补列);同时 `SCHEMA_VERSION + 1`。 -- 新增**表**不需要 `_MIGRATION_COLUMNS`:`CREATE TABLE IF NOT EXISTS` 对老库同样执行,建表即完成迁移,只需 `SCHEMA_VERSION + 1` 并补一条老库升级测试。 -- 删除**表**写进 `migrate._MIGRATION_DROPS`,走 `DROP TABLE IF EXISTS`(schema.sql 里删定义不会作用于老库,遗留表必须显式删);同时 `SCHEMA_VERSION + 1`。 -- 版本记在 `PRAGMA user_version`;库版本高于本程序支持时启动报错。 -- 删除凭证时显式清理其模型级冷却行(无外键级联),否则重建同 id 凭证会继承旧的模型冷却。 -- **升级只在启动时发生**:迁移与 bootstrap 都挂在 `build_app` 里,所以「改了 schema 但没重启」=老库 + 新前端。 +- 启动读 `schema.sql` 逐条 `CREATE TABLE IF NOT EXISTS`(只加不改)。 +- 新增**列**写进 `_MIGRATION_COLUMNS`,走 `ALTER TABLE ... ADD COLUMN`;`SCHEMA_VERSION + 1`。 +- 新增**表**不需 `_MIGRATION_COLUMNS`:建表即完成迁移,只需 `SCHEMA_VERSION + 1` + 老库升级测试。 +- 删除**表**写进 `_MIGRATION_DROPS`,走 `DROP TABLE IF EXISTS`;`SCHEMA_VERSION + 1`。 +- 删除**列**写进 `_MIGRATION_COLUMN_DROPS`,走 `ALTER TABLE ... DROP COLUMN`;`SCHEMA_VERSION + 1`(v29 就这样删掉积分三列)。 +- 版本记在 `PRAGMA user_version`;库版本高于本程序 → 启动报错。 +- 删除凭证显式清理其模型级冷却行(无外键级联),否则重建同 id 继承旧冷却。 +- **升级只在启动时发生**:迁移与 bootstrap 挂 `build_app`;「改了 schema 但没重启」= 老库 + 新前端。 ## 9. 模型目录与模型名 ### 9.1 三字段与归一键 -八条渠道(codebuddy / codebuddy_intl / trae / zen / kilo / qoder / codearts / officeace)的每个模型统一成三个字段,规则集中在渠道中立的 `src/provider/naming.py`(纯函数,不 import 任何 provider 子模块): +十渠道每模型统一三字段,规则在 `src/provider/naming.py`(纯函数,不 import provider 子模块): | 字段 | 函数 | 例 | |---|---|---| @@ -469,82 +682,147 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k | 归一键(合并键 / 多渠道对外 id) | `normalize_model_key` | `kilo-auto`、`longcat-2.5-preview`、`nemotron-3-ultra` | | 展示名 `name` | `display_model_name` | `LongCat 2.5 Preview`、`Qwen3.8 Max` | -`normalize_model_key` 剥掉两类上游噪声再 slug 化(空格/下划线/冒号/括号 → 连字符、小写)。**免费标记**是可用性命名约定、不是模型名的一部分,四种形态:尾缀 `-free`/`_free`(Zen)、路径段 `.../free`(Kilo)、冒号尾缀 `:free`、展示名括号词 `(free)`;同性质还有新版标记 `new`。**命名空间前缀**不是模型身份:路径式 `厂商/模型` 取**最后一段**,冒号式 `厂商: 模型`(要求冒号后有空白)削掉前缀。只删「整段/整词」标记(`freeplay`/`newson` 内嵌形式不动)。点号**不是**分隔(`glm-5.2` 压成 `glm-5-2` 就换了模型);非 ASCII 字母数字保留。剥完只剩噪声时回退原串小写,调用方据此回退展示原 id,不产出空键。`display_model_name` 清洗上游可读名(`Qwen3.8-Max` → `Qwen3.8 Max`、`NVIDIA: Nemotron 3 Ultra (free)` → `Nemotron 3 Ultra`)或由 id 派生;品牌与缩写按表纠正(机械 title-case 会把 `DeepSeek`/`MiMo`/`GLM`/`GPT` 写错)。**幂等**:清洗结果再洗不变。**合并键 = 归一键,八渠道统一**:同一模型各渠道内部代号互不相同(Qoder `kmodel_latest` = TRAE `kimi-k3-1` = CodeBuddy `kimi-k3`),纯 id 规则无法对齐,只能靠展示名——归一键即展示名再 slug 化,两路径永远同源。zen / kilo 也参与合并:两条免费渠道各用不同命名约定与**不同 id 规格**(Zen `nemotron-3-ultra-free` vs Kilo `nvidia/nemotron-3-ultra-550b-a55b:free`),id 对不上,但展示名都清洗成「Nemotron 3 Ultra」,走展示名能并成一条。**同模型异名显式规范**(`MODEL_SYNONYMS`):清洗规则对齐不了「上游给同一模型起了不带版本号的名字」(Qoder `dfmodel` 上游名只叫 `DeepSeek-Flash`,别家叫 `DeepSeek-V4.1-Flash`)。表把「异名归一键 → (规范归一键, 规范展示名)」,键与展示名一起收敛。另一类是**空格式厂商前缀**(CB 的 `Xiaomi MiMo-V2.6-Pro` vs TRAE 的 `MiMo-V2.6-Pro`,冒号式规则削不到,归一键会多出 `xiaomi-`)→ 登记 `xiaomi-mimo-v2.6-pro` → `mimo-v2.6-pro`。**只登记人工核实的同模型异名,不做模糊匹配**。**哨兵名不跨渠道合并**(`_SENTINEL_LABELS` = `auto` / `default`):这些是各上游自己的「自动路由 / 默认模型」占位,语义只在本渠道内成立(Kilo 的 `kilo-auto/free` 与 Qoder 的 `auto` 都归一成 `auto` 却是完全不同的东西),故退回原 id 的归一键、各自单列。**同渠道内归一键重复 → 退回原 id 的归一键**;该渠道也不登记歧义的名字别名。 +- `normalize_model_key` 剥上游噪声再 slug 化(空格/下划线/冒号/括号 → 连字符、小写)。**免费标记**四形态:尾缀 `-free`/`_free`、路径段 `.../free`、冒号尾缀 `:free`、括号词 `(free)`;同类还有 `new`。**命名空间前缀**非身份:路径式取末段;冒号式(冒号后须有空白)削前缀。只删整段/整词标记(`freeplay`/`newson` 不动)。点号非分隔(`glm-5.2` → `glm-5-2` 就换了模型);非 ASCII 字母数字保留。剥完只剩噪声 → 回退原串小写。 +- `display_model_name` 清洗可读名(`Qwen3.8-Max` → `Qwen3.8 Max`)或由 id 派生;品牌按表纠正(机械 title-case 写错 `DeepSeek`/`MiMo`/`GLM`/`GPT`)。**幂等**。 +- **合并键 = 归一键,十渠道统一**:同一模型各渠道代号互不相同(Qoder `kmodel_latest` = TRAE `kimi-k3-1` = CB `kimi-k3`)→ 纯 id 规则无法对齐,只能靠展示名。zen / kilo 也参与(Zen `nemotron-3-ultra-free` vs Kilo `nvidia/nemotron-3-ultra-550b-a55b:free`,展示名都清洗成「Nemotron 3 Ultra」)。 +- **同模型异名显式规范**(`MODEL_SYNONYMS`):如 Qoder `dfmodel` 上游名 `DeepSeek-Flash` vs 别家 `DeepSeek-V4.1-Flash`;表把「异名归一键 → (规范归一键, 规范展示名)」。另一类是空格式厂商前缀(`Xiaomi MiMo-V2.6-Pro` → `mimo-v2.6-pro`)。**只登记人工核实的,不做模糊匹配**。 +- **哨兵名不跨渠道合并**(`_SENTINEL_LABELS` = `auto` / `default`):语义只在本渠道成立 → 退回原 id 归一键、各自单列。**同渠道内归一键重复 → 退回原 id 归一键**,且不登记歧义别名。 +- **对外 id 一律归一键**:多渠道取最短展示名的归一键;单渠道取该条;兜底/哨兵取原代号归一键。原代号不露面,转发经 `services.model_aliases` 映射回(`executor.upstream_model_name` 消费);每渠道登记三键(对外 id、原代号、展示名及 slug 形式)。 +- **撞车消歧**(`_disambiguate`):按 `_sort_key` 序保留先到者,后者依次取 `_fallback_ids`(原代号归一键 → 加渠道后缀 → 数字后缀)。兜底/哨兵条目用 `_scoped_key`(`渠道\0归一原代号`)隔离,对外 id 仍是原代号归一键。 +- **展示顺序**:`_PROVIDER_RANK`(codebuddy 0 → codebuddy_intl 1 → trae 2 → qoder 3 → codearts 4 → officeace 5 → raccoon 6 → lingxi 7 → 其余 8),多渠道取 `min`,同级按归一键字典序。只影响展示;调度选号顺序(`KNOWN_PROVIDERS`)不变。 +- **自定义别名**(`MODEL_ALIASES`,热更,`model_alias.py` 解析 + `api/model_merge._apply_custom_aliases` 注入):`对外名=模型@渠道;对外名2=模型`,`@渠道` 可省。解析宽容(坏段忽略 + warning),注入期判撞名 / 目标不存在 / 渠道不持有 → 该段 ERROR 跳过;条目带 `custom_alias: true`。**无别名链**。用法见 README「模型名三字段」。 -**对外 id 一律是归一键**(`_finalize` / `_disambiguate` / `_register_aliases`):多渠道取最短展示名的归一键;单渠道取该条展示名的归一键;兜底/哨兵条目取**原代号的归一键**。**原代号不在对外 id 里露面**,但转发时一律经 `services.model_aliases` 映射回该渠道自己登记的原代号(由 `executor.upstream_model_name` 消费);每渠道登记三条键——对外 id、原代号、展示名(原样与 slug 形式),用户按任一种写法都能直连命中。**对外 id 撞车的消歧**(`_disambiguate`):按 `_sort_key` 序保留先到者,后者按 `_fallback_ids` 依次取(原代号归一键 → 加渠道名后缀 → 数字后缀)。**兜底/哨兵条目用渠道限定键隔离**(`_merge_key` 的 `_scoped_key` = `渠道\0归一原代号`),但对外 id 仍是原代号的归一键。**展示顺序(CB / TR 优先)**:合并后按 `_sort_key(entry)` 排序——渠道权重 `_PROVIDER_RANK`(codebuddy 0 → codebuddy_intl 1 → trae 2 → qoder 3 → codearts 4 → officeace 5 → 其余 6),多渠道条目取 `min` 权重,同级再按**归一键**字典序(`_sort_key` 在 `_finalize` 之前跑,此时对外 id 未算出,而 `_disambiguate` 需要确定顺序决定撞 id 时谁保留)。仅影响展示顺序,调度选号(`model_resolver` 的 `KNOWN_PROVIDERS` 顺序)不变。 ### 9.2 按凭证加载与 SWR -`list_models` 先用 `credential_providers()` 求「当前有可用凭证」的渠道集合(`candidates(selectable_only=True)`:未暂停、未硬禁用;冷却中的仍算有凭证,避免限流时列表闪没),循环里 `provider_id not in connected` 直接 `continue`——**没凭证的渠道不读缓存、不拉上游、不展示**。zen / kilo 自带虚拟凭证,不受影响。**失败也进 TTL(负缓存)**:`model_list_fetched_at` 记上次尝试时间(成功或失败都刷新),TTL 内不再打上游。**连续失败再按 2^(n-1)×TTL 退避**(`model_list_failures` 计数,封顶 `MODEL_LIST_BACKOFF_MAX_SECONDS` 2h,一次成功即清零):上游被长期封禁(zen 免费层按出口 IP 拦)时每 5 分钟重探一遍(十几秒真推理)纯属刷屏;日志只在**首次**失败打 WARNING、其后降 INFO,连续次数留在消息里。**HTTP 出口 stale-while-revalidate**:TTL 到期后 `GET /v1/models` 与 `GET /api/playground/models` 走 `serve_models`:**有缓存直接回旧列表**,过期渠道丢后台(`_schedule_refresh`)异步刷新。两条纪律:① **某渠道一条缓存都没有时才同步等它**(冷启动无快照 / 新接入渠道),否则列表会缺一条渠道;② 后台刷新收敛在 `services.model_refresh_tasks`,`model_refreshing` 去重,`lifespan` 关闭时逐个 `cancel()` + `await`。代价:列表最多滞后一个 TTL。后台预热 `_warm_model_list` 与兜底循环仍走同步 `list_models`。**逐渠道增量 publish**:每拉完一条渠道就 `publish_aliases()` 一次(从 `model_list_cache` 重建 + 就地更新,executor 闭包引用同一 dict),不再等最慢那条(zen 逐个免费模型真发探活可占 12–15s,期间别名表为空会让 `executor._narrow_providers` 拿不到归属而按「全部渠道」放行)。合并逻辑收敛到 `merged_entries()`,缓存兜底 / TTL 复用 / 落盘恢复三条路径共用。 +`list_models` 先用 `credential_providers()` 求「当前有可用凭证」的渠道(`candidates(selectable_only=True)`:未暂停、未硬禁用;冷却中仍算);`provider_id not in connected` 直接 `continue`——**没凭证不读缓存、不拉上游、不展示**。zen / kilo 有虚拟凭证。 + +- **失败也进 TTL**(负缓存):`model_list_fetched_at` 记上次尝试(成功或失败)。**连续失败按 2^(n-1)×TTL 退避**(`model_list_failures`,封顶 `MODEL_LIST_BACKOFF_MAX_SECONDS` 2h,成功清零);日志首次 WARNING、其后 INFO。 +- **HTTP 出口 SWR**:TTL 到期后 `/v1/models` 与 `/api/playground/models` 走 `serve_models`:有缓存直接回旧列表,过期渠道丢后台(`_schedule_refresh`)。两条纪律:① 某渠道一条缓存都没有时才同步等(冷启动 / 新渠道);② 后台刷新收敛在 `services.model_refresh_tasks`,`model_refreshing` 去重,`lifespan` 关闭时 `cancel()` + `await`。代价:列表最多滞后一个 TTL。 +- **逐渠道增量 publish**:每拉完一条渠道 `publish_aliases()` 一次(就地更新,executor 闭包引用同一 dict),不等最慢那条(zen 逐个探活可占 12–15s;期间别名表为空会让 `_narrow_providers` 按全部渠道放行)。合并逻辑收敛到 `merged_entries()`。 + +### 9.1.1 探活判死只影响展示,不影响归属 +`model_probes.failed()`(只看 `state='failed'`,**不再**要求负缓存仍生效)命中的 (渠道, 模型) 从**展示**里摘掉(`merged_entries` 逐渠道抽掉),但**别名登记保留**(`api/models._hidden_aliases` → `_register_entry_alias`)。 + +- **为什么去掉「负缓存仍生效」条件**:人工「立即探活」判死时通常没有负缓存(它只由真实请求写入),旧口径下这些模型会一直留在 Playground——上游目录撒谎(officeAce `/v2/models` 挂着 26 个模型,其中多个推理端点 404 `ModelArts.81009` / 403 `ModelArts.81004`)时最明显。 +- **到期口径改为复探**:隐藏没有时间上限,恢复入口只有「再探一次成功」——`ModelProbeTask.run_once` 每轮把 `state='failed'` 的行一起复探(`ModelProbeRepository.failed_rows`,用上游原名),`ok` 即恢复展示并清负缓存。若仍挂到期在负缓存上,负缓存过期后没人再探 = 永久隐藏。 +- **别名必须保留**:别名表是执行侧 `_narrow_providers` 收窄候选的唯一依据。摘掉后「没有任何渠道登记该模型」→ 回退成**全部渠道扇出**,请求打到一堆没有该模型的上游,各回「无此模型」(CodeBuddy 11102 / officeAce 81004·81009 / CodeArts InferHub.002002009),并被写上 (凭证, 模型) 负缓存——污染**从没有该模型**的渠道。实测 2026-10-26 `deepseek-v4.1-flash-1017`:officeAce `/v2/models` 挂着它但推理端点 404 `ModelArts.81009`,判死后别名丢归属 → 扇出到 codebuddy / codearts / codebuddy_intl → 8 条负缓存写在从没有该模型的渠道上。修好后收窄仍指向真正持有它的渠道(多半有负缓存兜底 → 快速 503「该模型暂时不可用」),其余渠道零调用、零负缓存。 +- **补登记的键推导与真实条目同源**(`_register_entry_alias`):两处各写一份迟早漂移,漂移表现是「用户按展示名请求时收窄失效」。**不覆盖**已有映射(真实条目优先);**只登记有可用凭证的渠道**(与 `merged_entries` 同口径)。 +- **兜底清理**:`scripts/cleanup_stale_blocked.py` 删「该渠道目录里根本没有这个模型」的负缓存行(扇出留下的噪声)。判据保守:目录里**有**这个模型的行一律保留(那是上游真把它下架了,负缓存正是应有的避让);目录快照读不出来 → 一律不删。 + +### 9.1.2 「上游没这个模型」独立成 error_type +`ErrKind.BLOCKED`(上游明确说没有这个模型)统计落 `error_type=model_not_found`,**不再并进 `invalid_request`**。后者是「请求无效」(`ErrKind.INVALID` / `ErrKind.REQUEST`),归因面板显示「请求无效」,读起来像客户端参数写错了——实际是上游把该模型下架了(实测 2026-10-26 officeAce 404 `ModelArts.81009` 被标成「请求无效」)。 + +- 受控枚举 `CONTROLLED_ERROR_TYPES` 增 `model_not_found`(前端 `USAGE_ERROR_LABEL` 同步:「上游无此模型」)。 +- **套餐不允许也归这里**(2026-10-29):CodeBuddy 11158 `model [...] is not allowed for user type [personal_free_user]` 与 11102「该后端无此模型」对**当前凭证**是同一件事——重试无意义、换号才有用、与账号健康无关。此前 11158 落进 `400 其他 → ErrKind.INVALID`,归因面板显示「请求无效」(实测 10:36 / 10:37 国际站免费号请求 `gpt-6-luna`、`gemini-3.8-flash`)。现在两者共用 `codebuddy.events.MODEL_BLOCKED_CODES`,列表探活(`client.filter_available_models`)也读同一个集合,不再各写一份。 +- 可用性视图(`GET /api/models/availability`)的计入口径增 `model_not_found`:它**仍属**模型级失败,只是分类更准。 +- **收尾行按真实原因落**(2026-11-03 修):轮换耗尽那行由 `_exhaust_error_type` 取最后一次错误类型——模型级冷却耗尽 → `model_unavailable`,其余走 `_failure_type`,只有一次都没选中凭证才 `no_healthy_credential`(此前一律写后者,把 zen 单凭证 429 冷却到次日也报成「无可用凭证」)。「上游没这个模型」(尝试级归因)与「这个模型现在全都不可用」(收尾行)是两件事。 + +### 9.1.3 收尾行按真实归因 + 限流/超时/上下文类独立成类 +`_failure_type(kind, error)` = 收尾行的受控类型:先判**本机网络/DNS 失败**(`httpx.ConnectError` / `socket.gaierror`,含 `UpstreamTransportError.__cause__`)→ `network_error`,再按异常形态判链路层瞬态(`httpx.TransportError` / `ssl.SSLError` / `UpstreamTransportError` → `upstream_timeout`),最后落 `_error_type_for(kind)`。 + +- `ErrKind.NETWORK` → `network_error`,属 `NO_PENALTY_KINDS`:DNS 解析 / TCP 建连失败发生在拿到响应之前,与凭证额度、上游限流无关。按 SOFT 冷却会把网络抖动放大成整池「无可用凭证」(实测 2026-11-05 睡眠唤醒后六渠道同时 `ENOTFOUND`)。`ConnectTimeout` 不在此列(可能上游慢),仍归 `upstream_timeout`。 +- `ErrKind.SOFT` → `rate_limit`:SOFT 主要是上游 429/限流,记「额度耗尽」比笼统 `upstream_error` 可行动;链路层超时被上一步拦走,不会误标。 +- `ErrKind.CONTEXT`(CodeBuddy 11115 `prompt is too long`)→ `context_overflow`;处置同请求级错误(不罚号、仍换号),但不能再混进「请求无效」。 +- `ErrKind.CAPABILITY`(CodeArts 001001020 not multimodal)→ `model_capability`;处置同 INVALID(`REJECT_KINDS` 一起跳过整个上游),但「模型不支持该请求形态」不是「请求写错了」。 +- **CodeArts `InferHub.001001005.400 param is invalid` → INVALID**(2026-11-04 修):`classify_error_code` 的 `"1005" in text` 是**裸子串**判据,而 `001001005` 是上游错误码前缀、子串里含 `1005`,于是「请求参数不合法」被判成 `PLAN` → 12h 账号级冷却(实测 2026-11-03 14:41–14:55 三条 codearts 凭证相继命中,整条渠道约 2.2 万剩余积分静默下线 12h)。现以 `_PARAM_INVALID_MARKERS = ("001001005", "param is invalid")` 前置判 INVALID(在 `_MODEL_CAPABILITY_MARKERS` 之后、`"1005" in text` 之前);真业务码 `1005`(权益耗尽)仍判 PLAN。与 `001001020` 同一手法:点分「前缀 + 子码」形态**不能**用裸子串匹配。 +- 三个新值进 `CONTROLLED_ERROR_TYPES`(前端 `USAGE_ERROR_LABEL` 同步);`model_capability` 计入模型可用性视图,`context_overflow` 不计(客户端上下文问题)。 +- **明细口径**:硬失败(DEAD/RESTRICTED/INVALID/CAPABILITY)每次尝试各落一行;限流 / 额度 / 超时 / 上下文等其余分类每次请求仅一行(收尾行),不逐次落——逐次落会把 `usage_hourly.requests` 与 Key 日请求额度一起刷高。 + +**单模型查询** `GET /v1/models/{model_id:path}`:与列表**共用** `_revalidated_providers` + `_served_entries`。命中三种写法(对外 id / 展示名 / 任一渠道 `raw_ids`),`strip().lower()` 较。`:path` 容纳带命名空间的原代号(`dots-studio/dots-3-note-preview:free`)。**白名单外与不存在同形 404**(`model_not_found`),客户端无法探测池子。能力分与列表同源。 -**单模型查询 `GET /v1/models/{model_id:path}`**(`api/models.py: serve_model`):与列表**共用** `_revalidated_providers`(同一 SWR 刷新时机)+ `_served_entries`(同一合并/定稿/别名 publish/白名单过滤)——两条出口看到同一份条目,避免「列表里有、单查 404」的错觉。命中口径三种写法(对外 id / 展示名 / 任一渠道 `raw_ids`),统一 `strip().lower()` 比较,故大小写不敏感;路径参数用 `:path` 以容纳带命名空间的 kilo 原代号(`dots-studio/dots-3-note-preview:free`)。**白名单外与不存在同形 404**(`error_payload(..., "model_not_found", 404)`):`_served_entries` 已按 Key 的 `allowed_models` 过滤,查不到即 404,客户端无法用单查探测「池子里还有哪些模型」。能力分与列表同源(`_benchmarks_of(request)`),刷新后下一请求即生效。 ### 9.3 落盘快照与启动回灌 -进程内缓存重启即丢,故每次成功拉取后把**未过滤原始表**原子写进 `DATA_DIR/model_catalog.json`(tmp + `os.replace`,格式 `{"version":1,"providers":{pid:{"saved_at":…,"models":[…]}}}`,序列化字段取 `dataclasses.fields(Model)`)。启动时 `main._restore_model_list` **同步**读回并立即 publish 别名表(`restore_model_catalog`,零上游请求、异常只记日志),预热退化为纯后台刷新;快照 `saved_at` 随表交回、折进 `model_list_fetched_at`,故预热(TTL 门禁)不会重拉刚恢复的表。三条纪律:① 落盘/读回一律宽容(损坏、版本不符、字段缺失、记录非 dict 只丢该渠道,`saved_at` 超 7 天整条丢弃);② 存原始表不过滤(黑名单热更立即生效);③ 恢复只覆盖「已注册且当前有可用凭证」的渠道——用户暂停的渠道不因快照复活。**启动预热不再阻塞启动**:`lifespan` 把预热丢给后台任务,不在 `yield` 前 `await`(zen 最慢探活可占十几秒,内联会让应用不响应 `/health`)。另有 `MODEL_CATALOG_MINUTES` 后台任务兜底刷新(保证纯 API 部署也真刷新)。 -### 9.4 模型目录(OpenRouter 单一来源:价格 + 明细 + 能力分) -**数据源**:`https://openrouter.ai/api/v1/models`(`OPENROUTER_MODELS_URL`,匿名可读、无需 key)。一次抓取同时得到刊例价(`pricing`)、明细元数据(`architecture` / `top_provider` / `supported_parameters`)与 Artificial Analysis 三项指数(`benchmarks.artificial_analysis`),供成本估算、「模型列表」页与 Playground 能力分三处使用。**架构**(后台抓 → 落盘 → `app.state` → 每请求现读): -- 后台任务 `openrouter_catalog`,周期由 `OPENROUTER_CATALOG_MINUTES` 控制(默认 1440 分钟、下限 60,可热更)。一轮抓取建三张表:价表(`build_price_table`)、明细目录(`build_model_catalog`)、能力表(`build_benchmark_table`)。拉到空价表视为失败,不覆盖已有三张表。 +每次成功拉取后把**未过滤原始表**原子写进 `DATA_DIR/model_catalog.json`(tmp + `os.replace`,格式 `{"version":1,"providers":{pid:{"saved_at":…,"models":[…]}}}`)。启动时 `main._restore_model_list` **同步**读回并立即 publish 别名表(零上游请求),预热退化为后台刷新;`saved_at` 折进 `model_list_fetched_at` → 不重拉刚恢复的表。 + +三条纪律:① 落盘/读回一律宽容(损坏、版本不符、字段缺失、记录非 dict 只丢该渠道;`saved_at` 超 7 天整条丢弃);② 存原始表不过滤(黑名单热更立即生效);③ 恢复只覆盖「已注册且当前有可用凭证」的渠道。**启动预热不阻塞**:`lifespan` 把预热丢后台(zen 最慢探活十几秒,内联会让 `/health` 不响应)。另有 `MODEL_CATALOG_MINUTES` 兜底刷新。 + +### 9.4 模型目录(OpenRouter 单一来源) +**数据源** `https://openrouter.ai/api/v1/models`(`OPENROUTER_MODELS_URL`,匿名)。一次抓取得刊例价、明细元数据(`architecture` / `top_provider` / `supported_parameters`)与 Artificial Analysis 三项指数,供成本估算、模型列表页与 Playground 能力分。 -**能力分口径**:Artificial Analysis 的三项指数(`intelligence_index` 综合智能 / `coding_index` 编程 / `agentic_index` 智能体),经 OpenRouter 公开接口分发。AA 官方 API 需 key,故不采用;上游字段类型意外时安静跳过,绝不让模型列表挂掉。 +- 后台任务 `openrouter_catalog`(`OPENROUTER_CATALOG_MINUTES`,默认 1440、下限 60、可热更)一轮建三张表:`build_price_table` / `build_model_catalog` / `build_benchmark_table`。空价表视为失败,不覆盖已有表。 +- **能力分**:`intelligence_index` / `coding_index` / `agentic_index`,经 OpenRouter 公开接口分发(AA 官方 API 需 key)。字段类型意外安静跳过。 +- **浮动别名取同族最新快照的分**(`_snapshot_info`):OpenRouter 同挂浮动别名与带日期快照,用 `canonical_slug` 声明同属一次发布,且只对同一快照发一个评测变体(实测浮动别名 `Non-reasoning` 24.7、`-1017` `Max` 39.5)。本项目默认开推理,与 Max 同口径 → 按 `canonical_slug` 日期取同族最新快照;显式带日期戳的 id 仍用自己的分。家族只从带 `-YYYYMMDD` 的 canonical 派生(`mistral-large` vs `mistral-large-2512` 是不同模型)。 +- **失败安静降级**:网络 / 结构坏 / 空表 / 快照损坏 / 版本不符 / 超龄一律退化为空表;条目不**带** `benchmarks` 字段(不是 `null`),页面显示 `—`。 +- 落盘 `data/openrouter_catalog.json`(`CATALOG_VERSION=2`,三表同文件同 `saved_at`,超 `MAX_AGE_SECONDS`=7 天过期 → 全退空),启动同步回灌;无快照时后台补拉(`_warm_model_catalog`)。 +- 挂 `app.state.price_table` / `models_dev_catalog` / `model_benchmarks` / `price_saved_at`。刷新是**就地 `clear()` + `update()`**,不换引用。 -**浮动别名取同族最新快照的分**(2026-10-19,`_snapshot_info`):OpenRouter 对同一模型会同时挂浮动别名(`deepseek/deepseek-v4.1-flash`)与带日期快照(`deepseek/deepseek-v4.1-flash-1017`),并用 `canonical_slug` 声明同属一次发布。上游把浮动别名钉在旧快照上、且对同一快照只发布一个评测变体(实测浮动别名标 `Non-reasoning` 24.7,`-1017` 标 `Max` 39.5),分数看着掉了 15 分其实只是换了变体。本项目转发默认开推理,与 Max 同口径,故 `build_benchmark_table` 按 `canonical_slug` 的日期取同族最新快照的分;显式带日期戳的 id(`deepseek-v4-pro-0813`)仍用自己的分。`source_model` 指向真正提供分数的上游 id。家族只从**带 `-YYYYMMDD` 的 canonical** 派生——`mistralai/mistral-large`(Feb '24)与 `mistralai/mistral-large-2512`(Mistral Large 3)削完日期同基却是两个模型,不互相借分。 -- **失败安静降级**:网络 / 结构坏 / 空表 / 快照损坏 / 版本不符 / 超龄,一律退化为空表,条目**不带** `benchmarks` 字段(不是 `null`),页面显示 `—`;模型列表与聊天不受影响。 -- 落盘 `data/openrouter_catalog.json`(`CATALOG_VERSION=2`,三张表同一个文件、同一个 `saved_at`,超 `MAX_AGE_SECONDS`=7 天视为过期 → 全退空),启动时同步回灌(`load_catalog_snapshot`,零上游请求);**无快照时**启动后台补拉一次(`_warm_model_catalog`)。 -- 挂 `app.state.price_table` / `app.state.models_dev_catalog` / `app.state.model_benchmarks` / `app.state.price_saved_at`。刷新是**就地 `clear()` + `update()`**,不换引用(每请求现读,换引用会让路由读到旧表)。 +**匹配纪律**(`src/model_match.py`,三处共用):`match_keys(*texts)` 把任意写法展开成候选键集合(归一键 + `_ALIAS_RULES` 显式等价 + 剥 `_DROP_SUFFIXES` + 版本号点/连字符互换);`lookup` **只做等值匹配**,命中不唯一返回 `None`(`glm-5.3` 绝不匹配 `glm-5.3-flash`)。规则全显式登记并附实测来源——渠道分发代号逐 id 登记(`deepseek-v4.1-flash-0930` / `-sg` → `deepseek-v4.1-flash`),因 OpenRouter 的 `-1017` 快照有自己的价位,通用剥日期会把两套价格混成一个。 + +**展示范围**(同一份表):`/v1/models` 每行可选 `benchmarks`;`/api/playground/models` 同上;`/api/model-catalog` 每行可选 `benchmarks` + `available` + 顶层 `saved_at`。**`available`** = 当前渠道真能提供(口径同 `/v1/models`),供默认关闭的「只看当前渠道」过滤。**排序**:`sort=intelligence` 按综合智能排(无分排末尾,不冒充 0 分)。**不做排序**处标注「Artificial Analysis 指数,经 OpenRouter 公开接口;非本服务实测」。**已知取舍**:`family` / `open_weights` / `release_date` 上游不给(后端固定 `null` / `false`)。 -**匹配纪律**(`src/model_match.py`,价表 / 明细 / 能力排行共用同一套):本项目对外的模型 id 是归一键(`kimi-k3`),OpenRouter 用 `moonshotai/kimi-k3` + 展示名 `MoonshotAI: Kimi K3`。`match_keys(*texts)` 把任意写法展开成候选键集合(归一键 + `_ALIAS_RULES` 显式等价 + 剥 `_DROP_SUFFIXES` 尾缀 + 版本号点/连字符互换),`lookup` **只做等值匹配**、命中不唯一返回 `None`(`glm-5.3` 绝不匹配 `glm-5.3-flash`)。规则全部显式登记,每条附实测来源,不做启发式推断——例如渠道分发代号逐 id 登记(`deepseek-v4.1-flash-0930` / `-sg` → `deepseek-v4.1-flash`),因为 OpenRouter 的 `-1017` 快照**有自己的价位**,通用剥日期尾缀会把两套价格混成一个。**快照家族**(`src/benchmarks.py:_snapshot_info`)是这层之上的另一条显式规则:同一次发布的多条快照记录按 `canonical_slug` 的 `-YYYYMMDD` 归族,浮动别名取最新一条的分(见上节),族内不改写显式快照 id。**展示范围**(三处,同一份表同一口径):`GET /v1/models` 每行可选 `benchmarks` 字段;`GET /api/playground/models` 同上;`GET /api/model-catalog` 每行可选 `benchmarks` + `available` + 顶层 `saved_at`。**`available`** 是「当前渠道真能提供」的布尔(口径与 `/v1/models` 一致:只算有可用凭证、过当前黑名单的渠道),用同一套候选键与渠道侧条目求交集——「模型列表」页据此提供默认关闭的「只看当前渠道」过滤。**排序**:`sort=intelligence` 按综合智能指数排(无分数的行排末尾,不冒充 0 分),只给这一个分项入口(一列对应一个入口,三项各排会挤爆表头)。**UI**:Playground 模型选择器行内徽章 + 选中模型的能力分卡;「模型列表」页「能力分」列(三行:智 / 编 / 体,列头可点排序)+ 「能力分来源」指标卡。**不做排序**——分数是第三方成绩,排序等于替用户下结论。每处都标注「Artificial Analysis 指数,经 OpenRouter 公开接口;非本服务实测」。**已知取舍**:`family` / `open_weights` / `release_date` 上游不提供(后端固定给 `null` / `false`,页面只展示「知识截止」)。**降级**:拉取失败 / 快照损坏 / 版本不符 / 超龄,一律退化为空表,条目**不带** `benchmarks` 字段(不是 `null`),页面显示 `—`。 ### 9.5 token 到期展示与预警 -**到期时间的来源**(决定实现,不能只读凭证字段):TRAE 凭证 JSON 的 `expires_at` 有值(= JWT `exp`);**CodeBuddy 恒为 0**(OAuth 登录与刷新响应都不带 `expires_at`),只能从 bearer token 的 JWT `exp` 解析;CodeArts 用 `expiration`(不是 `expires_at`),签发时间借 `refresh_token` JWT 的 `iat`。实现集中在 `provider/token_expiry.py`(渠道中立,刻意不 import provider 子模块,否则 `provider.codebuddy.events → engine.sse` 会被拖进 `db` 层): +**来源**:TRAE `expires_at` 有值(= JWT `exp`);**CB 恒为 0**(OAuth 登录与刷新都不带),只能从 JWT `exp` 解析;CodeArts 用 `expiration`,签发时间借 `refresh_token` JWT 的 `iat`。实现集中在 `provider/token_expiry.py`(渠道中立,不 import provider 子模块,否则把 `db` 层拖进来)。 + +- `credential_token_times(data)` 返回 `(签发, 到期)` epoch。到期优先显式 `expires_at`/`expiresAt`/`expiration`;缺失 / 非法时遍历 token 键解析 JWT `exp`;签发只来自 `iat`。拿不到返回 **0 = 未知**,**不猜本地 TTL**。**只借 `iat` 的兜底**:CodeArts access token 是 STS AK/SK(非 JWT),但 STS 响应同时轮转出 `refresh_token` JWT,其 `iat` 即签发时刻 → `_ISSUED_ONLY_KEYS` **只借 `iat`、不借 `exp`**(后者 30 天寿命会把 2h 凭证画成 30 天)。 +- `jwt_times` / `jwt_expiry` 只 base64url 解码不验签;异常一律 0。`normalize_epoch()` 归一毫秒。 -- `credential_token_times(data)`:返回 `(签发, 到期)` 两个 epoch。到期优先显式 `expires_at`/`expiresAt`/`expiration`,缺失 / 非法时遍历可能的 token 键解析 JWT `exp`;签发时间只来自 JWT `iat`。各自拿不到时返回 **0 = 未知**。**不猜本地 TTL**——捏造的到期时间会让管理台显示假预警。**只借 `iat` 的兜底**:CodeArts 的 access token 是 STS AK/SK(非 JWT),但签发它的 STS 响应同时轮转出一张 `refresh_token` JWT,其 `iat` 就是这组临时凭证的签发时刻,故 access token 键都取不到信息时再遍历 `_ISSUED_ONLY_KEYS` **只借 `iat`、不借 `exp`**(后者的 30 天寿命会把 2h 的临时凭证画成 30 天)。 -- `jwt_times(token)` / `jwt_expiry(token)`:只 base64url 解码不验签;非 JWT / 结构异常 / claim 非法一律 0。`normalize_epoch()`:毫秒时间戳归一。 +**落库**:`token_expires_at`(`add()` 与 `save_credential_data()` 都写回;JWT 派生值**不写回凭证 JSON**,否则刷新后旧值残留成「权威」)+ `token_issued_at`(仅诊断)。**老库不批量回填**:列表读到时从 `data_enc` 按需派生(`_token_times_from_blob()`,失败返回 `(0,0)`)。**读路径**只下发绝对 epoch,**不代前端算剩余秒数**;前端 `tokenExpiryView()` 用同一时钟现算,阈值经 `TOKEN_EXPIRY_WARNING_SECONDS` 下发。**短寿命凭证不标「即将到期」**:整段寿命 ≤ 预警窗 2 倍(CodeArts STS 2h、窗口 1h)恒标红会淹真告警,真过期(剩余 ≤0)仍标红。未知显示 `—`。`GET /api/credentials` 带 `token_expiry_warning_seconds`,每条带 `token_expires_at` / `token_issued_at`。 -**落库**:`credentials` 两列——`token_expires_at`(`add()` 与 `save_credential_data()` 都写回;JWT 派生值**不写回凭证 JSON**,否则刷新换到新 token 后旧派生值会残留成「权威」到期时间)与 `token_issued_at`(仅落库供诊断)。**老库升级不批量回填**:改在列表读到时按需从 `data_enc` 派生(`_token_times_from_blob()`,解密 / 解析失败返回 `(0, 0)`);一旦写回就只读列值。**读路径**:`list_all()` 只下发绝对 epoch,**不代前端算剩余秒数**(服务端算好的「剩余」不随页面 tick 更新)。剩余时间与预警判定由前端 `tokenExpiryView()` 用同一时钟现算,阈值由 `TOKEN_EXPIRY_WARNING_SECONDS` 经列表接口下发。**短寿命凭证不标「即将到期」**:整段寿命(`expires − issued`)≤ 预警窗口的 2 倍时,该 token 天生就在窗口附近(CodeArts STS 寿命 2h、窗口 1h),恒标红只会淹掉真告警;**真过期(剩余 ≤0)仍标红**。到期未知(0)时显示 `—`,**绝不当成已过期**。接口:`GET /api/credentials` 响应新增 `token_expiry_warning_seconds`,每条凭证新增 `token_expires_at` 与 `token_issued_at`(均为 0 表示未知)。 ### 9.6 积分变动流水 -**做法:在额度探测写回时比对余额,记一条流水**(`credit_events`)。写入放在 `save_quota` 的**同一个事务**里:分两次读改写会与并发探测交错,记出「before 是别人写过的值」的错行。**账本由凭证自己的渠道决定**(`quota_ledger`):`pool`=套餐 / 每日余额(CodeArts 之外的渠道),`credits`=积分(CodeArts 的额度来自积分接口,内置模型按积分计费)。两本账的数值量级与语义都不同,混读会被当成同一本账的连续读数。**归因纪律**:diff 只能看到 `[上次探测, 本次探测]` 区间的**净变化**,其间签到、成长领取与对话消耗可能同时发生,无法区分谁贡献了多少。所以 `source` 列**只表达归因已知度**:`observed` = 两次探测之间的净变化(delta 可正可负或 NULL);`sync` = 首次建立基线、没有对照(delta 为 NULL)。前端文案一律说「净变化」,不写「签到 +5」。**只记有信息量的行**:首次探测记 `sync`(delta NULL);余额未变**不记**;余额变化记 `observed`;任一端未知时记(delta NULL,「余额变未知」本身是该追的异常);两端都未知不记;凭证被并发删除不记。`window_start` 记的是**上次探测时刻**(不是「上次有变动」)。接口:`GET /api/credentials/{id}/credit-events?limit=20&ledger=pool|credits`(管理员;未知凭证 400 而不是空列表;未知 `ledger` 回落 `pool`)。保留期由 `RetentionTask` 按 `usage_events` 同一保留期(90 天)回收,报告里体现为 `purged_credit_events`。 +**做法:额度探测写回时比对余额,记一条流水**(`credit_events`),写入放 `save_quota` **同一事务**(分两次读改写会与并发探测交错)。 + +- **账本由渠道决定**(`quota_ledger`):`pool` = 套餐/每日余额(CodeArts 之外),`credits` = 积分(CodeArts)。两本账量级与语义不同,混读会被当成同一本账。 +- **归因纪律**:diff 只能看到 `[上次探测, 本次探测]` 的**净变化**(其间签到、成长、对话可能同时发生)。`source` 只表达归因已知度:`observed`(净变化,delta 可正可负或 NULL)、`sync`(首次建基线,delta NULL)。前端文案一律「净变化」,不写「签到 +5」。 +- **只记有信息量的行**:首次记 `sync`;余额未变不记;变化记 `observed`;任一端未知记(delta NULL);两端都未知不记;凭证被并发删除不记。`window_start` = **上次探测时刻**。 +- 接口 `GET /api/credentials/{credential_id}/credit-events?limit=20&ledger=pool|credits`(未知凭证 400,未知 `ledger` 回落 `pool`)。保留期 90 天(`purged_credit_events`)。 + ### 9.7 用量统计与成本 -- **口径**:`overview` / `by_provider` / `by_model` / `by_user` / `timeline` / `model-timeline` 均读**小时汇总**(`usage_hourly`,永久保留),只有 `events`(逐请求明细)读 `usage_events`(90 天)。统一口径让选「全部」时总览与图表同值。代价:最近 ≤5 分钟未进汇总的请求不计入。**凭证维度读明细表**(小时汇总主键里没有 `credential_id`),故只覆盖最近 90 天。 -- **小时汇总双写**:`record()` 写明细的同时增量累加当前小时行,新请求立即可见于统计页;`rollup_hourly`(`RetentionTask` 每 5 分钟)全量重算作对账,两者幂等一致。 -- **TRAE credit 为推算值**(`credit_estimated`):TRAE 上游 `token_usage` 只给 token 数、无单请求积分,按官方计费公式(`(输入−缓存)×输入价 + 输出×输出价 + 缓存×缓存价`,单价为积分/百万,见 `src/provider/trae/pricing.py`)折算;展示层加 `≈`。CodeBuddy 的 credit 是上游真值,恒不标推算。与「刊例 × 折扣」不符的模型走 `pricing.MEASURED_EFFECTIVE_OVERRIDE`(缓存命中价需单独实测,如 DeepSeek-V4.1-Flash 实测缓存价高于刊例)。 -- **成本(估算)**:按 `token × 公开刊例价` 估算,不是上游真实扣费。单价取自 OpenRouter 公开目录(`pricing.prompt` / `completion` / `input_cache_read`,抓取时乘 1e6 转 USD / 百万 token),按 `(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价` 折算美元,再按**写入时生效的汇率**(`USD_CNY_RATE`)折人民币。**写入时定值**(`usage_events.cost_usd` / `cost_cny`),历史行不随价表 / 汇率重算。**匹配不到就不计**(未匹配或未上报输入 token 的 `cost_known=0` → 回 `None`、显示 `—`,不拿 0 冒充免费),故成本是**下限**。 -- **缓存命中率** = 命中 token ÷ 输入 token(保留 1 位小数);未上报或输入为 0 时显示 `—`。前端 `formatCacheRate` 是唯一实现,三处共用:概览「Token 消耗」卡片 hint、分组统计表 Token 合并列(「输入 / 输出 · 命中率」)、请求明细 Token 合并列(悬停详情含命中率)。分组表那列是**派生只读列**(不给排序键,与「成功率」同例):比值随行内两个 token 字段现算。 -- **分组表合并列**:分组统计表把同类指标并成五列——`请求`(请求数 · 成功率,悬停含成功数)、`Token`(输入 / 输出 · 命中率,悬停含命中 token)、`速度`(正文 / 总产出,tok/s,悬停写全两口径)、`平均延迟`(平均耗时 / 平均首字)、`P95 延迟`(P95 耗时 / P95 首字),列数 14 → 8。排序仍走 `SortMenuHead`,子列键即 `GROUP_SORT_COLUMNS` 的白名单键;延迟拆成两列而不是一列四个子项,是因为均值读小时汇总、P95 读明细,两套口径/覆盖期不同,需在列名与表尾文案上分开说明。速度并成一列同理:分子分别是「正文」(`output_tokens - reasoning_tokens`)与「总产出」(含思考 token),分母相同,并成一格 `正文 / 总产出`;排序键仍分开(`tps_visible` / `tps`)。 -- **明细合并列与命中率排序**:请求明细把同类列并成一列——`Token`(默认「输入 / 输出 · 命中率」,悬停含命中 token)、`延迟`(默认「耗时 / 首字」)、`速度`(默认「正文 / 总产出」,tok/s)与 `渠道 / 凭证`(凭证名在上、渠道 icon + 名在下,悬停看完整凭证 ID),列数 15 → 约 10。`时间`列拆成两行(`formatTimeParts`:`HH:mm:ss` 在上、`YYYY-MM-DD` 在下,具体时刻优先;等宽数字纵向对齐,请求 ID 提示仍挂这一格),不占两列。排序不丢:`SortMenuHead`(`web/src/components/SortMenuHead.tsx`)让一个列头承载多个排序键,点列头弹下拉选子列(渠道 / 凭证列的子列即 `provider` / `credential`)。其中命中率是**派生列**,走 `EVENT_SORT_COLUMNS["cache_rate"]`:`CASE WHEN e.input_tokens > 0 THEN CAST(e.cached_tokens AS REAL) / e.input_tokens ELSE NULL END`(输入为 0 / 未上报给 `NULL`,与其它可空列同 SQLite 排序语义;`CAST ... REAL` 避免整数除法截断成 0)。速度两列同理由 `_row_speed_sql("e.")` 生成(`EVENT_SORT_COLUMNS["tps_visible"] / ["tps"]`),与 `events()` 的 SELECT 列**同一 SQL 片段**,避免「排序按 A 算、展示按 B 算」。 -- **请求 ID(`request_id`)**:每个请求带一个 `X-Request-ID`(`src/webapp/request_id.py` 挂在最外层,任意响应含 413/400 都带头)。入站头合法(`[A-Za-z0-9._:-]{1,128}`)则沿用,否则 `uuid4().hex` 自生成;同一中间件还把会话标识头(`x-session-id` 等)经同一套白名单校验后写进 contextvar 供会话粘性读取,请求结束复原;ID 经 `src/request_context.py` 的 contextvar 传递,`StatsCollector.record` 现读落进 `usage_events.request_id`(跨引擎层,不为一个可观测性字段给所有中间层加参数)。`events` 端点透出该列,统计页时间列悬停展示。**不透传上游**(各渠道出站头语义不同,见 `request_context.py` 模块注释)。 -- **延迟均值只算成功请求**(分子 `SUM(latency_ms WHERE ok=1)` 与分母 `ok_count` 配对)。 -- **分组表延迟四列**(`avg_latency_ms` / `avg_ttfb_ms` / `p95_latency_ms` / `p95_ttfb_ms`,四个维度一致下发):均值读小时汇总(与 `overview()` 同口径,永久保留);**分位值只能从明细表算**——`usage_hourly` 只有 `latency_sum` / `ttfb_sum`(和与成功计数),数学上还原不出分布,故 `_percentile_join()` 生成一个「按维度分组在 `usage_events` 上排名」的 LEFT JOIN 子查询,**独立**应用作用域与时间窗(取值列 `ts`,与主查询的 `hour_utc` 区分)。用 LEFT JOIN 而非 INNER:全失败 / 无成功样本的组仍要出现在表里(请求数 > 0、延迟列 NULL)。**排名用 SQLite 窗口函数**(`ROW_NUMBER() OVER (PARTITION BY key ORDER BY (col IS NULL), col)` + `COUNT(col) OVER (PARTITION BY key)`,要求 SQLite ≥3.25;Python 3.12 内置 3.54、Docker `python:3.14-slim` 更高),**nearest-rank** 名次用整数运算写 `(95 * n + 99) / 100`(= `⌈0.95n⌉`,避开浮点误差把名次算偏)。`ttfb_ms` 对非流式 / 未产出首字的成功请求为 NULL,故 latency 与 ttfb **各自独立排名**(`COUNT(col)` 忽略 NULL,天然只数有值样本)。排序键进 `GROUP_SORT_COLUMNS`:均值映射到 `CAST(latency_sum AS REAL) / ok_count`(SQLite 允许 `ORDER BY` 引用 SELECT 别名并做表达式,`ok_count = 0` 除零得 NULL、降序自然排最后),分位直接映射 JOIN 列别名。代价:分位列只覆盖明细保留期(90 天),选「全部」时比均值列旧——表尾文案写明,不假装全覆盖。实测(本地 5.7 万行明细)按渠道算 P95 约 0.09s,加 24h 时间窗后约 0.006s。 -- **输出速度(`tps` / `tps_visible` / `tps_estimated`)**:`tokens ÷ 生成时间总和`,**不是逐条比值平均**(短请求噪声大,逐条平均会把整体带歪)。两列**同分母**:`tps_visible`(正文速度)= `MAX(output_tokens - reasoning_tokens, 0)` 之和,`tps`(总产出速度)= `output_tokens` 之和——思考 token 也计入总产出行(它同样是模型产出),但成批快速吐出,与正文混算会得到远高于屏幕刷新速度的数字(实测思考占输出 50–67% 时两者约 190 与 63 tok/s)。主口径只算经路由的成功请求且该请求**有输出 token**(`output_tokens > 0`,**不设输出量下限**——短输出也是真实产出),分母是 `latency_ms - ttfb_ms`(扣首字等待),并加两道物理合理性下限:生成窗口 < `SPEED_MIN_GEN_MS`(200ms)或单条样本 > `SPEED_MAX_TPS`(1000 tok/s)的样本按「生成时长不可测」剔除——上游把整段内容攒成一批吐时首帧与末帧几乎同时到达,分母趋近 0 会把比值放大成天文数字(实测 901 token / 3ms = 30 万 tok/s),这是测量失效而非「很快」。`ttfb_ms` 为 NULL 的行(非流式等)无生成时间可分,不进主口径。某组主口径**无样本**时回落估算口径:分母用端到端 `latency_ms`(含首字等待)、样本门槛与主口径一致(同样 `output_tokens > 0`,两口径只差在分母),并套用同样两道下限,`tps_estimated=1`(前端加 `≈`)。两套条件聚合在 `_percentile_join()` 的同一次明细扫描里顺带算完(`ranked` 子查询多带 `output_tokens` / `reasoning_tokens` 两列),不另扫一遍;`overview()` 因无法按单请求输出量过滤小时汇总,另在 `usage_events` 上跑同一段聚合(`_speed_aggregates(ref="")`)。排序键 `GROUP_SORT_COLUMNS["tps"]` / `["tps_visible"]` 是「有路由样本用路由、否则用估算」的 CASE 表达式(tokens/ms 与 tok/s 单调同序)。`_speed_value()` 对每个分子单独判空:正文分子为 0(整段输出都是思考)时 `tps_visible=None`,前端显示 `—` 而不是 0。代价与分位同:只覆盖明细保留期(90 天)。 -- **逐请求速度(明细 / 实时流)**:`events()` 下发的每行带 `tps` / `tps_visible`,由 `_row_speed_sql("e.")` 一个 SQL 片段同时供 SELECT 列与排序键(`EVENT_SORT_COLUMNS`)使用。规则与聚合口径同源(`_routed_guard`),但**不回落估算口径**:逐行没有相邻样本摊平首字等待,拿端到端耗时除出来的单条比值只会误导,故无生成窗口(`ttfb_ms` 为 NULL、输出 < 100、窗口 < 200ms 或 > 1000 tok/s)的行给 `NULL`(前端 `—`);正文为 0 时正文列给 `NULL`(`NULLIF(…, 0)`),不拿 0 冒充速度。实时流不查库,`StatsCollector._notify` 广播前调 `row_speed()`(`query.py` 的 Python 版,同一套判定)补这两个字段——否则 SSE 那几行速度列恒为 `—`。 -- **回填脚本**:`scripts/backfill_trae_credit.py`(TRAE 推算积分)、`scripts/backfill_cost.py`(成本,默认预览、写库前自动备份、幂等)、`scripts/rollup_hourly.py`(小时汇总重算)。均先报告缺口,再 `--apply` 写库。 -- **自定义时间窗(`since` / `until`)**:所有统计端点(`overview` / `by-*` / `timeline` / `model-timeline` / `events` / 导出)接受 `since` + `until`(epoch 秒),语义是**半开区间** `[since, until)`:`since` 闭下界、`until` 开上界。两者可单独给(单边不限)或都不给(全部,与历史一致)。取值列随数据源分流:小时汇总用 `hour_utc`、明细用 `ts`——都在 `StatsQuery._where` 里按 `since_col` / `until_col` 指定,与已有的 `before`(rowid 游标,**不是**时间)分开。前端「自定义」给两个 `datetime-local`(`src/lib/customRange.ts`:本地时区解析、`toLocalInput` 手工拼串避开 `toISOString` 的 UTC 归一),起点不早于终点时整窗作废并提示;`overview` 查询挂 `keepPreviousData`,避免改时间窗时骨架屏把正在编辑的输入框卸载。 -- **钻取与导出**:`events` 端点接受 `provider` / `model` / `credential_id` 三个明细过滤(`AND`),管理台分组表点行即按该维度钻取(用户维度走作用域参数 `username`),条件可叠加。`?format=csv` 走导出:仅 admin/operator(`require_operator`),固定旧→新按 `rowid ASC`,多取一条判截断(上限 `EXPORT_MAX_ROWS = 10000`,超限截断并置响应头 `X-Export-Truncated: 1`)。CSV 生成收敛在 `src/stats/csv.py` 纯函数:**公式注入防护**给以 `= + - @`(及制表符/回车)开头的**文本**单元格加前导单引号,数值(含负数)原样(`-1.5` 在 Excel 里本就是数字,加引号反而毁掉可计算性);带 UTF-8 BOM 供 Excel 认编码;时间列 ISO 8601 UTC;`\r\n` 换行(RFC 4180)。**列与页面明细表同口径**:`export_events()` 与 `events()` 共用 `_ROW_SPEED_VISIBLE` / `_ROW_SPEED_TOTAL`,CSV 因此多出「正文速度 tok/s」「总产出速度 tok/s」两列——不可测的行留空(`format_cell(None) == ""`),不是 0。 -- **实时流(SSE)**:`GET /api/stats/events/stream` 推送**刚落库**的明细。**进程内广播**而非轮询:写入点最清楚「刚刚发生了什么」,`StatsCollector.record` 在**事务提交后**调用 `LiveEventHub.publish`(`src/stats/live.py`),订阅者拿到的是已落库行(可拿 `rowid` 接着翻页),事务回滚的行不会漏出。**背压**:每个订阅者一个**有界 deque**(`MAX_BUFFERED = 200`),满了丢**最旧**的一条——实时流看的是「现在」,慢客户端不能拖住写入路径,也不能让缓冲无界增长;唤醒用 `asyncio.Event`。**并发上限** `MAX_SUBSCRIBERS = 8`,超出 `TooManySubscribers` → 429 + `Retry-After: 10`(不排队、不踢已有订阅)。**作用域与过滤在订阅者侧**:一条事件可能被多个条件不同的订阅者同时看到,故 `_scope`(admin/operator 可传 `username`、viewer 恒本人)与 `provider`/`model`/`credential_id` 过滤都在生成器里逐条判定,`None` = 不限制;`since`/`until` 不参与(实时流没有时间窗语义)。**脱敏**:广播 payload = `/api/stats/events` 行 + `rowid`,不含 `credential_name`;仅 admin/operator 的连接由端点逐条调 `CredentialRepository.nickname_of` 补名字(与明细端点同口径,viewer 看不到池里他人昵称)。**保活与中间层**:15s 一条注释帧(`:` 开头,`EventSource` 忽略)防止代理断掉静默连接;响应头 `Cache-Control: no-store` + `X-Accel-Buffering: no` 防止反向代理缓存或整段缓冲把「实时」变成批量。生成器 `finally` 里注销订阅——客户端关标签页时生成器被取消,不注销会让槽位泄漏、8 次后全员 429。前端 `useStatsEventStream`(`web/src/api/hooks.ts`)只管连接生命周期,开销是「依赖变化即重建连接」(`EventSource` 无法改查询串);页面缓冲最近 50 条、新→旧,与下方翻页历史分开展示。**单进程限制**:广播是进程内的,多进程 / 多副本部署时每个进程只推自己写入的行(本项目是单进程 SQLite 部署,故成立)。 +- **口径**:`overview` / `by_provider` / `by_model` / `by_user` / `timeline` / `model-timeline` 读**小时汇总**(`usage_hourly`,永久),只有 `events` 读明细(90 天);代价是最近 ≤5 分钟未进汇总的不计入。**凭证维度读明细表**(小时汇总主键无 `credential_id`),只覆盖 90 天。 +- **双写**:`record()` 写明细同时增量累加当前小时行;`rollup_hourly`(每 5 分钟)全量重算对账,两者幂等。 +- **TRAE credit 为推算值**(`credit_estimated`):上游 `token_usage` 只给 token,按官方公式折算(`src/provider/trae/pricing.py`),展示加 `≈`;CB 是上游真值。与刊例不符走 `pricing.MEASURED_EFFECTIVE_OVERRIDE`。 +- **成本(估算)**:`token × 公开刊例价`(单价取自 OpenRouter,抓取时乘 1e6 转 USD/百万);按 `(输入−命中)×输入价 + 命中×缓存价 + 输出×输出价` 折美元,再按**写入时汇率**(`USD_CNY_RATE`)折人民币。**写入时定值**,历史不重算。**匹配不到就不计**(`cost_known=0` → `None`、显示 `—`)→ 成本是下限。 +- **缓存命中率** = 命中 ÷ 输入 token(1 位小数);未上报或输入 0 显示 `—`。`formatCacheRate` 唯一实现,三处共用;分组表该列是**派生只读列**(不给排序键)。 +- **分组表合并列**:`请求`(数 · 成功率)、`Token`(输入/输出 · 命中率)、`速度`(正文/总产出)、`平均延迟`(耗时/首字)、`P95 延迟`,14 → 8 列。排序走 `SortMenuHead`,子列键即 `GROUP_SORT_COLUMNS` 白名单键;延迟拆两列(均值读小时汇总、P95 读明细,口径与覆盖期不同)。 +- **明细合并列**:`Token` / `延迟` / `速度` / `渠道 / 凭证`,15 → 约 10 列。`时间` 拆两行(`HH:mm:ss` 在上、日期在下),请求 ID 提示挂该格。`SortMenuHead` 让一个列头承载多个排序键。命中率走 `EVENT_SORT_COLUMNS["cache_rate"]`:`CASE WHEN e.input_tokens > 0 THEN CAST(e.cached_tokens AS REAL) / e.input_tokens ELSE NULL END`;速度两列由 `_row_speed_sql("e.")` 生成,与 `events()` 的 SELECT **同一片段**。 +- **请求 ID**:`X-Request-ID`(`webapp/request_id.py` 最外层)。入站合法(`[A-Za-z0-9._:-]{1,128}`)沿用,否则 `uuid4().hex`;同一中间件把会话标识头写进 contextvar 供粘性读取;ID 经 `request_context` 传进 `usage_events.request_id`。**不透传上游**。 +- **失败归因**(`GET /api/stats/errors`,operator+):按 `(渠道, 模型, 错误类型)` 聚合窗口内 `ok=0` 明细。`failures` 是**尝试**口径(每次上游拒绝一行);`rescued` = 同用户同 `request_id` 另有成功行的行数(不叠加时间窗,走 `idx_usage_request`);`failures − rescued` = 未被救回的失败尝试数(同一请求多次失败算多次)。DEAD(硬禁用)额外落一行 `credential_unavailable`(额度耗尽证据成立的 DEAD 例外降级为 `CREDIT`,不落该归因);11140 风控落 `account_restricted`(见 §3.2);轮换耗尽时不再重复落收尾行,避免 `requests` 与 Key 日额度双计。 +- **延迟均值只算成功请求**(`SUM(latency_ms WHERE ok=1)` / `ok_count`)。 +- **图表时序指标**(`timeline` / `model-timeline`):`METRIC_KINDS` 一张表定语义,查询侧固定取齐 10 列聚合(`_METRIC_AGGREGATES`),由 `_metric_value(metric, row)` 归一。派生指标**在聚合后相除**(成功率 `ok_count/requests`、缓存命中率 `cached/input`、单请求成本 `cost/requests`),不是逐小时比率平均——后者被低量小时等权带偏。无样本一律 `null` 而非 0(均值 0 会画成「0 ms」假低谷,比率 0 会显示「0%」)。 +- **图表时间轴稠密**:`usage_hourly` 只在有请求时有行,稀疏下发会让前端把「空档 5 小时」和「相邻 1 小时」画成一样宽。`_dense_hours()` 按 `since`/`until` 补齐小时桶(都不给则补首末观测之间),跨度超 `MAX_DENSE_HOURS`(92 天)时退回观测小时。缺失桶由 `_empty_value()` 按语义给:可加量补 0,均值/比率/成本补 `null`(前端 `connectNulls={false}` 断线)。前端 X 轴用 `type="number"` + `scale="time"` + 毫秒时间戳。 +- **`timeline` 的 `__total`**:全渠道合计单独按小时聚合一次(`GROUP BY hour_utc`),**不是前端把各渠道值相加**——均值与比率相加无意义,必须用同一小时的 `sum/count` 重新相除。 +- **延迟分位时序**(`percentile-timeline`,`metric=latency|ttfb`):按小时在 `usage_events` 上算 P50/P95(SQL 见「分组表延迟四列」,nearest-rank `(95*n+99)/100`),只统计成功请求,仅覆盖明细保留期(90 天)。供前端「P50 中位数线 + P95 上界带」——均值摊平长尾(实测均值 10 s 时 P95 达 21 s)。 +- **图表可读性约束**(前端):一张图一个系列,多系列改为控件切换(渠道切换读 `__total` / 单渠道);模型趋势最多 3 条线,其余交给分组表;曲线一律 `type="linear"`(`monotone` 会凭空造值);比率轴在数据全部 ≥ 90% 时截断下界并在图例标注「Y 轴自 90% 起」(否则 98%–100% 的波动贴在顶边看不出)。Y 轴必须显式给 `dataKey` 或数值 `domain`,否则 recharts 会把毫秒时间戳纳入定义域(曾算出 400% 上界);模型趋势**不能只给单条线的 `dataKey` + `auto`**(上限按那条算,Top2/Top3 峰值更高时被裁),故按所有在画线的公共最大值定 `domain`(`modelTrendAxis`,比率 / 单请求成本复用主图口径)。 +- **分组表延迟四列**(`avg_latency_ms` / `avg_ttfb_ms` / `p95_latency_ms` / `p95_ttfb_ms`):均值读小时汇总,**分位只能从明细算**(`usage_hourly` 只有 `latency_sum` / `ttfb_sum`)。`_percentile_join()` 生成「按维度分组在 `usage_events` 排名」的 LEFT JOIN 子查询,**独立**应用作用域与时间窗(列 `ts`);LEFT JOIN 而非 INNER,全失败组仍出现。排名 `ROW_NUMBER() OVER (PARTITION BY key ORDER BY (col IS NULL), col)` + `COUNT(col) OVER (...)`(SQLite ≥3.25),nearest-rank 整数 `(95 * n + 99) / 100`;`ttfb_ms` NULL 时两类各自独立排名。实测本地 5.7 万行按渠道 P95 约 0.09s,加 24h 窗约 0.006s。 +- **输出速度**(`tps` / `tps_visible` / `tps_estimated`):`tokens ÷ 生成时间总和`,**非逐条比值平均**。同分母两列:`tps_visible` = `MAX(output_tokens - reasoning_tokens, 0)` 之和,`tps` = `output_tokens` 之和(思考计入总产出;占 50–67% 时两者约 190 与 63 tok/s)。主口径只算经路由的成功请求且 `output_tokens > 0`,分母 `latency_ms - ttfb_ms`;两道下限:窗口 < `SPEED_MIN_GEN_MS`(200ms)或单样本 > `SPEED_MAX_TPS`(1000 tok/s)剔除(实测 901 token / 3ms = 30 万 tok/s 属测量失效)。`ttfb_ms` NULL 不进主口径。某组无样本 → 回落 `latency_ms` + `tps_estimated=1`(前端加 `≈`)。两套聚合在 `_percentile_join()` 同一次扫描算完,`overview()` 另跑 `_speed_aggregates(ref="")`;`_speed_value()` 对每分子单独判空(正文 0 → `None`,显示 `—`)。 +- **逐请求速度**:`events()` 每行带 `tps` / `tps_visible`,由 `_row_speed_sql("e.")` 同时供 SELECT 与排序键。规则同源(`_routed_guard`)但**不回落估算**:无生成窗口(`ttfb_ms` NULL、输出 < 100、窗口 < 200ms 或 > 1000 tok/s)给 `NULL`;正文 0 用 `NULLIF(…, 0)`。实时流不查库,`StatsCollector._notify` 广播前调 `row_speed()`(Python 版,同一判定)。 +- **回填脚本**:`scripts/backfill_trae_credit.py` / `backfill_cost.py` / `rollup_hourly.py`,均先报缺口再 `--apply`。 +- **自定义时间窗**:所有统计端点接受 `since` + `until`(epoch 秒),**半开区间** `[since, until)`;可单边或都不给。取值列分流:小时汇总 `hour_utc`、明细 `ts`(`_where` 里按 `since_col` / `until_col` 指定),与 `before`(rowid 游标)分开。前端「自定义」两个 `datetime-local`(`src/lib/customRange.ts` 本地时区解析);起点不早于终点整窗作废;`overview` 挂 `keepPreviousData`。 +- **钻取与导出**:`events` 接受 `provider` / `model` / `credential_id`(AND);分组表点行即钻取(用户维度走 `username`)。`?format=csv` 仅 admin/operator,固定 `rowid ASC`,多取一条判截断(`EXPORT_MAX_ROWS = 10000`,超限置 `X-Export-Truncated: 1`)。`src/stats/csv.py` 纯函数:以 `= + - @`(及制表符/回车)开头的**文本**单元格加前导单引号(数值原样);UTF-8 BOM;ISO 8601 UTC;`\r\n`。CSV 与页面共用 `_ROW_SPEED_VISIBLE` / `_ROW_SPEED_TOTAL`,不可测行留空。 +- **实时流(SSE)** `GET /api/stats/events/stream`:**进程内广播**而非轮询;`StatsCollector.record` 在**事务提交后**调 `LiveEventHub.publish`(回滚行不漏出)。**背压**:每订阅者有界 deque(`MAX_BUFFERED = 200`),满则丢最旧,唤醒用 `asyncio.Event`。**并发上限** `MAX_SUBSCRIBERS = 8`,超出 429 + `Retry-After: 10`。**作用域与过滤在订阅者侧**(`_scope` + 三过滤,`None` 不限制),`since`/`until` 不参与。**脱敏**:payload 不含 `credential_name`,仅 admin/operator 由端点逐条补。15s 注释帧保活,`Cache-Control: no-store` + `X-Accel-Buffering: no`,生成器 `finally` 注销订阅。**单进程限制**:多进程各推自己写入的行。 + ### 9.8 列表排序 -所有 `GET` 列表端点接受统一的 `sort`(白名单键)+ `order`(`asc`/`desc`)。**未知 `sort` 键或非法 `order` 值回落到默认排序,不报错**;`sort` 是白名单键而非 SQL 列名,SQL 片段只由受控常量拼出,用户输入永不进 SQL,注入面为零。契约在 `src/sorting.py`(db 与 api 共用,避免 db → api 反向依赖):`parse_sort_order`(唯一解析入口)、`sql_order`(`tiebreak` 兜底稳定次序)、`sort_rows`(组装后再排,`None` 值无论升降序都排**最后**)。逐请求明细默认(`time` 降序)走 rowid 游标分页返回 `next_before`;按其它列排序 / 时间升序 / 显式 `offset` 时改用 `LIMIT/OFFSET` 并返回 `total`。适用端点:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。 +所有 `GET` 列表接受 `sort`(白名单键)+ `order`(`asc`/`desc`);**未知 `sort` 或非法 `order` 回落默认、不报错**,`sort` 是白名单键而非 SQL 列名 → 用户输入永不进 SQL。契约在 `src/sorting.py`:`parse_sort_order`(唯一入口)、`sql_order`(`tiebreak` 稳定次序)、`sort_rows`(`None` 无论升降序都排最后)。明细默认(`time` 降序)走 rowid 游标返回 `next_before`;按其它列 / 时间升序 / 显式 `offset` 改 `LIMIT/OFFSET` 并返回 `total`。适用:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{credential_id}/credit-events`、`/api/credentials/{credential_id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。 ### 9.9 列表分页 -扁平列表**统一后端分页**,前端共享 `Pagination` 组件(`web/src/components/Pagination.tsx`)——同一套页码文案(`第 N / M 页`)、每页选项(`PAGE_SIZE_OPTIONS = [20, 50, 100]`,默认 `DEFAULT_PAGE_SIZE = 50`)与上下页按钮,`data-testid` 前缀按表区分(`${prefix}-page-info` / `-page-size` / `-prev` / `-next`)。 - -契约:`GET` 列表端点接受 `limit` + `offset`,响应在**原列表键旁**新增 `total`(**筛选后**条数,前端据此算页数;键名不变、向后兼容)。仓储层 `limit=None` 表示不分页返回全量,保持内部调用点旧行为。实现分流:`users` / `api-keys` / `alerts` / `audit` 在 SQL 层 `LIMIT/OFFSET` + 独立 `count`;`model-catalog` 排序键含派生字段(能力分 / `available`),先 `sort_rows` 再内存切片。`/api/model-catalog` 的服务端筛选收敛在 `_filter_catalog`(`q` 大小写不敏感匹配 `id` / 名称,`only_available` 只留 `available=true`),`total` 是筛选后条数、顶层 `count` 仍是目录全量。游标分页的明细端点(`/api/stats/events` 默认 `time` 降序)无总页数,页码文案退化为 `第 N 页`(未知总数)。分组类表(凭证按渠道分组、用量统计分组表)不分页。 +扁平列表统一后端分页,前端共享 `Pagination`(`PAGE_SIZE_OPTIONS = [10, 20, 50, 100]`,`DEFAULT_PAGE_SIZE = 50`)。契约:`GET` 接受 `limit` + `offset`;响应在原列表键旁新增 `total`(**筛选后**条数)。仓储 `limit=None` 表示不分页。实现分流:`users` / `api-keys` / `alerts` / `audit` 走 SQL `LIMIT/OFFSET` + 独立 `count`;`model-catalog` 排序键含派生字段 → 先 `sort_rows` 再内存切片(`_filter_catalog` 支持 `q` 与 `only_available`;顶层 `count` 仍是目录全量)。游标分页的明细端点无总页数,文案退化为 `第 N 页`。分组类表不分页。 ## 10. 后台任务 -`TaskRunner`(`runner.py`)每类任务一个独立 asyncio 循环,失败只记日志不拖垮服务;间隔有安全下限,避免打爆上游。`Pacer` 有两种模式:后台任务用**严格模式**(默认,单锁串行化跨任务/跨凭证的相邻请求);聊天用**并发模式**(`allow_concurrent=True`),按「渠道 + 凭证身份」分桶并在桶内放行并发——同渠道同模型的并发请求不再被逐级串行化,只有同凭证「上一请求已结束、紧接着又来一个」的顺序连发才补足最小间隔。请求结束由 provider `stream_chat` 的 `finally` 调 `release(key)` 归还名额。**间隔抖动(`JITTER_RATIO = 0.25`)**:定值间隔(`min == max`,聊天节流器的形态,如 `Pacer(5, 5)`)逐个账号精确同拍,多实例 / 多账号会同时刻撞上游,故 `next_interval()` 对定值**只向上**抖 `[low, low × 1.25]`——`low` 是频率风控下限,不可击穿;`min < max` 的区间本就均匀随机(文档承诺的 `[min, max]`),不再叠加。抖动源是构造期的 `random.Random(0)`(测试可复现),非热更项。 +`TaskRunner`(`runner.py`)每类任务一个独立 asyncio 循环,失败只记日志;间隔有安全下限。`Pacer` 两模式:后台任务**严格模式**(单锁串行化);聊天**并发模式**(`allow_concurrent=True`,按「渠道 + 凭证身份」分桶放行,只同凭证顺序连发才补最小间隔)。`stream_chat` 的 `finally` 调 `release(key)`。**抖动(`JITTER_RATIO = 0.25`)**:定值间隔(`min == max`)只向上抖 `[low, low × 1.25]`——`low` 是频率风控下限,不可击穿;`min < max` 的区间本就均匀随机,不再叠加。抖动源为构造期 `random.Random(0)`(测试可复现)。 | 任务 | 周期 | 行为 | |---|---|---| -| 额度探测(quota_probe) | 启动立即一轮(不节流)+ 每 `QUOTA_PROBE_MINUTES`(默认 60) | 探测剩余额度 → 写 `credentials.quota_*` / `quota_expiry_ladder` / `quota_packages` / `health`(CodeArts 走积分接口,见 §5.4) | -| token 预刷新(refresh) | 每 `REFRESH_INTERVAL_MINUTES`(默认 30,下限 5) | 到期前 `REFRESH_SKEW_HOURS`(默认 24h)窗口内轮换 refresh token;短寿命渠道自行封顶窗口(CodeArts `refresh_skew_cap_seconds=2700`)——封顶值必须**严格宽于**本周期;上游明确回 `UpstreamReloginRequired` 时标记「需重新登录」并硬禁用 | -| 每日签到(checkin) | 每 10 分钟(全天) | 成功即封账该凭证(`日期:scope` → 截止时刻,进程内内存态,跨天清理);默认封到本地当日结束,渠道可回填 `CheckinResult.seal_until`(Qoder 每日 10:00 换窗口);失败持续重试 | -| 成长中心(growth) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5) | 仅 CodeBuddy:7 类领取;结果落 `growth_events` + 回写 `credentials.growth_last_result` | -| 活跃上报(activity,默认关闭) | 每 10 分钟醒一次,仅 `ACTIVITY_REPORT_HOUR`(默认 10 点)窗口内执行 | CodeBuddy 与 CodeBuddy 国际版:补发一条 `chat_request_send` 续连登;按「endpoint + userId」隔离、当日封账;成功落一行 `growth_events` | -| 明细清理(retention) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert,与 record 的增量双写对账)+ 90 天前明细清理;同期限回收 `credit_events` / `audit_events` / `alert_events` / `growth_events`(否则无界增长)。整轮跑在 `asyncio.to_thread`(全表 GROUP BY 是同步 SQLite,直接在事件循环执行会卡住并发请求) | -| 数据库备份(backup) | 每 `BACKUP_INTERVAL_MINUTES`(默认 1440,下限 60) | `VACUUM INTO` 写主库一致性快照到 `DATA_DIR/backups/` + `integrity_check` + 0600,保留最近 `BACKUP_KEEP`(默认 7)份。整轮跑在 `asyncio.to_thread`(同步磁盘 IO) | -| 渠道模型列表刷新(注入协程,`api/models.py::list_models`) | 每 `MODEL_CATALOG_MINUTES`(默认 30,下限 5) | 兜底重拉各渠道模型表(TTL 门禁 + 逐渠道 publish + 落盘快照)。周期跟 zen 判活缓存(30 分钟)对齐——更密不会让 zen 多探一次 | -| 模型目录刷新(OpenRouter,注入协程) | 每 `OPENROUTER_CATALOG_MINUTES`(默认 1440,下限 60) | 拉 OpenRouter → 建三张表 → 落盘 → 就地换入 | -| 运维告警(alert) | 每 `ALERT_INTERVAL_MINUTES`(默认 5,下限 1) | 评估四类风险(池耗尽 / 任务连续失败 / token 临近到期 / 上游错误率骤升),命中落 `alert_events` 并可推送 webhook;同 `(规则, 对象)` 在静默窗内只报一次 | -| 上游状态页聚合(upstream_status) | 每 `UPSTREAM_STATUS_MINUTES`(默认 5,下限 1) | 并发抓各模型 / 网关提供方的官方状态接口(单源超时 `UPSTREAM_STATUS_TIMEOUT_SECONDS`)→ 归一化 → 原子替换内存快照;单源失败只记该源 `error`,**整轮永不抛异常**。启动时后台预热一轮 | -| 模型主动探活(model_probe) | 每 `MODEL_PROBE_MINUTES`(默认 0 = 关闭,下限 1) | 只探仍被 `blocked` 负缓存锁住的 (渠道, 模型):直连 `provider.stream_chat` 发一条最小对话(`max_tokens=16`)→ 三态判定(成功清该渠道负缓存 / `BLOCKED`·`INVALID` 记 `failed` / 其余 `inconclusive` 且绝不禁用)→ 结论落 `model_probes`。不写 `usage_events`、不动冷却(除成功时清)、不改健康度。`manual != ''` 的行整条跳过 | - -**渠道模型列表刷新与模型目录刷新是「注入协程」而非 tasks 模块**:`tasks/` 不 import `api/` / `benchmarks`(反向依赖会把 HTTP 层拖进后台任务),所以 `TaskRunner` 接的是 `Callable[[], Awaitable[object]]`,由 `main.lifespan` 闭包注入;`None` 时不装配这条循环,管理台也不展示该卡片。**并发保护**:HTTP 出口与后台循环都会调 `list_models`,故 `api/models.py` 里 `_refresh_providers` 整段持模块级 `asyncio.Lock`(粒度是「整次列表刷新」而非单渠道——跨渠道合并与别名表 publish 需要看到一致的全集)。**签到 / 成长中心的「同账号」隔离键**:`checkin_scope(data) or f"credential|{credential_id}"`(provider 在身份未知时返回空串,共享空 scope 会让第二个账号被 `seen` 集合永久跳过)。**记录口径**:`_guarded` 在任务返回 `None` 时**不入账**(`sleep` 到点但 `due()=False` 是 no-op,若当成一次成功执行,页面会显示「签到刚跑过」而当天其实一次都没签)。`TaskRun` 只在真实执行(返回非 `None`)或抛异常时写入;异常也入账(`last_error`)。计数 `runs` 同样只涨真实执行。运行态只存在于**本次进程**内,重启归零。**任务清单**:`tasks/status.py` 的 `TASK_SPECS` 是静态描述(key / 名称 / 一句话说明),与 `TaskRunner.start()` 建立的循环一一对应;周期与开关**运行时现算**(`TaskRunner.task_status()` 读热更值)。未装配的任务不出现在清单里。接口 `GET /api/tasks`(admin)返回 `{tasks: [...], server_time}`,每条含 `key`/`name`/`description`/`interval_seconds`/`enabled`/`running`/`runs`/`last_started_at`/`last_finished_at`/`last_ok`/`last_report`/`last_error`。带 `server_time` 是为了让前端用**服务端时钟**算「距今多久」。**手动触发**:`POST /api/tasks/{key}/run`(admin + CSRF)返回 `{...同一条运行态, ran, busy}`,未知 / 未装配 key 回 400。它复用 `_loop_specs()`(周期循环与手动触发**共用一份清单**,两处各写一份会让「手动能跑但定时没接线」),差别只有两点:`manual=True` 时 `activity` 走**绕过 `due()`** 的分支(手动就是「立刻跑」,不再问「到点了没」),以及开关**不绕**——`run_once` 返回 `None` 的 no-op 轮次记 `ran=false`。**并发**:`self._running: set[str]` 在真跑期间占住 key(asyncio 单线程下「查 + 加」原子,不用锁),`_run_guarded` 撞上已占用的 key 直接跳过本轮;手动触发在 `_run_guarded` **之前**先查一次,命中即回 `busy=true` 且不等结果。`ran` 的口径是「真跑了一轮(含失败)」——用 `status.runs(key)` 的增量判定,no-op 不入账;失败看 `last_ok`/`last_error`。 - -**成长中心的失败判定**(三层分开):协议层(`growth.py`)非 2xx 抛 `GrowthRejected`;业务码非 0 / 缺 data / 非 JSON 抛 `UpstreamProtocolViolation`(HTTP 200 也可能是失败)。编排层(`growth_runner.py`)4xx 业务规则(名额用完、未解锁、抽奖没次数、能量不足)记 `IDLE` 而非 `FAILED`;5xx 与协议违规记 `FAILED`;401/403 立即置 `session_dead` 并停止后续请求。结论:`failed 且 gained=False` 才算整体失败(部分成功仍是成功)。接单失败分三类:`prerequisite not met: ` 按原因归并成一条并标 `reportable=True`(用户需知道去做什么),记 IDLE;`task does not require acceptance` 正常应答,不产生任何步骤;其余逐条记 FAILED。`GrowthStep.reportable` 控制该步是否进「一行汇报」。**任务契约是五态**:`not_accepted` → 接单、`accepted`/`in_progress` → 等用户完成、`completed` → 领奖、`claimed` → 跳过。接单 `POST /tasks/accept` body 是**复数数组** `{"task_codes": [...]}`;领奖 `POST /tasks/{task_code}/claim`。**403 不总是登录失效**:未解锁档位返回 403 +「连续登录天数不足」,必须先于 session 判定处理。不可逆动作(抽奖 / 连登兑换 / 开 Buddy 盲盒 / 消耗补登卡)由 `GROWTH_IRREVERSIBLE_ACTIONS` 总开关控制,**手动入口与定时任务读同一个开关**;开关只挡「消耗」,不挡「查询」(`/streak` 是只读接口)。**清理切点必顶对齐到小时边界**(`purge_expired`):`rollup_hourly` 对整行是 REPLACE 语义,只有保证「仍有明细的小时保有全部明细」重算才精确;若边界小时只删一半,下一轮 rollup 会把汇总行覆盖成剩下那一半,被删部分永久丢失。代价:明细最多多留 1 小时。**运维告警**:规则判定是纯函数(`tasks/alerting.evaluate_alerts`),阈值统一用「≤0 即关闭该规则」表达。 +| 额度探测(quota_probe) | 启动立即一轮(不节流)+ 每 `QUOTA_PROBE_MINUTES`(默认 60) | 写 `credentials.quota_*` / `quota_expiry_ladder` / `quota_packages` / `health`;硬禁用与 `enabled=0` 直接跳过(口径同 refresh / checkin / growth / activity——死号只会白打上游并灌日志) | +| token 预刷新(refresh) | 每 `REFRESH_INTERVAL_MINUTES`(默认 30,下限 5) | 到期前 `REFRESH_SKEW_HOURS`(默认 24h)窗口内轮换;短寿命渠道自行封顶(CodeArts 2700s,必须**严格宽于**本周期);`UpstreamReloginRequired` 时标记并硬禁用 | +| 每日签到(checkin) | `CHECKIN_TIMES` 留空 → 每 10 分钟;配了时点 → 每 1 分钟醒(窗口 `[时点−抖动, 时点]`,10 分钟周期会整轮错开、一天一次都不签) | 成功即封账(`日期:scope` → 截止时刻,内存态,跨天清理);渠道可回填 `seal_until`(Qoder 10:00 换窗口);**失败按连续次数指数退避**(10m → 20m → 40m → 封顶 1h,成功清零;管理台「立即运行」`trigger=manual` 不受限制),避免上游一直回「今日积分尚未发放」时每 10 分钟空锤一次。每次签到落一行 `checkin_events`(成功/失败/已签都记) | +| 成长中心(growth) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5) | 仅 CB:7 类领取;落 `growth_events` + 回写 `growth_last_result` | +| 活跃上报(activity,默认关) | 每 10 分钟醒,仅 `ACTIVITY_REPORT_HOUR`(默认 10 点)窗口内执行 | CB + 国际版:补发 `chat_request_send`;按「endpoint + userId」隔离、当日封账 | +| 明细清理(retention) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert)+ 90 天清理;同期限回收 `credit_events` / `audit_events` / `alert_events` / `growth_events` / `checkin_events`;顺带清过期冷却(模型级删行 + 账号级 `cooling_until` 置 NULL)。跑在 `asyncio.to_thread` | +| 数据库备份(backup) | 每 `BACKUP_INTERVAL_MINUTES`(默认 1440,下限 60) | `VACUUM INTO` + `integrity_check` + 0600,保留 `BACKUP_KEEP`(默认 7)。跑在 `asyncio.to_thread` | +| 渠道模型列表刷新(注入协程 `api/models.py::list_models`) | 每 `MODEL_CATALOG_MINUTES`(默认 30,下限 5) | 兜底重拉(TTL 门禁 + 逐渠道 publish + 落盘)。周期跟 zen 判活缓存(30 分钟)对齐 | +| 模型目录刷新(注入协程) | 每 `OPENROUTER_CATALOG_MINUTES`(默认 1440,下限 60) | 拉 OpenRouter → 三张表 → 落盘 → 就地换入 | +| 运维告警(alert) | 每 `ALERT_INTERVAL_MINUTES`(默认 5,下限 1) | 评估九类规则(下表 8 类 + §11.3e 预算分层),命中落 `alert_events` 并可推 webhook;同 `(规则, 对象)` 静默窗内只报一次 | +| 上游状态聚合(upstream_status) | 每 `UPSTREAM_STATUS_MINUTES`(默认 5,下限 1) | 并发抓官方状态(单源超时 `UPSTREAM_STATUS_TIMEOUT_SECONDS`)→ 归一 → 原子替换内存快照;单源失败只记该源 `error`,**整轮永不抛**。启动预热一轮 | +| 模型主动探活(model_probe) | 每 `MODEL_PROBE_MINUTES`(默认 0 关闭,下限 1) | 只探仍被 `blocked` 锁住的 (渠道, 模型):`max_tokens=16` 最小对话 → 三态判定 → 落 `model_probes`。不写 `usage_events`、不动冷却、不改健康度。`manual != ''` 跳过 | + +**两个刷新任务是「注入协程」而非 tasks 模块**:`tasks/` 不 import `api/` / `benchmarks`(反向依赖)→ `TaskRunner` 接 `Callable[[], Awaitable[object]]`,由 `main.lifespan` 闭包注入;`None` 时不装配、管理台不展示。 + +**并发保护**:HTTP 出口与后台循环都调 `list_models` → `_refresh_providers` 持模块级 `asyncio.Lock`(粒度是整次列表刷新,跨渠道合并与别名 publish 需看到一致全集)。 + +**签到 / 成长的同账号隔离键**:`checkin_scope(data) or f"credential|{credential_id}"`(provider 身份未知时返回空串;共享空 scope 会让第二个账号被 `seen` 永久跳过)。 + +**记录口径**:`_guarded` 在任务返回 `None` 时**不入账**(`sleep` 到点但 `due()=False` 是 no-op,当成成功会让页面显示「签到刚跑过」而当天一次没签)。`TaskRun` 只在真实执行或抛异常时写入,`runs` 同口径;运行态只在本进程内,重启归零。`TASK_SPECS` 是静态描述,与 `TaskRunner.start()` 循环一一对应,`group` 归属 `TASK_GROUPS`(账号维护 / 模型与渠道 / 运维与数据),随 `/api/tasks` 的 `groups` 下发;周期与开关运行时现算。`GET /api/tasks` 带 `server_time`(前端按服务端时钟算「距今多久」)。 + +**手动触发** `POST /api/tasks/{key}/run`(admin + CSRF)返回 `{...运行态, ran, busy}`,未知 key 400。复用 `_loop_specs()`;差别只有 `manual=True` 时 `activity` 绕过 `due()`,开关**不绕**(`run_once` 返回 `None` 记 `ran=false`)。**并发**:`self._running: set[str]` 占 key(asyncio 单线程下「查 + 加」原子),撞上直接跳过本轮;手动触发在 `_run_guarded` 之前先查,命中回 `busy=true` 不等结果。`ran` = 「真跑了一轮(含失败)」。 + +**成长失败判定**(三层):协议层非 2xx → `GrowthRejected`;业务码非 0 / 缺 data / 非 JSON → `UpstreamProtocolViolation`(HTTP 200 也失败)。编排层 4xx 业务规则(名额用完、未解锁、抽奖没次数、能量不足)→ `IDLE`;5xx + 协议违规 → `FAILED`;401/403 → `session_dead` 并停后续。整体失败 = `failed 且 gained=False`。接单失败三类:`prerequisite not met: ` 归一条 + `reportable=True`(IDLE);`task does not require acceptance` 正常应答;其余逐条 `FAILED`。**契约五态**:`not_accepted` 接单、`accepted`/`in_progress` 等完成、`completed` 领奖、`claimed` 跳过;接单 body **复数数组** `{"task_codes": [...]}`。**403 非登录失效**:未解锁档位 403 +「连续登录天数不足」先于 session 判定。不可逆动作由 `GROWTH_IRREVERSIBLE_ACTIONS` 总控,只挡消耗不挡查询。**清理切点对齐小时边界**(`purge_expired`):`rollup_hourly` 整行 REPLACE,边界小时删一半会被下轮覆盖而永久丢(代价:明细多留 ≤1 小时)。**告警判定纯函数**(`tasks/alerting.evaluate_alerts`),阈值 ≤0 关闭。 | 规则 | 触发 | 信号来源 | |---|---|---| @@ -552,86 +830,101 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k | `task_failed` | 某任务连续失败 ≥ `ALERT_TASK_FAILURES` | `TaskStatusStore.failing`(成功一轮清零) | | `token_expiring` | token 剩余落在 `(now, now+ALERT_TOKEN_EXPIRY_HOURS]` | `CredentialRepository.expiring_tokens` | | `error_rate` | 窗内失败占比 ≥ `ALERT_ERROR_RATE_THRESHOLD` 且样本 ≥ `ALERT_ERROR_RATE_MIN_REQUESTS` | `StatsCollector.window_error_rate` | +| `key_burst` | 单 Key 窗内 token ≥ `ALERT_KEY_BURST_TOKENS`(窗口 `ALERT_KEY_BURST_WINDOW_MINUTES`,与错误率窗口解耦) | `StatsCollector.window_key_usage`(滥用 / Key 泄露信号,不是错误) | +| `off_hours` | 本地时区落在 `[ALERT_OFF_HOURS_START, END)` **之外**且请求数 ≥ `ALERT_OFF_HOURS_MIN_REQUESTS` | `StatsCollector.window_error_rate` 的请求数 | +| `channel_dead`(critical) | 窗内有流量但**一次都没成功**,且请求数 ≥ `ALERT_CHANNEL_DEAD_MIN_REQUESTS`(默认 20,窗口 `ALERT_CHANNEL_DEAD_WINDOW_MINUTES` 默认 60) | `StatsCollector.window_dead_channels`(`{provider, requests, last_ok}`) | +| `checkin_failing` | 同凭证最近**连续** N 轮签到失败 ≥ `ALERT_CHECKIN_FAIL_MIN_STREAK`(默认 3,中间成功一次即清零;回看窗 `ALERT_CHECKIN_FAIL_WINDOW_HOURS` 默认 24) | `CheckinEventRepository.consecutive_failures`(读 `checkin_events`) | -- `pool_empty` 取 `total>0`:池里一个凭证都没有是「没配」而非「耗尽」,不报。 -- `task_failed` 只列**真跑过且达阈值**的 key:从未运行的任务不在 `_failure_streaks` 里,不会被误报。 -- `token_expiring`:`token_expires_at` 为 NULL 时从密文按需派生;派生不出(0)视为未知、不算「即将到期」;硬禁用凭证排除;**寿命本就短于窗口**的凭证排除(CodeArts STS 只有 2h,而默认窗口 24h,恒「即将到期」只会把真告警淹掉)。 -- `error_rate` 读 `usage_events` **明细**而非小时汇总(告警窗是分钟级,小时粒度要么整点才更新、要么跨小时口径错乱)。 +- `pool_empty` 取 `total>0`:一个凭证都没有是「没配」而非「耗尽」。 +- `task_failed` 只列真跑过且达阈值的 key。 +- `token_expiring`:NULL 时从密文派生,派生不出视为未知;硬禁用排除;**寿命本就短于窗口**的排除(CodeArts STS 2h vs 默认窗口 24h)。 +- `error_rate` 读 `usage_events` **明细**(告警窗是分钟级)。 +- `key_burst` / `off_hours` 都是「用量形态异常」而非上游故障,默认阈值 0 = 关闭;跨零点写法(22 → 7)合法,区间半开、`start == end` 关闭。 +- `channel_dead` / `checkin_failing` 补两个盲区:前者绕开 `error_rate` 被 fallback 稀释(实测 2026-11-08 officeace 积分耗尽 8 小时无成功未报),后者绕开 `task_failed`(签到「任务跑成功、上游业务失败」不算任务失败,raccoon 曾连续 5 天全失败)。 -**投递**:站内每条命中落 `alert_events`(管理台倒序回看;落库而非只留内存,因为告警的价值恰在「错过的那段时间发生了什么」,保留期由 `RetentionTask` 按 90 天清理);webhook 配置 `ALERT_WEBHOOK_URL` 时逐地址 POST JSON(多个逗号分隔,全部成功才算 delivered),留空只留站内记录,**投递失败只记 `delivery_error`、绝不抛错**。**静默去重**:同一 `(rule, scope)` 在 `ALERT_SILENCE_MINUTES` 窗内只落库 / 推送一次。`AlertTask` **与运行态共享同一个 `TaskStatusStore`**——若自建 store,「任务连续失败」规则会永远读到 0 而静默失效。 +**投递**:每条命中落 `alert_events`;`ALERT_WEBHOOK_URL`(逗号分隔多个)逐地址 POST 完整 JSON + 签名,全部成功才算 delivered;飞书 / Slack(`ALERT_FEISHU_WEBHOOK_URL` / `ALERT_SLACK_WEBHOOK_URL`)各只认自己格式(飞书 `msg_type=text`、Slack `{"text":…}`,**只带标题行、不带 detail**),签名按各自实际 body 重算;**投递失败只记 `delivery_error`**。**静默去重**:同 `(rule, scope)` 在 `ALERT_SILENCE_MINUTES` 内只落库 / 推送一次。`AlertTask` **与运行态共享同一个 `TaskStatusStore`**(自建 store 会让「任务连续失败」永远读到 0)。 -**迟滞与恢复**(`ALERT_FOR_TICKS` / `ALERT_CLEAR_TICKS`):单轮采样可能只是毛刺,故「连续 `for_ticks` 轮命中」才进入告警(默认 1 = 不迟滞,保持向后兼容),「连续 `clear_ticks` 轮不再命中」才确认恢复(默认 2)。恢复时给该 `(rule, scope)` **全部尚未恢复**的行写 `resolved_at`(同一次故障期间静默窗过后会再落「提醒」行,恢复时一起收尾;否则更早的提醒永远停在未恢复,角标数 `resolved_at IS NULL` 就永远清不干净),并外推一条 `status="resolved"` 的 webhook;管理台把 `resolved_at` 非空的行显示为「已恢复」而非严重度。**迟滞计数**(连击/恢复轮数)进程内维护、重启归零重新累积,与签到去重同范式;但「当前哪些告警仍未恢复」是**已落库的事实**,`AlertTask` 构造时从 `AlertRepository.unresolved()` 重建进程内 `_active`——不重建的话重启后恢复分支只遍历空集合,历史未恢复行永远等不到 `resolve()`(规则早已不再命中却无人写 `resolved_at`),角标与「已恢复」标记会永久卡住。重建只恢复「告警中」集合与恢复通知所需的文案/detail(PII 片段按 `_PII_DETAIL_KEYS` 从 detail 反推,保证外发仍打码),不恢复连击计数。 +**迟滞与恢复**(`ALERT_FOR_TICKS` / `ALERT_CLEAR_TICKS`):连续 for_ticks 轮命中才告警(默认 1),连续 clear_ticks 轮不命中才确认恢复(默认 2)。恢复时给该 `(rule, scope)` **全部未恢复**行写 `resolved_at` 并外推 `status="resolved"` webhook,管理台把 `resolved_at` 非空显示为「已恢复」。**迟滞计数**进程内、重启归零;「哪些仍未恢复」是落库事实 → `AlertTask` 构造时从 `unresolved()` 重建 `_active`(不重建则重启后恢复分支遍历空集,历史行永远等不到 `resolved_at`)。重建只恢复集合与通知文案(PII 片段按 `_PII_DETAIL_KEYS` 反推),不恢复连击计数。 -**签名**:webhook 请求恒带 `X-Coding2API-Signature: sha256=`,签名算在**实际发送的字节**上(故发送预序列化 `content` 而非 `json=`,否则 httpx 二次序列化后无法对账),收端用同一 `APP_SECRET` 复算即可验真。不设开关——多一个开关只会多一种「以为开了其实没开」的静默状态。 +**签名**:webhook 恒带 `X-Coding2API-Signature: sha256=`,签名算在**实际发送字节**上(故发预序列化 `content` 而非 `json=`)。不设开关。 -**上游状态页聚合**(`src/upstream_status.py` + `src/tasks/upstream_status.py`):并发抓各模型 / 网关提供方的官方状态接口并归一化,管理台「上游状态」页只读展示。定位是**只做解释不做决策**——本服务自己的 429/5xx 冷却只能回答「这个号现在不行」,官方状态页补上「是上游整体挂了还是我这边的问题」;但状态页滞后于本服务的模型级冷却(`src/provider/kilo/client.py`),把它并入选号 / 熔断只会让调度更慢更糊涂。**只存内存、不落盘**:状态数据分钟级易变,重启后拿一份几小时前的「一切正常」比显示「尚未抓取」危险得多。 +**上游状态聚合**(`src/upstream_status.py` + `src/tasks/upstream_status.py`):**只做解释不做决策**,不进选号 / 熔断。**只存内存、不落盘**(重启后拿几小时前的「一切正常」比显示「尚未抓取」更危险)。 -- **声明式源清单**:`SOURCE_SPECS` 是 `SourceSpec` 的元组(key / 展示名 / `api_url` / `page_url` / `kind` / `scope` / `related_providers`),加源只需加一行。`scope=channel` 表示与本站渠道一一对应(opencode→zen、kilo→kilo);`scope=model` 表示模型 / 上游方,经渠道间接使用,靠 `related_providers` 标注关联,**不做模型名模糊匹配**(必然出错的启发式)。 -- **四种解析器**(各自纯函数,`ValueError` 表示顶层形状不可用):`statuspage`(Atlassian Statuspage v2 `/api/v2/summary.json`,Kilo / Moonshot / MiniMax / OpenAI / Anthropic 共用)、`opencode`(OpenCode Zen `/api/status`,自定义 JSON,**逐模型 TTFT / tps**,`breakdown` 展开成多行)、`betterstack`(Better Stack `/index.json`,JSON:API,逐资源可用率)、`atom`(DeepSeek `/history.atom`,Flashduty 产出,只有事故史、无当前组件态)。 -- **六档归一**:`operational` / `degraded` / `partial` / `major` / `maintenance` / `unknown`;Statuspage `critical` → `major`(多一档只让配色更碎)。`aggregate_level` 取最差,但 `unknown` 权重 -1——**一个源连不上不该压过其余源报出的真实故障**;空输入回 `unknown`。 -- **失败隔离**:`fetch_source` 捕获一切(网络 / HTTP / 解析)→ `SourceStatus(level=unknown, error=...)`;`run_once` 全源失败也只返回 `{"sources","abnormal","failed"}` 而不抛——断网不值得触发「后台任务连续失败」告警。页面用 `summary.abnormal`(除 operational / unknown 外的一切)与 `summary.unknown`(抓取失败)分开展示。 -- **事故归一**:各源侧生命周期写法不同(Statuspage 的 investigating/identified/monitoring、OpenCode 的 `resolvedAt`、DeepSeek 正文里的 `Status: resolved`),统一成 `Incident.ongoing` 布尔;先过滤再截断到 `MAX_INCIDENTS = 5`(OpenAI 这类源会把卡片撑到几百行)。 -- **启动预热**:`_warm_upstream_status` 后台跑一轮(与 `_warm_model_catalog` 同范式),失败仅记日志、不阻断服务;关闭时取消任务,避免 in-flight 的 httpx 请求被带出事件循环。 +- **声明式源清单** `SOURCE_SPECS`(key / 展示名 / `api_url` / `page_url` / `kind` / `scope` / `related_providers`),加源加一行。`scope=channel` 与本站渠道一一对应(opencode→zen、kilo→kilo);`scope=model` 靠 `related_providers` 标注,**不做模型名模糊匹配**。 +- **四种解析器**(纯函数,`ValueError` = 顶层形状不可用):`statuspage`(`/api/v2/summary.json`,Kilo / Moonshot / MiniMax / OpenAI / Anthropic)、`opencode`(`/api/status`,逐模型 TTFT / tps,`breakdown` 展开多行)、`betterstack`(`/index.json`,JSON:API)、`atom`(DeepSeek `/history.atom`,只有事故史)。 +- **六档归一**:`operational` / `degraded` / `partial` / `major` / `maintenance` / `unknown`;Statuspage `critical` → `major`。`aggregate_level` 取最差,`unknown` 权重 -1(一个源连不上不该压过真实故障);空输入 `unknown`。 +- **失败隔离**:`fetch_source` 捕获一切 → `SourceStatus(level=unknown, error=...)`;`run_once` 全源失败也只返回不抛。页面用 `summary.abnormal`(除 operational / unknown)与 `summary.unknown`(抓取失败)分开展示。 +- **事故归一**:各源生命周期写法不同 → 统一成 `Incident.ongoing` 布尔;先过滤再截断到 `MAX_INCIDENTS = 5`。 +- **启动预热**:`_warm_upstream_status` 后台跑一轮,失败仅记日志;关闭时取消任务。 ## 11. 出口辅助端点与错误语义 ### 11.1 余额与健康检查 -- `GET /v1/user/balance`:兼容 DeepSeek 余额接口形状,Bearer `sk-...` 鉴权,供 Cherry Studio / cc-switch 等客户端显示余额。余额来自凭证池的额度探测缓存(`QUOTA_PROBE_MINUTES` 周期刷新),按可用凭证汇总,单位为上游 credits。池内凭证从未探测成功时 `balance_known` 为 `false`(余额未知,不是 0)。 -- `GET /health`:只回 `{"status":"ok"}`(进程存活,适合容器存活探针),不鉴权。 -- `GET /healthz`:额外给出凭证池计数,供外部监控在池子耗尽时提前告警——那是「服务活着但用不了」的状态。两个端点都不鉴权。 +- `GET /v1/user/balance`:DeepSeek 形状,Bearer `sk-...`,供 Cherry Studio / cc-switch。余额来自额度探测缓存,按可用凭证汇总,单位上游 credits。从未探测成功 → `balance_known` 为 `false`(未知,不是 0)。 +- `GET /health`:`{"status":"ok"}`,存活探针,不鉴权。 +- `GET /healthz`:额外给池计数,`ready=0` 时 503(就绪)。两者都不鉴权,都接受 `HEAD`。 ```json { - "status": "ok", "service": "coding2api", "version": "0.9.1", + "status": "ok", "service": "coding2api", "version": "0.13.1", "credentials": {"total": 5, "ready": 4, "cooling": 1, "paused": 0, "disabled": 0} } ``` -五类计数互斥且合计 = `total`,判定优先级 `disabled → paused → cooling → ready`(系统禁用 / 用户暂停优先于冷却)。口径**复用调度器的 `Candidate.is_selectable`**(`CredentialRepository.pool_counts`),不另写 SQL——两套口径一旦分叉,健康检查会报出与实际选号不符的「可用数」。`ready=0` 时对话请求直接返回 503。 +四类计数互斥(`ready` / `cooling` / `paused` / `disabled`)、合计 = `total`;判定优先级 `disabled → paused → cooling → ready`。口径**复用调度器的 `Candidate.is_selectable`**(`CredentialRepository.pool_counts`),不另写 SQL。 -> ⚠️ **`/healthz` 的 `ready` 不看 `health`**:`is_selectable` 只判 disabled / enabled / cooling,额度耗尽的号(`health < 0`)仍算 `ready`。侧栏「凭证管理」角标是**另一套口径**(`attention_counts`:`disabled + health < 0`),刻意与 `ready` 不同——一个是「能不能拿去打上游」,一个是「有没有待办」。两者不一致不是 bug。 +> ⚠️ **`/healthz` 的 `ready` 不看 `health`**:额度耗尽的号(`health < 0`)仍算 `ready`。侧栏「凭证管理」角标是**另一套口径**(`attention_counts`:`disabled + health < 0`)——一个问「能不能打上游」,一个问「有没有待办」,不一致不是 bug。 ### 11.2 侧栏数据提醒(`GET /api/nav-badges`) +一个聚合端点覆盖整个侧栏(避免 `Layout` 挂多个页面级 hook),60s 轮询。 -一个聚合端点覆盖整个侧栏,避免 `Layout` 挂 7 个页面级 hook(那些返回全量列表,路由切换会重拉重数据)。侧栏 60s 轮询。 - -- **按角色裁剪 payload**,**缺失的键 = 无权看**(不是 0):admin-only 的 `alerts`/`tasks`/`users` 不下发给 operator/viewer;operator 的 `credentials`/`upstream`/`upstream_level`/`model_catalog_stale` 不下发给 viewer;`api_keys` 全员但只数本人。这是聚合端点,**不做整体 403**(viewer 也要看自己的 API Key)。前端 `lib/navBadges.ts` 先判 `undefined` 再判值,越权与「无待办」都不渲染。 -- **计数与页面数据不是同一口径**:`alerts` 只数 `resolved_at IS NULL`(页面 `total` 含已恢复历史);`tasks` 用 `last_ok is False`(首次失败即提示,不等告警规则的「连续失败 ≥ 阈值」),`last_ok is None`(从未运行)与已关闭的不计;`users` 数 `activation_digest IS NOT NULL`(含已过期令牌——需管理员重发,仍是待办);`api_keys` 数 `expires_at <= now + 7d`(含已过期,`NULL`=永不过期不计)。 -- **上游角标**直接读 `summary.abnormal`(含 maintenance),不另立「异常」口径;`upstream_level`(`aggregate_level` 最差档)只用于定色调(maintenance → 黄,partial/major → 红)。 -- **模型目录陈旧**:`price_saved_at` 为空 → `false`(从未拉取,新装不该报警);否则 `now - price_saved_at > 2 × OPENROUTER_CATALOG_MINUTES` 才算。四元组同生共死,「从未拉取」与「目录丢了」在事实上同一态。 -- **未装配回退**:`task_runner` / `upstream_status` 未挂时回 0 / `unknown` 而非 500(与 `admin_upstream.py` 的空快照同范式)。 -- **前端防御**:角标挂在全局侧栏,脏 payload 会被当 React child 渲染并打崩整棵树(页面测试的 catch-all stub 撞到过),故 `count()` 显式校验 `typeof === "number"` + 有限正数,非数字静默不渲染。 +- **按角色裁剪 payload**,**缺失的键 = 无权看**(不是 0):admin-only 的 `alerts`/`tasks`/`users` 不下发给 operator/viewer;operator 的 `credentials`/`upstream`/`upstream_level`/`model_catalog_stale` 不下发给 viewer;`api_keys` 全员但只数本人。**不做整体 403**(viewer 也要看自己的 Key)。前端 `lib/navBadges.ts` 先判 `undefined` 再判值。 +- **计数与页面数据不是同一口径**:`alerts` 只数 `resolved_at IS NULL`;`tasks` 用 `last_ok is False`(`last_ok is None` 与已关闭不计);`users` 数 `activation_digest IS NOT NULL`(含已过期);`api_keys` 数 `expires_at <= now + 7d`(含已过期,NULL 不计)。 +- **上游角标**读 `summary.abnormal`(含 maintenance);`upstream_level` 只用于定色调。 +- **模型目录陈旧**:`price_saved_at` 空 → `false`;否则 `now - price_saved_at > 2 × OPENROUTER_CATALOG_MINUTES` 才算。 +- **未装配回退**:`task_runner` / `upstream_status` 未挂时回 0 / `unknown` 而非 500。 +- **前端防御**:`count()` 显式校验 `typeof === "number"` + 有限正数;非数字静默不渲染。 ### 11.3 Prometheus 指标(`GET /metrics`) -`src/api/metrics.py` 零依赖手写 Prometheus text 格式(不引 `prometheus_client`:本服务只暴露一组固定小指标,官方客户端会带进整套注册表与默认采集器)。默认开启、**不鉴权**(与 `/healthz` 同口径),`METRICS_ENABLED=false`(可热更)时返回 404。路由注册在 SPA catch-all 之前,否则会被通配路由吞掉。 +`src/api/metrics.py` 零依赖手写 text 格式(不引 `prometheus_client`)。默认开、默认**不鉴权**;`METRICS_ENABLED=false`(可热更)时 404;`METRICS_TOKEN` 非空时要求 `Authorization: Bearer `(恒时比较,错误 401),用于对外部署时收口。路由注册在 SPA catch-all 之前。 | 指标 | 类型 | 标签 | 数据源 | |---|---|---|---| -| `coding2api_requests_total` | counter | `provider` / `model` / `status`(ok\|error) | `usage_hourly` 永久小时汇总(`StatsQuery.metrics_provider_model`),`error = requests − ok_count` | -| `coding2api_tokens_total` | counter | `provider` / `type`(input\|output\|reasoning\|cached) | `usage_hourly`(`StatsQuery.metrics_provider_tokens`) | -| `coding2api_credentials` | gauge | `state`(ready\|cooling\|paused\|disabled) | `CredentialRepository.pool_counts()`(与 `/healthz` 同口径) | -| `coding2api_upstream_errors_total` | counter | `provider` / `kind`(受控 `error_type`) | `StatsCollector.error_totals()`(进程内计数,重启归零) | -| `coding2api_task_failures_total` | counter | `task` | `TaskStatusStore.failures()`(进程内累计失败数,重启归零) | +| `coding2api_requests_total` | counter | `provider` / `model` / `status`(ok / error) | `usage_hourly`(`error = requests − ok_count`) | +| `coding2api_tokens_total` | counter | `provider` / `type`(input / output / reasoning / cached) | `usage_hourly` | +| `coding2api_credentials` | gauge | `state`(ready / cooling / paused / disabled) | `pool_counts()`(与 `/healthz` 同口径) | +| `coding2api_upstream_errors_total` | counter | `provider` / `kind` | `StatsCollector.error_totals()`(进程内,重启归零) | +| `coding2api_task_failures_total` | counter | `task` | `TaskStatusStore.failures()`(进程内) | -三个口径要点:① **禁高基数标签**——不打 `credential_id` / `api_key_id` / `username` / `request_id`,一个账号一条时间序列会让 Prometheus 内存与查询失控;② `requests_total` / `tokens_total` 读**永久**小时汇总而非 90 天明细,故 scrape 不做明细全表扫描、且计数跨重启单调;③ 进程内计数器(失败原因、任务失败)随重启归零,Prometheus 原生按 counter reset 处理。文本转义只处理 `\` / `"` / 换行(模型名可能含引号,不转义会拼坏整份响应)。**已知偏离扫描报告**:报告写 `upstream_errors_total` 数据源为 `usage_hourly`,但小时汇总没有 `error_type` 维度,用明细当 `_total` 会随裁剪下降,故 `kind` 改用进程内计数。 +要点:① **禁高基数标签**(不打 `credential_id` / `api_key_id` / `username` / `request_id`);② 前两项读**永久**小时汇总,计数跨重启单调;③ 进程内计数器随重启归零,Prometheus 按 counter reset 处理。文本转义只处理 `\` / `"` / 换行。**已知偏离**:`kind` 用进程内计数(小时汇总无 `error_type` 维度;用明细会随裁剪下降)。 ### 11.3b 请求级结构化日志(`src/webapp/access_log.py`) -`RequestLogMiddleware`(纯 ASGI)在每个**业务请求**结束时写一行 logfmt:`event=request request_id=… method=… route=… status=… duration_ms=…`。它是 uvicorn 裸 access log 的补充而非替代——后者的行里带完整 query string(客户端可能塞 token)、没有请求 ID、没有耗时,也没法和 `usage_events.request_id` 对上。 +`RequestLogMiddleware`(纯 ASGI)每个**业务请求**结束写一行 logfmt:`event=request request_id=… method=… route=… status=… duration_ms=…`。补充而非替代 uvicorn access log(后者带 query string、无请求 ID、无法与 `usage_events.request_id` 对上)。 -- **脱敏口径**:只取 `scope["path"]`,**从不读 `query_string`**;不碰 body / 请求头;**不写用户名**(靠 `request_id` 关联 `usage_events`,那里才有身份列)。路由用**模板**(`scope["route"].path`)而不是实际路径,资源 ID 不进日志也不会让日志按 ID 分叉;命中 SPA catch-all `/​{path:path}` 或根本没匹配到路由时回落为清洗过的原始路径(`[A-Za-z0-9._~/%:@-]` 之外替换为 `?`、截断 120 字符,防换行注入)。 -- **只记业务请求**:`/api/*`、`/v1/*`、`/authorize`。静态资源与 `/health`、`/healthz`、`/metrics` 不记——uvicorn 已覆盖,重复记只会把业务行挤出视野。 -- **级别**:`5xx` → WARNING,其余(含 4xx)→ INFO。`finally` 里发日志,故下游异常冒泡(服务器回 500)也留痕,`status` 取不到时按 500 记。 -- **装配顺序**:在 `RequestIdMiddleware` 内侧(要用它生成的 ID)、在 `BodySizeLimitMiddleware` 外侧(413 也是请求结果,要记)。热更开关 `REQUEST_LOG_ENABLED` 走取值器,改完下一请求生效。 +- **脱敏**:只取 `scope["path"]`,**从不读 `query_string`**;不碰 body / 头;**不写用户名**(靠 `request_id` 关联)。路由用**模板**(`scope["route"].path`);未匹配回落清洗过的原始路径(`[A-Za-z0-9._~/%:@-]` 之外替换 `?`、截断 120 字符)。 +- **只记业务请求**:`/api/*`、`/v1/*`、`/authorize`;静态资源与 `/health`、`/healthz`、`/metrics` 不记。 +- **级别**:`5xx` → WARNING,其余 INFO。`finally` 里发;`status` 取不到按 500。 +- **装配顺序**:`RequestIdMiddleware` 内侧、`BodySizeLimitMiddleware` 外侧。热更 `REQUEST_LOG_ENABLED`。 ### 11.3c 服务端日志页(`src/webapp/log_buffer.py` + `src/api/logs.py`) -`LogBufferHandler` 是个 `logging.Handler`,把本进程的记录写进**有界环形缓冲**(容量现读 `LOG_BUFFER_LINES`,热更调小立即裁掉最旧)。数据出口只有两个 admin 端点:`GET /api/logs?limit=`(默认 200,上限 = 缓冲容量)与 `GET /api/logs/stream`(SSE)。`LOG_BUFFER_LINES == 0` 时两个端点都 404,root 上的 handler 也摘掉——「页面关了」与「确实没有日志」必须能区分。 +`LogBufferHandler` 把本进程记录写进**有界环形缓冲**(容量现读 `LOG_BUFFER_LINES`,调小立即裁最旧)。出口只有两个 admin 端点:`GET /api/logs?limit=`(默认 200)与 `GET /api/logs/stream`(SSE)。`LOG_BUFFER_LINES == 0` → 两者 404 且 root handler 摘掉。 + +- **脱敏是硬前提**:`redact_or_drop(line)` 命中即**整行丢弃**:独立敏感词(`token` / `secret` / `authorization` / `cookie` / `password` / `passwd` / `api_key` 及 `apikey` / `api-key` / 复数,任意大小写)、键值形态(`access_token=`、`X-Api-Key:`、`set-cookie:`、`client_secret =`、JSON 键)、密钥形状(`sk-…`,前面不能是词字符故 `task-run` 不误伤)、裸凭证(`Bearer <值>`)。空行也丢。代价:「token 已刷新」这类正常行一起被吞——**宁可漏一条,也不把凭证推进浏览器**。 +- **只收本进程**:attach 到 root logger,不读文件、不 tail。 +- **SSE 同范式**:先发注释帧,再回放快照转实时;回放与实时按 `seq` 去重。15s 保活;订阅者上限 4,超限 429 + `Retry-After: 10`;响应带 `Cache-Control: no-store` 与 `X-Accel-Buffering: no`。`finally` 注销订阅。 +- **行内容**:`seq` / `ts` / `level` / `logger` / `message`(已脱敏、截断 4000 字符)/ `request_id`。前端只做筛选、搜索、跟随最新、暂停与清空,**无写入入口**。 + +### 11.3e 预算分层告警 +`ALERT_BUDGET_CNY` > 0 时按**本地自然月**累计 `usage_hourly.cost_cny_sum`,达到 `ALERT_BUDGET_LEVELS`(默认 50/80/95/100)任一档即告警: -- **脱敏是硬前提**:`redact_or_drop(line)` 是纯函数,命中即**整行丢弃**(返回 `None`):独立敏感词(`token` / `secret` / `authorization` / `cookie` / `password` / `passwd` / `api_key` 及其 `apikey` / `api-key` / 复数形态,任意大小写)、键值形态(`access_token=`、`X-Api-Key:`、`set-cookie:`、`client_secret =`、JSON 键 `"access_token": "…"`)、密钥形状(`sk-…`,前面不能是词字符,故 `task-run` 不误伤)、裸凭证(`Bearer <值>`)。空行/纯空白也丢。代价是 `token 已刷新` 这类正常行会一起被吞——**宁可漏一条日志,也不把凭证推进浏览器**。 -- **只收本进程**:attach 到 root logger,不读文件、不 tail。多进程 / 容器下各看各的,这是刻意的(轮转与权限会让文件视图骗人)。 -- **SSE 与 `/api/stats/events/stream` 同一范式**:先发注释帧让 EventSource 进 OPEN,再回放快照、转实时;回放与实时用每行 `seq` 去重(订阅先建、快照后取,交叠的行只发一次)。15s 注释保活,订阅者上限 4,超限 429 + `Retry-After: 10`,响应带 `Cache-Control: no-store` 与 `X-Accel-Buffering: no`。`finally` 注销订阅(关标签页会取消生成器,漏注销会把槽位耗到全员 429)。 -- **行内容**:`seq` / `ts`(epoch 秒)/ `level` / `logger` / `message`(已脱敏、单行截断 4000 字符)/ `request_id`(上下文有则带)。前端只做客户端侧的级别筛选、关键字过滤、跟随最新、暂停与清空,**没有任何写入入口**。 +- 只报**当前达到的最高档**(`rule=budget`, `scope=budget_{level}`);升级到更高档时旧档不再命中,由迟滞状态机写成「已恢复」,不会多档并存。100%(含超支)为 critical。 +- 成本只覆盖匹配到价表与汇率的明细 → 未定价模型不计入,预算消耗偏低。`window_cost` 另返回可定价小时数备用。 +- 月初成本归零 → 上月末的预算告警自然恢复。 + +### 11.3d 月度账单(`GET /api/billing`) +`require_operator`。`?month=YYYY-MM`(默认当月,**服务器本地时区**整月)+ `?format=json|csv|jsonl`:`json` 给 `{month,start,end,users,keys,totals}`(按人读小时汇总,按 Key 读明细——小时汇总主键无 `api_key_id`,明细只留 90 天);`csv` 按人汇总(带 BOM);`jsonl` 该月逐条明细。两种导出都写 `ACTION_DATA_EXPORT` 审计,文件名 `billing-.`,`Cache-Control: no-store`。非法 `month` / `format` → 400 `invalid_request`。面向用户在 README「月度账单」。 ### 11.4 面向用户的错误语义 -管理端点返回的失败原因必须是**稳定的机器可读枚举**,不能是 Python 异常类名(类名是实现细节,重构时会漂移)。`POST /api/credentials/{id}/probe` 的失败响应: +管理端点失败原因须是**稳定的机器可读枚举**,不能是异常类名。`POST /api/credentials/{credential_id}/probe` 的失败响应: ```json { "probed": false, "reason": "credential_rejected", "detail": "upstream http 401" } @@ -639,95 +932,119 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k | `reason` | 含义 | 用户该做什么 | |---|---|---| -| `credential_rejected` | 上游 401/403 拒绝凭证 | 重新登录该账号 | +| `credential_rejected` | 上游 401/403 拒绝凭证 | 重新登录 | | `rate_limited` | 上游 429 | 稍后重试 | -| `upstream_unavailable` | 上游 5xx | 等待上游恢复,与本账号无关 | +| `upstream_unavailable` | 上游 5xx | 等上游恢复 | | `upstream_rejected` | 其他 4xx | 检查账号状态 | -| `upstream_response_invalid` | 响应结构不符 | 可能是官方接口变更 | +| `upstream_response_invalid` | 响应结构不符 | 可能官方接口变更 | | `upstream_timeout` | 请求超时 | 重试 | -| `network_unreachable` | 连不上渠道服务器(DNS / 连接被拒 / 建连超时 / TLS 中断) | 检查本机网络或代理,确认渠道域名可达 | +| `network_unreachable` | 连不上(DNS / 拒绝 / 建连超时 / TLS 中断) | 检查网络或代理 | | `unknown_error` | 未归类 | 查 `detail` | -**客户端读体中途断开**(`starlette.requests.ClientDisconnect`)不是服务端错误:异常处理器按网关惯例回 `499` + `error_payload(..., "client_disconnected", 499)`,不落 500 + ASGI Traceback——以前它会冒泡到 `ServerErrorMiddleware`,既刷屏日志又把「客户端自己取消」混进服务错误统计。 +**客户端读体中途断开**(`ClientDisconnect`)不是服务端错误:回 `499` + `client_disconnected`,不落 500(以前会冒泡到 `ServerErrorMiddleware`,刷屏且污染错误统计)。 + +`detail` 仅供排查,界面不得当主提示。前端 `probeFailureLabel()` 翻中文并对未知值兜底。对外 400/503 文案只带受控标识,**不回显上游正文**。 + +### 11.4b 出口 IP 粘性(一号一出口,`provider/proxy.py`) +`CREDENTIAL_PROXY_POOL`(启动期,分号分隔)非空时,同一凭证固定走同一出口: -`detail` 保留原始错误摘要**仅供排查**,界面不得把它当作主提示展示。前端 `probeFailureLabel()` 把 `reason` 翻成中文并对未知值兜底到 `unknown_error`,不得透传原始字符串。对外 400/503 文案同理只带受控标识(HTTP 状态码 / 业务码 / 类名),**不回显上游正文**(可能含上游内部 schema,甚至回显的凭证片段)。 +- 分配 = `sha256(credential_id) → int % len(pool)`,**不依赖进程状态**,重启后不漂移(进程内轮转会等于没做)。池成员增删会让部分凭证换桶——改池子本身就是要换出口。 +- 凭证 id 走 contextvar(`request_context.set_credential_id`),由 engine / 任务层设置:推理(executor `_stream_source`,每次换号重试都重设)、额度探测、签到、token 预刷新、成长中心、管理台单条 probe/checkin。**不依赖凭证 data 里的账号字段**(十个渠道字段名不统一,缺失就会静默回落直连)。 +- `httpx` 的代理是连接池级参数,所以 `LazyClients` 按 `(流/短, 代理)` 维护多份池(`_pools`);未配池时只有一条池,行为与之前逐字节一致。显式注入的 client(测试替身)永远优先。 +- 优先级:凭证池分配 → `PROVIDER_PROXIES[渠道]` → 直连。**OAuth 登录阶段不覆盖**:登录时凭证还没入库(没有 id),仍走渠道级代理/直连。 + +### 11.5 工具调用健壮性(`src/compat/openai/`) +- **`id` 缺失**:按 index 生成 `call_{index}_{seq}` 并记忆,每片复用同一个值(聚合型客户端按 id 拼片,空 id 会把一次调用拆成两条)。 +- **DSML 泄漏回读**(`dsml.py`):DeepSeek V3.2/V4 偶发把工具调用写成 `〈||DSML||tool_calls〉…` 文本。解析单/双竖线两种方言、`parameter` 标签与 V4 的「自闭合 `invoke` 带 `string` 属性」、以及缺外层包装的裸 `invoke` 块;可见正文剥掉标记。流式用增量过滤器:普通文本零缓冲,只有出现标记(或结尾停在标记前缀上)才缓冲;不完整的标记在收尾时原样放行,绝不吞正文。回读出调用时 `finish_reason` 从 `stop` 改写为 `tool_calls`(否则客户端不执行)。 +- **坏参数 fail-closed**:非流式 `aggregate` 校验每个调用的 `arguments`(空串 → 合法;其余必须是 JSON **对象**),非法 → `InvalidToolArguments`(`kind()=INVALID`):换上游重试、全部失败对外 400,**不冷却凭证**、不把坏 JSON 递给客户端。流式分片天然不完整,不逐片校验。 +- **用量对账**(`engine/executor.py::_check_usage_consistency`):成功请求比较正文体量与上报的 `output_tokens`——≥200 字符却无 token、或 >12 字符/token → WARNING(上游吞 usage / 静默替换模型的间接信号)。只告警,不改响应、不写 `error_type`。 ## 12. 测试策略 -**目录组织**:`tests/` 按 `src/` 子包镜像(`api/ auth/ compat/ config/ db/ deploy/ engine/ provider/<渠道>/ scripts/ stats/ tasks/ webapp/`),每个目录一个 `__init__.py`。跨模块回归与纯补覆盖用例集中在 `tests/regression/`。逐字节等价的常量/纯函数收敛到 `tests/helpers/`;夹具按作用域放在 `tests/conftest.py` 或目录级 `conftest.py`;测试私有替身类放非收集模块 `_support.py`(不得以 `test_` 开头)。**真连上游豁免**:默认全部用例走 mock;需要真实探活上游的用例用 marker 显式标注(`@pytest.mark.real_upstream("zen" | "kilo" | "openrouter")`),`conftest` 据 marker 放行对应网络访问。marker 已在 `pyproject.toml` 注册(`--strict-markers` 生效,新增必须注册)。 +**目录**:`tests/` 按 `src/` 子包镜像(`api/ auth/ compat/ config/ db/ deploy/ engine/ provider/<渠道>/ scripts/ stats/ tasks/ webapp/`),每目录一个 `__init__.py`。跨模块回归集中 `tests/regression/`;逐字节等价的常量/纯函数收敛 `tests/helpers/`;夹具按作用域放 `conftest.py`;私有替身放非收集模块 `_support.py`。**真连上游豁免**:默认全 mock;需真探活的用例标 `@pytest.mark.real_upstream("zen" | "kilo" | "openrouter")`(已注册,`--strict-markers` 生效)。 | 目标 | 覆盖 | 方式 | |---|---|---| -| 调度器(到期指标/冷却/三态排序/轮换/pin) | 100% | 纯单元,注入假 provider | -| 鉴权(users/apikey/session/rbac) | 100% | 单元 + FastAPI TestClient | -| SSE 帧解析、provider 事件映射、OpenAI 协议适配、OAuth/回调解析 | 100% | 单元 + 真实样本 fixture 契约测试(codebuddy / trae) | -| HTTP 客户端 | 100% | respx mock 状态码 + body → classify 断言 | +| 调度器(到期/冷却/三态排序/轮换/pin) | 100% | 纯单元,注入假 provider | +| 鉴权(users/apikey/session/rbac) | 100% | 单元 + TestClient | +| SSE 解析、事件映射、OpenAI 适配、OAuth/回调 | 100% | 单元 + 真实样本 fixture(codebuddy / trae) | +| HTTP 客户端 | 100% | respx mock 状态码 + body → classify | | 统计/查询 | 100% | sqlite 内存库集成 | | 其余 | 100% | — | -**全量 100%(行 + 分支)是硬门槛**:CI 里 `pytest --cov-fail-under=100`,新增/修改的代码必须带测试,缺口一律补测试解决,不用 pragma / 排除达标。允许的例外(必须带注释说明为何不可达):src/ 中「按构造不可达」的防御兜底(`__main__` 入口、协议演进防御分支、前置 return 已排除的死分支);tests/ 中测试替身实现了宽于本用例所需的接口方法。**平台差异分支不用 pragma,必须 monkeypatch 测全**(`platform.system`/`machine` 等;本地 macOS 通过不代表 CI 的 ubuntu-latest 通过)。**测试不得依赖本地 `.env`**:CI 从不带 `.env`,而本地 `.env` 会经 pydantic-settings 补上 `APP_SECRET` 等必填项。任何构造 `Settings()` 的测试都要**显式传值**(`Settings(_env_file=None, APP_SECRET=...)` 或 `setenv`),否则本地绿、CI 红。**验证手法**:临时把 `.env` 移走再跑全量,通过才算数。**fixture**:存于 `src/provider/fixtures/`(真实 SSE/JSON 样本,覆盖正文、思考、工具调用、错误码与额度),断言两个方向:**解析正确**(样本 → 期望 Event)与**不静默**(畸形样本 → `UpstreamProtocolViolation`)。当前只有 codebuddy / trae 有落盘样本;zen / kilo / qoder / codearts 的协议已用逐帧单测覆盖(含畸形帧),但**尚未补真实样本文件**——需拿到上游实际抓包后按同一契约补,避免用合成样本冒充「真实样本」。 +**全量 100%(行+分支)硬门槛**:`--cov-fail-under=100`;缺口补测试,禁 pragma 达标。例外(注释说明不可达):src/ 构造不可达兜底(`__main__`、协议演进防御、前置 return 死分支);tests/ 替身实现宽于本用例的方法。**平台差异分支禁 pragma,须 monkeypatch 测全**。**测试不得依赖本地 `.env`**:`Settings()` 须显式传值(`Settings(_env_file=None, APP_SECRET=...)`),验证时临时移走 `.env`。**fixture** 存 `src/provider/fixtures/`,断言两方向:解析正确 + **不静默**(畸形 → `UpstreamProtocolViolation`)。codebuddy / trae 有落盘样本;zen / kilo / qoder / codearts 用逐帧单测(含畸形帧),**尚未补真实样本**——不用合成样本冒充。 -## 13. 部署与运行时配置热更 +## 13. 部署与配置热更 ### 13.1 运行时配置覆盖层 -**分层**: - | 类别 | 例子 | 位置 | 变更方式 | |---|---|---|---| -| 启动期不可变项 | `APP_SECRET` / `HOST` / `PORT` / `DATA_DIR` / `USERS_FILE` / 上游端点白名单 / `PROVIDER_PROXIES` | `config.Settings`(frozen) | 改 env + 重启;**不进白名单** | -| 运行时可覆盖项 | `runtime_settings.HOT_SETTINGS`(51 项) | `RuntimeSettings` 覆盖层 | 管理台改,立即生效 | +| 启动期不可变 | `APP_SECRET` / `HOST` / `PORT` / `DATA_DIR` / `USERS_FILE` / 上游端点白名单 / `PROVIDER_PROXIES` | `config.Settings`(frozen) | 改 env + 重启;**不进白名单** | +| 运行时可覆盖 | `runtime_settings.HOT_SETTINGS`(85 项) | `RuntimeSettings` 覆盖层 | 管理台改,立即生效 | + +启动期项拒绝热更:它们决定进程如何启动或作用于连接池,运行期改只会让进程与磁盘配置静默分叉。**优先级**:`runtime_settings` 表 > `.env`;覆盖行只有被改过的 key,「恢复默认」= 删行。**读取**:热更 key 返回覆盖值,其余 `__getattr__` 委托 `Settings`;覆盖值内存缓存,写入后 `reload()`,读取不查库。**消费端** `config.live(value)` 把标量或零参 callable 统一成取值器(生产传 `lambda: runtime.xxx`)。已接线点:`Scheduler.expiry_window` / `secondary_expiry_window`、`ConversationAffinity.ttl_seconds`、`Pacer.min/max_seconds`、`Executor.default_model` / `force_disable_thinking` / `reasoning_effort_map`、`GrowthTask.allow_irreversible`、`TaskRunner` 周期与活跃上报时点、上下文压缩五项、`USD_CNY_RATE`、`anthropic_thinking_efforts`、`nonstream_keepalive_seconds`、`max_inflight_per_credential`、`model_blocklist`、`metrics_enabled` / `request_log_enabled` / `log_buffer_lines` / `prefix_debug_enabled`、`upstream_status_enabled`。**不在白名单**(改 env + 重启):`AUTO_CONTINUE_MAX`、各渠道端点与 `*_ALLOWED_ENDPOINTS`;`QUEUE_MAX_ROUNDS` / `MODEL_COOLDOWN_*` / `SPEED_*` 这类引擎常量写死在代码里。 + +**校验**:白名单外 key 拒绝;类型与范围写入前校验;跨字段组合(`pacer_min ≤ pacer_max`)用「这批写完之后」的对端值比较,失败整批不落库;坏行读取时跳过并记警告。**接口**:`GET /api/settings` 返回 `snapshot()`(`key`/`env_name`/`label`/`description`/`kind`/`value`/`default`/`overridden`/`task`/`group`)+ 覆盖计数 + `groups`;`PUT /api/settings` body `{"values": {key: 标量 | null}}`,`null` 恢复默认,写操作记审计。网关卡组表即 `GATEWAY_GROUPS`。不变量:每项**要么**属一个任务、**要么**落一个网关卡组,互斥且都有归属。 + +### 13.2 后台任务可视化 +`SettingsPage` 两级 tabs:一级「后台任务 / 网关配置」;任务按后端下发的业务分组(`TASK_GROUPS`:账号维护 / 模型与渠道 / 运维与数据)二级切换,组内每任务一个 tab(运行态 + 配置项),只有一个任务时省掉第三排;网关配置按 `GATEWAY_GROUPS` 二级切换。无配置项 / 无已装配任务的分组不出 tab;归属对不上落「其他」,绝不吞配置。配置项按后端下发的 `section` 分节(运维告警 25 项 → 基础与迟滞 / 规则阈值 / 单 Key 与时段 / 渠道与签到 / 月度预算)。保存按钮与「N 项待保存」常驻顶部,草稿跨 tab 保留。`useTasks` 以 `refetchInterval: 30_000` 刷新,`/api/settings` 不自动刷新。任务卡片「立即运行」在运行中 / 已关闭时禁用;结果三态——**已跑一轮**(带 `last_report`)、**没排上**(`busy=true`)、**跑了但 no-op**(`ran=false`);把第三种说成「已运行」就是谎报。 -启动期项拒绝热更的原因:它们决定进程如何启动(监听地址、加密密钥、上游白名单)或作用于连接池;运行期变更只会让「当前进程」与「磁盘配置」静默分叉。**生效优先级**:`runtime_settings` 表(DB 覆盖)> `.env`(默认值来源)。覆盖行只有被管理台改过的 key;「恢复默认」= 删除该行,不是写入 env 当前值。**读取机制**:`RuntimeSettings` 对热更 key 返回覆盖值,其余属性 `__getattr__` 透明委托给 `Settings`——调用方仍写 `settings.default_model`。覆盖值在内存缓存,写入后 `reload()` 刷新;读取路径不查库。**消费端**:`config.live(value)` 把「标量或零参 callable」统一成取值器;生产装配传 `lambda: runtime.xxx`(每次读当前值),测试与一次性任务传标量。已接线的热更点:`Scheduler.expiry_window` / `secondary_expiry_window`(读取时跑 `expiry_windows()` 归一化)、`ConversationAffinity.ttl_seconds`、`Pacer.min/max_seconds`(校验从构造期挪到读取期)、`Executor.default_model` / `max_auto_continues` / `force_disable_thinking` / `reasoning_effort_map`、`GrowthTask.allow_irreversible`、`TaskRunner` 周期与活跃上报开关时点、上下文压缩四项、`USD_CNY_RATE`、Anthropic 出口的 `anthropic_thinking_efforts`(在 `api/messages.py` 的 parse 闭包里现读)。**校验**:白名单外 key 直接拒绝;类型与范围(min/max)写入前校验;跨字段组合(`pacer_min ≤ pacer_max`)用「这一批写完之后」的对端值比较,失败则整批不落库。表里坏行读取时跳过并记警告。**接口**:`GET /api/settings`(admin)返回 `snapshot()`(`key`/`env_name`/`label`/`description`/`kind`/`value`/`default`/`overridden`/`task`/`group`)+ 覆盖计数 + 网关卡组表 `groups`;`PUT /api/settings`(admin + CSRF)body `{"values": {key: 标量 | null}}`,`null` 表示恢复默认。写操作记审计日志。`snapshot()` 里的 `task` 是**配置项 → 后台任务 key** 的归属(`null` = 非任务项),`group` 是**非任务项 → 网关卡组 key** 的归属(`GATEWAY_GROUPS`:模型路由 / 选号与会话 / 渠道节流 / 后台任务节流 / 可观测性)。不变量:每项**要么**属于一个任务,**要么**落在一个网关卡组,两者互斥且都有归属(测试守住,否则前端会漏渲染该配置项)。 -### 13.2 后台任务可视化(管理台「任务与配置」页) -`SettingsPage` 用一排 tabs 控制信息量:每个任务一个 tab(该任务运行态 + 配置项),无任务归属的配置按后端下发的网关卡组各占一个 tab;一屏只呈现一块。没有配置项的分组不出 tab;归属对不上的项落进「其他」tab,绝不吞掉配置。保存按钮与「N 项待保存」常驻面板顶部,草稿跨 tab 保留、一次提交全部改动。`useTasks` 以 `refetchInterval: 30_000` 自动刷新(运行态是随时间变化的观测量),`/api/settings` 不自动刷新。任务卡片带「立即运行」按钮(`POST /api/tasks/{key}/run`),运行中 / 任务已关闭时禁用;结果文案区分三态——**已跑一轮**(带 `last_report`)、**没排上**(`busy=true`,另一轮在跑)、**跑了但 no-op**(`ran=false`,任务被开关跳过)。把第三种说成「已运行」就是在谎报,任务其实一步没动。 ### 13.3 按渠道出站代理 -`PROVIDER_PROXIES`(启动期项,默认 `""` 直连):`渠道=代理URL;渠道2=代理URL2`,渠道取 `KNOWN_PROVIDERS`,协议 `http/https/socks5/socks5h`。作用于该渠道**全部出站请求**(聊天流、额度/模型拉取、后台任务与 OAuth 登录)。**严格解析**:未知渠道 / 非法协议 / 缺 `=` / 空 URL 一律 `ValueError`,只忽略空段——代理常带合规 / 隐私意图,「以为走了代理其实直连」是静默的安全问题,启动报错远好过静默直连。工厂 `build_client(timeout, proxy)` 统一构造 `httpx.AsyncClient(..., trust_env=False, proxy=proxy)`;各 provider client 与三个 OAuth 流(CodeBuddy/Qoder/CodeArts)增 `proxy` 参数。**为什么启动期而非热更**:代理作用于连接池,运行中改值需重建在途连接池(6 个客户端 + 3 个 OAuth 流)。 +`PROVIDER_PROXIES`(启动期,默认 `""` 直连):`渠道=代理URL;渠道2=代理URL2`,渠道取 `KNOWN_PROVIDERS`,协议 `http/https/socks5/socks5h`。作用于该渠道**全部出站请求**(聊天、额度/模型、后台任务、OAuth)。**严格解析**:未知渠道 / 非法协议 / 缺 `=` / 空 URL 一律 `ValueError`,只忽略空段。工厂 `build_client(timeout, proxy)` 统一构造 `httpx.AsyncClient(..., trust_env=False, proxy=proxy)`。**为什么启动期**:代理作用于连接池;运行中改需重建 6 个客户端 + 3 个 OAuth 流。 -**`trust_env=False` 是全项目纪律,不只 provider 客户端**:httpx 默认 `trust_env=True` 会读宿主环境与 **macOS 系统代理**(`scutil --proxy`),本机开着 Clash(`127.0.0.1:7897`)时状态页 / OpenRouter 目录这类**公共只读**出站会被交给该代理,代理握手失败即整批失败——实测「上游状态全部抓取失败」「模型目录全空」。因此凡自建 `httpx.AsyncClient` 的公共出站点都必须显式 `trust_env=False`:`upstream_status.fetch_all`、`benchmarks.fetch_openrouter_models`、`tasks/alerting` 的 webhook(`src/provider/proxy.build_client` 已内建)。 -### 13.4 重启是必须的 -**静态产物与进程内代码的刷新方式不同**: +**`trust_env=False` 是全项目纪律**:httpx 默认读宿主环境与 macOS 系统代理(`scutil --proxy`)→ 本机开 Clash 时状态页 / OpenRouter 目录这类公共只读出站会被交给该代理,握手失败即整批失败(实测「上游状态全部抓取失败」「模型目录全空」)。自建 `httpx.AsyncClient` 的公共出站点都必须显式关闭:`upstream_status.fetch_all`、`benchmarks.fetch_openrouter_models`、`tasks/alerting` 的 webhook(`provider/proxy.build_client` 已内建)。 -- **前端**:后端对 `web/dist` 用 `FileResponse` **每次请求现读磁盘**,重新构建后刷新浏览器即生效,无需重启。 -- **后端**:`src/` 下的一切(路由、依赖、`build_app` 装配)只在**进程启动时**装载。进程管理器只在进程**退出**时重新拉起、**不监听源码变化**。 +### 13.4 重启是必须的 +- **前端**:后端对 `web/dist` 用 `FileResponse` **每次请求现读磁盘**,重建后刷新浏览器即生效。 +- **后端**:`src/` 下一切只在**进程启动时**装载;进程管理器只在进程退出时拉起、不监听源码变化。 -两者可独立更新,于是会出现 **「新前端 + 旧后端」**的错配:页面能打开(静态文件是新的),但新端点全部失败——旧后端没有该路由,未匹配的 `/api/*` 按约定返回 JSON `404`(`webapp/static.py` 的 SPA catch-all 排除 `/api` 与 `/v1`),前端把它归一成**无 `message` 的通用失败**,弹出与实际原因无关的兜底文案。 +于是出现 **「新前端 + 旧后端」**:页面能打开,但新端点全 404(`webapp/static.py` 的 catch-all 排除 `/api` 与 `/v1`)→ 前端归一成通用失败,弹出与真实原因无关的兜底文案。 ```bash docker compose up -d --force-recreate # Docker / compose sudo systemctl restart coding2api # systemd ``` -**诊断顺序**(先确认版本,再查业务): +诊断顺序(先确认版本再查业务): ```bash -# 1) schema 版本已迁移(期望 29)且账号已导入 +# 1) schema 版本已迁移(期望 32)且账号已导入 sqlite3 data/coding2api.sqlite3 "PRAGMA user_version; SELECT username, role, enabled FROM users;" # 2) 路由存在:期望 401(未登录),404 = 旧后端进程 curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users ``` -**结论**:改 `src/` 必须重启进程;改 `web/` 只需重新构建。升级 schema 时重启同时完成迁移与引导(bootstrap 幂等)。 +**结论**:改 `src/` 须重启;改 `web/` 只需重建。升级 schema 时重启同时完成迁移与引导(幂等)。 + ### 13.5 日志 -应用只写 **stdout / stderr**,不写文件、不自行轮转:部署形态与采集方式各异,都靠这两个 fd 对接,轮转交给平台工具(Docker json-file 驱动 / journald 自带 / logrotate,模板见 `deploy/`)。**必须在 `build_app` 里配 root logger**:uvicorn 默认 `LOGGING_CONFIG` 只配 `uvicorn` / `uvicorn.access`(`propagate=false`),**从不配 root**;root 默认 `WARNING` 且无 handler,导致 `logging.getLogger(__name__)` 的 INFO 静默丢失。生产路径 `uvicorn src.main:build_app --factory` 不经过 `run()`,所以配置必须挂在 `build_app`(幂等,见 `src/webapp/logging.py`)。级别用 `LOG_LEVEL`(默认 `INFO`);**审计日志是 INFO 级**,调到 `WARNING` 会把它一并关掉。 +只写 **stdout / stderr**,不写文件、不自行轮转(轮转交 Docker json-file / journald / logrotate,模板见 `deploy/`)。**须在 `build_app` 配 root logger**:uvicorn 默认 `LOGGING_CONFIG` 只配 `uvicorn` / `uvicorn.access`(`propagate=false`),root 默认 WARNING 且无 handler → `logging.getLogger(__name__)` 的 INFO 静默丢失;生产路径不经 `run()`,故配置挂 `build_app`(幂等)。级别 `LOG_LEVEL`(默认 `INFO`);**审计日志是 INFO**,调到 WARNING 会一并关掉。 + ### 13.6 账号体系与审计 -账号存在 SQLite 的 `users` 表(`admin` / `operator` / `viewer` 三角色)。**三层引导**:① 导入 `users.txt`;② 把 `ADMIN_USERNAMES` 里点名的用户提权为 `admin`(该 env 只剩**引导期**语义);③ **仍无活跃 admin 则启动失败**。`require_admin` 管用户管理、配置、审计、运维告警;`require_operator` 管凭证写操作与只读运维页(上游状态 / 模型列表,admin 与 operator 都放行);`Principal.is_operator` 是派生 property,统计口径同样以 `is_operator` 为界。**会话吊销不建会话表**:`users.session_epoch` 进签名 Cookie 的 `ep` 声明,改密 / 降级 / 禁用/启用 / 硬删一律 `session_epoch + 1`,`principal_from_request` 每请求比一次,旧 Cookie 当场失效。**角色不进 Cookie,每请求现读 DB**(塞进已签名 Cookie 会让降级延迟生效,与「降级必须立即可信」冲突)。向后兼容:老 Cookie 无 `ep` 按 0 处理。**一次性激活令牌**:管理台建号/重置密码时写随机占位密码 + 32 字节 URL-safe 令牌,库里只存 SHA-256 摘要 + 过期时间(TTL 24h),明文只在创建/重置响应里回显一次,前端拼成 `/activate?token=...`。用户在 `/activate` 自设密码即完成激活。**首登强制改密**:`must_change_password=1` 时 `principal_from_request` 用**精确白名单**(`GET /api/auth/session`、`POST /api/auth/password`、`POST /api/auth/logout`)放行,其余端点返回 403 `password_change_required`。**审计**:`audit_events(id, ts, actor, action, target, detail, ip, ok)`,动作常量集中在 `audit/actions.py`。覆盖登录成功/失败、账号建/激活/改角色/启停/改密/重置/硬删、凭证导入/删除/启停/pin/复活/切换账号、告警人工标记恢复。**绝不记密码或令牌明文**。保留期与 `usage_events` 同入口,由 `RetentionTask` 统一裁剪。**防锁死**三层:Web 端 `_guard_last_admin` + `SelfTargetError`;CLI 端删最后活跃 admin 拒绝;bootstrap 无活跃 admin 启动失败。硬删只在 `scripts/create_user.py --delete --force`(`usage_events.username` 是裸文本无外键,硬删会留下查不到用户名的孤儿统计,故 UI 不暴露 DELETE)。 +三层引导:① 导入 `users.txt`;② `ADMIN_USERNAMES` 点名提权为 `admin`(只剩引导期语义);③ **仍无活跃 admin → 启动失败**。`require_admin` 管用户管理、配置、审计、告警;`require_operator` 管凭证写与只读运维页;`Principal.is_operator` 是派生 property,统计口径同界。 + +**会话吊销不建会话表**:`users.session_epoch` 进 Cookie 的 `ep` 声明,改密 / 降级 / 启停 / 硬删一律 `+1`,`principal_from_request` 每请求比一次。**角色不进 Cookie,每请求现读 DB**(塞进已签名 Cookie 会让降级延迟生效);老 Cookie 无 `ep` 按 0 处理。**整库重建**(服务器重置 / 换机)时新库 epoch 从 0 重来,只比 `ep` 会让上一个库签的 Cookie 重新生效(issue #37)——故同时校验 `iat` 不早于当前账号 `created_at`(留 60s 时钟余量);老 Cookie 无 `iat` 跳过该项。 + +**一次性激活令牌**:建号 / 重置写随机占位密码 + 32 字节 URL-safe 令牌;库里只存 SHA-256 摘要 + 过期时间(TTL 24h);明文只在响应回显一次,前端拼 `/activate?token=...`。**首登强制改密**:`must_change_password=1` 时精确白名单放行(`GET /api/auth/session`、`POST /api/auth/password`、`POST /api/auth/logout`),其余 403 `password_change_required`。 + +**审计** `audit_events(id, ts, actor, action, target, detail, ip, ok)`,动作常量在 `audit/actions.py`。覆盖登录成功/失败、账号建/激活/改角色/启停/改密/重置/硬删、凭证导入/导出/删除/启停/pin/复活/切换账号、告警人工恢复,以及**API Key 发/删**(`api_key.create` / `api_key.delete`:明文只回显一次,事后只能靠审计行追责)、**用户策略**(`user.policy`)、**运行时配置**(`settings.update`,含 kill switch 与请求体落盘开关)、**明细导出**(`data.export`)。**绝不记密码或令牌明文**。保留期同 `usage_events`。 + +**用户级策略(`users.allowed_models` / `users.quota`)**:`PUT /api/users/{username}/policy` 按人设出站策略,Playground(免 Key 会话直连)与 `/v1`(API Key)两条入口都判。模型白名单走与 Key 相同的 `ensure_model_allowed_for`(含「省略模型名即默认模型」的绕过口子),且与 Key 自己的白名单**同时**生效(两个都要放行)——只看 Key 的话,用户自签一个空白名单的 Key 就绕开了按人设限;`/v1/models` 与单模型查询也按两层过滤,避免「列表看得到、调用 400」。配额走独立的 `services.user_quota`,用量按**用户名**聚合(该用户经任何入口的流量都计入)。缺这层时 viewer 用 Cookie 就能直调任意模型、绕过 Key 的白名单与配额。 + +**kill switch(`gateway_paused` / `gateway_pause_reason`,热更)**:拦在 API Key 鉴权依赖里(`api/deps._ensure_not_paused`)而不是各端点——新端点不会漏拦;返 503 `service_paused` 受控文案(原因只进日志),管理台走会话鉴权因此**暂停期间仍可关掉开关**。开关动作进审计并单独记 WARNING。 + +**请求体诊断落盘(`dump_request_bodies`,热更)**:全服务唯一把对话写进磁盘的开关,目录 0700 / 文件 0600 (含对已存在目录的收紧)、保留 200 份且不超过 24h;开关动作进审计并单独记 WARNING。**防锁死三层**:Web 端 `_guard_last_admin` + `SelfTargetError`;CLI 删最后活跃 admin 拒绝;bootstrap 无活跃 admin 启动失败。硬删只在 `scripts/create_user.py --delete --force`(`usage_events.username` 裸文本无外键 → 硬删留孤儿统计 → UI 不暴露 DELETE)。 -## 14. 已知取舍备忘 +## 14. 已知取舍 - **同步 sqlite3 而非 aiosqlite**:本地微秒级操作,asyncio 封装开销大于收益。 -- **双 httpx 客户端**:聊天流 `read=None` 防长流截断;短请求总超时 30s 防悬挂;共享 `trust_env=False`。非流式由引擎聚合同一流式上游(无独立 HTTP),悬挂兜底是引擎层聚合整体超时(`UPSTREAM_COMPLETE_TIMEOUT_SECONDS`,默认 600s,超时按瞬态错误换号重试)与事件条数上限(`complete_max_events`,防「快而无穷」)。 -- **手写 SQL 而非 ORM**:11 张表规模下 ORM 收益为负。 -- **polling OAuth 不转回调**:上游协议决定;TRAE 回调走主端口 + `PUBLIC_BASE_URL`。`/authorize` 无鉴权(浏览器 302 不带 key),防滥用靠三条:无进行中登录一律拒绝;待完成登录 600s TTL(长期挂着的 pending 会被同网络任何人用自己的 refreshToken 兑换——凭证入池、归属记为发起登录的管理员);回调必须带**与本次 pending 匹配的 `loginTraceID`**(TRAE 原样回显登录 URL 里的 `(machine_id + device_id)[-16:]`,只有发起那次拿得到)。**刻意不用 Origin/Referer 判同源**:TRAE 回调本就由 `www.trae.cn` 跨源 302 跳来,https 部署下还会因协议降级 / `Referrer-Policy` 丢头,按 Referer 判既拦不住真攻击(加 `no-referrer` 即绕过)又可能误杀真回调。`app.state` 只保留 pending 的 state,不驻留含 refreshToken 的完整回调 URL。 -- **Responses / Anthropic 出口只做各自客户端的子集**:不做 `store=true` / `previous_response_id`(服务端无状态);Responses 的 `include=["reasoning.encrypted_content"]` 接受并忽略,终止事件不发 `[DONE]` 哨兵。Responses 出口经官方 SDK 契约验证并对真实 CB 上游冒烟,**未经真实 Codex CLI 端到端验证**。 -- **effort 档位映射默认不做,但缺省补值 + 可选机制**:客户端给什么档位上游都接受,硬编码模型白名单与实际命名无关;但客户端**完全不发** `reasoning_effort` 时上游退化成「可见推演」,故 `stream_chat` 用 `setdefault` 补 `medium`——只补缺省、不覆盖显式值。**内置默认矩阵仍然不做**(未核实上游接受性);改为提供默认关闭的机制:出站 `REASONING_EFFORT_MAP`(按渠道改写客户端显式档位)、Anthropic 入站 `ANTHROPIC_THINKING_EFFORTS`(`thinking.budget_tokens` → 档位)、`FORCE_DISABLE_THINKING`(响应侧剥离思考内容)。三者默认关 / 留空 = 现状逐字节不变,见 §3.11。 -- **Zen / Kilo 无凭证渠道用「虚拟凭证行」而非特判**:调度 / 冷却 / 统计 / 粘性全部按 `credentials` 行工作,种一条空凭证(`added_by="system"`)比在每个环节加「无凭证 provider」分支代价小,也让它天然出现在管理台凭证池里可暂停 / 探测 / 删除。`probe_quota` 恒 `probe_failed=True`(health `None` = 未知,**不是耗尽**)。种子只在默认装配路径执行且幂等;删除后重启复活是刻意的。 -- **Zen 门禁伪装注入 `bash`/`read` 是「对上游撒谎、对客户端诚实」**:上游要求 tools 含这两者,客户端未必声明过,所以注入只补缺。回包对**本次注入的名字**按 `index` 跟踪分片:客户端有同义工具就改写成同名透传,没有才过滤并收敛 `finish_reason`。用户自带同名工具时既不注入也不处理。 -- **内容风控指纹清洗只改出站副本**:`system`/`assistant` 正文命中指纹串时替换为占位符,客户端历史不受影响,避免 11128 持续拒绝。 -- **统计一律以 `usage_hourly` 为准**:`overview` / `by_provider` / `timeline` / `model-timeline` 均读小时汇总,只有 `events` 读明细。代价:最近 ≤5 分钟未进汇总的请求不计入,刷新一次即可。 -- **成本是下限**:匹配不到刊例价或未上报输入 token 的不计入(聚合值 `null` 显示 `—`,不拿 0 冒充免费),历史行不随价表 / 汇率重算。 -- **应用日志只写 stderr,轮转交给平台**:应用自己写文件会与平台轮转争抢同一文件,容器里还会写进镜像层。 -- **批量运维端点收「已有单条端点」的动作,不做通用批量通道**:`POST /api/credentials/batch` 只支持 `probe` / `toggle` / `checkin` / `revive`。`refresh`(token 预刷新)没有单条入口、且由后台按到期窗口驱动,批量强刷会白耗一次性票据;`delete` 破坏性太强,需要单独的二次确认流。批量动作与单条端点共用同一份实现(`probe_credential_once` / `checkin_credential_once`),避免「批量版」与「单条版」各写一套后探测失败的 reason 翻译只在一处修好。 -- **批量 `toggle` 传显式 `enabled` 而非「各自取反」**:混选「已启用 + 已暂停」时取反会让一半号走向相反方向,而操作员点「暂停」的意图只有一个。 -- **批量审计只记一条汇总**(`toggle` / `revive`):50 个号的批量暂停是一次操作、一个意图,逐条落库会把审计流水刷成噪声。`probe` / `checkin` 与单条端点同为只读语义、不写审计。 -- **批量 `probe` / `checkin` 用信号量限制在途数,不复用后台 Pacer**:Pacer 是跨后台任务的严格串行化节流,批量端点由操作员手动触发,完全串行会让 50 个号等上几分钟才返回。折中是 `BATCH_CONCURRENCY=4` 的在途上限——既不打爆上游,也不长时间挂住请求。`toggle` / `revive` 是本地 DB 写,无上游压力,不受该上限约束。 -- **窄屏表格卡片化改样式不改 DOM**(`Table cards` + `styles.css`「窄屏表 → 卡片」):列多的表格(凭证 7 列、明细 13 列)在 <768px 从横向滚动改为「一行 = 一张卡」。**刻意不做「按视口渲染两套 JSX」**——六张表被页面测试按 `xxx-table` / 行 testid 断言,DOM 一变「同一行」的语义就没了,读屏也会拿到一份重复内容。字段名走 `TableCell label` → `data-label` + CSS `::before`(不新增节点)。**`display:block` 会让 Chromium/WebKit 丢掉隐式表格语义**,故卡片模式打开时由 `TableCardsContext` 给 table/thead/tbody/tr/th/td 补显式 `role`(`table`/`rowgroup`/`row`/`columnheader`/`cell`);不开卡片模式的表格不加任何 `role`,沿用浏览器隐式语义。列宽约束(`w-8` / `max-w-*`)在卡片模式下被统一放开,否则「字段名 : 值」会被挤成两行。断点与 Tailwind `md` 对齐(`767.98px`)。 -- **一个渠道一个组**:`web/src/lib/credentialList.ts` 的 `groupByProvider()` 按真实 `provider` 聚合——`ProviderGroup.key === provider`,`label` 就是渠道展示名(曾有的 CodeArts「内置 / 福利」虚拟渠道拆分随福利链路一起下线)。 - - 组上的 `ledger` 由 `providerLedger()` 派生:`codearts` → `credits`(额度来自积分接口,内置模型按积分计费),其余 → `pool`(套餐 / 每日余额);组头额度合计标签因此对 CodeArts 写「积分合计」、其余写「额度合计」。行内额度数字、口径悬浮说明(`quotaSemantics`)与变动流水入口(`GET /api/credentials/{id}/credit-events?ledger=`)都按 `ledger` 取;`credentialState(credential, now?)` 只认主账 `health`。**为什么后端不真拆**:`refresh_token` 一次性且与 `client_id` + DPoP 私钥绑定,两条凭证行会各刷一次、第二条必报 `the refresh token has been used` 并烧掉票据。 +- **双 httpx 客户端**(流式 / 短请求分离,共享 `trust_env=False`,见 §1、§13.3):聊天流 `read=None` 防长流截断;短请求总超时 30s 防悬挂。非流式由引擎聚合同一流式上游;悬挂兜底 = `UPSTREAM_COMPLETE_TIMEOUT_SECONDS`(默认 600s,超时按瞬态错误换号)+ 事件条数上限(`complete_max_events`)。 +- **手写 SQL 而非 ORM**:14 张表规模下 ORM 收益为负。 +- **polling OAuth 不转回调**:上游协议决定。TRAE 回调走主端口 + `PUBLIC_BASE_URL`(基址为回环 / `0.0.0.0` 时用请求自身 origin)。`/authorize` 无鉴权,防滥用三条:无进行中登录一律拒;pending 600s TTL;回调须带匹配 `loginTraceID`。绑定优先会话 Cookie;Cookie 不在回调入口(LAN 发起 / 隧道回调)时按 `loginTraceID` 反查(trace 只见于发起那次 auth_url,等价一次性凭证)。浏览器顶层导航(`Accept: text/html`)返回中文说明页,程序化客户端拿 JSON。**刻意不用 Origin/Referer 判同源**(回调本就跨源 302,`no-referrer` 可绕过)。`app.state` 只保 pending state。回调地址在浏览器侧打不开(NAS / 远程部署常见「网络错误」,refreshToken 只留在地址栏)时走粘贴兜底:`POST /api/auth/upstream/complete`(已鉴权)按会话用户名取同一条 pending,校验 state 与 `loginTraceID` 后调 `provider.complete_callback`;`/upstream/cancel` 对回调轨道只清 pending。 +- **effort 映射默认不做,但缺省补值 + 可选机制**:`setdefault` 补 `medium`(只补缺省不覆盖显式值);内置默认矩阵不做,改为默认关闭的三项机制(§3.11)。 +- **Zen / Kilo 用虚拟凭证行而非特判**:调度 / 冷却 / 统计 / 粘性全按 `credentials` 行工作;种一条空凭证比在每个环节加「无凭证 provider」分支代价小。细节见 §5.1。 +- **Zen 注入 `bash`/`read` 是「对上游撒谎、对客户端诚实」**:只补缺;回包按 `index` 跟踪分片,有同义工具就改写透传,没有才过滤并收敛 `finish_reason`。 +- **Responses / Anthropic 出口只做各自客户端子集**(细节见 §3.7、§3.8):不做 `store=true` / `previous_response_id`;不发 `[DONE]`;**未经真实 Codex CLI 端到端验证**。 +- **内容风控指纹清洗只改出站副本**:客户端历史不受影响(§3.2)。 +- **统计、成本、日志三条口径**(细节见 §9.7、§13.5):统计以 `usage_hourly` 为准,代价是最近 ≤5 分钟未进汇总的不计入;成本是下限(匹配不到或未上报输入 token 不计,`null` 显示 `—`,不拿 0 冒充免费);应用日志只写 stderr(自己写文件会与平台轮转争抢,容器里还会写进镜像层)。 +- **批量运维只收「已有单条端点」的动作**:`probe` / `toggle` / `checkin` / `revive`。`refresh` 无单条入口且强刷白耗一次性票据;`delete` 破坏性太强需二次确认。批量与单条共用实现(`probe_credential_once` / `checkin_credential_once`);`toggle` 传显式 `enabled` 而非各自取反(混选时取反会让一半号走向相反方向)。 +- **批量审计只记一条汇总**(`toggle` / `revive`):50 个号是一次操作、一个意图;`probe` / `checkin` 只读、不写审计。 +- **批量 `probe` / `checkin` 用信号量限制在途**(`BATCH_CONCURRENCY=4`),不复用后台 Pacer:全串行会让 50 个号等几分钟。`toggle` / `revive` 是本地 DB 写,不受限。 +- **窄屏表格卡片化改样式不改 DOM**:`<768px` 一行 = 一张卡;不做「按视口渲染两套 JSX」(页面测试按 testid 断言,DOM 一变换语义就没了)。字段名走 `TableCell label` → `data-label` + CSS `::before`。`display:block` 会让 Chromium/WebKit 丢隐式表格语义 → 卡片模式由 `TableCardsContext` 补显式 `role`;不开卡片模式的表格不加 `role`。断点对齐 Tailwind `md`(`767.98px`)。 +- **一个渠道一个组**:`web/src/lib/credentialList.ts` 的 `groupByProvider()` 按真实 `provider` 聚合。组上 `ledger` 由 `providerLedger()` 派生:`codearts` → `credits`,其余 → `pool`;组头额度合计标签对 CodeArts 写「积分合计」。行内额度、口径悬浮(`quotaSemantics`)与流水入口都按 `ledger` 取;`credentialState()` 只认主账 `health`。**后端不真拆**:`refresh_token` 一次性且与 `client_id` + DPoP 私钥绑定 → 两条凭证行各刷一次、第二条必报 `the refresh token has been used` 并烧掉票据。 diff --git a/assets/screenshots/alerts.webp b/assets/screenshots/alerts.webp new file mode 100644 index 00000000..a80077ab Binary files /dev/null and b/assets/screenshots/alerts.webp differ diff --git a/assets/screenshots/apikeys.webp b/assets/screenshots/apikeys.webp new file mode 100644 index 00000000..4ec6df0d Binary files /dev/null and b/assets/screenshots/apikeys.webp differ diff --git a/assets/screenshots/audit.webp b/assets/screenshots/audit.webp new file mode 100644 index 00000000..ce3204b8 Binary files /dev/null and b/assets/screenshots/audit.webp differ diff --git a/assets/screenshots/credentials.webp b/assets/screenshots/credentials.webp new file mode 100644 index 00000000..7b58dc30 Binary files /dev/null and b/assets/screenshots/credentials.webp differ diff --git a/assets/screenshots/logs.webp b/assets/screenshots/logs.webp new file mode 100644 index 00000000..6b62066f Binary files /dev/null and b/assets/screenshots/logs.webp differ diff --git a/assets/screenshots/models.webp b/assets/screenshots/models.webp new file mode 100644 index 00000000..7af1c35d Binary files /dev/null and b/assets/screenshots/models.webp differ diff --git a/assets/screenshots/playground.webp b/assets/screenshots/playground.webp new file mode 100644 index 00000000..6430ae85 Binary files /dev/null and b/assets/screenshots/playground.webp differ diff --git a/assets/screenshots/settings.webp b/assets/screenshots/settings.webp new file mode 100644 index 00000000..c6be27a2 Binary files /dev/null and b/assets/screenshots/settings.webp differ diff --git a/assets/screenshots/stats.webp b/assets/screenshots/stats.webp new file mode 100644 index 00000000..b24d2b8e Binary files /dev/null and b/assets/screenshots/stats.webp differ diff --git a/assets/screenshots/upstream.webp b/assets/screenshots/upstream.webp new file mode 100644 index 00000000..92ac34a2 Binary files /dev/null and b/assets/screenshots/upstream.webp differ diff --git a/assets/screenshots/users.webp b/assets/screenshots/users.webp new file mode 100644 index 00000000..235cb143 Binary files /dev/null and b/assets/screenshots/users.webp differ diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 00000000..32bae746 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,58 @@ +# 公网入口(Docker):Caddy 终止 TLS,反代到同网络的 coding2api:PORT。 +# +# 由 `docker compose -f docker-compose.yml -f docker-compose.public.yml` 启动, +# 环境变量来自 compose(PUBLIC_DOMAIN / ACME_EMAIL / TLS_MODE / PORT,见 .env)。 +# 证书与 ACME 账号落 /data(compose 具名卷 caddy_data)——**别 `down -v`**, +# 清掉会重新申请,撞 Let's Encrypt 每周同域名 5 次的下发限额。 +# +# TLS 两种模式(站点块首行按 TLS_MODE 二选一 import): +# - acme(默认):站点地址是域名(或 sslip.io / nip.io 这类通配 DNS 名)→ +# 自动向 Let's Encrypt 申请 / 续期,ACME_EMAIL 收到期通知。 +# - internal:站点地址是**公网 IP**、没有域名 → Caddy 用自带 CA 自签。 +# 证书浏览器不认(告警 / 需 `curl -k` 或装根证书),但链路仍是加密的。 +# Caddy 对 IP 站点地址本来就不会走 ACME,`tls internal` 只是显式声明。 +# +# 本地校验语法(不起服务、不联网签发): +# docker run --rm -e PUBLIC_DOMAIN=ai-api.example.com -e ACME_EMAIL=ops@example.com \ +# -e PORT=8000 -e TLS_MODE=acme -v "$PWD/deploy/caddy:/etc/caddy:ro" \ +# caddy:2.11.7-alpine caddy validate --config /etc/caddy/Caddyfile + +{ + # 无 SNI 的客户端要有个默认站点名来选证书。浏览器与 curl 对 **IP 字面量** + # 一律不发 SNI(RFC 6066 只允许主机名),自签模式(站点是公网 IP)没有这行 + # 握手会直接失败(客户端只看到 `tlsv1 alert internal error`)。 + default_sni {$PUBLIC_DOMAIN} +} + +{$PUBLIC_DOMAIN} { + # tls_acme.caddyfile:issuer acme + ACME_EMAIL;tls_internal.caddyfile:tls internal + import /etc/caddy/tls_{$TLS_MODE}.caddyfile + + # /metrics 默认无鉴权(可选 METRICS_TOKEN,见 .env.example),公网入口直接摘掉, + # 需要时从宿主 127.0.0.1:${PORT} 直取(或自己按令牌放行)。 + @metrics path /metrics + respond @metrics 404 + + # 压缩:SSE 响应不落这条(Caddy 跳过 event-stream) + encode zstd gzip + + # 上游超时一律交给应用自己的口径(STREAM_*_TIMEOUT_SECONDS / + # UPSTREAM_COMPLETE_TIMEOUT_SECONDS)——反代再加一层会先掐断长请求 + reverse_proxy coding2api:{$PORT} { + # 关掉响应缓冲:逐帧下发,否则流式首字要等到缓冲区满 + flush_interval -1 + # 客户端可自带 XFF 伪造来源 IP(API Key 的 allowed_ips):只认直连对端, + # 覆盖式写入单值(Caddy 默认也会忽略入站 X-Forwarded-For,这行是显式挡一层)。 + # TRUST_PROXY=true 才让应用采信它。 + header_up X-Forwarded-For {http.request.remote.host} + header_up X-Forwarded-Proto {scheme} + transport http { + dial_timeout 10s + } + } + + log { + output stdout + format console + } +} diff --git a/deploy/caddy/tls_acme.caddyfile b/deploy/caddy/tls_acme.caddyfile new file mode 100644 index 00000000..464a65b0 --- /dev/null +++ b/deploy/caddy/tls_acme.caddyfile @@ -0,0 +1,13 @@ +# TLS 模式 acme(TLS_MODE=acme,默认):站点地址是域名 → 自动申请 / 续期证书。 +# +# 由 deploy/caddy/Caddyfile 的站点块 import。显式写 issuer 是为了固定签发方: +# 不写时 Caddy 默认会同时注册 Let's Encrypt 与 ZeroSSL 两个 ACME 账号(后者 +# 多数情况用不上,却会占一个账号)。只留 Let's Encrypt,靠站点地址判定签发。 +# +# ACME_EMAIL 必填(域名模式):既是 Let's Encrypt 的到期通知收件人,也是 ACME +# 账号标识。留空会在这里解析失败——报错指向本文件,属预期。 +tls { + issuer acme { + email {$ACME_EMAIL} + } +} diff --git a/deploy/caddy/tls_internal.caddyfile b/deploy/caddy/tls_internal.caddyfile new file mode 100644 index 00000000..b0cf9d35 --- /dev/null +++ b/deploy/caddy/tls_internal.caddyfile @@ -0,0 +1,11 @@ +# TLS 模式 internal(TLS_MODE=internal):站点地址是公网 IP、没有域名时用。 +# +# 由 deploy/caddy/Caddyfile 的站点块 import。Caddy 用自带 CA 自签证书: +# 链路加密,但证书不在任何公共信任库里——浏览器告警,命令行客户端要 `-k` +# 或把根证书装进客户端信任(根证书在 caddy_data 卷里: +# `/data/pki/authorities/local/root.crt`)。 +# +# 想要**被信任**的证书又没有自己的域名:把 PUBLIC_DOMAIN 填成 sslip.io / nip.io +# 这类公共通配 DNS 名(如 `203-0-113-10.sslip.io`,它解析到 203.0.113.10), +# 不用改模式、Caddy 照常走 ACME。详见 README「公网部署(Docker)」。 +tls internal diff --git a/deploy/launchd/com.coding2api.logrotate.plist b/deploy/launchd/com.coding2api.logrotate.plist new file mode 100644 index 00000000..43073bae --- /dev/null +++ b/deploy/launchd/com.coding2api.logrotate.plist @@ -0,0 +1,60 @@ + + + + + + Label + com.coding2api.logrotate + + ProgramArguments + + /bin/bash + __PROJECT_ROOT__/scripts/rotate-logs.sh + + + WorkingDirectory + __PROJECT_ROOT__ + + + StartCalendarInterval + + + Hour0 + Minute17 + + + Hour6 + Minute17 + + + Hour12 + Minute17 + + + Hour18 + Minute17 + + + + + RunAtLoad + + + + StandardOutPath + __PROJECT_ROOT__/logs/logrotate.out.log + StandardErrorPath + __PROJECT_ROOT__/logs/logrotate.err.log + + diff --git a/deploy/launchd/com.coding2api.plist b/deploy/launchd/com.coding2api.plist index c69996c3..ae09b4c4 100644 --- a/deploy/launchd/com.coding2api.plist +++ b/deploy/launchd/com.coding2api.plist @@ -27,13 +27,29 @@ RunAtLoad - + KeepAlive - + + SuccessfulExit + + ThrottleInterval 10 + + ExitTimeOut + 95 + StandardOutPath __PROJECT_ROOT__/logs/launchd.out.log StandardErrorPath diff --git a/deploy/newsyslog/coding2api.conf b/deploy/newsyslog/coding2api.conf deleted file mode 100644 index 4cccd67f..00000000 --- a/deploy/newsyslog/coding2api.conf +++ /dev/null @@ -1,28 +0,0 @@ -# macOS 日志轮转规则(newsyslog) -# -# 安装(需 root,newsyslog 只读 /etc/newsyslog.d/): -# ./scripts/install-newsyslog.sh # 安装 + 立即生效 -# ./scripts/install-newsyslog.sh --uninstall -# 脚本会用本机当前用户与仓库实际路径替换下面的占位符后写入 -# /etc/newsyslog.d/,因此克隆到任意目录 / 任意用户名都能直接用。 -# -# 字段含义(newsyslog.conf(5),已核 man page): -# logfilename owner:group mode count size(KB) when flags -# -# size=10240 单文件超 10MB 即轮转 -# when=* 仅依据 size 轮转(不按时间);磁盘占用只与总量相关 -# count=7 最多保留 7 份归档(不含当前日志) -# flags=J 归档用 bzip2 压缩 -# flags=N 轮转后不发信号(进程以 >> 追加模式持有文件句柄, -# newsyslog 轮转后新文件同名,进程继续写入) -# -# 注意:日志路径必须与 launchd plist 的 StandardOutPath / StandardErrorPath -# 一致。改 plist 时同步改这里。 -# 本仓库的两个 plist 各自写两路日志,因此这里给四条规则: -# com.coding2api.plist → launchd.out.log / launchd.err.log -# com.coding2api.frpc.plist → frpc.out.log / frpc.err.log - -__PROJECT_ROOT__/logs/launchd.out.log __LOG_OWNER__ 644 7 10240 * JN -__PROJECT_ROOT__/logs/launchd.err.log __LOG_OWNER__ 644 7 10240 * JN -__PROJECT_ROOT__/logs/frpc.out.log __LOG_OWNER__ 644 7 10240 * JN -__PROJECT_ROOT__/logs/frpc.err.log __LOG_OWNER__ 644 7 10240 * JN diff --git a/deploy/nginx/coding2api.conf b/deploy/nginx/coding2api.conf index e3d290dc..ef7dfb3a 100644 --- a/deploy/nginx/coding2api.conf +++ b/deploy/nginx/coding2api.conf @@ -36,10 +36,24 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; - # 与应用的 BodySizeLimitMiddleware 对齐(16MB):nginx 默认 1m 会**先于** - # 应用返回 413,且错误页不是应用约定的 JSON 形状,客户端拿到的是 nginx 的 - # HTML。两侧同值才能让超限统一由应用裁决。 + # 与应用的 BodySizeLimitMiddleware 对齐(默认 16MB):nginx 默认 1m 会 + # **先于**应用返回 413,且错误页不是应用约定的 JSON 形状,客户端拿到的是 + # nginx 的 HTML。两侧同值才能让超限统一由应用裁决。 client_max_body_size 16m; + # 备份恢复例外:端点是 512MB(`webapp/limits.BACKUP_UPLOAD_LIMIT`), + # 这里不单独放宽的话,公网入口下大库的「恢复备份」永远 413 且报的是 + # nginx 的 HTML 页。 + location = /api/backups/restore { + client_max_body_size 512m; + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } location / { proxy_pass http://127.0.0.1:8000; diff --git a/deploy/systemd/coding2api.service b/deploy/systemd/coding2api.service index 082600fa..ab261fcb 100644 --- a/deploy/systemd/coding2api.service +++ b/deploy/systemd/coding2api.service @@ -37,13 +37,13 @@ Environment=HOST=127.0.0.1 Environment=PORT=8000 Environment=DATA_DIR=/opt/coding2api/data -ExecStart=/opt/coding2api/.venv/bin/python -m uvicorn src.main:build_app --factory --timeout-graceful-shutdown 30 -Restart=always +ExecStart=/opt/coding2api/.venv/bin/python -m src.main +Restart=on-failure RestartSec=5 -# SIGTERM 后在途请求排空窗口:必须 >= uvicorn 的 --timeout-graceful-shutdown(30), -# 否则 systemd 会在 uvicorn 排空完成前 SIGKILL,长流请求被硬断、统计落不下来。 -TimeoutStopSec=35 +# SIGTERM 后在途请求排空窗口:必须 >= src/main.py GRACEFUL_SHUTDOWN_SECONDS(90), +# 否则 systemd 会在排空完成前 SIGKILL,长流请求被硬断、统计落不下来。 +TimeoutStopSec=95 # 日志交给 journald(自带轮转) StandardOutput=journal diff --git a/design-system/coding2api/MASTER.md b/design-system/coding2api/MASTER.md index 43a8596b..3920cf65 100644 --- a/design-system/coding2api/MASTER.md +++ b/design-system/coding2api/MASTER.md @@ -1,31 +1,29 @@ # Design System — Coding2API 管理台 -> **LOGIC:** 构建具体页面前,先看 `design-system/coding2api/pages/[page-name].md`。 -> 存在则其规则**覆盖**本文件;否则严格遵循本文件。 +> **LOGIC:** 全仓唯一设计规范,建页一律遵循本文件(早期按页面拆分的 `pages/*.md` 从未落地,已废弃)。 --- **Project:** Coding2API -**Updated:** 2026-10-22 +**Updated:** 2026-10-30(对照 `web/src/styles.css` 与实际组件核过) **Category:** Developer tool / API gateway control plane **Source of truth:** `web/src/styles.css`(token) + `web/src/ui/index.tsx`(组件适配层;底层原语在 `web/src/components/ui/`) -> ⚠️ 本文件是**手工核定**的设计系统,不是 `search.py --design-system` 的自动输出。 -> 改 token 时**先改 `styles.css`**,再回写本表——不要反向操作。 +> ⚠️ 本文件**手工核定**,非 `search.py --design-system` 自动输出。 +> 改 token **先改 `styles.css`** 再回写本表——不要反向操作。 --- ## 1. 设计方向:「Control Plane」工业化控制台 -产品故事是**多路渠道汇入单一出口**(gateway)。视觉语言要传达:**实时、可信、可扫读**。 +产品故事:**多路渠道汇入单一出口**(gateway)。视觉语言:**实时、可信、可扫读**。 -- **保留青绿品牌色**:是产品故事的视觉锚,不做通用蓝/紫。 -- **表面分层 > 装饰**:层级靠背景/卡面/浮层的明度梯度表达,不堆阴影或渐变。 +- **保留青绿品牌色**:视觉锚,不做通用蓝/紫。 +- **表面分层 > 装饰**:层级靠背景/卡面/浮层明度梯度,不堆阴影或渐变。 - **克制的技术质感**:细刻度、通道色条、等宽数字。**不做** HUD/霓虹/发光。 - **暗色是一等公民**:分层用明度梯度(非阴影),单独校验对比度。 -**反模式(禁用)**:emoji 当图标、低对比灰字、布局位移的 hover、`z-[9999]` 乱值、 -颜色作唯一状态指示、无 `prefers-reduced-motion` 降级。 +**反模式(禁用)**:emoji 当图标、低对比灰字、布局位移的 hover、`z-[9999]` 乱值、颜色作唯一状态指示、无 `prefers-reduced-motion` 降级。 --- @@ -37,24 +35,21 @@ |---|---|---|---| | `--primary` | `oklch(0.52 0.13 190)` | `oklch(0.74 0.12 190)` | 品牌主色(青绿) | | `--primary-foreground` | `oklch(0.99 0 0)` | `oklch(0.17 0.012 258)` | 主色上的文字 | -| `--ok` | `oklch(0.54 0.14 155)` | `oklch(0.70 0.14 155)` | 成功 / 可用 | -| `--warn` | `oklch(0.566 0.15 65)` | `oklch(0.78 0.13 70)` | 警告 / 未探测 / 冷却 | -| `--danger` | `oklch(0.55 0.21 25)` | `oklch(0.70 0.20 22)` | 错误 / 已耗尽 | +| `--ok` | `oklch(0.54 0.14 155)` | `oklch(0.70 0.14 155)` | 成功/可用 | +| `--warn` | `oklch(0.566 0.15 65)` | `oklch(0.78 0.13 70)` | 警告/未探测/冷却 | +| `--danger` | `oklch(0.55 0.21 25)` | `oklch(0.70 0.20 22)` | 错误/已耗尽 | | `--ring` | `oklch(0.62 0.11 190)` | `oklch(0.66 0.11 190)` | 焦点环 | -> **对比度约束(亮色)**:语义色若直接当文字用(`text-ok`/`text-warn` 等)必须满足 -> 对白底 ≥4.5:1,故亮色 `--ok`/`--warn` 明度比暗色更低一档。放在同色浅底 -> (`bg-ok/12` 之类)上的文字,额外混入 25% `--ink`(`text-ok-ink` 等,见 -> `styles.css` 的 `--color-*-ink`),因为纯色号在自身浅底上对比会掉到 ~3.4–4.4。 +> **对比度约束(亮色)**:语义色作文字(`text-ok`/`text-warn` 等)对白底 ≥4.5:1 → 亮色 `--ok`/`--warn` 明度低暗色一档。同色浅底(`bg-ok/12` 之类)上的文字混入 25% `--ink`(`text-ok-ink` 等,见 `styles.css` 的 `--color-*-ink`)——纯色号在自身浅底掉到 ~3.4–4.4。 ### 2.2 三级表面(关键) | Token | 亮色 | 暗色 | 用途 | |---|---|---|---| -| `--surface-sunken` | `oklch(0.968 0.004 252)` | `oklch(0.145 0.012 258)` | 页面底 / 内嵌区 / 代码块 | +| `--surface-sunken` | `oklch(0.968 0.004 252)` | `oklch(0.145 0.012 258)` | 页面底/内嵌区/代码块 | | `--surface` | `oklch(0.985 0.003 252)` | `oklch(0.175 0.012 258)` | 页面背景 | -| `--panel` | `oklch(1 0 0)` | `oklch(0.215 0.013 258)` | 卡片 / 面板 | -| `--panel-raised` | `oklch(1 0 0)` | `oklch(0.245 0.014 258)` | 浮层 / 下拉 / 弹窗 | +| `--panel` | `oklch(1 0 0)` | `oklch(0.215 0.013 258)` | 卡片/面板 | +| `--panel-raised` | `oklch(1 0 0)` | `oklch(0.245 0.014 258)` | 浮层/下拉/弹窗 | ### 2.3 文字与描边 @@ -66,8 +61,8 @@ ### 2.4 图表 / 渠道色 -渠道色**随渠道稳定**(`web/src/api/providers.ts` 为唯一来源):`chart-1`..`chart-6`。 -新增渠道只改 `providers.ts`,不要在图表里写死颜色。 +渠道色**随渠道稳定**(`web/src/api/providers.ts` 唯一来源):`chart-1`..`chart-6`。 +新增渠道只改 `providers.ts`,图表不写死颜色。 --- @@ -81,7 +76,7 @@ - 基础字号 16px;正文 `leading-relaxed`(1.625)。 - 正文最小 12px(`text-xs`),不得更小。 -- 数字列一律 `tabular-nums`(避免翻页时列宽抖动)。 +- 数字列一律 `tabular-nums`(防翻页列宽抖动)。 --- @@ -110,23 +105,23 @@ ## 5. 组件规范 -组件统一从 `web/src/ui/index.tsx` 取(它是 shadcn 适配层)。**不要**在页面里直接拼裸样式。 +组件统一取 `web/src/ui/index.tsx`(shadcn 适配层)。**不要**页面直接拼裸样式。 | 组件 | 规范 | |---|---| | `Panel` | 卡片容器;`rounded-xl` + `ring-1 ring-border` + `shadow-sm`;标题行 + `action` 槽 | | `Button` | 变体 `default`(描边)/`primary`/`danger`/`ghost`/`link`;焦点环可见 | | `Badge` | 语义色调 `ok/warn/danger/muted/accent`;**必须带文字**,色不单独表意 | -| `NavBadge` | 侧栏「数据提醒」计数角标:数字用 `tabular-nums`(轮询刷新不抖宽)、`>99` 收敛成 `99+`、值为 0/空不渲染;可见数字 `aria-hidden` + `sr-only` 全语义文案(读屏读「运维告警 3 条告警尚未恢复」而非两段无关的话) | +| `NavBadge` | 侧栏「数据提醒」计数角标:`tabular-nums`(轮询不抖宽)、`>99` → `99+`、0/空不渲染;可见数字 `aria-hidden` + `sr-only` 全语义文案(读屏读「运维告警 3 条告警尚未恢复」,不读两段无关的话) | | `Metric` | KPI 卡:标签 + 大号 `tabular-nums` 数值 + hint;可选 icon 与 tone | | `Notice` | 行内提示;`danger` 用 `role=alert`;左侧语义色条 | -| `ToastViewport` + `useToasts` | 操作结果浮层(顶部居中,`web/src/components/Toast.tsx`):数秒自动消失、可手动关闭;`danger` 用 `role=alert`、其余 `role=status`;同 `testId` 只保留最新一条;语气用 `Badge`/`Notice` 同款 tint 底 + `-ink` 图标(亮色口径色描边叠白底对比过低,不可只用描边) | +| `ToastViewport` + `useToasts` | 操作结果浮层(顶部居中,`web/src/components/Toast.tsx`):数秒自动消失、可手动关;`danger` 用 `role=alert`、其余 `role=status`;同 `testId` 只留最新一条;语气同 `Badge`/`Notice`:tint 底 + `-ink` 图标(亮色口径色描边叠白底对比过低,不可只用描边) | | `EmptyState` | 空态:图标 + 标题 + 描述 + 可选操作 | | `Tabs` | 分段器;激活项 `bg-background` + `shadow-sm` | -| `Table` | 圆角容器 + 表头底色 + 行 hover;移动端横向滚动。窄屏(<768px)可开 `cards` 转卡片:列名变排序片、每格用 `TableCell label` 显示字段名(DOM 不变,见 `styles.css`「窄屏表 → 卡片」) | +| `Table` | 圆角容器 + 表头底色 + 行 hover;移动端横向滚动。窄屏(<768px)`cards` 转卡片:列名变排序片、每格 `TableCell label` 显示字段名(DOM 不变,见 `styles.css`「窄屏表 → 卡片」) | | `PageHeader` | eyebrow(可选)+ 标题 + 说明 + 右侧操作槽 | | `PageSkeleton` | 加载骨架,替代纯文字「载入中…」 | -| `Tip` / `LongTextTip` | 悬浮说明两档表面(`web/src/components/Tip.tsx` → `components/ui/tooltip.tsx` 的 `surface`):**短提示走 `ink`**(反色气泡 + 箭头,一句话强对比);**多行清单 / 明细走 `panel`**(`panel-raised` + `ring-border` + `shadow-lg`,与下拉 / 弹窗同层,**不出箭头**——箭头会盖住描边留缺口)。`panel` 档内容自带「头部(身份 + 条数胶囊)/ 限高内滚清单 / 脚注口径」,左对齐且 `items-start`:多行居中会让每行起点都不同,读不成列 | +| `Tip` / `LongTextTip` | 悬浮说明两档表面(`web/src/components/Tip.tsx` → `components/ui/tooltip.tsx` 的 `surface`):**短提示走 `ink`**(反色气泡 + 箭头,一句强对比);**多行清单/明细走 `panel`**(`panel-raised` + `ring-border` + `shadow-lg`,与下拉/弹窗同层,**不出箭头**——箭头盖住描边留缺口)。`panel` 档自带「头部(身份 + 条数胶囊)/限高内滚清单/脚注口径」,左对齐 `items-start`:多行居中每行起点不同,读不成列 | --- diff --git a/diagrams/account-flow.json b/diagrams/account-flow.json new file mode 100644 index 00000000..8724f609 --- /dev/null +++ b/diagrams/account-flow.json @@ -0,0 +1,449 @@ +{ + "schema_version": 2, + "diagram_type": "workflow", + "meta": { + "title": "账号开通与角色治理流程", + "subtitle": "一次性激活令牌 → 登录签发会话 → 每请求现读角色 → 改角色/禁用即时吊销", + "locale": "zh-CN", + "output": "diagrams/coding2api-account-flow.html", + "quality_profile": "showcase", + "repository": { + "url": "git@github.com:RobbsLuo/Coding2API.git", + "provider": "github", + "link_mode": "local-only", + "revision": "82cf0efd063e13f5323c69940666e9b64a8d1a68" + }, + "views": [ + { + "id": "onboarding", + "label": "开通", + "focus": [ + "a_create", + "s_create", + "d_user", + "u_activate", + "s_activate" + ], + "note": "建号只产生一次性令牌摘要;用户自设密码后令牌立即作废。" + }, + { + "id": "session", + "label": "登录与会话", + "focus": [ + "u_login", + "s_login", + "d_audit", + "s_session", + "s_guard" + ], + "note": "会话 Cookie 带 epoch;角色不入 Cookie,每次请求从库里现读。" + }, + { + "id": "revoke", + "label": "变更与吊销", + "focus": [ + "a_change", + "d_epoch", + "s_guard", + "u_selfpwd" + ], + "note": "改角色、禁用、重置密码都让 epoch +1,旧 Cookie 下一次请求即 401。" + } + ] + }, + "lanes": [ + { + "id": "adm", + "label": "管理员" + }, + { + "id": "usr", + "label": "被邀请用户" + }, + { + "id": "svc", + "label": "账号与鉴权服务" + }, + { + "id": "db", + "label": "数据与审计" + } + ], + "phases": [ + { + "id": "ph_create", + "label": "建号", + "fromCol": 0, + "toCol": 0 + }, + { + "id": "ph_store", + "label": "落库", + "fromCol": 1, + "toCol": 1 + }, + { + "id": "ph_activate", + "label": "激活", + "fromCol": 2, + "toCol": 2 + }, + { + "id": "ph_login", + "label": "登录", + "fromCol": 3, + "toCol": 3, + "variant": "emphasis" + }, + { + "id": "ph_session", + "label": "会话", + "fromCol": 4, + "toCol": 4 + }, + { + "id": "ph_guard", + "label": "鉴权与吊销", + "fromCol": 5, + "toCol": 5, + "variant": "security" + } + ], + "mainPath": [ + "a_create", + "s_create", + "u_activate", + "s_activate", + "u_login", + "s_login", + "s_session", + "s_guard" + ], + "nodes": [ + { + "id": "a_create", + "lane": "adm", + "col": 0, + "type": "frontend", + "label": "新建用户", + "sublabel": "用户名 + 角色 → 一次性链接", + "tag": "带外转交", + "width": 144 + }, + { + "id": "a_change", + "lane": "adm", + "col": 5, + "type": "frontend", + "label": "改角色 / 禁用 / 重置", + "sublabel": "降级必须立刻可信", + "width": 144, + "sources": [ + { + "path": "src/db/repo.py", + "line": 1273, + "label": "role / epoch 更新" + } + ] + }, + { + "id": "u_activate", + "lane": "usr", + "col": 2, + "type": "external", + "label": "打开 /activate", + "sublabel": "自设密码(≥ 8 位)", + "width": 144 + }, + { + "id": "u_login", + "lane": "usr", + "col": 3, + "type": "external", + "label": "登录", + "sublabel": "用户名 + 密码", + "width": 132 + }, + { + "id": "u_selfpwd", + "lane": "usr", + "col": 4, + "type": "external", + "label": "首登强制改密", + "sublabel": "其余端点一律 403", + "width": 144 + }, + { + "id": "s_create", + "lane": "svc", + "col": 1, + "type": "security", + "label": "创建账号", + "sublabel": "随机占位密码 + 令牌摘要", + "tag": "管理员也不知道初始密码", + "width": 144, + "sources": [ + { + "path": "src/api/admin_users.py", + "line": 116, + "label": "create_user" + } + ] + }, + { + "id": "s_activate", + "lane": "svc", + "col": 2, + "type": "security", + "label": "校验令牌", + "sublabel": "摘要匹配 · TTL · 一次性", + "width": 144, + "sources": [ + { + "path": "src/api/activate.py", + "line": 43, + "label": "activate" + } + ] + }, + { + "id": "s_login", + "lane": "svc", + "col": 3, + "type": "security", + "label": "验密与限流", + "sublabel": "PBKDF2 · IP / 用户窗口", + "tag": "先卡传输层再做哈希", + "width": 144, + "sources": [ + { + "path": "src/api/admin_auth.py", + "line": 166, + "label": "login" + } + ] + }, + { + "id": "s_session", + "lane": "svc", + "col": 4, + "type": "backend", + "label": "签发会话 Cookie", + "sublabel": "签名 + session_epoch", + "tag": "角色不进 Cookie", + "width": 144, + "sources": [ + { + "path": "src/auth/session.py", + "line": 27, + "label": "create_session_token" + }, + { + "path": "src/api/admin_auth.py", + "line": 99, + "label": "issue_session" + } + ] + }, + { + "id": "s_guard", + "lane": "svc", + "col": 5, + "type": "security", + "label": "四道校验", + "sublabel": "签名 · 禁用 · epoch · 角色", + "tag": "require_admin / require_operator", + "width": 144, + "sources": [ + { + "path": "src/api/deps.py", + "line": 159, + "label": "principal_from_request" + } + ] + }, + { + "id": "d_user", + "lane": "db", + "col": 1, + "type": "database", + "label": "users 行", + "sublabel": "role · enabled · 摘要 · TTL", + "width": 144, + "sources": [ + { + "path": "src/db/schema.sql", + "line": 270, + "label": "users" + } + ] + }, + { + "id": "d_audit", + "lane": "db", + "col": 3, + "type": "database", + "label": "audit_events", + "sublabel": "登录成败都留痕", + "width": 144, + "sources": [ + { + "path": "src/db/schema.sql", + "line": 303, + "label": "audit_events" + } + ] + }, + { + "id": "d_epoch", + "lane": "db", + "col": 5, + "type": "database", + "label": "users 行", + "sublabel": "role · enabled · epoch + 1", + "width": 144, + "sources": [ + { + "path": "src/db/schema.sql", + "line": 270, + "label": "users.session_epoch" + } + ] + } + ], + "edges": [ + { + "id": "e_form", + "from": "a_create", + "to": "s_create", + "label": "POST /api/users", + "variant": "emphasis", + "role": "main" + }, + { + "id": "e_store", + "from": "s_create", + "to": "d_user", + "label": "落库", + "variant": "dashed", + "role": "async" + }, + { + "id": "e_handoff", + "from": "s_create", + "to": "u_activate", + "label": "明文令牌只回显一次", + "variant": "default", + "role": "main" + }, + { + "id": "e_redeem", + "from": "u_activate", + "to": "s_activate", + "label": "token + 新密码", + "variant": "emphasis", + "role": "main" + }, + { + "id": "e_consume", + "from": "s_activate", + "to": "d_user", + "label": "写哈希 · 清摘要", + "variant": "dashed", + "role": "branch" + }, + { + "id": "e_signin", + "from": "u_login", + "to": "s_login", + "label": "提交密码", + "variant": "emphasis", + "role": "main" + }, + { + "id": "e_track", + "from": "s_login", + "to": "d_audit", + "label": "login.success / failure", + "variant": "dashed", + "role": "async" + }, + { + "id": "e_cookie", + "from": "s_login", + "to": "s_session", + "label": "验密通过", + "variant": "emphasis", + "role": "main" + }, + { + "id": "e_percall", + "from": "s_session", + "to": "s_guard", + "label": "Cookie 随每请求", + "variant": "default", + "role": "main" + }, + { + "id": "e_forcepwd", + "from": "s_guard", + "to": "u_selfpwd", + "label": "must_change_password=1", + "variant": "security", + "role": "branch" + }, + { + "id": "e_changed", + "from": "a_change", + "to": "s_guard", + "label": "旧 Cookie 立即 401", + "variant": "security", + "role": "main" + }, + { + "id": "e_bump", + "from": "a_change", + "to": "d_epoch", + "label": "改角色 / 禁用", + "variant": "dashed", + "role": "async" + }, + { + "id": "e_tologin", + "from": "s_activate", + "to": "u_login", + "label": "激活完成 → 登录", + "variant": "default", + "role": "main" + } + ], + "cards": [ + { + "dot": "violet", + "title": "角色边界", + "items": [ + "admin:用户管理、任务与配置、审计、日志、告警、备份、上游 OAuth 登录", + "operator:凭证读写、全站统计、账单、上游状态、模型列表与探活", + "viewer:只读;统计强制收敛到本人,凭证昵称不下发" + ] + }, + { + "dot": "rose", + "title": "防锁死与撤销", + "items": [ + "不能停用或降级最后一个活跃管理员,也不能对自己降级 / 停用", + "改角色、改密、禁用都会 bump epoch —— 代价是重新登录,这是有意的", + "ADMIN_USERNAMES 与 users.txt 只在「库里还没有活跃 admin」的引导期生效" + ] + }, + { + "dot": "emerald", + "title": "为什么不用共享初始密码", + "items": [ + "建号时写随机占位密码,管理员也不知道,用户只能用令牌自设", + "令牌只存 SHA-256 摘要,明文仅在创建响应里出现一次,24 小时过期", + "登录同样校验 Origin,防 login CSRF" + ] + } + ] +} diff --git a/diagrams/coding2api-account-flow.html b/diagrams/coding2api-account-flow.html new file mode 100644 index 00000000..63ecdbb2 --- /dev/null +++ b/diagrams/coding2api-account-flow.html @@ -0,0 +1,14965 @@ + + + + + + + + 账号开通与角色治理流程 + + + + + + + + + +
+ +
+
+
+

账号开通与角色治理流程

+
+

一次性激活令牌 → 登录签发会话 → 每请求现读角色 → 改角色/禁用即时吊销

+
+ + + + +
+ + 账号开通与角色治理流程 + 一次性激活令牌 → 登录签发会话 → 每请求现读角色 → 改角色/禁用即时吊销 + + + + + + + + + + + + + + + + + + + + + + + + + + 01 / 管理员 + + + 02 / 被邀请用户 + + + 03 / 账号与鉴权服务 + + + 04 / 数据与审计 + + + + + 建号 + + + 落库 + + + 激活 + + + 登录 + + + 会话 + + + 鉴权与吊销 + + + + + + + + + + + + + + + + + + + + + + 新建用户 · 用户名 + 角色 → 一次性链接 · 管理员 › 建号 · 带外转交 + + + + 新建用户 + 用户名 + 角色 → 一次性链接 + 带外转交 + + + + 改角色 / 禁用 / 重置 · 降级必须立刻可信 · 管理员 › 鉴权与吊销 + + + + 改角色 / 禁用 / 重置 + 降级必须立刻可信 + + + + 打开 /activate · 自设密码(≥ 8 位) · 被邀请用户 › 激活 + + + + 打开 /activate + 自设密码(≥ 8 位) + + + + 登录 · 用户名 + 密码 · 被邀请用户 › 登录 + + + + 登录 + 用户名 + 密码 + + + + 首登强制改密 · 其余端点一律 403 · 被邀请用户 › 会话 + + + + 首登强制改密 + 其余端点一律 403 + + + + 创建账号 · 随机占位密码 + 令牌摘要 · 账号与鉴权服务 › 落库 · 管理员也不知道初始密码 + + + + 创建账号 + 随机占位密码 + 令牌摘要 + 管理员也不知道初始密码 + + + + 校验令牌 · 摘要匹配 · TTL · 一次性 · 账号与鉴权服务 › 激活 + + + + 校验令牌 + 摘要匹配 · TTL · 一次性 + + + + 验密与限流 · PBKDF2 · IP / 用户窗口 · 账号与鉴权服务 › 登录 · 先卡传输层再做哈希 + + + + 验密与限流 + PBKDF2 · IP / 用户窗口 + 先卡传输层再做哈希 + + + + 签发会话 Cookie · 签名 + session_epoch · 账号与鉴权服务 › 会话 · 角色不进 Cookie + + + + 签发会话 Cookie + 签名 + session_epoch + 角色不进 Cookie + + + + 四道校验 · 签名 · 禁用 · epoch · 角色 · 账号与鉴权服务 › 鉴权与吊销 · require_admin / require_operator + + + + 四道校验 + 签名 · 禁用 · epoch · 角色 + require_admin / require_operator + + + + users 行 · role · enabled · 摘要 · TTL · 数据与审计 › 落库 + + + + users 行 + role · enabled · 摘要 · TTL + + + + audit_events · 登录成败都留痕 · 数据与审计 › 登录 + + + + audit_events + 登录成败都留痕 + + + + users 行 · role · enabled · epoch + 1 · 数据与审计 › 鉴权与吊销 + + + + users 行 + role · enabled · epoch + 1 + + + + + + 改角色 / 禁用 + + + + 旧 Cookie 立即 401 + + + + 写哈希 · 清摘要 + + + + 验密通过 + + + + must_change_password=1 + + + + POST /api/users + + + + 明文令牌只回显一次 + + + + Cookie 随每请求 + + + + token + 新密码 + + + + 提交密码 + + + + 落库 + + + + 激活完成 → 登录 + + + + login.success / failure + + + + + 图例 + + + 用户界面 + + + + Agent 逻辑 + + + + 策略 + + + + 上下文 / 追踪 + + + + 外部系统 + + + +

+ + + + + + + + + +
+ + + +
+ + + + diff --git a/diagrams/coding2api-data-model.html b/diagrams/coding2api-data-model.html new file mode 100644 index 00000000..8f7eb538 --- /dev/null +++ b/diagrams/coding2api-data-model.html @@ -0,0 +1,16072 @@ + + + + + + + + coding2api 数据模型(13 张表) + + + + + + + + + +
+ +
+
+
+

coding2api 数据模型(13 张表)

+
+

SQLite 单库;表间归属由应用层校验,数据库不建外键(虚线)

+
+ + + + +
+ + coding2api 数据模型(13 张表) + SQLite 单库;表间归属由应用层校验,数据库不建外键(虚线) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 身份 + + + + + + 凭证池 + + + + + + 凭证策略 + + + + + + 用量明细 + + + + + + 用量汇总 + + + + + + 运营事件 + + + + + + 运维审计 + + + + + + + + + + + + + + + + + + + + users · 管理台账号 · 三角色 · 实体 · 身份 + + + + + + users + + + + + PK + username + TEXT + + + + + + password_hash + TEXT + + + + + + role + TEXT + + + + + + enabled + INTEGER + + + + + + must_change_password + INTEGER + + + + + + session_epoch + INTEGER + + + + + + activation_digest + TEXT + + + + + + activation_expires_at + INTEGER + + + + + + created_at + INTEGER + + + + + + updated_at + INTEGER + + + + + + created_by + TEXT + + + + + + allowed_models + TEXT + + + + + + quota + TEXT + + + + + api_keys · 出口 Key · 策略与配额 · 实体 · 身份 + + + + + + api_keys + + + + + PK + id + TEXT + + + + + FK + username + TEXT + + + + + + name + TEXT + + + + + UK + key_digest + TEXT + + + + + + preview + TEXT + + + + + + created_at + INTEGER + + + + + + last_used_at + INTEGER + + + + + + provider_binding + TEXT + + + + + + allowed_ips + TEXT + + + + + + allowed_models + TEXT + + + + + + expires_at + INTEGER + + + + + + quota + TEXT + + + + + credentials · 上游账号池 · 加密凭证 · 实体 · 凭证池 + + + + + + credentials + + + + + PK + id + TEXT + + + + + + provider + TEXT + + + + + + nickname + TEXT + + + + + + data_enc + BLOB + + + + + + enabled + INTEGER + + + + + + disabled + INTEGER + + + + + + disabled_reason + TEXT + + + + + + pinned + INTEGER + + + + + + health + INTEGER + + + + + + cooling_until + INTEGER + + + + + + err_count + INTEGER + + + + + + quota_remaining + REAL + + + + + + quota_total + REAL + + + + + + quota_cycle_end + INTEGER + + + + + + quota_expiry_ladder + TEXT + + + + + + quota_packages + TEXT + + + + + + quota_probed_at + INTEGER + + + + + + token_expires_at + INTEGER + + + + + + token_issued_at + INTEGER + + + + + + growth_last_run_at + INTEGER + + + + + + growth_last_result + TEXT + + + + + + tags + TEXT + + + + + + created_at + INTEGER + + + + + + added_by + TEXT + + + + + credential_limits · 凭证级限额(缺行=不限) · 实体 · 凭证策略 + + + + + + credential_limits + + + + + PKFK + credential_id + TEXT + + + + + + keep_credits + REAL + + + + + + daily_tokens + INTEGER + + + + + + daily_credits + REAL + + + + + + per_model_tokens + TEXT + + + + + + updated_at + INTEGER + + + + + + updated_by + TEXT + + + + + credential_model_cooldowns · (凭证, 模型) 级冷却 · 实体 · 凭证策略 + + + + + + credential_model_cooldowns + + + + + PKFK + credential_id + TEXT + + + + + PK + model + TEXT + + + + + + cooling_until + INTEGER + + + + + + hits + INTEGER + + + + + + reason + TEXT + + + + + model_probes · 主动探活结论 · 与调度隔离 · 实体 · 凭证策略 + + + + + + model_probes + + + + + PK + provider + TEXT + + + + + PK + model + TEXT + + + + + + state + TEXT + + + + + + failures + INTEGER + + + + + + last_probe_at + INTEGER + + + + + + last_ok_at + INTEGER + + + + + + last_error + TEXT + + + + + + manual + TEXT + + + + + + updated_at + INTEGER + + + + + usage_events · 逐请求明细 · 保留 90 天 · 实体 · 用量明细 + + + + + + usage_events + + + + + PK + id + TEXT + + + + + + ts + INTEGER + + + + + FK + username + TEXT + + + + + + provider + TEXT + + + + + FK + credential_id + TEXT + + + + + + model + TEXT + + + + + + ok + INTEGER + + + + + + error_type + TEXT + + + + + + input_tokens + INTEGER + + + + + + output_tokens + INTEGER + + + + + + reasoning_tokens + INTEGER + + + + + + cached_tokens + INTEGER + + + + + + credit + REAL + + + + + + credit_estimated + INTEGER + + + + + + cost_usd + REAL + + + + + + cost_cny + REAL + + + + + + latency_ms + INTEGER + + + + + + ttfb_ms + INTEGER + + + + + + request_id + TEXT + + + + + FK + api_key_id + TEXT + + + + + usage_hourly · 小时汇总 · 永久保留 · 实体 · 用量汇总 + + + + + + usage_hourly + + + + + PK + hour_utc + INTEGER + + + + + PK + username + TEXT + + + + + PK + provider + TEXT + + + + + PK + model + TEXT + + + + + + requests + INTEGER + + + + + + ok_count + INTEGER + + + + + + input_tokens + INTEGER + + + + + + output_tokens + INTEGER + + + + + + reasoning_tokens + INTEGER + + + + + + cached_tokens + INTEGER + + + + + + cached_known + INTEGER + + + + + + credit_sum + REAL + + + + + + credit_known + INTEGER + + + + + + credit_estimated_known + INTEGER + + + + + + cost_usd_sum + REAL + + + + + + cost_cny_sum + REAL + + + + + + cost_known + INTEGER + + + + + + latency_sum + INTEGER + + + + + + ttfb_sum + INTEGER + + + + + growth_events · 成长中心一轮一行 · 实体 · 运营事件 + + + + + + growth_events + + + + + PK + id + TEXT + + + + + FK + credential_id + TEXT + + + + + + ts + INTEGER + + + + + + ok + INTEGER + + + + + + session_dead + INTEGER + + + + + + report + TEXT + + + + + + credit + REAL + + + + + + energy + INTEGER + + + + + + streak_days + INTEGER + + + + + + trigger + TEXT + + + + + credit_events · 额度探测净变化 · 双账本 · 实体 · 运营事件 + + + + + + credit_events + + + + + PK + id + TEXT + + + + + FK + credential_id + TEXT + + + + + + ts + INTEGER + + + + + + window_start + INTEGER + + + + + + before + REAL + + + + + + after + REAL + + + + + + delta + REAL + + + + + + source + TEXT + + + + + + ledger + TEXT + + + + + audit_events · 登录与写操作留痕 · 实体 · 运维审计 + + + + + + audit_events + + + + + PK + id + TEXT + + + + + + ts + INTEGER + + + + + + actor + TEXT + + + + + + action + TEXT + + + + + + target + TEXT + + + + + + detail + TEXT + + + + + + ip + TEXT + + + + + + ok + INTEGER + + + + + alert_events · 七类运维告警 · 静默去重 · 实体 · 运维审计 + + + + + + alert_events + + + + + PK + id + TEXT + + + + + + ts + INTEGER + + + + + + rule + TEXT + + + + + + severity + TEXT + + + + + + scope + TEXT + + + + + + message + TEXT + + + + + + detail + TEXT + + + + + + delivered + INTEGER + + + + + + delivery_error + TEXT + + + + + + resolved_at + INTEGER + + + + + runtime_settings · 热更配置覆盖 · 白名单校验 · 实体 · 运维审计 + + + + + + runtime_settings + + + + + PK + key + TEXT + + + + + + value + TEXT + + + + + + updated_at + INTEGER + + + + + + + 归属 + + + + 添加人 + + + + 护栏 + + + + 模型冷却 + + + + 计量归属 + + + + Key 归属 + + + + 落点凭证 + + + + 活动记录 + + + + 额度流水 + + + + + 图例 + + PK + 主键 + + + FK + 逻辑外键(应用层校验) + + + UK + 唯一键 + + + + 一 + + + + 多 + + + + 可空 / 可缺行 + + + +

+ + + + + + + + + +
+ + + +
+ + + + diff --git a/diagrams/coding2api-permission-matrix.html b/diagrams/coding2api-permission-matrix.html new file mode 100644 index 00000000..64e2f3a4 --- /dev/null +++ b/diagrams/coding2api-permission-matrix.html @@ -0,0 +1,15200 @@ + + + + + + + + 权限点 × 角色矩阵 + + + + + + + + + +
+ +
+
+
+

权限点 × 角色矩阵

+
+

行 = 权限点,列 = 角色;空白 = 无该权限。管理员包含操作员与只读的全部权限

+
+ + + + +
+ + 权限点 × 角色矩阵 + 行 = 权限点,列 = 角色;空白 = 无该权限。管理员包含操作员与只读的全部权限 + + + + + + + + + + + + + + + + + + + + + + + + + + 01 / 登录与自助改密 + + + 02 / 用户与角色管理 + + + 03 / 首登强制改密 + + + 04 / 自己的 API Key + + + 05 / 查看凭证池 + + + 06 / 凭证写操作 + + + 07 / 凭证策略与运营 + + + 08 / 模型探活 + + + 09 / 用量统计 + + + 10 / 失败归因与事件流 + + + 11 / 账单与上游状态 + + + 12 / 模型列表与目录 + + + 13 / 审计日志 + + + 14 / 服务端日志与告警 + + + 15 / 备份与恢复 + + + 16 / 任务与配置热更 + + + 17 / 上游 OAuth 登录 + + + 18 / /v1 网关调用 + + + + + admin + + + operator + + + viewer + + + + + + + + + + 允许 · 登录与自助改密 › admin + + + + 允许 + + + + 允许 · 登录与自助改密 › operator + + + + 允许 + + + + 允许 · 登录与自助改密 › viewer + + + + 允许 + + + + 允许 · 建号 · 改角色 · 禁用 · 用户与角色管理 › admin + + + + 允许 + 建号 · 改角色 · 禁用 + + + + 允许 · 首登强制改密 › admin + + + + 允许 + + + + 允许 · 首登强制改密 › operator + + + + 允许 + + + + 允许 · 首登强制改密 › viewer + + + + 允许 + + + + 允许 · 增删 · 配额 · 白名单 · 自己的 API Key › admin + + + + 允许 + 增删 · 配额 · 白名单 + + + + 允许 · 自己的 API Key › operator + + + + 允许 + + + + 允许 · 自己的 API Key › viewer + + + + 允许 + + + + 全量 · 含昵称与额度 · 查看凭证池 › admin + + + + 全量 + 含昵称与额度 + + + + 全量 · 查看凭证池 › operator + + + + 全量 + + + + 脱敏 · 昵称不下发 · 查看凭证池 › viewer + + + + 脱敏 + 昵称不下发 + + + + 允许 · 导入 · 删除 · 启停 · 复活 · 凭证写操作 › admin + + + + 允许 + 导入 · 删除 · 启停 · 复活 + + + + 允许 · 凭证写操作 › operator + + + + 允许 + + + + 允许 · 限额 · 标签 · pin · 签到 · 成长 · 凭证策略与运营 › admin + + + + 允许 + 限额 · 标签 · pin · 签到 · 成长 + + + + 允许 · 凭证策略与运营 › operator + + + + 允许 + + + + 允许 · 探活 · 人工接管 · 模型探活 › admin + + + + 允许 + 探活 · 人工接管 + + + + 允许 · 模型探活 › operator + + + + 允许 + + + + 全站 · 可按用户切换 · 用量统计 › admin + + + + 全站 + 可按用户切换 + + + + 全站 · 用量统计 › operator + + + + 全站 + + + + 仅本人 · 强制收敛 · 用量统计 › viewer + + + + 仅本人 + 强制收敛 + + + + 允许 · 归因 · SSE · CSV · 失败归因与事件流 › admin + + + + 允许 + 归因 · SSE · CSV + + + + 允许 · 失败归因与事件流 › operator + + + + 允许 + + + + 允许 · 账单导出 · 上游状态 · 账单与上游状态 › admin + + + + 允许 + 账单导出 · 上游状态 + + + + 允许 · 账单与上游状态 › operator + + + + 允许 + + + + 允许 · 目录 · 可用性 · 模型列表与目录 › admin + + + + 允许 + 目录 · 可用性 + + + + 允许 · 模型列表与目录 › operator + + + + 允许 + + + + 允许 · 登录成败与写操作 · 审计日志 › admin + + + + 允许 + 登录成败与写操作 + + + + 允许 · 日志流 · 告警回看 · 服务端日志与告警 › admin + + + + 允许 + 日志流 · 告警回看 + + + + 允许 · 创建 · 下载 · 恢复 · 备份与恢复 › admin + + + + 允许 + 创建 · 下载 · 恢复 + + + + 允许 · 热更 · 手动跑任务 · 任务与配置热更 › admin + + + + 允许 + 热更 · 手动跑任务 + + + + 允许 · TRAE 浏览器登录 · 上游 OAuth 登录 › admin + + + + 允许 + TRAE 浏览器登录 + + + + 凭 Key · /v1 网关调用 › admin + + + + 凭 Key + + + + 凭 Key · /v1 网关调用 › operator + + + + 凭 Key + + + + 凭 Key · /v1 网关调用 › viewer + + + + 凭 Key + + + + + + + + 图例 + + + 任意角色可用(含 viewer) + + + + operator 起(admin 也含) + + + + 仅 admin + + + +

+ + + + + + + + + +
+ + + +
+ + + + diff --git a/diagrams/coding2api-request-flow.html b/diagrams/coding2api-request-flow.html new file mode 100644 index 00000000..7c95579f --- /dev/null +++ b/diagrams/coding2api-request-flow.html @@ -0,0 +1,14938 @@ + + + + + + + + 对外请求业务流:从 API Key 到上游回帧 + + + + + + + + + +
+ +
+
+
+

对外请求业务流:从 API Key 到上游回帧

+
+

POST /v1/chat/completions · /v1/messages · /v1/responses 共用同一条准入与选号链路

+
+ + + + +
+ + 对外请求业务流:从 API Key 到上游回帧 + POST /v1/chat/completions · /v1/messages · /v1/responses 共用同一条准入与选号链路 + + + + + + + + + + + + + + + + + + + + + + + + + + 01 / 调用方 + + + 02 / 接入与准入 + + + 03 / 调度引擎 + + + 04 / 渠道适配 + + + 05 / 计量与状态 + + + + + 接入 + + + 准入判定 + + + 解析与选号 + + + 上游与回帧 + + + + 不触达上游即返回 + + 换号预算 3 次 + + + + + + + + + + + + + + + + + 客户端 SDK · OpenAI · Anthropic · Codex · 调用方 › 接入 + + + + 客户端 SDK + OpenAI · Anthropic · Codex + + + + 拒绝响应 · 401 / 403 / 429 / 503 · 调用方 › 准入判定 + + + + 拒绝响应 + 401 / 403 / 429 / 503 + + + + 响应流 · SSE 帧 / JSON · 调用方 › 上游与回帧 + + + + 响应流 + SSE 帧 / JSON + + + + Key 鉴权 · 摘要 · 到期 · IP 白名单 · 接入与准入 › 接入 · 用户停用即失效 + + + + Key 鉴权 + 摘要 · 到期 · IP 白名单 + 用户停用即失效 + + + + 准入闸门 · 模型白名单 · RPM/TPM · 并发 · 接入与准入 › 不触达上游即返回 › 准入判定 · 超限不耗上游 + + + + 准入闸门 + 模型白名单 · RPM/TPM · 并发 + 超限不耗上游 + + + + 目标解析 · 别名 → 渠道优先级链 · 调度引擎 › 解析与选号 + + + + 目标解析 + 别名 → 渠道优先级链 + + + + 选号 · 健康度 · 冷却 · 到期积分 · 调度引擎 › 解析与选号 · 粘性 / 手动 pin 优先 + + + + 选号 + 健康度 · 冷却 · 到期积分 + 粘性 / 手动 pin 优先 + + + + 事件转译 · SSE 帧 · 保活 · 错误帧 · 调度引擎 › 上游与回帧 + + + + 事件转译 + SSE 帧 · 保活 · 错误帧 + + + + 上游渠道 · 八家 provider · 会话续期 · 渠道适配 › 解析与选号 + + + + 上游渠道 + 八家 provider · 会话续期 + + + + 错误归类 · 账号级 / 模型级冷却 · 渠道适配 › 换号预算 3 次 › 上游与回帧 · 负缓存与半开 + + + + 错误归类 + 账号级 / 模型级冷却 + 负缓存与半开 + + + + 计量落库 · usage_events + usage_hourly · 计量与状态 › 上游与回帧 + + + + 计量落库 + usage_events + usage_hourly + + + + + + 已认证 + + + + 候选链 + + + + 失败归类 + + + + 选中凭证 + + + + 上游 SSE + + + + 成功 / 断开都记账 + + + + 放行 + + + + POST /v1/* + + + + 超限 / 策略不符 + + + + 帧转发 + + + + 换号重试 ≤ 3 + + + + + 图例 + + + 用户界面 + + + + Agent 逻辑 + + + + 策略 + + + + 工具操作 + + + + 上下文 / 追踪 + + + + 云服务 + + + + 外部系统 + + + +

+ + + + + + + + + +
+ + + +
+ + + + diff --git a/diagrams/data-model.json b/diagrams/data-model.json new file mode 100644 index 00000000..fe6ed022 --- /dev/null +++ b/diagrams/data-model.json @@ -0,0 +1,1064 @@ +{ + "schema_version": 1, + "diagram_type": "erd", + "meta": { + "title": "coding2api 数据模型(13 张表)", + "subtitle": "SQLite 单库;表间归属由应用层校验,数据库不建外键(虚线)", + "locale": "zh-CN", + "output": "diagrams/coding2api-data-model.html", + "quality_profile": "standard", + "repository": { + "url": "git@github.com:RobbsLuo/Coding2API.git", + "provider": "github", + "link_mode": "local-only", + "revision": "82cf0efd063e13f5323c69940666e9b64a8d1a68" + }, + "views": [ + { + "id": "identity", + "label": "身份与出口钥匙", + "focus": [ + "users", + "api_keys", + "usage_events" + ], + "note": "账号是唯一权威身份源;API Key 指向账号,可被停用、到期或按 IP 收窄。" + }, + { + "id": "pool", + "label": "凭证池与护栏", + "focus": [ + "credentials", + "credential_limits", + "credential_model_cooldowns", + "model_probes" + ], + "note": "凭证主体、凭证级限额、模型级冷却、探活结论四层分离,互不覆盖。" + }, + { + "id": "metering", + "label": "计量链路", + "focus": [ + "usage_events", + "usage_hourly", + "api_keys" + ], + "note": "明细按请求落一行并保留 90 天;小时汇总永久保留,读侧优先读汇总。" + }, + { + "id": "ops", + "label": "运营与审计", + "focus": [ + "growth_events", + "credit_events", + "audit_events", + "alert_events", + "runtime_settings" + ], + "note": "活动记录、额度流水、审计与告警都是追加写;配置覆盖只存管理台改过的键。" + } + ], + "legend": { + "entries": { + "pk": { + "label": "主键" + }, + "fk": { + "label": "逻辑外键(应用层校验)" + }, + "uk": { + "label": "唯一键" + }, + "one": { + "label": "一" + }, + "many": { + "label": "多" + }, + "optional": { + "label": "可空 / 可缺行" + } + } + } + }, + "layout": { + "mode": "grid", + "gapX": 56, + "gapY": 40, + "entityW": 264, + "headerH": 38, + "rowH": 18 + }, + "entities": [ + { + "id": "users", + "label": "users", + "sublabel": "管理台账号 · 三角色", + "tag": "身份", + "row": 0, + "col": 0, + "attributes": [ + { + "name": "username", + "type": "TEXT", + "key": "pk" + }, + { + "name": "password_hash", + "type": "TEXT" + }, + { + "name": "role", + "type": "TEXT" + }, + { + "name": "enabled", + "type": "INTEGER" + }, + { + "name": "must_change_password", + "type": "INTEGER" + }, + { + "name": "session_epoch", + "type": "INTEGER" + }, + { + "name": "activation_digest", + "type": "TEXT" + }, + { + "name": "activation_expires_at", + "type": "INTEGER" + }, + { + "name": "created_at", + "type": "INTEGER" + }, + { + "name": "updated_at", + "type": "INTEGER" + }, + { + "name": "created_by", + "type": "TEXT" + }, + { + "name": "allowed_models", + "type": "TEXT" + }, + { + "name": "quota", + "type": "TEXT" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 270, + "label": "users" + } + ] + }, + { + "id": "api_keys", + "label": "api_keys", + "sublabel": "出口 Key · 策略与配额", + "tag": "身份", + "row": 1, + "col": 0, + "attributes": [ + { + "name": "id", + "type": "TEXT", + "key": "pk" + }, + { + "name": "username", + "type": "TEXT", + "key": "fk", + "references": "users.username" + }, + { + "name": "name", + "type": "TEXT" + }, + { + "name": "key_digest", + "type": "TEXT", + "key": "uk" + }, + { + "name": "preview", + "type": "TEXT" + }, + { + "name": "created_at", + "type": "INTEGER" + }, + { + "name": "last_used_at", + "type": "INTEGER" + }, + { + "name": "provider_binding", + "type": "TEXT" + }, + { + "name": "allowed_ips", + "type": "TEXT" + }, + { + "name": "allowed_models", + "type": "TEXT" + }, + { + "name": "expires_at", + "type": "INTEGER" + }, + { + "name": "quota", + "type": "TEXT" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 18, + "label": "api_keys" + } + ] + }, + { + "id": "credentials", + "label": "credentials", + "sublabel": "上游账号池 · 加密凭证", + "tag": "凭证池", + "row": 0, + "col": 1, + "attributes": [ + { + "name": "id", + "type": "TEXT", + "key": "pk" + }, + { + "name": "provider", + "type": "TEXT" + }, + { + "name": "nickname", + "type": "TEXT" + }, + { + "name": "data_enc", + "type": "BLOB" + }, + { + "name": "enabled", + "type": "INTEGER" + }, + { + "name": "disabled", + "type": "INTEGER" + }, + { + "name": "disabled_reason", + "type": "TEXT" + }, + { + "name": "pinned", + "type": "INTEGER" + }, + { + "name": "health", + "type": "INTEGER" + }, + { + "name": "cooling_until", + "type": "INTEGER" + }, + { + "name": "err_count", + "type": "INTEGER" + }, + { + "name": "quota_remaining", + "type": "REAL" + }, + { + "name": "quota_total", + "type": "REAL" + }, + { + "name": "quota_cycle_end", + "type": "INTEGER" + }, + { + "name": "quota_expiry_ladder", + "type": "TEXT" + }, + { + "name": "quota_packages", + "type": "TEXT" + }, + { + "name": "quota_probed_at", + "type": "INTEGER" + }, + { + "name": "token_expires_at", + "type": "INTEGER" + }, + { + "name": "token_issued_at", + "type": "INTEGER" + }, + { + "name": "growth_last_run_at", + "type": "INTEGER" + }, + { + "name": "growth_last_result", + "type": "TEXT" + }, + { + "name": "tags", + "type": "TEXT" + }, + { + "name": "created_at", + "type": "INTEGER" + }, + { + "name": "added_by", + "type": "TEXT" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 42, + "label": "credentials" + } + ] + }, + { + "id": "credential_limits", + "label": "credential_limits", + "sublabel": "凭证级限额(缺行=不限)", + "tag": "凭证策略", + "row": 0, + "col": 2, + "attributes": [ + { + "name": "credential_id", + "type": "TEXT", + "key": [ + "pk", + "fk" + ], + "references": "credentials.id" + }, + { + "name": "keep_credits", + "type": "REAL" + }, + { + "name": "daily_tokens", + "type": "INTEGER" + }, + { + "name": "daily_credits", + "type": "REAL" + }, + { + "name": "per_model_tokens", + "type": "TEXT" + }, + { + "name": "updated_at", + "type": "INTEGER" + }, + { + "name": "updated_by", + "type": "TEXT" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 82, + "label": "credential_limits" + } + ] + }, + { + "id": "credential_model_cooldowns", + "label": "credential_model_cooldowns", + "sublabel": "(凭证, 模型) 级冷却", + "tag": "凭证策略", + "row": 1, + "col": 2, + "attributes": [ + { + "name": "credential_id", + "type": "TEXT", + "key": [ + "pk", + "fk" + ], + "references": "credentials.id" + }, + { + "name": "model", + "type": "TEXT", + "key": "pk" + }, + { + "name": "cooling_until", + "type": "INTEGER" + }, + { + "name": "hits", + "type": "INTEGER" + }, + { + "name": "reason", + "type": "TEXT" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 207, + "label": "credential_model_cooldowns" + } + ] + }, + { + "id": "model_probes", + "label": "model_probes", + "sublabel": "主动探活结论 · 与调度隔离", + "tag": "凭证策略", + "row": 2, + "col": 2, + "attributes": [ + { + "name": "provider", + "type": "TEXT", + "key": "pk" + }, + { + "name": "model", + "type": "TEXT", + "key": "pk" + }, + { + "name": "state", + "type": "TEXT" + }, + { + "name": "failures", + "type": "INTEGER" + }, + { + "name": "last_probe_at", + "type": "INTEGER" + }, + { + "name": "last_ok_at", + "type": "INTEGER" + }, + { + "name": "last_error", + "type": "TEXT" + }, + { + "name": "manual", + "type": "TEXT" + }, + { + "name": "updated_at", + "type": "INTEGER" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 233, + "label": "model_probes" + } + ] + }, + { + "id": "usage_events", + "label": "usage_events", + "sublabel": "逐请求明细 · 保留 90 天", + "tag": "用量明细", + "row": 0, + "col": 3, + "attributes": [ + { + "name": "id", + "type": "TEXT", + "key": "pk" + }, + { + "name": "ts", + "type": "INTEGER" + }, + { + "name": "username", + "type": "TEXT", + "key": "fk", + "references": "users.username" + }, + { + "name": "provider", + "type": "TEXT" + }, + { + "name": "credential_id", + "type": "TEXT", + "key": "fk", + "references": "credentials.id" + }, + { + "name": "model", + "type": "TEXT" + }, + { + "name": "ok", + "type": "INTEGER" + }, + { + "name": "error_type", + "type": "TEXT" + }, + { + "name": "input_tokens", + "type": "INTEGER" + }, + { + "name": "output_tokens", + "type": "INTEGER" + }, + { + "name": "reasoning_tokens", + "type": "INTEGER" + }, + { + "name": "cached_tokens", + "type": "INTEGER" + }, + { + "name": "credit", + "type": "REAL" + }, + { + "name": "credit_estimated", + "type": "INTEGER" + }, + { + "name": "cost_usd", + "type": "REAL" + }, + { + "name": "cost_cny", + "type": "REAL" + }, + { + "name": "latency_ms", + "type": "INTEGER" + }, + { + "name": "ttfb_ms", + "type": "INTEGER" + }, + { + "name": "request_id", + "type": "TEXT" + }, + { + "name": "api_key_id", + "type": "TEXT", + "key": "fk", + "references": "api_keys.id" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 92, + "label": "usage_events" + } + ] + }, + { + "id": "usage_hourly", + "label": "usage_hourly", + "sublabel": "小时汇总 · 永久保留", + "tag": "用量汇总", + "row": 1, + "col": 3, + "attributes": [ + { + "name": "hour_utc", + "type": "INTEGER", + "key": "pk" + }, + { + "name": "username", + "type": "TEXT", + "key": "pk" + }, + { + "name": "provider", + "type": "TEXT", + "key": "pk" + }, + { + "name": "model", + "type": "TEXT", + "key": "pk" + }, + { + "name": "requests", + "type": "INTEGER" + }, + { + "name": "ok_count", + "type": "INTEGER" + }, + { + "name": "input_tokens", + "type": "INTEGER" + }, + { + "name": "output_tokens", + "type": "INTEGER" + }, + { + "name": "reasoning_tokens", + "type": "INTEGER" + }, + { + "name": "cached_tokens", + "type": "INTEGER" + }, + { + "name": "cached_known", + "type": "INTEGER" + }, + { + "name": "credit_sum", + "type": "REAL" + }, + { + "name": "credit_known", + "type": "INTEGER" + }, + { + "name": "credit_estimated_known", + "type": "INTEGER" + }, + { + "name": "cost_usd_sum", + "type": "REAL" + }, + { + "name": "cost_cny_sum", + "type": "REAL" + }, + { + "name": "cost_known", + "type": "INTEGER" + }, + { + "name": "latency_sum", + "type": "INTEGER" + }, + { + "name": "ttfb_sum", + "type": "INTEGER" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 128, + "label": "usage_hourly" + } + ] + }, + { + "id": "growth_events", + "label": "growth_events", + "sublabel": "成长中心一轮一行", + "tag": "运营事件", + "row": 0, + "col": 4, + "attributes": [ + { + "name": "id", + "type": "TEXT", + "key": "pk" + }, + { + "name": "credential_id", + "type": "TEXT", + "key": "fk", + "references": "credentials.id" + }, + { + "name": "ts", + "type": "INTEGER" + }, + { + "name": "ok", + "type": "INTEGER" + }, + { + "name": "session_dead", + "type": "INTEGER" + }, + { + "name": "report", + "type": "TEXT" + }, + { + "name": "credit", + "type": "REAL" + }, + { + "name": "energy", + "type": "INTEGER" + }, + { + "name": "streak_days", + "type": "INTEGER" + }, + { + "name": "trigger", + "type": "TEXT" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 156, + "label": "growth_events" + } + ] + }, + { + "id": "credit_events", + "label": "credit_events", + "sublabel": "额度探测净变化 · 双账本", + "tag": "运营事件", + "row": 1, + "col": 4, + "attributes": [ + { + "name": "id", + "type": "TEXT", + "key": "pk" + }, + { + "name": "credential_id", + "type": "TEXT", + "key": "fk", + "references": "credentials.id" + }, + { + "name": "ts", + "type": "INTEGER" + }, + { + "name": "window_start", + "type": "INTEGER" + }, + { + "name": "before", + "type": "REAL" + }, + { + "name": "after", + "type": "REAL" + }, + { + "name": "delta", + "type": "REAL" + }, + { + "name": "source", + "type": "TEXT" + }, + { + "name": "ledger", + "type": "TEXT" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 185, + "label": "credit_events" + } + ] + }, + { + "id": "audit_events", + "label": "audit_events", + "sublabel": "登录与写操作留痕", + "tag": "运维审计", + "row": 2, + "col": 4, + "attributes": [ + { + "name": "id", + "type": "TEXT", + "key": "pk" + }, + { + "name": "ts", + "type": "INTEGER" + }, + { + "name": "actor", + "type": "TEXT" + }, + { + "name": "action", + "type": "TEXT" + }, + { + "name": "target", + "type": "TEXT" + }, + { + "name": "detail", + "type": "TEXT" + }, + { + "name": "ip", + "type": "TEXT" + }, + { + "name": "ok", + "type": "INTEGER" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 303, + "label": "audit_events" + } + ] + }, + { + "id": "alert_events", + "label": "alert_events", + "sublabel": "七类运维告警 · 静默去重", + "tag": "运维审计", + "row": 3, + "col": 4, + "attributes": [ + { + "name": "id", + "type": "TEXT", + "key": "pk" + }, + { + "name": "ts", + "type": "INTEGER" + }, + { + "name": "rule", + "type": "TEXT" + }, + { + "name": "severity", + "type": "TEXT" + }, + { + "name": "scope", + "type": "TEXT" + }, + { + "name": "message", + "type": "TEXT" + }, + { + "name": "detail", + "type": "TEXT" + }, + { + "name": "delivered", + "type": "INTEGER" + }, + { + "name": "delivery_error", + "type": "TEXT" + }, + { + "name": "resolved_at", + "type": "INTEGER" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 325, + "label": "alert_events" + } + ] + }, + { + "id": "runtime_settings", + "label": "runtime_settings", + "sublabel": "热更配置覆盖 · 白名单校验", + "tag": "运维审计", + "row": 4, + "col": 4, + "attributes": [ + { + "name": "key", + "type": "TEXT", + "key": "pk" + }, + { + "name": "value", + "type": "TEXT" + }, + { + "name": "updated_at", + "type": "INTEGER" + } + ], + "sources": [ + { + "path": "src/db/schema.sql", + "line": 253, + "label": "runtime_settings" + } + ] + } + ], + "relationships": [ + { + "id": "rel_key_owner", + "from": "api_keys", + "to": "users", + "label": "归属", + "fromCardinality": "many", + "toCardinality": "one", + "identifying": false + }, + { + "id": "rel_cred_adder", + "from": "credentials", + "to": "users", + "label": "添加人", + "fromCardinality": "many", + "toCardinality": "one", + "toOptional": true, + "identifying": false + }, + { + "id": "rel_limit_cred", + "from": "credential_limits", + "to": "credentials", + "label": "护栏", + "fromCardinality": "one", + "toCardinality": "one", + "fromOptional": true, + "identifying": false + }, + { + "id": "rel_cooldown_cred", + "from": "credential_model_cooldowns", + "to": "credentials", + "label": "模型冷却", + "fromCardinality": "many", + "toCardinality": "one", + "identifying": false + }, + { + "id": "rel_usage_user", + "from": "usage_events", + "to": "users", + "label": "计量归属", + "fromCardinality": "many", + "toCardinality": "one", + "identifying": false + }, + { + "id": "rel_usage_key", + "from": "usage_events", + "to": "api_keys", + "label": "Key 归属", + "fromCardinality": "many", + "toCardinality": "one", + "fromOptional": true, + "identifying": false + }, + { + "id": "rel_usage_cred", + "from": "usage_events", + "to": "credentials", + "label": "落点凭证", + "fromCardinality": "many", + "toCardinality": "one", + "fromOptional": true, + "identifying": false + }, + { + "id": "rel_growth_cred", + "from": "growth_events", + "to": "credentials", + "label": "活动记录", + "fromCardinality": "many", + "toCardinality": "one", + "identifying": false + }, + { + "id": "rel_credit_cred", + "from": "credit_events", + "to": "credentials", + "label": "额度流水", + "fromCardinality": "many", + "toCardinality": "one", + "identifying": false + } + ], + "cards": [ + { + "dot": "cyan", + "title": "约束分工", + "items": [ + "库内只有主键与唯一键;跨表归属由仓储层校验(schema.sql 注释明确不加外键)", + "策略类字段存 JSON 文本:quota、per_model_tokens、quota_expiry_ladder、quota_packages" + ] + }, + { + "dot": "emerald", + "title": "读写归属", + "items": [ + "users / api_keys / credentials 由管理台与鉴权层读写", + "usage_events 由统计采集器写;usage_hourly 由同一采集器聚合,保留期不同", + "audit_events / alert_events 只追加,管理台只读回看" + ] + }, + { + "dot": "amber", + "title": "保留与一致性", + "items": [ + "明细 90 天后由 retention 任务清理;小时汇总永久保留", + "信用/额度流水记的是两次探测的净变化,不是动作归因" + ] + } + ] +} \ No newline at end of file diff --git a/diagrams/permission-matrix.json b/diagrams/permission-matrix.json new file mode 100644 index 00000000..197e8d4a --- /dev/null +++ b/diagrams/permission-matrix.json @@ -0,0 +1,562 @@ +{ + "schema_version": 2, + "diagram_type": "workflow", + "meta": { + "title": "权限点 × 角色矩阵", + "subtitle": "行 = 权限点,列 = 角色;空白 = 无该权限。管理员包含操作员与只读的全部权限", + "locale": "zh-CN", + "output": "diagrams/coding2api-permission-matrix.html", + "quality_profile": "showcase", + "repository": { + "url": "git@github.com:RobbsLuo/Coding2API.git", + "provider": "github", + "link_mode": "local-only", + "revision": "82cf0efd063e13f5323c69940666e9b64a8d1a68" + }, + "legend": { + "entries": { + "frontend": { + "label": "任意角色可用(含 viewer)" + }, + "backend": { + "label": "operator 起(admin 也含)" + }, + "security": { + "label": "仅 admin" + } + } + }, + "views": [ + { + "id": "read", + "label": "只读与自助", + "focus": [ + "login", + "session", + "firstpwd", + "ownkeys", + "poolview", + "stats", + "gateway" + ], + "note": "viewer 能进管理台、看自己的用量与 Key,但看不到他人数据与凭证昵称。" + }, + { + "id": "ops", + "label": "运营面", + "focus": [ + "poolwrite", + "poolpolicy", + "probe", + "errors", + "billing", + "catalog" + ], + "note": "凭证池、统计全量、失败归因、账单、上游状态与模型目录都要 operator 起。" + }, + { + "id": "governance", + "label": "治理面", + "focus": [ + "users", + "audit", + "opslog", + "backup", + "settings", + "upstreamoauth" + ], + "note": "账号、审计、日志告警、备份、热更配置、上游 OAuth 登录只给 admin。" + } + ] + }, + "lanes": [ + { + "id": "login", + "label": "登录与自助改密" + }, + { + "id": "users", + "label": "用户与角色管理" + }, + { + "id": "firstpwd", + "label": "首登强制改密" + }, + { + "id": "ownkeys", + "label": "自己的 API Key" + }, + { + "id": "poolview", + "label": "查看凭证池" + }, + { + "id": "poolwrite", + "label": "凭证写操作" + }, + { + "id": "poolpolicy", + "label": "凭证策略与运营" + }, + { + "id": "probe", + "label": "模型探活" + }, + { + "id": "stats", + "label": "用量统计" + }, + { + "id": "errors", + "label": "失败归因与事件流" + }, + { + "id": "billing", + "label": "账单与上游状态" + }, + { + "id": "catalog", + "label": "模型列表与目录" + }, + { + "id": "audit", + "label": "审计日志" + }, + { + "id": "opslog", + "label": "服务端日志与告警" + }, + { + "id": "backup", + "label": "备份与恢复" + }, + { + "id": "settings", + "label": "任务与配置热更" + }, + { + "id": "upstreamoauth", + "label": "上游 OAuth 登录" + }, + { + "id": "gateway", + "label": "/v1 网关调用" + } + ], + "phases": [ + { + "id": "col_admin", + "label": "admin", + "fromCol": 0, + "toCol": 0, + "variant": "security" + }, + { + "id": "col_operator", + "label": "operator", + "fromCol": 2, + "toCol": 2, + "variant": "emphasis" + }, + { + "id": "col_viewer", + "label": "viewer", + "fromCol": 4, + "toCol": 4, + "variant": "default" + } + ], + "nodes": [ + { + "id": "m_login_a", + "lane": "login", + "col": 0, + "type": "frontend", + "label": "允许", + "width": 252 + }, + { + "id": "m_login_o", + "lane": "login", + "col": 2, + "type": "frontend", + "label": "允许", + "width": 252 + }, + { + "id": "m_login_v", + "lane": "login", + "col": 4, + "type": "frontend", + "label": "允许", + "width": 252 + }, + { + "id": "m_users_a", + "lane": "users", + "col": 0, + "type": "security", + "label": "允许", + "sublabel": "建号 · 改角色 · 禁用", + "width": 252, + "sources": [ + { + "path": "src/api/admin_users.py", + "line": 90, + "label": "require_admin" + } + ] + }, + { + "id": "m_first_a", + "lane": "firstpwd", + "col": 0, + "type": "frontend", + "label": "允许", + "width": 252 + }, + { + "id": "m_first_o", + "lane": "firstpwd", + "col": 2, + "type": "frontend", + "label": "允许", + "width": 252 + }, + { + "id": "m_first_v", + "lane": "firstpwd", + "col": 4, + "type": "frontend", + "label": "允许", + "width": 252 + }, + { + "id": "m_keys_a", + "lane": "ownkeys", + "col": 0, + "type": "frontend", + "label": "允许", + "sublabel": "增删 · 配额 · 白名单", + "width": 252 + }, + { + "id": "m_keys_o", + "lane": "ownkeys", + "col": 2, + "type": "frontend", + "label": "允许", + "width": 252 + }, + { + "id": "m_keys_v", + "lane": "ownkeys", + "col": 4, + "type": "frontend", + "label": "允许", + "width": 252, + "sources": [ + { + "path": "src/api/admin_keys.py", + "line": 101, + "label": "list_keys 仅本人" + } + ] + }, + { + "id": "m_poolv_a", + "lane": "poolview", + "col": 0, + "type": "frontend", + "label": "全量", + "sublabel": "含昵称与额度", + "width": 252 + }, + { + "id": "m_poolv_o", + "lane": "poolview", + "col": 2, + "type": "frontend", + "label": "全量", + "width": 252 + }, + { + "id": "m_poolv_v", + "lane": "poolview", + "col": 4, + "type": "frontend", + "label": "脱敏", + "sublabel": "昵称不下发", + "width": 252, + "sources": [ + { + "path": "src/api/admin_credentials.py", + "line": 259, + "label": "昵称仅 operator" + } + ] + }, + { + "id": "m_poolw_a", + "lane": "poolwrite", + "col": 0, + "type": "backend", + "label": "允许", + "sublabel": "导入 · 删除 · 启停 · 复活", + "width": 252 + }, + { + "id": "m_poolw_o", + "lane": "poolwrite", + "col": 2, + "type": "backend", + "label": "允许", + "width": 252, + "sources": [ + { + "path": "src/api/admin_credentials.py", + "line": 325, + "label": "require_operator" + } + ] + }, + { + "id": "m_policy_a", + "lane": "poolpolicy", + "col": 0, + "type": "backend", + "label": "允许", + "sublabel": "限额 · 标签 · pin · 签到 · 成长", + "width": 252 + }, + { + "id": "m_policy_o", + "lane": "poolpolicy", + "col": 2, + "type": "backend", + "label": "允许", + "width": 252 + }, + { + "id": "m_probe_a", + "lane": "probe", + "col": 0, + "type": "backend", + "label": "允许", + "sublabel": "探活 · 人工接管", + "width": 252 + }, + { + "id": "m_probe_o", + "lane": "probe", + "col": 2, + "type": "backend", + "label": "允许", + "width": 252 + }, + { + "id": "m_stats_a", + "lane": "stats", + "col": 0, + "type": "frontend", + "label": "全站", + "sublabel": "可按用户切换", + "width": 252 + }, + { + "id": "m_stats_o", + "lane": "stats", + "col": 2, + "type": "frontend", + "label": "全站", + "width": 252 + }, + { + "id": "m_stats_v", + "lane": "stats", + "col": 4, + "type": "frontend", + "label": "仅本人", + "sublabel": "强制收敛", + "width": 252, + "sources": [ + { + "path": "src/api/admin_stats.py", + "line": 51, + "label": "_scope" + } + ] + }, + { + "id": "m_err_a", + "lane": "errors", + "col": 0, + "type": "backend", + "label": "允许", + "sublabel": "归因 · SSE · CSV", + "width": 252 + }, + { + "id": "m_err_o", + "lane": "errors", + "col": 2, + "type": "backend", + "label": "允许", + "width": 252 + }, + { + "id": "m_bill_a", + "lane": "billing", + "col": 0, + "type": "backend", + "label": "允许", + "sublabel": "账单导出 · 上游状态", + "width": 252 + }, + { + "id": "m_bill_o", + "lane": "billing", + "col": 2, + "type": "backend", + "label": "允许", + "width": 252, + "sources": [ + { + "path": "src/api/admin_billing.py", + "line": 82, + "label": "require_operator" + } + ] + }, + { + "id": "m_cat_a", + "lane": "catalog", + "col": 0, + "type": "backend", + "label": "允许", + "sublabel": "目录 · 可用性", + "width": 252 + }, + { + "id": "m_cat_o", + "lane": "catalog", + "col": 2, + "type": "backend", + "label": "允许", + "width": 252 + }, + { + "id": "m_audit_a", + "lane": "audit", + "col": 0, + "type": "security", + "label": "允许", + "sublabel": "登录成败与写操作", + "width": 252, + "sources": [ + { + "path": "src/api/admin_audit.py", + "line": 24, + "label": "require_admin" + } + ] + }, + { + "id": "m_opslog_a", + "lane": "opslog", + "col": 0, + "type": "security", + "label": "允许", + "sublabel": "日志流 · 告警回看", + "width": 252 + }, + { + "id": "m_backup_a", + "lane": "backup", + "col": 0, + "type": "security", + "label": "允许", + "sublabel": "创建 · 下载 · 恢复", + "width": 252 + }, + { + "id": "m_settings_a", + "lane": "settings", + "col": 0, + "type": "security", + "label": "允许", + "sublabel": "热更 · 手动跑任务", + "width": 252 + }, + { + "id": "m_oauth_a", + "lane": "upstreamoauth", + "col": 0, + "type": "security", + "label": "允许", + "sublabel": "TRAE 浏览器登录", + "width": 252 + }, + { + "id": "m_gateway_a", + "lane": "gateway", + "col": 0, + "type": "frontend", + "label": "凭 Key", + "width": 252, + "sources": [ + { + "path": "src/api/deps.py", + "line": 237, + "label": "API Key 鉴权" + } + ] + }, + { + "id": "m_gateway_o", + "lane": "gateway", + "col": 2, + "type": "frontend", + "label": "凭 Key", + "width": 252 + }, + { + "id": "m_gateway_v", + "lane": "gateway", + "col": 4, + "type": "frontend", + "label": "凭 Key", + "width": 252 + } + ], + "edges": [], + "cards": [ + { + "dot": "cyan", + "title": "读法", + "items": [ + "行是权限点,列是角色;每一格都对应一个真实端点或页面", + "空白 = 该角色没有这个权限,后端 403、前端也不露出入口", + "权限累积:admin 拥有 operator 与 viewer 的全部能力" + ] + }, + { + "dot": "rose", + "title": "门槛实现", + "items": [ + "require_admin:用户、审计、日志、告警、备份、配置、上游登录", + "require_operator:凭证写、账单、上游状态、模型目录与探活", + "viewer 走同一批统计端点,但 _scope() 把 username 强制改回本人" + ] + }, + { + "dot": "emerald", + "title": "数据面与管理面分开", + "items": [ + "/v1 网关的身份是 API Key,角色只决定管理台能做什么", + "Key 的模型白名单、IP、到期与配额对三种角色一视同仁", + "角色每请求从库里现读,改角色后旧会话立即失效" + ] + } + ] +} \ No newline at end of file diff --git a/diagrams/request-flow.json b/diagrams/request-flow.json new file mode 100644 index 00000000..1c64968d --- /dev/null +++ b/diagrams/request-flow.json @@ -0,0 +1,417 @@ +{ + "schema_version": 2, + "diagram_type": "workflow", + "meta": { + "title": "对外请求业务流:从 API Key 到上游回帧", + "subtitle": "POST /v1/chat/completions · /v1/messages · /v1/responses 共用同一条准入与选号链路", + "locale": "zh-CN", + "output": "diagrams/coding2api-request-flow.html", + "quality_profile": "showcase", + "repository": { + "url": "git@github.com:RobbsLuo/Coding2API.git", + "provider": "github", + "link_mode": "local-only", + "revision": "82cf0efd063e13f5323c69940666e9b64a8d1a68" + }, + "views": [ + { + "id": "admission", + "label": "准入", + "focus": [ + "sdk", + "auth", + "gate", + "deny" + ], + "note": "Key 鉴权先于任何上游动作;配额与模型白名单在读体之后判定,超限直接 429。" + }, + { + "id": "routing", + "label": "选号", + "focus": [ + "resolve", + "pick", + "upstream" + ], + "note": "模型名先解析成渠道优先级链,再按健康度与冷却从凭证池选一个号。" + }, + { + "id": "failure", + "label": "失败与换号", + "focus": [ + "upstream", + "cooldown", + "pick", + "deny" + ], + "note": "上游错误按类别冷却:账号级 / 模型级分开,最多换 3 次号。" + } + ] + }, + "lanes": [ + { + "id": "client", + "label": "调用方" + }, + { + "id": "edge", + "label": "接入与准入" + }, + { + "id": "engine", + "label": "调度引擎" + }, + { + "id": "provider", + "label": "渠道适配" + }, + { + "id": "store", + "label": "计量与状态" + } + ], + "phases": [ + { + "id": "ph_intake", + "label": "接入", + "fromCol": 0, + "toCol": 1 + }, + { + "id": "ph_gate", + "label": "准入判定", + "fromCol": 2, + "toCol": 2, + "variant": "security" + }, + { + "id": "ph_route", + "label": "解析与选号", + "fromCol": 3, + "toCol": 4 + }, + { + "id": "ph_deliver", + "label": "上游与回帧", + "fromCol": 5, + "toCol": 5, + "variant": "emphasis" + } + ], + "groups": [ + { + "id": "grp_reject", + "label": "不触达上游即返回", + "lane": "edge", + "fromCol": 2, + "toCol": 2, + "variant": "security" + }, + { + "id": "grp_rotate", + "label": "换号预算 3 次", + "lane": "provider", + "fromCol": 5, + "toCol": 5, + "variant": "dashed" + } + ], + "mainPath": [ + "sdk", + "auth", + "gate", + "resolve", + "pick", + "upstream", + "relay", + "reply" + ], + "nodes": [ + { + "id": "sdk", + "lane": "client", + "col": 0, + "type": "external", + "label": "客户端 SDK", + "sublabel": "OpenAI · Anthropic · Codex", + "width": 152 + }, + { + "id": "deny", + "lane": "client", + "col": 2, + "type": "security", + "label": "拒绝响应", + "sublabel": "401 / 403 / 429 / 503", + "width": 132 + }, + { + "id": "reply", + "lane": "client", + "col": 5, + "type": "frontend", + "label": "响应流", + "sublabel": "SSE 帧 / JSON", + "width": 132 + }, + { + "id": "auth", + "lane": "edge", + "col": 1, + "type": "security", + "label": "Key 鉴权", + "sublabel": "摘要 · 到期 · IP 白名单", + "tag": "用户停用即失效", + "width": 152, + "sources": [ + { + "path": "src/api/deps.py", + "line": 237, + "label": "Key 鉴权" + } + ] + }, + { + "id": "gate", + "lane": "edge", + "col": 2, + "type": "security", + "label": "准入闸门", + "sublabel": "模型白名单 · RPM/TPM · 并发", + "tag": "超限不耗上游", + "width": 168, + "sources": [ + { + "path": "src/api/endpoints.py", + "line": 98, + "label": "模型白名单" + }, + { + "path": "src/api/endpoints.py", + "line": 150, + "label": "配额准入" + } + ] + }, + { + "id": "resolve", + "lane": "engine", + "col": 3, + "type": "backend", + "label": "目标解析", + "sublabel": "别名 → 渠道优先级链", + "width": 152, + "sources": [ + { + "path": "src/engine/executor.py", + "line": 422, + "label": "resolve_target" + } + ] + }, + { + "id": "pick", + "lane": "engine", + "col": 4, + "type": "backend", + "label": "选号", + "sublabel": "健康度 · 冷却 · 到期积分", + "tag": "粘性 / 手动 pin 优先", + "width": 164, + "sources": [ + { + "path": "src/engine/scheduler.py", + "line": 262, + "label": "select" + } + ] + }, + { + "id": "relay", + "lane": "engine", + "col": 5, + "type": "backend", + "label": "事件转译", + "sublabel": "SSE 帧 · 保活 · 错误帧", + "width": 152, + "sources": [ + { + "path": "src/engine/executor.py", + "line": 634, + "label": "_stream_loop" + } + ] + }, + { + "id": "upstream", + "lane": "provider", + "col": 4, + "type": "cloud", + "label": "上游渠道", + "sublabel": "八家 provider · 会话续期", + "width": 168, + "sources": [ + { + "path": "src/engine/executor.py", + "line": 1096, + "label": "_stream_source" + } + ] + }, + { + "id": "cooldown", + "lane": "provider", + "col": 5, + "type": "messagebus", + "label": "错误归类", + "sublabel": "账号级 / 模型级冷却", + "tag": "负缓存与半开", + "width": 152, + "sources": [ + { + "path": "src/engine/scheduler.py", + "line": 327, + "label": "note_error" + } + ] + }, + { + "id": "usage", + "lane": "store", + "col": 5, + "type": "database", + "label": "计量落库", + "sublabel": "usage_events + usage_hourly", + "width": 176, + "sources": [ + { + "path": "src/stats/collector.py", + "line": 136, + "label": "collector.record" + } + ] + } + ], + "edges": [ + { + "id": "e_post", + "from": "sdk", + "to": "auth", + "label": "POST /v1/*", + "variant": "emphasis", + "role": "main" + }, + { + "id": "e_authed", + "from": "auth", + "to": "gate", + "label": "已认证", + "variant": "default", + "role": "main" + }, + { + "id": "e_reject", + "from": "gate", + "to": "deny", + "label": "超限 / 策略不符", + "variant": "security", + "role": "error" + }, + { + "id": "e_pass", + "from": "gate", + "to": "resolve", + "label": "放行", + "variant": "emphasis", + "role": "main" + }, + { + "id": "e_chain", + "from": "resolve", + "to": "pick", + "label": "候选链", + "variant": "default", + "role": "main" + }, + { + "id": "e_cred", + "from": "pick", + "to": "upstream", + "label": "选中凭证", + "variant": "emphasis", + "role": "main" + }, + { + "id": "e_frames", + "from": "upstream", + "to": "relay", + "label": "上游 SSE", + "variant": "emphasis", + "role": "main" + }, + { + "id": "e_reply", + "from": "relay", + "to": "reply", + "label": "帧转发", + "variant": "emphasis", + "role": "return" + }, + { + "id": "e_classify", + "from": "upstream", + "to": "cooldown", + "label": "失败归类", + "variant": "security", + "role": "error" + }, + { + "id": "e_rotate", + "from": "cooldown", + "to": "pick", + "label": "换号重试 ≤ 3", + "variant": "dashed", + "role": "branch" + }, + { + "id": "e_meter", + "from": "upstream", + "to": "usage", + "label": "成功 / 断开都记账", + "variant": "dashed", + "role": "async" + } + ], + "cards": [ + { + "dot": "rose", + "title": "准入红线", + "items": [ + "用户被禁用或删除:该用户名下所有 Key 立即 401,不查缓存", + "Key 到期 / 来源 IP 不在白名单:401 / 403,不进入配额判定", + "模型不在 allowed_models(fnmatch glob):400,不选号", + "配额或并发超限:429,且本次不消耗任何上游调用" + ] + }, + { + "dot": "amber", + "title": "失败归类决定冷却范围", + "items": [ + "DEAD 会话失效:凭证硬禁用,等人工复活", + "RESTRICTED 上游风控:账号级长冷却,当前请求不再换号", + "MODEL / BLOCKED:只记 (凭证, 模型) 级冷却或负缓存", + "INVALID:请求本身无效,直接 400,不冷却凭证", + "QUEUE:等上游点名的秒数在原号重开,不占换号预算" + ] + }, + { + "dot": "emerald", + "title": "收尾记账", + "items": [ + "每个请求落一行明细(含 request_id 与 api_key_id),并累加小时汇总", + "客户端中途断开也按已产生的 token / credit 记账", + "换号成功记「已救回」;一次都没选中凭证时归因到候选渠道" + ] + } + ] +} diff --git a/docker-compose.public.yml b/docker-compose.public.yml new file mode 100644 index 00000000..a79e3208 --- /dev/null +++ b/docker-compose.public.yml @@ -0,0 +1,67 @@ +# 公网部署覆盖文件(Docker):在基础 compose 上叠一个 Caddy 入口。 +# +# docker compose -f docker-compose.yml -f docker-compose.public.yml up -d --build +# +# .env 新增:PUBLIC_DOMAIN(必填,域名或公网 IP;A / AAAA 记录已指向本机)、 +# ACME_EMAIL(域名模式必填,Let's Encrypt 到期通知)、TLS_MODE(可选, +# acme 默认 / internal 自签)。详见 README「公网部署(Docker)」。 +# +# 需要 Docker Compose ≥ 2.24(下方 `!override` 标签;老版本读不了会报错)。 + +services: + coding2api: + # 应用端口只绑宿主机回环:入口只能走 Caddy。绑 0.0.0.0 会留一条明文 http + # 旁路(TRUST_PROXY 已开,直连还能伪造 XFF 绕过 Key 的 allowed_ips)。 + ports: !override + - "127.0.0.1:${PORT:-8000}:${PORT:-8000}" + environment: + # PUBLIC_BASE_URL 决定 TRAE 登录回调基址与 HSTS 下发;https 才发 HSTS。 + # 它的主机名也自动进入 Host 白名单(ALLOWED_HOSTS 留空时的口径)。 + PUBLIC_BASE_URL: https://${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env (domain, or public IP with TLS_MODE=internal)} + # 前恰好一层 Caddy 且应用端口只在回环 → 采信 XFF 是安全的 + TRUST_PROXY: "true" + # Caddy 反代走 compose 网络,容器内必须监听 0.0.0.0;.env 里沿用 + # .env.example 的 HOST=127.0.0.1 会让容器只监听回环、Caddy 连不上。 + HOST: 0.0.0.0 + # 显式列全:非空即「完全替换」语义——若 .env 里已有 ALLOWED_HOSTS, + # 不补这一条会漏掉域名(全站 400 invalid host header);漏掉回环地址 + # 会让容器 healthcheck 打的 127.0.0.1 判 400 → 永远 unhealthy + # (Caddy 依赖应用 healthy,也就永远不启动) + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-},${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env},127.0.0.1,localhost + + caddy: + image: caddy:2.11.7-alpine + container_name: coding2api-caddy + restart: unless-stopped + # 应用 healthy 后再起入口:否则 Caddy 会在应用预热期持续 502 并刷日志 + depends_on: + coding2api: + condition: service_healthy + environment: + PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN in .env (domain, or public IP with TLS_MODE=internal)} + ACME_EMAIL: ${ACME_EMAIL:-} + # acme(默认):站点地址是域名 → 自动申请 / 续期;ACME_EMAIL 必填。 + # internal:站点地址是公网 IP → 用 Caddy 自带 CA 自签(浏览器告警)。 + TLS_MODE: ${TLS_MODE:-acme} + PORT: ${PORT:-8000} + ports: + # 80:ACME HTTP-01 挑战 + 之后 308 跳 https;443/udp 是 HTTP/3 + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + # 整个目录挂进去:站点块按 TLS_MODE import tls_{$TLS_MODE}.caddyfile + - ./deploy/caddy:/etc/caddy:ro + # 证书、ACME 账号与 Caddy 状态:必须持久化,否则每次重建都重新签发 + - caddy_data:/data + - caddy_config:/config + # 与 coding2api 同口径的日志轮转(json-file 默认无上限) + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml index 79d04711..6dfda9a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: CODEBUDDY_API_ENDPOINT: ${CODEBUDDY_API_ENDPOINT:-https://copilot.tencent.com} DEFAULT_MODEL: ${DEFAULT_MODEL:-deepseek-v4.1-flash} # 模型列表黑名单(fnmatch glob,逗号分隔,完全替换语义) - MODEL_BLOCKLIST: ${MODEL_BLOCKLIST:-custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,seed-code-pro-*,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,minimax-m2.5,deepseek-v3-*,hunyuan-*,hy3*,deepseek-v4-*,qwen3.7-*,auto,nemotron-3-*,glm-5.2*,nemotron-3*,lfm2*,north-mini-code,openrouter-models-router,fast,balanced,deep,primary,hy4-preview,inkling-small,minimax-m2.7,kimi-k2.*,minimax-m3,gpt-5.4,gpt-5.5,laguna*,qwen3-vl-235b,seed-2.1*,xing4.0-29b-a4,glyph-cluster,qwen3.8-flash,seed-evolving} + MODEL_BLOCKLIST: ${MODEL_BLOCKLIST:-custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent,default,fast,balanced,deep,primary,auto,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,glm-5.2*,glm.5.2*,kimi-k2.*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,kimi-k3,minimax-m2.5,minimax-m2.7,minimax-m3,deepseek-v3-*,deepseek-v4-*,qwen3.7-*,qwen3-*,qwen3.6*,qwen3-vl-235b,gpt-5.4,gpt-5.5,gpt-5.6*,hy3*,hy4-preview,hunyuan-*,seed-2.1*,seed-2-1-turbo,doubao-seed-2.1-turbo,seed-code-pro-*,seed-evolving,space-bunny,nemotron-3-*,nemotron-3*,lfm2*,laguna*,north-mini-code,inkling-small,xing4.0-29b-a4,glyph-cluster,openrouter-models-router,deepseek-v4.1-flash-0930,deepseek-v4.1-flash-bnt,glm-5.3-test,glm-5.3-zx,kimi-k3-test-1009,kimi-k3-1019} QUOTA_PROBE_MINUTES: ${QUOTA_PROBE_MINUTES:-60} # token 预刷新轮询周期(分钟):短寿命渠道(CodeArts STS 2h)的预刷新 # 窗口会被封顶,窗口必须窄于该周期才不会整轮漏过、拖到到期才刷 @@ -46,6 +46,8 @@ services: # webhook 留空 = 只留站内记录、不外推(多个地址用逗号分隔) ALERT_ENABLED: ${ALERT_ENABLED:-true} ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} + ALERT_FEISHU_WEBHOOK_URL: ${ALERT_FEISHU_WEBHOOK_URL:-} + ALERT_SLACK_WEBHOOK_URL: ${ALERT_SLACK_WEBHOOK_URL:-} ALERT_INTERVAL_MINUTES: ${ALERT_INTERVAL_MINUTES:-5} # 同一 (规则, 对象) 的静默窗(分钟);0 关闭静默(每轮都报) ALERT_SILENCE_MINUTES: ${ALERT_SILENCE_MINUTES:-30} @@ -63,6 +65,23 @@ services: ALERT_ERROR_RATE_THRESHOLD: ${ALERT_ERROR_RATE_THRESHOLD:-0.5} ALERT_ERROR_RATE_MIN_REQUESTS: ${ALERT_ERROR_RATE_MIN_REQUESTS:-20} ALERT_ERROR_RATE_WINDOW_MINUTES: ${ALERT_ERROR_RATE_WINDOW_MINUTES:-15} + # 单 Key 用量暴涨:窗内 token 总量达到阈值告警;0 关闭该规则 + ALERT_KEY_BURST_TOKENS: ${ALERT_KEY_BURST_TOKENS:-0} + ALERT_KEY_BURST_WINDOW_MINUTES: ${ALERT_KEY_BURST_WINDOW_MINUTES:-15} + # 非工作时段请求:工作时间 [start, end) 本地时区,start == end 关闭; + # 非工作时段请求数达到最小样本才告警(0 关闭) + ALERT_OFF_HOURS_START: ${ALERT_OFF_HOURS_START:-0} + ALERT_OFF_HOURS_END: ${ALERT_OFF_HOURS_END:-0} + ALERT_OFF_HOURS_MIN_REQUESTS: ${ALERT_OFF_HOURS_MIN_REQUESTS:-0} + # 预算分层告警:当月原始成本预算(元);0 关闭。分档默认 50,80,95,100 + ALERT_BUDGET_CNY: ${ALERT_BUDGET_CNY:-0} + ALERT_BUDGET_LEVELS: ${ALERT_BUDGET_LEVELS:-50,80,95,100} + # 渠道零成功告警:窗口内请求数达最小样本且一次都没成功才报;0 关闭 + ALERT_CHANNEL_DEAD_MIN_REQUESTS: ${ALERT_CHANNEL_DEAD_MIN_REQUESTS:-20} + ALERT_CHANNEL_DEAD_WINDOW_MINUTES: ${ALERT_CHANNEL_DEAD_WINDOW_MINUTES:-60} + # 签到连续失败告警:同一凭证连续 N 轮失败(成功一次清零);0 关闭 + ALERT_CHECKIN_FAIL_MIN_STREAK: ${ALERT_CHECKIN_FAIL_MIN_STREAK:-3} + ALERT_CHECKIN_FAIL_WINDOW_HOURS: ${ALERT_CHECKIN_FAIL_WINDOW_HOURS:-24} # SQLite 备份后台任务:周期(分钟)与保留份数;0 份表示不清理 BACKUP_INTERVAL_MINUTES: ${BACKUP_INTERVAL_MINUTES:-1440} BACKUP_KEEP: ${BACKUP_KEEP:-7} @@ -104,9 +123,21 @@ services: ZEN_ALLOWED_ENDPOINTS: ${ZEN_ALLOWED_ENDPOINTS:-https://opencode.ai} # Zen 门禁要求的 UA 版本(低于上游阈值会被 426 拒绝) ZEN_OPENCODE_VERSION: ${ZEN_OPENCODE_VERSION:-1.18.0} + # 门禁移动靶:根路径 / 工具集合 / UA 模板(默认值即仓库实测通过的形态) + ZEN_API_ROOT: ${ZEN_API_ROOT:-/zen/v1} + ZEN_GATE_TOOLS: ${ZEN_GATE_TOOLS:-bash,read} + ZEN_USER_AGENT: ${ZEN_USER_AGENT:-} # Kilo Gateway 免费层端点与其白名单(无凭证;白名单仅防误配) KILO_API_ENDPOINT: ${KILO_API_ENDPOINT:-https://api.kilo.ai/api/gateway} KILO_ALLOWED_ENDPOINTS: ${KILO_ALLOWED_ENDPOINTS:-https://api.kilo.ai/api/gateway} + # 小浣熊(商汤):标准 OpenAI 兼容网关,鉴权是网页会话 JWT。白名单只为 + # 防误配(避免把用户的会话 JWT 发往未授权主机)。 + RACCOON_API_ENDPOINT: ${RACCOON_API_ENDPOINT:-https://xiaohuanxiong.com} + RACCOON_ALLOWED_ENDPOINTS: ${RACCOON_ALLOWED_ENDPOINTS:-https://xiaohuanxiong.com} + # WPS 灵犀:桌面端 agent-runtime 私有通路,鉴权是 WPS 账号 cookie。 + # 白名单必须同时含主域与 kdocs 回落域(对话面按这两个域顺序回落)。 + LINGXI_API_ENDPOINT: ${LINGXI_API_ENDPOINT:-https://lingxi.wps.cn} + LINGXI_ALLOWED_ENDPOINTS: ${LINGXI_ALLOWED_ENDPOINTS:-https://lingxi.wps.cn,https://lingxi.kdocs.cn} # Qoder(阿里,COSY 私有协议):真实账号渠道。默认国内 openapi 网关; # 国际版改 QODER_API_ENDPOINT=https://openapi.qoder.sh 并把国际版域名 # 保留在白名单里。推理走 gateway.*,额度/登录走 openapi.*。白名单防把 @@ -126,6 +157,11 @@ services: ZEN_CHAT_MIN_INTERVAL: ${ZEN_CHAT_MIN_INTERVAL:-0} # Kilo 聊天最小间隔(秒):独立节流器,0(默认)关闭 KILO_CHAT_MIN_INTERVAL: ${KILO_CHAT_MIN_INTERVAL:-0} + # 小浣熊聊天最小间隔(秒)与每账号并发上限(实测 5 并发放行、第 6 个 429) + RACCOON_CHAT_MIN_INTERVAL: ${RACCOON_CHAT_MIN_INTERVAL:-0} + RACCOON_MAX_CONCURRENCY: ${RACCOON_MAX_CONCURRENCY:-2} + # 灵犀聊天最小间隔(秒):独立节流器,0(默认)关闭 + LINGXI_CHAT_MIN_INTERVAL: ${LINGXI_CHAT_MIN_INTERVAL:-0} # Qoder / CodeArts 聊天最小间隔(秒):真实账号渠道,独立节流器 QODER_CHAT_MIN_INTERVAL: ${QODER_CHAT_MIN_INTERVAL:-5} CODEARTS_CHAT_MIN_INTERVAL: ${CODEARTS_CHAT_MIN_INTERVAL:-5} @@ -150,6 +186,11 @@ services: PACER_MAX_SECONDS: ${PACER_MAX_SECONDS:-20} # 诊断:把 /v1 原始请求体落盘到 data/dumps/(含对话内容,仅排查用) DUMP_REQUEST_BODIES: ${DUMP_REQUEST_BODIES:-false} + # 全站暂停(kill switch):所有 Key 鉴权的 /v1 端点返 503,管理台仍可用。 + # 一般由管理台热更开关,这里透传只为「容器重建后仍保持暂停状态」。 + GATEWAY_PAUSED: ${GATEWAY_PAUSED:-false} + # 暂停原因:只进日志与审计,不回显给调用方 + GATEWAY_PAUSE_REASON: ${GATEWAY_PAUSE_REASON:-} # 截断续写:上游 finish_reason=length 时同凭证自动续写的最多次数;0 关闭 AUTO_CONTINUE_MAX: ${AUTO_CONTINUE_MAX:-10} UPSTREAM_COMPLETE_TIMEOUT_SECONDS: ${UPSTREAM_COMPLETE_TIMEOUT_SECONDS:-600} @@ -159,15 +200,21 @@ services: STREAM_IDLE_TIMEOUT_SECONDS: ${STREAM_IDLE_TIMEOUT_SECONDS:-0} # 短冷却「等待续上」(秒):全池都在短冷却时最多等这么久再取一次;0 关闭 SHORT_COOLDOWN_WAIT_SECONDS: ${SHORT_COOLDOWN_WAIT_SECONDS:-0} + # 非流式出口保活(秒):N 秒内没聚合完就转 chunked 吐空白心跳;0 关闭 + NONSTREAM_KEEPALIVE_SECONDS: ${NONSTREAM_KEEPALIVE_SECONDS:-0} # 单账号在途请求上限(跨渠道全局);0 = 不限 MAX_INFLIGHT_PER_CREDENTIAL: ${MAX_INFLIGHT_PER_CREDENTIAL:-0} # Prometheus 指标出口(GET /metrics,无鉴权,与 /healthz 同口径);false 关闭 METRICS_ENABLED: ${METRICS_ENABLED:-true} + # 留空 = /metrics 公开;设置后抓取需 Authorization: Bearer + METRICS_TOKEN: ${METRICS_TOKEN:-} # 请求级结构化日志(logfmt,只记 /api、/v1、/authorize;不写 query/正文/用户名) REQUEST_LOG_ENABLED: ${REQUEST_LOG_ENABLED:-true} # 签到定时:本地 HH:MM 逗号分隔,留空 = 全天每 10 分钟(旧行为);抖动分钟 CHECKIN_TIMES: ${CHECKIN_TIMES:-} CHECKIN_JITTER_MINUTES: ${CHECKIN_JITTER_MINUTES:-0} + # 跳过签到的渠道(逗号分隔,如 raccoon):只停签到,聊天不受影响 + CHECKIN_SKIP_PROVIDERS: ${CHECKIN_SKIP_PROVIDERS:-} # 管理台日志页环形缓冲行数(0 关闭该页) LOG_BUFFER_LINES: ${LOG_BUFFER_LINES:-500} # 出站剥离思考内容(只过滤响应、不改请求体);true 开启 @@ -181,12 +228,18 @@ services: CONTEXT_COMPRESS_RESERVE_TOKENS: ${CONTEXT_COMPRESS_RESERVE_TOKENS:-4096} CONTEXT_COMPRESS_MIN_KEEP_MESSAGES: ${CONTEXT_COMPRESS_MIN_KEEP_MESSAGES:-4} CONTEXT_COMPRESS_SAFETY_RATIO: ${CONTEXT_COMPRESS_SAFETY_RATIO:-0.95} + CONTEXT_COMPRESS_STICKY_ENABLED: ${CONTEXT_COMPRESS_STICKY_ENABLED:-true} + TOOL_OUTPUT_COMPRESS_ENABLED: ${TOOL_OUTPUT_COMPRESS_ENABLED:-false} + TOOL_OUTPUT_COMPRESS_MAX_CHARS: ${TOOL_OUTPUT_COMPRESS_MAX_CHARS:-8000} + PREFIX_DEBUG_ENABLED: ${PREFIX_DEBUG_ENABLED:-false} # 跨渠道 fallback 兼容组:主渠道全不可用时按组回退到同义模型(留空关闭) MODEL_FALLBACK_GROUPS: ${MODEL_FALLBACK_GROUPS:-} # 用户自定义模型别名:对外名=模型@渠道;...;留空关闭(列表与执行侧同一份) MODEL_ALIASES: ${MODEL_ALIASES:-} # 按渠道出站代理(启动期):渠道=代理URL;渠道2=代理URL2,留空直连 PROVIDER_PROXIES: ${PROVIDER_PROXIES:-} + # 出口 IP 粘性:全局代理池,按凭证 id 稳定哈希分配;留空 = 直连/渠道代理 + CREDENTIAL_PROXY_POOL: ${CREDENTIAL_PROXY_POOL:-} ENABLE_DOCS: ${ENABLE_DOCS:-false} # 服务监听地址(容器内必须 0.0.0.0 才能被映射访问) HOST: ${HOST:-0.0.0.0} @@ -201,9 +254,9 @@ services: # 应用监听 9000、映射却指向 8000,容器永远不 healthy。 - "${PORT:-8000}:${PORT:-8000}" # 停机排空窗口:docker 发 SIGTERM 后等这么久再 SIGKILL。必须 >= 应用侧 - # uvicorn --timeout-graceful-shutdown(30,见 src/main.py GRACEFUL_SHUTDOWN_SECONDS), + # uvicorn --timeout-graceful-shutdown(90,见 src/main.py GRACEFUL_SHUTDOWN_SECONDS), # 留 5s 余量让 uvicorn 排空完成、统计落库,再被强杀。 - stop_grace_period: 35s + stop_grace_period: 95s volumes: # 凭证与统计(SQLite)与用户文件分开挂载 - ./data:/app/data diff --git a/pyproject.toml b/pyproject.toml index 443af571..1a2555e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "coding2api" -version = "0.9.1" -description = "把 CodeBuddy(国内站 + 国际)、TRAE SOLO、OpenCode Zen、Kilo、Qoder、CodeArts、OfficeAce 八个上游渠道统一封装为 OpenAI / Responses / Anthropic 兼容 API" +version = "0.13.1" +description = "把 CodeBuddy(国内站 + 国际)、TRAE SOLO、OpenCode Zen、Kilo、Qoder、CodeArts、OfficeAce、小浣熊 九个上游渠道统一封装为 OpenAI / Responses / Anthropic 兼容 API" readme = "README.md" requires-python = ">=3.12" dependencies = [ diff --git a/scripts/_common.py b/scripts/_common.py index 07b6f659..51b0049e 100644 --- a/scripts/_common.py +++ b/scripts/_common.py @@ -17,11 +17,16 @@ from pathlib import Path -def backup(db_path: Path) -> Path: +def backup(db_path: Path, *, keep: int = 5) -> Path: """写库前备份主库文件到同目录(`.bak-<时间戳>`),失败即中止。 用 SQLite 在线备份 API 而不是复制文件:WAL 模式下直接 copy 主库文件会 丢掉尚未 checkpoint 的已提交事务。 + + `keep` 份保留:这些脚本是一次性运维工具,每跑一次落一份 40MB+ 的快照。 + 早先不清理,本机实测攒到 28 份 / 870MB(同名 `.bak-*` 从不清), + 首次排障时 `du -sh data` 全是它。按 mtime 倒序只留最近 `keep` 份; + `keep <= 0` 表示不清理。清理失败(权限/竞态)不阻断备份本身。 """ target = db_path.with_name(f"{db_path.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}") source = sqlite3.connect(str(db_path)) @@ -33,9 +38,28 @@ def backup(db_path: Path) -> Path: destination.close() finally: source.close() + if keep > 0: + _prune_backups(db_path, keep=keep) return target +def _prune_backups(db_path: Path, *, keep: int) -> None: + """只保留最近 `keep` 份 `.bak-*`,按 mtime 倒序(名字带时间戳但 mtime 更稳)。""" + try: + existing = sorted( + db_path.parent.glob(f"{db_path.name}.bak-*"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + except OSError: + return + for stale in existing[keep:]: + try: + stale.unlink() + except OSError: + continue + + class DbAdapter: """把裸连接包装成 `Database` 的形态(回填 / rollup 只用 connect、transaction)。 @@ -68,3 +92,37 @@ def transaction(self): self._depth = depth if depth == 0: self._conn.commit() + + +def hour_range(conn, where: str, params: list[object]) -> tuple[int, int] | None: + """`where` 命中行覆盖的小时区间(整点对齐、闭区间);无行返回 None。 + + 删除失败明细前先取这个区间,`sync_hourly` 只在该区间清孤儿行:区间外的 + 小时**本来就没有明细**(`purge_expired` 删 90 天前明细,小时汇总永久保留), + 无条件清会把历史汇总一起删掉。 + """ + row = conn.execute( + f"SELECT MIN(ts), MAX(ts) FROM usage_events WHERE {where}", params).fetchone() + if row[0] is None: + return None + return (int(row[0]) // 3600) * 3600, (int(row[1]) // 3600) * 3600 + + +def sync_hourly(conn, hours: tuple[int, int] | None) -> None: + """全量重算小时汇总,并清掉 `hours` 区间内已无明细对应的孤儿行。 + + `hours` 必须是**本次删除覆盖**的小时区间(见 `hour_range`);None = 没有 + 待删行,只重算不清孤儿。 + """ + from src.stats.collector import StatsCollector + + StatsCollector(DbAdapter(conn)).rollup_hourly() + if hours is None: + return + conn.execute( + "DELETE FROM usage_hourly WHERE hour_utc BETWEEN ? AND ? AND NOT EXISTS (" + " SELECT 1 FROM usage_events e" + " WHERE (e.ts / 3600) * 3600 = usage_hourly.hour_utc" + " AND e.username = usage_hourly.username" + " AND e.provider = usage_hourly.provider" + " AND e.model = usage_hourly.model)", hours) diff --git a/scripts/cleanup_invalid_stats.py b/scripts/cleanup_invalid_stats.py index d5f60ef9..bc917ca9 100644 --- a/scripts/cleanup_invalid_stats.py +++ b/scripts/cleanup_invalid_stats.py @@ -11,8 +11,10 @@ 时认定为白打,整组删除。两边都没成功过的模型名(如手滑输错的请求)保留; 本上游有成功记录的(偶发拒绝)保留;非 invalid_request 的错误保留。 -汇总同步:usage_hourly 由明细全量重算(StatsCollector.rollup_hourly 幂等), -删除后重跑 rollup,再清掉不再有明细对应的小时行,保证明细与汇总严格一致。 +汇总同步:`usage_hourly` 由明细全量重算(`StatsCollector.rollup_hourly` 幂等), +删除后再清掉**本次删除覆盖的小时**里不再有明细对应的孤儿行,保证明细与汇总 +严格一致。只清这些小时:小时汇总永久保留,90 天前的明细已被 `purge_expired` +删掉,无条件清孤儿会把历史汇总一起删掉。 用法: python3 scripts/cleanup_invalid_stats.py # 预览,不写库 @@ -28,34 +30,33 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from scripts._common import DbAdapter, backup # noqa: E402 +from scripts._common import backup, sync_hourly # noqa: E402 from src.db.conn import Database # noqa: E402 -from src.stats.collector import StatsCollector # noqa: E402 -def stale_groups(conn) -> list[tuple[str, str, int]]: - """白打组列表:(provider, model 原样, 待删条数)。""" +def stale_groups(conn) -> list[tuple[str, str, int, int, int]]: + """白打组列表:(provider, model 原样, 待删条数, 最早 ts, 最晚 ts)。""" success: dict[tuple[str, str], int] = {} for provider, model, count in conn.execute( "SELECT provider, LOWER(model), COUNT(*) FROM usage_events " "WHERE ok = 1 GROUP BY provider, LOWER(model)"): success[(provider, model)] = count - stale: list[tuple[str, str, int]] = [] - for provider, model, count in conn.execute( - "SELECT provider, model, COUNT(*) FROM usage_events " + stale: list[tuple[str, str, int, int, int]] = [] + for provider, model, count, low, high in conn.execute( + "SELECT provider, model, COUNT(*), MIN(ts), MAX(ts) FROM usage_events " "WHERE ok = 0 AND error_type = 'invalid_request' " "GROUP BY provider, model"): if success.get((provider, model.lower()), 0) == 0 and any( ok_count for (pid, lower), ok_count in success.items() if pid != provider and lower == model.lower()): - stale.append((provider, model, count)) + stale.append((provider, model, count, low, high)) return stale -def delete_stale(conn, stale: list[tuple[str, str, int]]) -> int: +def delete_stale(conn, stale: list[tuple[str, str, int, int, int]]) -> int: """删除白打明细,返回总条数。""" total = 0 - for provider, model, _count in stale: + for provider, model, _count, _low, _high in stale: cursor = conn.execute( "DELETE FROM usage_events WHERE provider = ? AND model = ? " "AND ok = 0 AND error_type = 'invalid_request'", (provider, model)) @@ -63,16 +64,10 @@ def delete_stale(conn, stale: list[tuple[str, str, int]]) -> int: return total -def sync_hourly(conn) -> None: - """小时汇总与明细对齐:全量重算 + 清孤儿行。""" - StatsCollector(DbAdapter(conn)).rollup_hourly() - conn.execute( - "DELETE FROM usage_hourly WHERE NOT EXISTS (" - " SELECT 1 FROM usage_events e" - " WHERE (e.ts / 3600) * 3600 = usage_hourly.hour_utc" - " AND e.username = usage_hourly.username" - " AND e.provider = usage_hourly.provider" - " AND e.model = usage_hourly.model)") +def _deleted_hours(stale: list[tuple[str, str, int, int, int]]) -> tuple[int, int]: + """待删白打行覆盖的小时区间(整点对齐);只让这些小时参与孤儿清理。""" + return (min(group[3] for group in stale) // 3600 * 3600, + max(group[4] for group in stale) // 3600 * 3600) def main(argv: list[str] | None = None) -> int: @@ -94,9 +89,9 @@ def main(argv: list[str] | None = None) -> int: if not stale: print("没有需要清理的记录") return 0 - for provider, model, count in sorted(stale): + for provider, model, count, _low, _high in sorted(stale): print(f" {provider:<10} {model:<24} x{count}") - total = sum(count for _, _, count in stale) + total = sum(group[2] for group in stale) print(f"共 {total} 条白打明细({len(stale)} 组)") if not args.apply: @@ -104,8 +99,9 @@ def main(argv: list[str] | None = None) -> int: return 0 target = backup(db_path) + hours = _deleted_hours(stale) delete_stale(conn, stale) - sync_hourly(conn) + sync_hourly(conn, hours) conn.commit() print(f"已删除 {total} 条明细,小时汇总已同步(备份: {target})") return 0 diff --git a/scripts/cleanup_stale_blocked.py b/scripts/cleanup_stale_blocked.py new file mode 100644 index 00000000..686bebfd --- /dev/null +++ b/scripts/cleanup_stale_blocked.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""清理「该渠道从来没有这个模型」的负缓存行(`reason='blocked'`)。 + +背景:负缓存是 (凭证, 模型) 粒度的「该后端无此模型」,由上游明确拒绝触发 +(CodeBuddy 11102 / officeAce 81004·81009 / CodeArts InferHub.002002009)。 +它本该只落在**真正提供过该模型**的渠道上。 + +但 2026-10-26 实测到一批被污染的负缓存:`deepseek-v4.1-flash-1017` 只有 +officeAce 的 `/v2/models` 挂着(26 个模型之一),探活判死后别名表丢了归属, +请求扇出到 codebuddy / codearts / codebuddy_intl,三家各回「无此模型」, +于是 8 条负缓存写在了**从没有该模型**的渠道上——它们锁的是「上游没有这个 +模型」,可这些上游本来就没有,无从谈起,纯粹是噪声。 + +判定(保守):一条 blocked 负缓存行,若该**渠道的模型目录里根本没有这个 +模型**(按小写原名比对),则是扇出留下的噪声 → 可删。 + +为什么这条判据是安全的: + * 负缓存的语义是「该后端无此模型」,前提是**该后端曾被问过这个模型**。 + 目录里没有 → 从没被问过(或问的是别的名字)→ 这条缓存拦不到任何东西。 + * 反过来,目录里**有**这个模型的行一律保留:那是上游真的把它下架了 + (officeAce 的 81009),负缓存正是应有的避让。 + * 目录是**缓存快照**(`data/model_catalog.json`),可能过期。只用它做 + 「该渠道确实不认识这个名字」的判据,不做「上游已恢复」的判据。 + +用法: + python3 scripts/cleanup_stale_blocked.py # 预览,不写库 + python3 scripts/cleanup_stale_blocked.py --apply # 备份后删除 + python3 scripts/cleanup_stale_blocked.py --db PATH # 指定数据库 + python3 scripts/cleanup_stale_blocked.py --all # 连「目录里有」的 + # 也删(不推荐) +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scripts._common import backup # noqa: E402 + +# 落盘目录快照里的字段名(见 `src/api/model_catalog.py` 的格式说明)。 +_CATALOG_PROVIDERS = "providers" +_CATALOG_MODELS = "models" + + +def _catalog_models(data_dir: Path) -> dict[str, set[str]]: + """落盘目录快照 → {渠道: {小写原名}}。 + + 快照损坏 / 不存在 → 空 dict(本脚本据此**什么也不删**:没有目录就没有 + 「该渠道不认识这个名字」的判据)。读取失败绝不静默成「目录为空」—— + 那会把所有负缓存都当成噪声删掉。 + """ + path = data_dir / "model_catalog.json" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + providers = payload.get(_CATALOG_PROVIDERS) + if not isinstance(providers, dict): + return {} + tables: dict[str, set[str]] = {} + for provider_id, record in providers.items(): + models = record.get(_CATALOG_MODELS) if isinstance(record, dict) else None + if not isinstance(models, list): + continue + tables[provider_id] = { + str(item.get("id", "")).lower() + for item in models if isinstance(item, dict) and item.get("id") + } + return tables + + +def stale_rows(conn, tables: dict[str, set[str]], + *, include_known: bool = False) -> list[tuple[str, str, str, int]]: + """待删的负缓存行:(渠道, 模型, 凭证 id, 到期时刻)。 + + `include_known=False`(默认)只返回「该渠道的目录里没有这个模型」的行。 + `include_known=True` 时连「目录里有」的也返回——那是管理员确认上游已恢复 + 后的强制清理。 + """ + rows = conn.execute( + "SELECT cred.provider AS provider, c.model AS model, " + " c.credential_id AS credential_id, c.cooling_until AS cooling_until " + "FROM credential_model_cooldowns c " + "JOIN credentials cred ON cred.id = c.credential_id " + "WHERE c.reason = 'blocked' AND c.cooling_until > ? " + "ORDER BY cred.provider, c.model, c.credential_id", + (int(time.time()),), + ).fetchall() + stale: list[tuple[str, str, str, int]] = [] + for row in rows: + provider, model, credential_id, cooling_until = row + known = tables.get(provider) + if known is None: + # 该渠道的目录快照没有它 → 无从判断,保守保留 + continue + if model.lower() in known and not include_known: + continue + stale.append((provider, model, credential_id, int(cooling_until))) + return stale + + +def delete_rows(conn, stale: list[tuple[str, str, str, int]]) -> int: + """删除待清理的负缓存行,返回删除条数。""" + total = 0 + for _provider, model, credential_id, _until in stale: + cursor = conn.execute( + "DELETE FROM credential_model_cooldowns " + "WHERE credential_id = ? AND model = ? AND reason = 'blocked'", + (credential_id, model)) + total += cursor.rowcount + return total + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="清理「该渠道从来没有这个模型」的负缓存行") + parser.add_argument("--db", help="SQLite 路径(默认 DATA_DIR/coding2api.sqlite3)") + parser.add_argument("--data-dir", help="数据目录(默认取数据库所在目录)") + parser.add_argument("--apply", action="store_true", + help="实际删除(默认只预览)") + parser.add_argument("--all", action="store_true", + help="连「目录里登记过」的负缓存也删(上游已恢复时用)") + args = parser.parse_args(argv) + + from src.config import load_settings + from src.db.conn import Database + + db_path = Path(args.db) if args.db else Path(load_settings().db_path) + if not db_path.is_file(): + parser.error(f"数据库不存在: {db_path}") + data_dir = Path(args.data_dir) if args.data_dir else db_path.parent + + tables = _catalog_models(data_dir) + if not tables: + print("没有可用的模型目录快照(缺 model_catalog.json 或读取失败):" + "无判据,不删任何行") + return 0 + + database = Database(db_path) + conn = database.connect() + stale = stale_rows(conn, tables, include_known=args.all) + if not stale: + print("没有需要清理的负缓存") + return 0 + for provider, model, credential_id, until in stale: + print(f" {provider:<14} {model:<28} {credential_id} " + f"到期 {until}") + total = len(stale) + scope = "全部" if args.all else "该渠道目录里没有的" + print(f"共 {total} 条负缓存({scope})") + + if not args.apply: + print("预览模式,未修改数据库;确认无误后加 --apply 执行") + return 0 + + target = backup(db_path) + deleted = delete_rows(conn, stale) + conn.commit() + print(f"已删除 {deleted} 条负缓存(备份: {target})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/create_user.py b/scripts/create_user.py index a406641b..e11249ab 100644 --- a/scripts/create_user.py +++ b/scripts/create_user.py @@ -28,7 +28,10 @@ from src.audit.actions import ACTION_USER_CREATE, ACTION_USER_DELETE # noqa: E402 from src.auth.rbac import ROLE_ADMIN, ROLES # noqa: E402 -from src.auth.users import create_password_hash # noqa: E402 +from src.auth.users import ( # noqa: E402 + FORBIDDEN_USERNAME_CHARS, + create_password_hash, +) from src.db.conn import Database # noqa: E402 from src.db.migrate import apply_schema # noqa: E402 from src.db.repo import AuditRepository, UserRepository # noqa: E402 @@ -153,8 +156,11 @@ def main(argv: list[str] | None = None) -> int: parser.error("缺少用户名(或用 --list)") if username != args.username: parser.error("用户名不能包含首尾空白") - if any(ch in username for ch in ("\n", "\r")): - parser.error("用户名不能包含换行/回车") + # 禁字符与写 users.txt 的脚本同源(`src/auth/users.py`):冒号会截断 + # `用户名:hash` 字段、换行会注入额外记录(PROPOSAL §6)。 + for char in FORBIDDEN_USERNAME_CHARS: + if char in username: + parser.error(f"用户名不能包含 {char!r}") if args.delete: if not args.force: diff --git a/scripts/hash_password.py b/scripts/hash_password.py index e5fe53a1..2821199d 100644 --- a/scripts/hash_password.py +++ b/scripts/hash_password.py @@ -19,13 +19,17 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from src.auth.users import PBKDF2_ITERATIONS, create_password_hash # noqa: E402 +from src.auth.users import ( # noqa: E402 + FORBIDDEN_USERNAME_CHARS, + PBKDF2_ITERATIONS, + create_password_hash, +) DEFAULT_OUTPUT = "secrets/users.txt" # 用户名会写进 `用户名:hash` 行:冒号会截断字段、换行/回车会注入额外记录, -# 控制字符一律拒绝。 -_FORBIDDEN_USERNAME_CHARS = (":", "\n", "\r") +# 控制字符一律拒绝。规则本体在 `src/auth/users.py`(与 CLI 建号脚本同源)。 +_FORBIDDEN_USERNAME_CHARS = FORBIDDEN_USERNAME_CHARS def read_password(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str: diff --git a/scripts/install-logrotate.sh b/scripts/install-logrotate.sh new file mode 100755 index 00000000..93a7a965 --- /dev/null +++ b/scripts/install-logrotate.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# 安装 macOS 日志轮转定时任务(launchd,无需 sudo)。 +# +# ./scripts/install-logrotate.sh # 渲染 + 安装 + 加载 +# ./scripts/install-logrotate.sh --uninstall # 停止并移除 +# ./scripts/install-logrotate.sh --now # 安装后立即跑一次轮转 +# +# 为什么不用 newsyslog: +# macOS 的 newsyslog 只有 rename 式轮转(flags 无 copytruncate),而 launchd +# 重定向给服务的 fd 仍指向旧 inode → 轮转后进程继续写已归档的文件,新路径 +# 的文件直到重启才出现,中间日志全丢(实测丢过 21.7h)。详见 +# scripts/rotate-logs.sh 顶部说明。 +# +# 模板 deploy/launchd/com.coding2api.logrotate.plist 用 __PROJECT_ROOT__ +# 占位符;launchd 不展开环境变量,路径只能在安装时渲染成绝对路径。 +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LABEL="com.coding2api.logrotate" +TEMPLATE="$ROOT/deploy/launchd/$LABEL.plist" +DEST="$HOME/Library/LaunchAgents/$LABEL.plist" +DOMAIN="gui/$(id -u)" + +if [[ "${1:-}" == "--uninstall" ]]; then + launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true + rm -f "$DEST" + echo "已卸载 $LABEL 并移除 $DEST" + exit 0 +fi + +if [[ ! -f "$TEMPLATE" ]]; then + echo "找不到模板: $TEMPLATE" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$DEST")" +# LC_ALL=C:模板含中文注释,按字节替换占位符,避免 locale 改坏 UTF-8。 +LC_ALL=C sed "s|__PROJECT_ROOT__|$ROOT|g" "$TEMPLATE" > "$DEST" + +if ! plutil -lint "$DEST" >/dev/null; then + echo "渲染后的 plist 非法,已保留现场: $DEST" >&2 + exit 1 +fi + +# 先卸载旧的(未加载时 bootout 会失败,忽略),再加载新定义。 +# bootout 是异步的:立即 bootstrap 会撞上「Input/output error」,小睡重试。 +launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true +loaded=1 +for _ in 1 2 3 4 5; do + sleep 1 + if launchctl bootstrap "$DOMAIN" "$DEST" 2>/dev/null; then + loaded=0 + break + fi +done +if [[ "$loaded" -ne 0 ]]; then + echo "bootstrap 失败:launchd 迟迟未释放旧实例,稍后手动重跑本脚本" >&2 + exit 1 +fi +echo "已安装: $DEST(每 6 小时按阈值截断式轮转)" +echo "查看状态: launchctl print $DOMAIN/$LABEL | grep -E 'state|last exit'" +echo "手动跑一次: launchctl kickstart $DOMAIN/$LABEL" + +if [[ "${1:-}" == "--now" ]]; then + echo "== 立即执行一次 ==" + "$ROOT/scripts/rotate-logs.sh" --dry-run + echo "(上面是 dry-run;真要轮转请直接跑 ./scripts/rotate-logs.sh)" +fi diff --git a/scripts/install-newsyslog.sh b/scripts/install-newsyslog.sh deleted file mode 100755 index 6f6b43b5..00000000 --- a/scripts/install-newsyslog.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# 安装 macOS newsyslog 轮转规则(需 sudo:newsyslog 只读 /etc/newsyslog.d/)。 -# -# ./scripts/install-newsyslog.sh # 安装 + 立即生效 -# ./scripts/install-newsyslog.sh --uninstall -# -# deploy/newsyslog/coding2api.conf 是**模板**:路径写成 __PROJECT_ROOT__、 -# 属主写成 __LOG_OWNER__ 占位符,避免把开发机路径 / 用户名带进仓库。 -# 本脚本在安装时替换成本机实际仓库根与当前用户:组,再写入 -# /etc/newsyslog.d/。日志位置取自 launchd plist,因此 plist 改了日志路径时 -# 需要重新跑本脚本。 -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -RULES_SRC="$ROOT/deploy/newsyslog/coding2api.conf" -RULES_DST="/etc/newsyslog.d/coding2api.conf" -LOG_OWNER="$(id -un):$(id -gn)" - -if [[ "${1:-}" == "--uninstall" ]]; then - sudo rm -f "$RULES_DST" - sudo launchctl kickstart -k system/com.apple.newsyslog - echo "已移除 $RULES_DST" - exit 0 -fi - -if [[ ! -f "$RULES_SRC" ]]; then - echo "找不到规则文件: $RULES_SRC" >&2 - exit 1 -fi - -# 渲染占位符到临时文件(LC_ALL=C:模板含中文注释,按字节替换,避免改坏 UTF-8) -RENDERED="$(mktemp)" -trap 'rm -f "$RENDERED"' EXIT -LC_ALL=C sed -e "s|__PROJECT_ROOT__|$ROOT|g" \ - -e "s|__LOG_OWNER__|$LOG_OWNER|g" "$RULES_SRC" > "$RENDERED" - -# 渲染后不应再残留占位符,否则说明模板改了字段名而脚本漏改 -if grep -q "__PROJECT_ROOT__\|__LOG_OWNER__" "$RENDERED"; then - echo "渲染后仍残留占位符,检查模板与脚本: $RULES_SRC" >&2 - exit 1 -fi - -# 校验:每条有效规则 7 列,且日志路径必须与 plist 写的位置一致 -echo "== 规则内容 ==" -cat "$RENDERED" - -FIRST_LOG="$(awk 'NF && $1 !~ /^#/ {print $1; exit}' "$RENDERED")" -if [[ ! -f "$FIRST_LOG" ]]; then - echo "警告: 规则里的日志 $FIRST_LOG 尚不存在(后端还没起过则正常)" >&2 -fi - -sudo cp "$RENDERED" "$RULES_DST" -sudo launchctl kickstart -k system/com.apple.newsyslog -echo "已安装: $RULES_DST(单文件超 10MB 轮转,保留 7 份,bzip2 压缩)" -echo "查看结果: ls -la $(dirname "$FIRST_LOG")" diff --git a/scripts/launchd-server.sh b/scripts/launchd-server.sh index f629dfba..bc5c3ed9 100755 --- a/scripts/launchd-server.sh +++ b/scripts/launchd-server.sh @@ -19,6 +19,17 @@ set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +# 残留的 newsyslog 规则会静默吞日志:它只做 rename,launchd 给进程的 fd 仍 +# 指向旧 inode → 轮转后到下次重启之间的日志全部丢失(本机实测丢过 19h,见 +# scripts/rotate-logs.sh 顶部)。仓库侧已删干净并有测试守着,但 /etc 下的 +# 副本属于本机状态、仓库管不到,只能在启动时吼一声,否则又要靠翻日志才发现。 +NEWSYSLOG_RULE="/etc/newsyslog.d/coding2api.conf" +if [[ -f "$NEWSYSLOG_RULE" ]]; then + echo "launchd-server: 警告:检测到残留的 newsyslog 规则 $NEWSYSLOG_RULE" >&2 + echo "launchd-server: 警告:它会 rename 轮转导致日志丢失,请执行:" >&2 + echo "launchd-server: sudo rm $NEWSYSLOG_RULE && sudo launchctl kickstart -k system/com.apple.newsyslog" >&2 +fi + echo "launchd-server: 构建前端(失败不阻断启动)…" if ! "$ROOT/scripts/build-web.sh"; then echo "launchd-server: 警告:前端构建失败,将使用 web/dist 里的既有产物启动" >&2 diff --git a/scripts/probe_intl_restricted.py b/scripts/probe_intl_restricted.py new file mode 100644 index 00000000..6e6dd71a --- /dev/null +++ b/scripts/probe_intl_restricted.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""一次性验证:codebuddy_intl 单个凭证在当前出口下是否仍被 11140 限制。 + +只发极少量真实请求(默认 3 次、间隔 10s),只挑请求量最少的凭证。 +用法: + uv run python scripts/probe_intl_restricted.py --requests 3 --interval 10 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from src.config import load_settings # noqa: E402 +from src.db.conn import Database # noqa: E402 +from src.db.crypto import CredentialCipher # noqa: E402 +from src.provider.base import EventKind # noqa: E402 +from src.provider.codebuddy.client import CodeBuddyClient # noqa: E402 +from src.provider.codebuddy.credential import CodeBuddyCredential # noqa: E402 +from src.provider.codebuddy.headers import INTL_ENDPOINT # noqa: E402 +from src.provider.proxy import parse_provider_proxies # noqa: E402 + +MODEL = "deepseek-v4.1-flash" + + +def pick_credential(db_path: str, secret: str) -> tuple[str, dict]: + """挑 30 天内请求量最少、且当前未被限制的 intl 凭证。""" + db = Database(db_path) + cipher = CredentialCipher(secret) + conn = db.connect() + rows = conn.execute( + "SELECT id, data_enc FROM credentials WHERE provider = 'codebuddy_intl' " + "AND enabled = 1 AND disabled = 0").fetchall() + counts = {row["credential_id"]: row["n"] for row in conn.execute( + "SELECT credential_id, count(*) n FROM usage_events " + "WHERE provider = 'codebuddy_intl' GROUP BY 1")} + if not rows: + raise SystemExit("没有可用的 codebuddy_intl 凭证") + best = min(rows, key=lambda row: counts.get(row["id"], 0)) + data = json.loads(cipher.decrypt(best["data_enc"]).decode("utf-8")) + return best["id"], data + + +def all_credentials(db_path: str, secret: str) -> list[tuple[str, dict]]: + db = Database(db_path) + cipher = CredentialCipher(secret) + rows = db.connect().execute( + "SELECT id, data_enc FROM credentials WHERE provider = 'codebuddy_intl' " + "AND enabled = 1 AND disabled = 0 ORDER BY created_at").fetchall() + return [(row["id"], json.loads(cipher.decrypt(row["data_enc"]).decode("utf-8"))) + for row in rows] + + +def pick_by_id(db_path: str, secret: str, credential_id: str) -> tuple[str, dict]: + for cid, data in all_credentials(db_path, secret): + if cid == credential_id: + return cid, data + raise SystemExit(f"凭证 {credential_id} 不存在或不可用") + + +async def attempt(client: CodeBuddyClient, data: dict) -> tuple[str, str]: + payload = { + "stream": True, + "max_tokens": 16, + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Reply with the single word: pong"}, + ], + } + text = "" + try: + # 直接调用客户端(绕过 pacer):探活只发 3 次,不需要排队节流 + iterator = client.stream_chat( + CodeBuddyCredential.from_dict(data), payload, MODEL) + async for event in iterator: + if event.kind is EventKind.ERROR: + return "ERROR", event.error_message or "-" + if event.kind is EventKind.CONTENT and event.content: + text += event.content + return ("OK", text[:60]) if text else ("EMPTY", "-") + except Exception as error: # noqa: BLE001 + return type(error).__name__, str(error)[:300] + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--requests", type=int, default=3) + parser.add_argument("--interval", type=float, default=10.0) + parser.add_argument("--credential", default="") + parser.add_argument("--all", action="store_true", help="每个 intl 凭证各测一次") + args = parser.parse_args() + + config = load_settings() + # 代理走配置里的 PROVIDER_PROXIES(与其他 probe 脚本一致),不写死本机端口: + # codebuddy_intl 通常必须走代理,但端口因机器而异。 + proxies = parse_provider_proxies(config.provider_proxies, ( + "trae", "codebuddy", "codebuddy_intl", "zen", "kilo", "qoder", "codearts")) + client = CodeBuddyClient(endpoint=INTL_ENDPOINT, require_leading_system=True, + proxy=proxies.get("codebuddy_intl")) + if args.all: + for credential_id, data in all_credentials(config.db_path, config.app_secret): + status, detail = await attempt(client, data) + print(f"{credential_id}: {status} {detail[:120]}") + await asyncio.sleep(args.interval) + return + + credential_id, data = (pick_by_id(config.db_path, config.app_secret, args.credential) + if args.credential else + pick_credential(config.db_path, config.app_secret)) + print(f"凭证 {credential_id} · 模型 {MODEL} · 出口 {config.provider_proxies}") + for i in range(args.requests): + status, detail = await attempt(client, data) + print(f" 第 {i + 1} 次: {status} {detail}") + if status != "OK": + break + if i + 1 < args.requests: + await asyncio.sleep(args.interval) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/probe_model_field.py b/scripts/probe_model_field.py new file mode 100644 index 00000000..444c9283 --- /dev/null +++ b/scripts/probe_model_field.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""临时诊断:上游(Qoder / TRAE)响应里到底有没有「真实模型名」。 + +背景:`Event.model` 观测在 Qoder 上只看到 `auto`、TRAE 上只有空串,等于盲区。 +本脚本发一次最小对话,把**原始** SSE / 信封载荷的字段名与 model 类字段打印出来, +判断是「上游根本不给」还是「给在别的键里」。 + +只读:不改库、不改 src/(只在进程内包一层解析函数)。用法: + uv run python scripts/probe_model_field.py --provider qoder --model deepseek-v4.1-flash + uv run python scripts/probe_model_field.py --provider trae --model glm-5.2 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from src.config import load_settings # noqa: E402 +from src.db.conn import Database # noqa: E402 +from src.db.crypto import CredentialCipher # noqa: E402 +from src.provider.qoder.client import QoderClient # noqa: E402 +from src.provider.qoder.events import parse_inner_chunk as qoder_parse # noqa: E402 +from src.provider.trae.client import TraeClient # noqa: E402 +from src.provider.trae.events import SSEFrame # noqa: E402 +from src.provider.trae.events import parse_frame as trae_parse # noqa: E402 + +SEEN: set[str] = set() + + +def dump_fields(label: str, obj: Any) -> None: + """打印一帧里所有「可能是模型名」的字段(每类只打一次,避免刷屏)。""" + if not isinstance(obj, dict) or label in SEEN: + return + keys = [key for key in obj if isinstance(key, str) + and ("model" in key.lower() or "route" in key.lower() + or "deploy" in key.lower() or "engine" in key.lower())] + if not keys: + return + SEEN.add(label) + print(f" [{label}] {json.dumps({k: obj.get(k) for k in keys}, ensure_ascii=False)[:400]}") + + +def load_credentials(db_path: str, secret: str, provider: str) -> list[tuple[str, dict]]: + db = Database(db_path) + cipher = CredentialCipher(secret) + rows = db.connect().execute( + "SELECT id, data_enc FROM credentials WHERE provider = ? " + "AND enabled = 1 AND disabled = 0 ORDER BY created_at", (provider,)).fetchall() + return [(row["id"], json.loads(cipher.decrypt(row["data_enc"]).decode("utf-8"))) + for row in rows] + + +def patch_qoder() -> None: + def wrapped(body: str): + try: + payload = json.loads(body) + except json.JSONDecodeError: + return qoder_parse(body) + dump_fields("qoder inner chunk", payload) + choice = (payload.get("choices") or [{}])[0] + if isinstance(choice, dict): + dump_fields("qoder choice", choice) + return qoder_parse(body) + + import src.provider.qoder.client as qoder_client + import src.provider.qoder.events as qoder_events + qoder_events.parse_inner_chunk = wrapped + qoder_client.qoder_events.parse_inner_chunk = wrapped + + +def patch_trae() -> None: + def wrapped(frame: SSEFrame): + if frame.data: + try: + payload = json.loads(frame.data) + except json.JSONDecodeError: + payload = None + dump_fields(f"trae {frame.event}", payload) + return trae_parse(frame) + + import src.provider.trae.client as trae_client + import src.provider.trae.events as trae_events + trae_events.parse_frame = wrapped + trae_client.trae_events.parse_frame = wrapped + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--provider", required=True, choices=("qoder", "trae")) + parser.add_argument("--model", required=True) + parser.add_argument("--db", default=os.environ.get("DB_PATH", "data/coding2api.sqlite3")) + args = parser.parse_args() + settings = load_settings() + + creds = load_credentials(args.db, settings.app_secret, args.provider) + if not creds: + raise SystemExit(f"没有可用的 {args.provider} 凭证") + credential_id, data = creds[0] + print(f"=== {args.provider} / {args.model} / cred {credential_id} ===") + + payload = { + "model": args.model, + "stream": True, + "max_tokens": 8, + "messages": [{"role": "user", "content": "ping"}], + } + + if args.provider == "qoder": + from src.provider.qoder.credential import QoderCredential + patch_qoder() + client = QoderClient() + credential = QoderCredential.from_dict(data) + stream = client.stream_chat(credential, payload, args.model) + else: + from src.provider.trae.credential import TraeCredential + patch_trae() + client = TraeClient() + credential = TraeCredential.from_dict(data) + stream = client.stream_chat(credential, payload, args.model) + + seen_events = 0 + try: + async for event in stream: + seen_events += 1 + if event.model: + print(f" [Event.model] {event.model!r} kind={event.kind}") + if seen_events > 400: + break + finally: + await client.aclose() + print(f"事件数 {seen_events};model 类字段见上(无输出 = 上游不回显)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/purge_failure_stats.py b/scripts/purge_failure_stats.py new file mode 100644 index 00000000..6da92e11 --- /dev/null +++ b/scripts/purge_failure_stats.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""清理失败明细(`usage_events.ok = 0`),即「失败归因」面板的数据源。 + +背景:失败归因(`GET /api/stats/errors`)直接聚合**明细表**里 `ok=0` 的行, +没有独立表可清。想让面板归零,只能删明细。本脚本与 +`cleanup_invalid_stats.py` 的区别:后者只删「跨上游白打」这一种保守判定, +本脚本按**显式过滤条件**删,用于「自己压测/联调打出来的一堆失败」这类 +确认过没有诊断价值的数据。 + +判定(必须显式给条件之一,避免误删全部历史): + - `--provider` 只删该渠道 + - `--error-type` 只删该错误类型 + - `--days N` 只删最近 N 天 + - `--all` 无条件删全部失败明细(最危险,需 `--apply --yes` 同时确认, + 且不能再叠加 `--provider` / `--error-type` / `--days`) +成功行(`ok=1`)一律不动。 + +汇总同步:`usage_hourly` 由明细全量重算(`StatsCollector.rollup_hourly` +幂等),删除后再清掉**本次删除覆盖的小时**里不再有明细对应的孤儿行,保证明细 +与汇总严格一致。只清这些小时是关键:小时汇总永久保留,而 90 天前的明细已被 +`purge_expired` 删掉,无条件清孤儿会把历史汇总一起删掉。注意副作用:失败明细 +删掉后,该小时的 `requests` 会下降、成功率会「变好」——这是删数据的必然结果, +不是统计口径变了。 + +用法: + python3 scripts/purge_failure_stats.py --provider qoder # 预览 + python3 scripts/purge_failure_stats.py --provider qoder --apply + python3 scripts/purge_failure_stats.py --all --apply --yes # 全部失败明细 + python3 scripts/purge_failure_stats.py --db PATH ... +""" + +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scripts._common import backup, hour_range, sync_hourly # noqa: E402 +from src.db.conn import Database # noqa: E402 + + +def _filters(*, provider: str | None, error_type: str | None, + days: int | None, now: int | None) -> tuple[str, list[object]]: + """构造 `ok=0` 之外的过滤条件(只允许 AND 收窄)。""" + clauses = ["ok = 0"] + params: list[object] = [] + if provider is not None: + clauses.append("provider = ?") + params.append(provider) + if error_type is not None: + clauses.append("error_type = ?") + params.append(error_type) + if days is not None: + clauses.append("ts >= ?") + params.append((now if now is not None else int(time.time())) - days * 86400) + return " AND ".join(clauses), params + + +def failure_groups(conn, *, provider: str | None = None, + error_type: str | None = None, days: int | None = None, + now: int | None = None) -> list[tuple[str, str, str | None, int, int]]: + """待删分组:`(provider, model, error_type, 条数, 最近时刻)`,按条数降序。""" + where, params = _filters(provider=provider, error_type=error_type, + days=days, now=now) + rows = conn.execute( + f"SELECT provider, model, error_type, COUNT(*) AS n, MAX(ts) AS last_ts " + f"FROM usage_events WHERE {where} " + f"GROUP BY provider, model, error_type ORDER BY n DESC, provider, model", + params).fetchall() + return [(row[0], row[1], row[2], int(row[3]), int(row[4])) for row in rows] + + +def delete_groups(conn, *, provider: str | None = None, + error_type: str | None = None, days: int | None = None, + now: int | None = None) -> int: + """按同一组条件删除失败明细,返回删除条数。""" + where, params = _filters(provider=provider, error_type=error_type, + days=days, now=now) + return conn.execute(f"DELETE FROM usage_events WHERE {where}", params).rowcount + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="清理失败明细(失败归因数据源)") + parser.add_argument("--db", help="SQLite 路径(默认 DATA_DIR/coding2api.sqlite3)") + parser.add_argument("--provider", help="只删该渠道的失败明细") + parser.add_argument("--error-type", dest="error_type", + help="只删该错误类型的失败明细") + parser.add_argument("--days", type=int, help="只删最近 N 天的失败明细") + parser.add_argument("--all", action="store_true", + help="删除全部失败明细(必须再叠加 --yes)") + parser.add_argument("--yes", action="store_true", + help="确认执行 --all(没有它只预览/拒绝)") + parser.add_argument("--apply", action="store_true", help="实际删除(默认只预览)") + args = parser.parse_args(argv) + + if not (args.all or args.provider or args.error_type or args.days): + parser.error("必须给出 --provider / --error-type / --days / --all 之一") + if args.days is not None and args.days <= 0: + parser.error("--days 必须为正整数") + if args.all and (args.provider or args.error_type or args.days): + # --all 写着「无条件删全部」,叠加收窄条件会静默只删一部分 + parser.error("--all 不能与 --provider / --error-type / --days 叠加") + if args.all and args.apply and not args.yes: + parser.error("--all --apply 需要 --yes 二次确认") + + from src.config import load_settings + + db_path = Path(args.db) if args.db else Path(load_settings().db_path) + if not db_path.is_file(): + parser.error(f"数据库不存在: {db_path}") + + database = Database(db_path) + conn = database.connect() + groups = failure_groups(conn, provider=args.provider, + error_type=args.error_type, days=args.days) + if not groups: + print("没有需要清理的记录") + return 0 + for provider, model, error_type, count, last_ts in groups: + stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(last_ts)) + print(f" {provider:<14} {model:<24} {error_type or '(未上报)':<22} " + f"x{count:<4} 最近 {stamp}") + total = sum(group[3] for group in groups) + print(f"共 {total} 条失败明细({len(groups)} 组)") + + if not args.apply: + print("预览模式,未修改数据库;确认无误后加 --apply 执行") + return 0 + + target = backup(db_path) + # 先取区间再删:删完就查不出自己动过哪些小时了 + hours = hour_range(conn, *_filters(provider=args.provider, + error_type=args.error_type, days=args.days, + now=None)) + deleted = delete_groups(conn, provider=args.provider, + error_type=args.error_type, days=args.days) + sync_hourly(conn, hours) + conn.commit() + print(f"已删除 {deleted} 条明细,小时汇总已同步(备份: {target})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/rotate-logs.sh b/scripts/rotate-logs.sh new file mode 100755 index 00000000..11b3ae57 --- /dev/null +++ b/scripts/rotate-logs.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# macOS 日志轮转:**截断式**(truncate in place),由 launchd 定时拉起。 +# +# 为什么不用 newsyslog: +# macOS 的 newsyslog 只支持 rename 式轮转(flags 只有 B C D G J N U Z, +# 没有 copytruncate——那是 Linux logrotate 的选项)。rename 之后 +# launchd 重定向给进程的 fd 仍指向**旧 inode**,进程继续往里写,而 +# 新路径下的文件直到下次重启才被创建。实测(2026-10-30): +# mv app.log app.log.old → 进程继续写 app.log.old,app.log 不存在 +# 后果就是「轮转后到下次重启之间的日志全丢」——本机实测丢过 21.7h / +# 19.6h / 2.0h / 1.2h,每次都止于一次重启,是排障盲区而不是噪声问题。 +# +# 截断为什么安全: +# launchd 以 O_APPEND 打开 StandardOutPath(实测:truncate 后进程从 +# offset 0 继续写,不留空洞),所以「先拷贝再清空」不会让后续写入 +# 写到旧偏移上。这也是 Linux 侧 deploy/logrotate/coding2api 用 +# copytruncate 的同一理由,两边口径一致。 +# +# **只用 bash 3.2 可用的语法**:macOS 自带 /bin/bash 是 3.2,没有 +# mapfile / readarray。launchd 拉起本脚本时不走登录 shell,拿到的就是它。 +# +# 用法: +# ./scripts/rotate-logs.sh # 按阈值轮转 +# ./scripts/rotate-logs.sh --dry-run # 只报告会做什么 +# ./scripts/rotate-logs.sh --force # 忽略阈值,立即轮转 +# +# 由 deploy/launchd/com.coding2api.logrotate.plist 定时调用 +# (scripts/install-logrotate.sh 安装)。 +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +#: 日志目录可用环境变量覆盖:**测试必须覆盖**,否则会把真实 logs/ 轮转掉 +#: (本脚本按自身位置解析 ROOT,在别的目录 cd 进去也改不了目标)。 +LOGS="${ROTATE_LOGS_DIR:-$ROOT/logs}" + +#: 单文件超这个大小(KB)就轮转,与旧 newsyslog 规则的 10240 一致。 +MAX_KB="${ROTATE_MAX_KB:-10240}" +#: 每类日志保留几份归档(不含当前文件),与旧规则 count=7 一致。 +KEEP="${ROTATE_KEEP:-7}" + +DRY_RUN=0 +FORCE=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + *) echo "未知参数: $arg" >&2; exit 2 ;; + esac +done + +STAMP="$(date +%Y%m%d-%H%M%S)" +rotated=0 + +# 取文件字节数。不用 stat:BSD 是 `-f %z`、GNU 是 `-c %s`,而 GNU 的 +# `stat -f` 会**成功**返回文件系统信息(不是报错),`||` 兜底不会触发。 +# `wc -c` 两边行为一致。本脚本只在 macOS 跑,但测试在 Linux CI 真跑, +# 所以必须跨平台——不要为了「只在 macOS」而让 CI 红。 +file_size() { + wc -c < "$1" | tr -d ' ' +} + +# 固定四个日志(本体两路 + frpc 两路),不 glob:glob 在无匹配时会让 +# 后续 ls 把当前目录当成参数,从而误删。 +LOGS_LIST="launchd.out.log launchd.err.log frpc.out.log frpc.err.log" + +for name in $LOGS_LIST; do + file="$LOGS/$name" + [[ -f "$file" ]] || continue + + bytes=$(file_size "$file") + # 空文件不值得归档(notifempty 的等价物) + (( bytes == 0 )) && continue + + size_kb=$(( bytes / 1024 )) + if (( ! FORCE && size_kb < MAX_KB )); then + continue + fi + + archive="$file.$STAMP" + if (( DRY_RUN )); then + echo "[dry-run] $file (${size_kb}KB) → $(basename "$archive").bz2,然后截断" + rotated=$((rotated + 1)) + continue + fi + + # 顺序即安全性:先拷出内容、压缩成功、再截断。 + # 任何一步失败都不截断——宁可继续涨,也不能丢还没归档的日志。 + if ! cp -p "$file" "$archive"; then + echo "轮转失败(拷贝):$file" >&2 + rm -f "$archive" + continue + fi + if ! bzip2 -f "$archive"; then + echo "轮转失败(压缩):$file,保留原文件不截断" >&2 + rm -f "$archive" + continue + fi + if ! : > "$file"; then + echo "轮转失败(截断):$file" >&2 + continue + fi + rotated=$((rotated + 1)) + echo "已轮转 $file → $(basename "$archive").bz2(${size_kb}KB)" +done + +# 清理超额归档:按 mtime 倒序保留最近 KEEP 份。 +# 用 while read 而不是 mapfile(bash 3.2 没有);ls 前先确认有匹配, +# 否则无匹配时 ls 会列出当前目录。 +if (( KEEP > 0 )); then + for name in $LOGS_LIST; do + # shellcheck disable=SC2086 # 需要 glob 展开 + set -- "$LOGS/$name".*.bz2 + [[ -f "$1" ]] || continue # 无匹配时 glob 原样保留,-f 判否 + ls -1t "$@" 2>/dev/null | tail -n +$((KEEP + 1)) | while IFS= read -r stale; do + if (( DRY_RUN )); then + echo "[dry-run] 删除旧归档 $(basename "$stale")" + else + rm -f "$stale" + fi + done + done +fi + +exit 0 diff --git a/scripts/rotate_app_secret.py b/scripts/rotate_app_secret.py new file mode 100644 index 00000000..3d3ada7d --- /dev/null +++ b/scripts/rotate_app_secret.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""原地轮换 APP_SECRET:用旧密钥解密全部凭证、用新密钥重新加密。 + +`src/db/crypto.py` 从 APP_SECRET 派生 Fernet 密钥(无盐单次 SHA-256), +**换掉 APP_SECRET 等于换掉密钥**:不做这一步,已有凭证全部不可解,只能重录。 +PROPOSAL §6 明确「不做密钥轮换」,所以这里刻意做成一次性运维脚本而不是启动 +期的自动迁移——轮换是需要停机 + 备份 + 人工确认的动作。 + +用法(**先停机**,否则运行中的进程会继续用旧密钥写库): + + python3 scripts/backup_db.py # 1. 先留一份一致性备份 + python3 scripts/rotate_app_secret.py \\ + --old-secret-file /path/to/old.env \\ + --new-secret-file /path/to/new.env # 2. 重新加密 + # 3. 把 .env 的 APP_SECRET 换成新值,chmod 600 .env,重启服务 + +`--old-secret-file` / `--new-secret-file` 接受 `.env` 文件或只含密钥文本的文件 +(自动识别 `APP_SECRET=` 前缀)。不要把密钥写在命令行参数里:会进 shell +history 与 `ps`。 + +脚本是**幂等安全**的:所有行先解密再加密,全部成功才在一个事务里提交; +任何一条解不开就整体回滚,库保持原样。 +""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from src.db.crypto import CredentialCipher # noqa: E402 + + +def _read_secret(path: Path) -> str: + """从 `.env` 或纯文本文件读密钥;拒绝空值与含换行的可疑内容。""" + text = path.read_text(encoding="utf-8") + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("APP_SECRET="): + value = stripped.split("=", 1)[1].strip().strip("'\"") + if not value: + raise SystemExit(f"{path}: APP_SECRET 为空") + return value + # 没有 `APP_SECRET=` 前缀:把整个文件当密钥(单行) + value = text.strip() + if not value: + raise SystemExit(f"{path}: 文件为空") + if "\n" in value: + raise SystemExit(f"{path}: 既不是 .env 也不是单行密钥") + return value + + +def rotate(db_path: Path, old_secret: str, new_secret: str) -> int: + """把 `credentials.data_enc` 全部用新密钥重写;返回处理行数。""" + old = CredentialCipher(old_secret, allow_known_weak=True) + new = CredentialCipher(new_secret) + if old_secret == new_secret: + raise SystemExit("新旧 APP_SECRET 相同,无需轮换") + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute("SELECT id, data_enc FROM credentials").fetchall() + # 先在内存里把每一行都解出来:任何一条失败就在写库前中止, + # 避免「改了一半」留下新旧密钥混杂、谁也用不了的库。 + prepared: list[tuple[bytes, str]] = [] + for row in rows: + try: + plaintext = old.decrypt(row["data_enc"]) + except Exception as error: # noqa: BLE001 - 逐行报告,整体中止 + raise SystemExit( + f"凭证 {row['id']} 无法用旧密钥解密({type(error).__name__}):" + "旧密钥不对,或该行已被别的密钥加密。未做任何修改。") from error + # 校验解出来是合法 JSON:坏行在这里暴露,而不是等到运行时 + try: + json.loads(plaintext.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise SystemExit( + f"凭证 {row['id']} 解密后不是合法 JSON:{error}") from error + prepared.append((new.encrypt(plaintext), row["id"])) + + with conn: # 单事务:全成或全不成 + for ciphertext, credential_id in prepared: + conn.execute("UPDATE credentials SET data_enc = ? WHERE id = ?", + (ciphertext, credential_id)) + return len(prepared) + finally: + conn.close() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="轮换 APP_SECRET 并重加密全部凭证") + parser.add_argument("--db", required=True, help="SQLite 主库路径") + parser.add_argument("--old-secret-file", required=True, + help="含旧 APP_SECRET 的文件(.env 或单行文本)") + parser.add_argument("--new-secret-file", required=True, + help="含新 APP_SECRET 的文件(.env 或单行文本)") + args = parser.parse_args(argv) + + db_path = Path(args.db) + if not db_path.is_file(): + parser.error(f"数据库不存在: {db_path}") + count = rotate(db_path, + _read_secret(Path(args.old_secret_file)), + _read_secret(Path(args.new_secret_file))) + print(f"已用新密钥重新加密 {count} 条凭证。") + print("下一步:把 .env 的 APP_SECRET 换成新值、chmod 600 .env、重启服务。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/admin_auth.py b/src/api/admin_auth.py index 3a5d76ff..fc4d69e1 100644 --- a/src/api/admin_auth.py +++ b/src/api/admin_auth.py @@ -7,12 +7,16 @@ from __future__ import annotations import inspect +import ipaddress +import logging import time +from urllib.parse import parse_qs, urlparse from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from ..audit.actions import ( + ACTION_CREDENTIAL_IMPORT, ACTION_LOGIN_FAILURE, ACTION_LOGIN_SUCCESS, ACTION_USER_PASSWORD_CHANGE, @@ -22,9 +26,14 @@ from ..auth.users import create_password_hash, verify_password from ..compat.openai.request import InvalidRequest from ..config import Settings +from ..provider.base import AuthResult +from ..provider.trae.callback import login_trace_id from ..webapp.security import session_cookie_secure +from .authorize import PENDING_CALLBACK_TTL_SECONDS +from .credential_login import save_login_credential from .deps import ( SESSION_COOKIE, + Principal, Services, csrf_protected, origin_protected, @@ -35,10 +44,83 @@ MIN_PASSWORD_LENGTH = 8 ACTIVATION_TTL_SECONDS = 24 * 3600 +logger = logging.getLogger(__name__) -def resolve_public_callback_url(settings: Settings) -> str: - """PUBLIC_BASE_URL + /authorize(远程部署必须可被浏览器访问)。""" - return settings.public_base_url.rstrip("/") + "/authorize" + +# 浏览器打不开的基址主机:回环与通配监听。PUBLIC_BASE_URL 默认值就是 +# 127.0.0.1:8000,远程/容器部署忘了改时,回调会跳到**用户自己这台机器**, +# 浏览器既回不到服务也不报错(issue #21)。 +_LOCALHOST_NAMES = frozenset({"localhost"}) + + +def _host_of(authority: str) -> str: + """从 `host[:port]` / `scheme://host[:port]/path` 取出小写主机名。""" + text = authority.strip().split("://", 1)[-1].split("/", 1)[0] + if text.startswith("["): + return text.partition("]")[0].lstrip("[").lower() + return text.split(":", 1)[0].lower() + + +def _browser_cannot_reach(host: str) -> bool: + """回环 / 通配监听:浏览器打开它到不了本服务(或到不了发请求的那个人)。""" + if host in _LOCALHOST_NAMES: + return True + try: + address = ipaddress.ip_address(host) + except ValueError: # 普通域名 / 无效字面量:按可达处理 + return False + return address.is_loopback or address.is_unspecified + + +def _request_origin(request: Request) -> str: + """当前请求的浏览器 origin(采信反代/隧道的 X-Forwarded-Proto)。""" + proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower() + if proto not in ("http", "https"): + proto = request.url.scheme + authority = request.headers.get("host", "") or request.url.netloc + return f"{proto}://{authority}" + + +def _record_oauth_import(services: Services, principal: Principal, provider_id: str, + credential_id: str, *, merged: bool = False) -> None: + """OAuth 登录落库的审计行(与手工导入 `credential.import` 同一 action)。 + + 三条 OAuth 路径(poll / 粘贴回调 / `/authorize`)原来都**不写审计**,于是 + 「谁什么时候把号塞进池子」只能从手工导入那一条回答——登录进来的号在审计 + 页上完全消失。这里统一补上,action 与手工导入一致,管理台的「导入凭证」 + 过滤能同时看到两条来源,detail 里区分渠道与「新增 / 重新登录合并」。 + + 两条分支只差「动作名 + detail」,日志与审计的其余字段一致:合并写一处, + 以后再改字段不必同时动两段。 + """ + action = "重新登录并更新" if merged else "新增" + outcome = "合并已有凭证" if merged else "登录" + logger.info("管理员 %s 通过 OAuth %s凭证 %s(上游 %s)", + principal.username, action, credential_id, provider_id) + services.audit.record(actor=principal.username, action=ACTION_CREDENTIAL_IMPORT, + target=credential_id, + detail=f"上游 {provider_id};来源 OAuth {outcome}") + + +def resolve_public_callback_url(settings: Settings, + request: Request | None = None) -> str: + """回调基址 + /authorize。 + + `PUBLIC_BASE_URL` 是权威值,但它有两个已知的「静默失效」形态: + 远程部署忘了改(默认 `http://127.0.0.1:8000` 指向用户自己的机器)、 + docker-compose 只透传了默认值。这两种情况下回调地址由**浏览器**打开, + 必然是坏的,而用户只会看到「授权完成但没回调」。 + + 因此基址落在回环/通配主机、而当前请求来自别的主机时,改用请求自身的 + origin:能打到本服务的那个地址,浏览器同样能打到。请求已通过 + `host_allowed` 白名单,故这里采信 Host 头不会放大攻击面。 + """ + base = settings.public_base_url.rstrip("/") + if request is not None and _browser_cannot_reach(_host_of(base)): + request_host = _host_of(request.headers.get("host", "") or request.url.netloc) + if request_host and not _browser_cannot_reach(request_host): + base = _request_origin(request).rstrip("/") + return base + "/authorize" def issue_session(response: JSONResponse, services: Services, username: str, *, @@ -63,6 +145,48 @@ def _record_login(services: Services, *, username: str, ok: bool, ip: str) -> No detail="" if ok else "密码错误或用户不存在") +async def _complete_callback_paste(services: Services, pending: dict[str, tuple[float, str]], + username: str, provider_id: str, + raw_url: str, state: str) -> AuthResult: + """callback 轨道(TRAE)的粘贴兑换:按发起人的 pending 登录完成回调。 + + 与 `/authorize` 的差别只在**入口**:那边是浏览器 302(无鉴权,靠 + `loginTraceID` 绑定),这边是已鉴权的管理台写请求,因此用会话里的 + 用户名直接定位 pending。仍复用同一条 TTL,且 state 必须与 pending 一致 + (`/authorize` 是从 pending 里取 state,这边是用户可能贴错链接)。 + + 粘回来的地址若带 `loginTraceID`,必须与这次登录的 trace 相符——防止 + 用户把另一次登录(另一个浏览器标签)的回调贴进来,把两边的 + machine/device id 搞混。 + """ + provider = services.registry.get(provider_id) + completer = getattr(provider, "complete_callback", None) + if provider is None or not callable(completer): + raise InvalidRequest( + f"provider {provider_id!r} does not support callback completion") + entry = pending.get(username) + if entry is None: + raise InvalidRequest( + "没有进行中的登录。请回到管理台重新点击「登录」,再把回调地址粘回来。") + started_at, pending_state = entry + if time.monotonic() - started_at > PENDING_CALLBACK_TTL_SECONDS: + pending.pop(username, None) + raise InvalidRequest( + f"这次登录已超过 {PENDING_CALLBACK_TTL_SECONDS // 60} 分钟有效期," + "请回到管理台重新发起登录。") + if state and state != pending_state: + raise InvalidRequest( + "粘贴的回调地址不属于当前这次登录,请重新发起登录后再粘贴。") + pasted_trace = (parse_qs(urlparse(raw_url).query).get("loginTraceID") or [""])[0] + machine_id, _, device_id = pending_state.partition(":") + if pasted_trace and pasted_trace != login_trace_id(machine_id, device_id): + raise InvalidRequest( + "粘贴的回调地址不属于当前这次登录,请重新发起登录后再粘贴。") + pending.pop(username, None) + data = await completer(raw_url, pending_state) + return AuthResult(credential_data=data, nickname=str(data.get("nickname") or "")) + + def create_router(services: Services) -> APIRouter: router = APIRouter() @@ -125,6 +249,12 @@ async def change_password(request: Request, payload: dict, """自助改密:当前密码 + 新密码。任意角色可用(首登强制改密也走这里)。 当前密码必须正确:会话被盗时不能直接改密把主人锁在外面。 + + **PBKDF2 必须离开事件循环**:`verify_password` / `create_password_hash` + 各 600k 迭代(实测单次约 45ms,两次近 100ms)。同步调用会独占事件循环 + ——实测 10 次连续调用期间 1ms 心跳一次都没跑起来,20 并发改密足以让 + SSE、`/health`、`/v1` 全部排队。登录路径早已用 `throttle.verify` + (线程池 + 信号量)躲开这个坑,这里必须同口径。 """ current = str(payload.get("current_password") or "") new_password = str(payload.get("new_password") or "") @@ -132,10 +262,20 @@ async def change_password(request: Request, payload: dict, raise InvalidRequest( f"new password must be at least {MIN_PASSWORD_LENGTH} characters") row = services.user_repo.get(principal.username) - if row is None or not verify_password(current, row["password_hash"]): + # 复用登录限流器:它同时给出「线程池 + PBKDF2 并发上限」与「猜当前密码 + # 的失败计数」。后者原先完全缺失——会话被盗后可无限次试当前密码。 + # 这里可以**先查后验**(登录路径必须后查):调用方已通过会话认证为该用户, + # 用户名窗口只会被他自己或攻击者用同一用户名抬高,与登录同口径接受。 + throttle = services.login_throttle + source_ip = request_ip(request, services.settings) + throttle.check(ip=source_ip, username=principal.username) + verified = (row is not None and await throttle.verify( + verify_password, current, row["password_hash"])) + if not verified: + throttle.record_failure(ip=source_ip, username=principal.username) raise InvalidRequest("current password is incorrect") - services.user_repo.set_password( - principal.username, create_password_hash(new_password)) + hashed = await throttle.verify(create_password_hash, new_password) + services.user_repo.set_password(principal.username, hashed) services.audit.record(actor=principal.username, action=ACTION_USER_PASSWORD_CHANGE, target=principal.username, @@ -169,7 +309,7 @@ async def upstream_auth_start(request: Request, payload: dict, builder = getattr(provider, "start_auth", None) if not callable(builder): raise InvalidRequest(f"provider {provider_id!r} does not support login") - session = builder(resolve_public_callback_url(services.settings)) + session = builder(resolve_public_callback_url(services.settings, request)) # 按发起人分键登记待完成回调:单槽全局态会让两个管理员的 # 并发登录互相覆盖,把 A 的凭证记到 B 名下;按用户名分键后, # 同网段的人也无法用自己的 refreshToken 顶掉别人进行中的登录。 @@ -192,47 +332,76 @@ async def upstream_auth_poll(payload: dict, result = await oauth.poll(state, principal.username) if result is None: return {"status": "pending"} - # 登录成功:直接落库,绝不在响应里回传 token - credential_id = services.credentials.add( - provider=provider_id, credential_data=result.credential_data, + # 登录成功:直接落库,绝不在响应里回传 token。 + # 同一上游账号再次登录 = 更新既有凭证,避免池子里出现重复账号。 + credential_id, merged = await save_login_credential( + services, provider_id=provider_id, + credential_data=result.credential_data, nickname=result.nickname, added_by=principal.username) + _record_oauth_import(services, principal, provider_id, credential_id, + merged=merged) services.schedule_probe(credential_id) - return {"status": "success", "credential_id": credential_id} + return {"status": "success", "credential_id": credential_id, "merged": merged} @router.post("/api/auth/upstream/cancel") - async def upstream_auth_cancel(payload: dict, + async def upstream_auth_cancel(request: Request, payload: dict, _csrf: None = Depends(csrf_protected), principal=Depends(principal_from_request)): require_admin(principal) - oauth = services.upstream_auth.get(str(payload.get("provider") or "")) + provider_id = str(payload.get("provider") or "") + state = str(payload.get("state") or "") + oauth = services.upstream_auth.get(provider_id) if oauth is None: + # callback 轨道(TRAE)没有上游轮询会话可取消,但要清掉待完成 + # 登录,否则取消后它还会挂到 TTL 到期,期间用户重新点「登录」 + # 会被这条僵尸 pending 抢走(按用户名分键,后写覆盖前写, + # 本身不会错记,但残留条目会让「已取消」的假象持续)。 + pending = request.app.state.pending_callbacks + entry = pending.get(principal.username) + if (provider_id in services.registry and entry is not None + and (not state or entry[1] == state)): + pending.pop(principal.username, None) + return {"cancelled": True} raise InvalidRequest("provider does not support polling login") - cancelled = oauth.store.cancel(str(payload.get("state") or ""), principal.username) + cancelled = oauth.store.cancel(state, principal.username) return {"cancelled": cancelled} @router.post("/api/auth/upstream/complete") - async def upstream_auth_complete(payload: dict, + async def upstream_auth_complete(request: Request, payload: dict, _csrf: None = Depends(csrf_protected), principal=Depends(principal_from_request)): - """粘贴回调链接完成登录(CodeArts 等回调落不到本服务的渠道)。 + """粘贴回调链接完成登录(回调落不到本服务、或浏览器打不开回调地址的渠道)。 + + 两条路: + + - **paste 轨道**(CodeArts 等):服务端从 start 起就登记了 PKCE/ticket + 会话,`oauth.complete_callback(url, state, username)` 自己按 state 找。 + - **callback 轨道**(TRAE):回调本应 302 回本服务 `/authorize`,但 + 远程 / NAS 部署里 TRAE 侧跳过去常直接「网络错误」,refreshToken 只 + 存在浏览器地址栏里,服务端永远收不到(issue #21 后半段)。用户把 + 那条地址整条粘回来,这里用与 `/authorize` 相同的 pending 绑定换 token。 - 用户把门户回跳地址(`http://127.0.0.1:{port}/oauth/callback?code=…`) - 粘回来,服务端用其中的 code + start 时登记的 PKCE 私钥换 token, 与 `poll` 一样直接落库、不回传 token。 """ require_admin(principal) provider_id = str(payload.get("provider") or "") + raw_url = str(payload.get("url") or "") + state = str(payload.get("state") or "") oauth = services.upstream_auth.get(provider_id) completer = getattr(oauth, "complete_callback", None) - if oauth is None or not callable(completer): - raise InvalidRequest( - f"provider {provider_id!r} does not support callback completion") - result = await completer(str(payload.get("url") or ""), - str(payload.get("state") or ""), principal.username) - credential_id = services.credentials.add( - provider=provider_id, credential_data=result.credential_data, + if oauth is not None and callable(completer): + result = await completer(raw_url, state, principal.username) + else: + result = await _complete_callback_paste( + services, request.app.state.pending_callbacks, + principal.username, provider_id, raw_url, state) + credential_id, merged = await save_login_credential( + services, provider_id=provider_id, + credential_data=result.credential_data, nickname=result.nickname, added_by=principal.username) + _record_oauth_import(services, principal, provider_id, credential_id, + merged=merged) services.schedule_probe(credential_id) - return {"status": "success", "credential_id": credential_id} + return {"status": "success", "credential_id": credential_id, "merged": merged} return router diff --git a/src/api/admin_backups.py b/src/api/admin_backups.py new file mode 100644 index 00000000..297c5888 --- /dev/null +++ b/src/api/admin_backups.py @@ -0,0 +1,143 @@ +"""备份管理:列表 / 立即备份 / 下载 / 删除 / 上传恢复。 + +恢复**不在运行中换库**:上传文件先校验(完整性 + 核心表 + schema 版本),落到 +`DATA_DIR/restore/pending.sqlite3`,下次启动在建连接之前原子替换(见 +`db/backup.apply_pending_restore`)。这样旧连接与 WAL 不会和新库拼在一起。 + +下载只认 `backups/` 下的普通 `*.sqlite3` 文件:拒绝路径分隔符、`..`、符号 +链接,避免把任意文件当备份下发。 +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import FileResponse + +from ..audit.actions import ( + ACTION_BACKUP_CREATE, + ACTION_BACKUP_DELETE, + ACTION_BACKUP_DOWNLOAD, + ACTION_BACKUP_RESTORE, +) +from ..auth.rbac import require_admin +from ..compat.openai.request import InvalidRequest +from ..db.backup import ( + backup_database, + backup_dir, + pending_restore_info, + stage_restore, +) +from .deps import Services, csrf_protected, principal_from_request + +logger = logging.getLogger(__name__) + +# 上传上限:备份是整库文件,正常几十 MB;512MB 足够兜住异常体积又不至于让 +# 单请求把内存吃光(FastAPI 的原始 body 读取是全量的)。 +# **必须与 `webapp/limits.BACKUP_UPLOAD_LIMIT` 同值**:中间件先于端点裁决, +# 不同值时端点声称的 512MB 永远不可达,大库的「恢复备份」静默失效。 +MAX_UPLOAD_BYTES = 512 * 1024 * 1024 + + +def _entries(directory: Path) -> list[dict]: + """备份目录下的普通 sqlite 文件(含手工放入的),按 mtime 倒序。""" + if not directory.is_dir(): + return [] + items = [] + for path in directory.glob("*.sqlite3"): + if path.is_symlink() or not path.is_file(): + continue + stat = path.stat() + items.append({"name": path.name, "size": stat.st_size, + "created_at": int(stat.st_mtime)}) + items.sort(key=lambda item: item["created_at"], reverse=True) + return items + + +def _resolve(directory: Path, name: str) -> Path: + """把文件名收敛到备份目录内的普通文件;非法名/不存在一律 400。""" + if not name or Path(name).name != name or not name.endswith(".sqlite3"): + raise InvalidRequest("backup name must be a plain *.sqlite3 filename") + path = directory / name + if path.is_symlink() or not path.is_file(): + raise InvalidRequest("backup not found") + return path + + +def create_router(services: Services) -> APIRouter: + router = APIRouter() + runtime = services.settings + db_path = runtime.db_path + + @router.get("/api/backups") + async def list_backups(principal=Depends(principal_from_request)): + require_admin(principal) + return {"backups": _entries(backup_dir(db_path)), + # 已上传、等重启生效的恢复文件(页面顶部提示) + "pending": pending_restore_info(db_path)} + + @router.post("/api/backups") + async def create_backup(_csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + require_admin(principal) + try: + # VACUUM INTO 是阻塞 IO;大库时不能占着事件循环 + target = await asyncio.to_thread( + backup_database, db_path, keep=runtime.backup_keep) + except (OSError, RuntimeError) as error: + raise InvalidRequest(str(error)) from error + stat = target.stat() + logger.info("管理员 %s 手动备份 %s(%d 字节)", + principal.username, target.name, stat.st_size) + services.audit.record(actor=principal.username, action=ACTION_BACKUP_CREATE, + target=target.name, detail=f"size={stat.st_size}") + return {"backup": {"name": target.name, "size": stat.st_size, + "created_at": int(stat.st_mtime)}} + + @router.get("/api/backups/{name}") + async def download_backup(name: str, + principal=Depends(principal_from_request)): + require_admin(principal) + path = _resolve(backup_dir(db_path), name) + services.audit.record(actor=principal.username, action=ACTION_BACKUP_DOWNLOAD, + target=name) + return FileResponse(path, media_type="application/x-sqlite3", filename=name) + + @router.delete("/api/backups/{name}") + async def delete_backup(name: str, _csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + require_admin(principal) + path = _resolve(backup_dir(db_path), name) + path.unlink() + logger.info("管理员 %s 删除备份 %s", principal.username, name) + services.audit.record(actor=principal.username, action=ACTION_BACKUP_DELETE, + target=name) + return {"ok": True} + + @router.post("/api/backups/restore") + async def upload_restore(request: Request, filename: str = "", + _csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + """上传备份文件 → 校验 → 暂存;响应 `restart_required=true`。""" + require_admin(principal) + # 读完再判体积:Starlette 的 body() 本来就是全量读取,预检 Content-Length + # 只多了「客户端可撒谎」的分支;上限由下面的实际长度兜住。 + body = await request.body() + if len(body) > MAX_UPLOAD_BYTES: + raise InvalidRequest(f"备份文件不能超过 {MAX_UPLOAD_BYTES // (1024 * 1024)}MB") + try: + meta = await asyncio.to_thread( + stage_restore, db_path, body, filename=filename) + except (OSError, RuntimeError) as error: + raise InvalidRequest(str(error)) from error + logger.warning("管理员 %s 上传待恢复备份 %s(%d 字节),重启后生效", + principal.username, meta["filename"], meta["size"]) + services.audit.record(actor=principal.username, action=ACTION_BACKUP_RESTORE, + target=meta["filename"], + detail=f"size={meta['size']};重启后生效") + return {"staged": True, "restart_required": True, **meta} + + return router diff --git a/src/api/admin_billing.py b/src/api/admin_billing.py new file mode 100644 index 00000000..37268f00 --- /dev/null +++ b/src/api/admin_billing.py @@ -0,0 +1,117 @@ +"""月度账单:按人 / 按 Key 聚合 + CSV / JSONL 导出。 + +管理面端点(`require_operator`):账单天然是池级数据,viewer 拿不到—— +与明细导出同口径。时间窗按**服务器本地时区**的整月计算(运维看的「10 月账单」 +就是本地 10 月,不是 UTC 10 月)。 + +三种格式共用同一份查询结果: + +- `json`(默认):`{month, start, end, users, keys, totals}`,前端页面直接渲染; +- `csv`:按人汇总,带 BOM(Excel 打开中文表头不乱码); +- `jsonl`:该月**逐条明细**(一行一个 JSON 对象),便于下游账务系统二次聚合。 + 明细只保留 90 天,很早的月份会得到空文件——这是保留策略,不是 bug。 + +导出同样写审计(与 `GET /api/stats/events?format=csv` 一致):谁在什么时候 +把哪个月的账单拉走了。 +""" + +from __future__ import annotations + +import json +import time +from collections.abc import Iterator + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse + +from ..audit.actions import ACTION_DATA_EXPORT +from ..auth.rbac import require_operator +from ..compat.openai.request import InvalidRequest +from ..stats.csv import CSV_BOM, csv_line +from .deps import Services, principal_from_request, request_ip + +# 账单 CSV 的列(与前端表格列同源;顺序固定,下游按列名解析而不是按位置) +BILLING_HEADERS: tuple[str, ...] = ( + "username", "requests", "ok_count", "input_tokens", "output_tokens", + "cached_tokens", "credit", "cost_cny", "success_rate", +) + + +def month_range(month: str) -> tuple[int, int]: + """`YYYY-MM` → 本地时区 `[月初, 下月初)` 的 epoch 秒;格式错误抛 InvalidRequest。""" + parts = month.split("-") + if len(parts) != 2 or len(parts[0]) != 4 or len(parts[1]) != 2: + raise InvalidRequest("month 必须是 YYYY-MM") + try: + year, mon = int(parts[0]), int(parts[1]) + except ValueError as error: + raise InvalidRequest("month 必须是 YYYY-MM") from error + # 4 位年份上限由格式校验保证(`len(parts[0]) == 4` → ≤ 9999),这里只挡下界 + if year < 1970 or not 1 <= mon <= 12: + raise InvalidRequest("month 超出有效范围") + nxt = (year + 1, 1) if mon == 12 else (year, mon + 1) + start = int(time.mktime((year, mon, 1, 0, 0, 0, 0, 0, -1))) + end = int(time.mktime((nxt[0], nxt[1], 1, 0, 0, 0, 0, 0, -1))) + return start, end + + +def _cell(value: object) -> str: + return "" if value is None else str(value) + + +def _users_csv(users: list[dict]) -> Iterator[str]: + yield CSV_BOM + csv_line(list(BILLING_HEADERS)) + for row in users: + yield csv_line([_cell(row.get(column)) for column in BILLING_HEADERS]) + + +def _events_jsonl(rows: list[dict]) -> Iterator[str]: + for row in rows: + yield json.dumps(row, ensure_ascii=False) + "\n" + + +def create_router(services: Services) -> APIRouter: + router = APIRouter() + + @router.get("/api/billing") + async def billing(request: Request, + principal=Depends(principal_from_request), + month: str | None = None, + format: str = "json"): + """月度账单(默认当月)。`format` ∈ json / csv / jsonl。""" + require_operator(principal) + if format not in ("json", "csv", "jsonl"): + raise InvalidRequest(f"不支持的 format: {format!r}") + now = int(time.time()) + current = time.strftime("%Y-%m", time.localtime(now)) + chosen = month or current + start, end = month_range(chosen) + if format == "jsonl": + rows = services.stats_query.export_events(since=start, until=end) + services.audit.record( + actor=principal.username, action=ACTION_DATA_EXPORT, + target="billing", detail=f"JSONL 账单 {chosen} {len(rows)} 行", + ip=request_ip(request, services.settings)) + return StreamingResponse( + _events_jsonl(rows), media_type="application/x-ndjson; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="billing-{chosen}.jsonl"', + "X-Export-Row-Count": str(len(rows)), + "Cache-Control": "no-store", + }) + report = services.stats_query.billing(start=start, end=end) + if format == "csv": + services.audit.record( + actor=principal.username, action=ACTION_DATA_EXPORT, + target="billing", + detail=f"CSV 账单 {chosen} {len(report['users'])} 人", + ip=request_ip(request, services.settings)) + return StreamingResponse( + _users_csv(report["users"]), media_type="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="billing-{chosen}.csv"', + "Cache-Control": "no-store", + }) + return {"month": chosen, **report} + + return router diff --git a/src/api/admin_credentials.py b/src/api/admin_credentials.py index aae2b981..4f49d1ff 100644 --- a/src/api/admin_credentials.py +++ b/src/api/admin_credentials.py @@ -10,14 +10,18 @@ from __future__ import annotations import asyncio +import json import logging import math +import time import httpx from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse from ..audit.actions import ( ACTION_CREDENTIAL_DELETE, + ACTION_CREDENTIAL_EXPORT, ACTION_CREDENTIAL_IMPORT, ACTION_CREDENTIAL_LIMITS, ACTION_CREDENTIAL_PIN, @@ -25,10 +29,17 @@ ACTION_CREDENTIAL_SWITCH_ACCOUNT, ACTION_CREDENTIAL_TAGS, ACTION_CREDENTIAL_TOGGLE, + ACTION_CREDENTIAL_WEB_COOKIE, + ACTION_CREDENTIAL_WEIGHT, ) from ..auth.rbac import require_operator from ..compat.openai.request import InvalidRequest -from ..provider.base import GrowthResult, GrowthStep, StepStatus +from ..engine.limits import day_start +from ..provider.base import CheckinResult, GrowthResult, GrowthStep, StepStatus +from ..provider.login_identity import login_identities_for +from ..provider.probe_failure import relogin_reason +from ..provider.tos import tos_for +from ..request_context import credential_scope from ..sorting import sort_rows from ..tags import parse_tags from .deps import Services, csrf_protected, principal_from_request @@ -53,6 +64,19 @@ # 同时排进事件循环。 BATCH_MAX_IDS = 200 +# 批量导入上限:每条都要解密去重 + 写库 + 入队探测,允许无上限等于允许一次 +# 请求里塞几千次数据库写。100 条覆盖正常「换号」场景,又不会把事件循环占死。 +BATCH_IMPORT_MAX_ITEMS = 100 + +# 网页 Cookie 的最大长度:正常一条 Qoder 会话 Cookie 几百字符,1 万已远超; +# 上限只为拦住误粘贴整段请求头/文件内容,不做格式校验(Cookie 结构随上游变)。 +WEB_COOKIE_MAX_LENGTH = 10_000 + +# 调度权重范围:正数越优先、负数越靠后,0 = 默认。上限只为拦住误输入, +# 不参与归一化(同档内按数值稳定排序)。 +WEIGHT_MIN = -1000 +WEIGHT_MAX = 1000 + # 批量的在途并发上限。后台任务用 Pacer 严格串行化上游请求,但那是跨任务 # 节流;批量端点由操作员手动触发,若完全串行则 50 个号要等几分钟才返回。 # 折中:限制同时在途的凭证数,既不打爆上游,也不至于让请求长时间挂住。 @@ -163,9 +187,17 @@ async def probe_credential_once(credentials, provider, data, credential_id: str) 把 Python 类名当 reason 吐给用户。 """ try: - quota = await provider.probe_quota(data) + with credential_scope(credential_id): + quota = await provider.probe_quota(data) except Exception as error: # noqa: BLE001 - 探测失败 → unknown,不当作 0 - credentials.mark_probe_failed(credential_id) + # DEAD(401)已经是确定性结论:和聊天路径一样硬禁用,管理台直接显示 + #「重新粘贴 Cookie」,而不是停留在「未探测到额度」继续被调度。 + login_reason = relogin_reason( + credentials.provider_of(credential_id) or "", error) + if login_reason: + credentials.mark_relogin_required(credential_id, login_reason) + else: + credentials.mark_probe_failed(credential_id) reason = describe_probe_failure(error) logger.info("额度探测失败 %s: %s", credential_id, reason) # detail 只在**服务端日志**里带上游原文(可能是 2048 字的响应体摘要); @@ -178,11 +210,41 @@ async def probe_credential_once(credentials, provider, data, credential_id: str) async def checkin_credential_once(credentials, provider, data, credential_id: str, - schedule_probe) -> dict: - """签到一条凭证,返回对外响应体(单条与批量共用)。""" - result = await provider.checkin(data) - if result.ok and not result.already_checked_in: - schedule_probe(credential_id) # 只有真签到了才会发积分 + schedule_probe, checkin_events) -> dict: + """签到一条凭证,返回对外响应体(单条与批量共用)。 + + **上游失败是正常结果,不是异常**:账号被禁用 / 登录过期 / 限流都是上游 + 对这条凭证的真实答复,直接抛出去会变成 ASGI 500 + 一整屏 traceback + (实测 2026-10-26 14:47 管理台点「签到」回 500,前端只弹「签到失败」, + 看不出是账号被禁用)。这里把异常收成 `ok=False` + 可读 message: + 前端本来就按 `ok` / `message` 渲染(见 `CredentialsPage` 的 checkin 分支), + 批量端点也不必再单独兜一次。 + + `checkin_events` 是签到审计日志仓储:成功/失败/已签都落一行(trigger=manual), + 逐次结果落库后,管理台「签到记录」才能回答「这个号上次签到是什么时候、 + 领了多少、上游回了什么」。 + """ + try: + with credential_scope(credential_id): + result = await provider.checkin(data) + except Exception as error: # noqa: BLE001 - 上游失败 → 失败结果,非 500 + reason = describe_probe_failure(error) + logger.info("签到失败 %s: %s", credential_id, reason) + # detail 只进服务端日志:上游原文可能回显凭证片段 + logger.info("签到失败详情 %s: %s", credential_id, str(error)[:200]) + checkin_events.record( + credential_id=credential_id, + result=CheckinResult(ok=False, message=f"签到失败:{reason}"), + trigger="manual") + return {"ok": False, "credit": None, "code": None, + "message": f"签到失败:{reason}", "already_checked_in": False, + "status": None} + if result.ok: + # 签到成功就会改变额度(领到积分 / 确认今日已发):立刻重探测一次, + # 套餐明细与额度合计不必等下一轮周期探测。`already_checked_in` 也照样 + # 重探——它同样意味着「上游那边有积分了」。 + schedule_probe(credential_id) + checkin_events.record(credential_id=credential_id, result=result, trigger="manual") # already_checked_in 必须透传:前端靠它区分「刚签到」与「今天已签过」 return {"ok": result.ok, "credit": result.credit, "code": result.code, "message": result.message, @@ -234,9 +296,17 @@ async def list_credentials(sort: str | None = None, order: str | None = None, # 限额配置按凭证附带(缺行 = null = 不受限):管理台据此显示护栏与 # 编辑入口;吊销/未配置的渠道不额外占字段。 limits_map = services.limits.map() + # 今日签到:组头要「一眼确认签没签成功」,故按凭证附带本地审计日志里 + # **今天(北京时间)最近一条**结果。不发上游请求——30s 轮询不该为几十个 + # 号各打一次签到场,也不看 HTTP 200,只看落库的结论(与「签到记录」 + # 抽屉同一份数据)。今日无记录 = 缺席(任务未轮到 / 暂停 / 渠道不支持), + # 不是失败。 + checkin_today = services.checkin_events.latest_since( + day_start(int(time.time()))) for row in rows: record = limits_map.get(row["id"]) row["limits"] = record.as_dict() if record is not None else None + row["checkin_today"] = checkin_today.get(row["id"]) return {"credentials": rows, "expiry_window_seconds": window, "expiry_secondary_window_seconds": secondary, @@ -247,33 +317,131 @@ async def list_credentials(sort: str | None = None, order: str | None = None, "is_admin": principal.is_admin, # 凭证写门槛(admin 或 operator):前端据此决定是否显示 # 导入/启停/删除等写操作,与后端 require_operator 同一口径。 - "is_operator": principal.is_operator} + "is_operator": principal.is_operator, + # 渠道 ToS 风险登记(green/gray/red + 理由):导入对话框在管理员 + # 选渠道时直接展示,避免「加完号才发现是灰区」。只做提示与审计, + # 不参与调度、不阻断导入(红线渠道本就没接)。 + "tos_risks": {pid: tos_for(pid).as_dict() for pid in registry}} - @router.post("/api/credentials") - async def import_credential(payload: dict, - _csrf: None = Depends(csrf_protected), - principal=Depends(principal_from_request)): - require_operator(principal) - provider_id = str(payload.get("provider") or "") + def prepare_import(item: dict) -> tuple[str, dict, list[str]]: + """校验一条导入项 → (渠道, 标准化后的凭证, 标签)。 + + 单条与批量导入共用:映射失败(渠道不认识模型/字段)、标签非法都在这里 + 收敛成 400 文案,批量端点只需带上下标即可。 + """ + provider_id = str(item.get("provider") or "") if provider_id not in registry: raise InvalidRequest(f"unknown provider {provider_id!r}") credential_data = registry[provider_id].import_credential( - payload.get("credential") or {}) + item.get("credential") or {}) try: # 导入时可选带标签(列表页「添加」对话框不填即为空) - tags = parse_tags(payload.get("tags")) + tags = parse_tags(item.get("tags")) except ValueError as error: raise InvalidRequest(str(error)) from error - credential_id = credentials.add(provider=provider_id, - credential_data=credential_data, - nickname=str(payload.get("nickname") or ""), - added_by=principal.username, tags=tags) - logger.info("管理员 %s 新增凭证 %s(上游 %s)", principal.username, credential_id, - provider_id) + return provider_id, credential_data, tags + + def register_import(provider_id: str, credential_data: dict, tags: list[str], + nickname: str, principal) -> dict: + """落库 + 审计 + 探测入队;返回凭证 id、ToS 风险与是否就地合并。 + + 灵犀没有 OAuth 轨道,手工导入就是它的「重新登录」:`uid` 相同的凭据 + 必须更新既有行而不是新增。否则同一个账号重登一次就多一条重复凭证, + 旧行的 Cookie 已被新登录顶掉、只能等下一次 401 才被硬禁用。 + """ + merged = False + credential_id = "" + if provider_id == "lingxi": + target = login_identities_for(provider_id, credential_data) + existing = credentials.find_login_credential( + provider_id, + lambda data: login_identities_for(provider_id, data), + target) if target else None + if existing is not None: + merged = credentials.merge_login_credential( + existing, credential_data, nickname) + if merged: + credential_id = existing + if not merged: + credential_id = credentials.add(provider=provider_id, + credential_data=credential_data, + nickname=nickname, + added_by=principal.username, tags=tags) + logger.info("管理员 %s %s凭证 %s(上游 %s)", principal.username, + "更新" if merged else "新增", credential_id, provider_id) + risk = tos_for(provider_id) services.audit.record(actor=principal.username, action=ACTION_CREDENTIAL_IMPORT, - target=credential_id, detail=f"上游 {provider_id}") + target=credential_id, + detail=f"上游 {provider_id};合规={risk.level}({risk.reason})") + if risk.level != "green": + # 灰区不是每次都能被管理员看到(列表页「添加」可能走批量脚本), + # 日志留一条,事后能回答「这号当时知不知道是灰区」 + logger.warning("导入 %s 凭证 %s:ToS 评级 %s —— %s", + provider_id, credential_id, risk.level, risk.reason) schedule_probe(credential_id) - return {"id": credential_id} + # 灵犀:同一浏览器登录新账号会顶掉旧会话。新号导入后顺手复探其它 + # 灵犀凭证——只有探测确认 401 才硬禁用,不用「新登录=旧号必死」 + # 的猜测误伤同时有效的多账号(例如各自独立浏览器配置导出的 Cookie)。 + if provider_id == "lingxi": + for candidate in credentials.candidates(providers=["lingxi"]): + if (candidate.credential_id != credential_id + and candidate.enabled and not candidate.disabled): + schedule_probe(candidate.credential_id) + return {"id": credential_id, "tos": risk.as_dict(), "merged": merged} + + @router.post("/api/credentials") + async def import_credential(payload: dict, + _csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + require_operator(principal) + provider_id, credential_data, tags = prepare_import(payload) + return register_import(provider_id, credential_data, tags, + str(payload.get("nickname") or ""), principal) + + @router.post("/api/credentials/batch-import") + async def batch_import_credentials(payload: dict, + _csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + """批量导入:逐条校验,坏条目只进 failed、不连累其他条目。 + + 去重口径 = 同渠道且解密后内容完全一致(批内重复 + 与库里已有凭证 + 重复都跳过)。返回带下标的 created/skipped/failed,前端据此原样 + 保留失败行供重试。 + """ + require_operator(principal) + items = payload.get("items") + if not isinstance(items, list) or not items: + raise InvalidRequest("items must be a non-empty array") + if len(items) > BATCH_IMPORT_MAX_ITEMS: + raise InvalidRequest(f"一次最多导入 {BATCH_IMPORT_MAX_ITEMS} 条") + created: list[dict] = [] + skipped: list[dict] = [] + failed: list[dict] = [] + seen: set[str] = set() + for index, item in enumerate(items): + if not isinstance(item, dict): + failed.append({"index": index, "reason": "entry must be an object"}) + continue + try: + provider_id, credential_data, tags = prepare_import(item) + except InvalidRequest as error: + failed.append({"index": index, "reason": str(error)}) + continue + fingerprint = ( + f"{provider_id}:" + + json.dumps(credential_data, sort_keys=True, ensure_ascii=False) + ) + if fingerprint in seen or credentials.has_same_credential( + provider_id, credential_data): + skipped.append({"index": index, "reason": "duplicate"}) + continue + seen.add(fingerprint) + row = register_import(provider_id, credential_data, tags, + str(item.get("nickname") or ""), principal) + created.append({"index": index, **row}) + logger.info("管理员 %s 批量导入:成功 %d,跳过 %d,失败 %d", + principal.username, len(created), len(skipped), len(failed)) + return {"created": created, "skipped": skipped, "failed": failed} @router.post("/api/credentials/{credential_id}/toggle") async def toggle_credential(credential_id: str, payload: dict, @@ -290,14 +458,21 @@ async def toggle_credential(credential_id: str, payload: dict, return {"ok": True} @router.post("/api/credentials/{credential_id}/revive") - async def revive_credential(credential_id: str, + async def revive_credential(credential_id: str, payload: dict | None = None, _csrf: None = Depends(csrf_protected), principal=Depends(principal_from_request)): - """解除硬禁用/冷却,让重新登录后的凭证回到池子。""" + """解除硬禁用,让重新登录后的凭证回到池子。 + + `force=True`(可选请求体字段)才连带清除**上游点名恢复时刻**的冷却 + (限流 / 余额 / 权益 / 风控):默认保留,否则刚解除禁用就会立刻再撞一次 + 上游同一个 429。默认清除只影响本地冷却(见 `repo.revive`)。 + """ require_operator(principal) - if not credentials.revive(credential_id): + force = bool((payload or {}).get("force")) + if not credentials.revive(credential_id, force=force): raise InvalidRequest("credential not found") - logger.info("管理员 %s 恢复了凭证 %s", principal.username, credential_id) + logger.info("管理员 %s 恢复了凭证 %s(force=%s)", + principal.username, credential_id, force) services.audit.record(actor=principal.username, action=ACTION_CREDENTIAL_REVIVE, target=credential_id) return {"ok": True} @@ -329,6 +504,33 @@ async def set_credential_limits(credential_id: str, payload: dict, record = services.limits.get(credential_id) return {"limits": record.as_dict() if record is not None else None} + @router.put("/api/credentials/{credential_id}/weight") + async def set_credential_weight(credential_id: str, payload: dict, + _csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + """设置调度权重(`null` = 恢复默认)。 + + 只影响选号顺序,不改变可调度性:同一健康分档内权重高者优先,档外 + 次序不变(exhausted / unknown 仍垫底)。改完立即生效——选号每轮都从 + 库里读候选,没有需要失效的缓存。 + """ + require_operator(principal) + weight = payload.get("weight") + if weight is not None: + if isinstance(weight, bool) or not isinstance(weight, int): + raise InvalidRequest("weight must be an integer or null") + if not WEIGHT_MIN <= weight <= WEIGHT_MAX: + raise InvalidRequest( + f"weight must be within [{WEIGHT_MIN}, {WEIGHT_MAX}]") + if not credentials.set_routing_weight(credential_id, weight): + raise InvalidRequest("credential not found") + label = "(默认)" if weight is None else str(weight) + logger.info("管理员 %s 设置凭证 %s 调度权重: %s", + principal.username, credential_id, label) + services.audit.record(actor=principal.username, action=ACTION_CREDENTIAL_WEIGHT, + target=credential_id, detail=label) + return {"routing_weight": weight} + @router.put("/api/credentials/{credential_id}/tags") async def set_credential_tags(credential_id: str, payload: dict, _csrf: None = Depends(csrf_protected), @@ -351,16 +553,61 @@ async def set_credential_tags(credential_id: str, payload: dict, target=credential_id, detail=",".join(tags) or "(清空)") return {"tags": tags} + @router.put("/api/credentials/{credential_id}/web-cookie") + async def set_web_cookie(credential_id: str, payload: dict, + _csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + """写入/清除网页 Cookie(逐包/逐笔到期数据源)。 + + Qoder:device token 打不通网页额度接口;CodeArts:AK/SK 签名打不通 + 控制台 portal。只有网页登录会话能拿到「哪笔积分什么时候过期」。 + Cookie 属于凭证数据(加密落库),不进返回体、不进审计 detail。空串 = + 清除,额度探测回退到汇总接口口径。 + """ + require_operator(principal) + provider, data = provider_for( + credential_id, "set_web_cookie", + error="credential does not support web cookie") + cookie = payload.get("cookie") + if not isinstance(cookie, str): + raise InvalidRequest("cookie must be a string") + cookie = cookie.strip() + if len(cookie) > WEB_COOKIE_MAX_LENGTH: + raise InvalidRequest( + f"cookie too long (max {WEB_COOKIE_MAX_LENGTH} characters)") + credentials.save_credential_data( + credential_id, provider.set_web_cookie(data, cookie)) + # 新 Cookie 的有效性以下一轮探测为准:先清掉旧状态,避免「刚粘贴仍显示 + # 已失效」;清除 Cookie 时同样回到无状态。 + credentials.set_web_cookie_state(credential_id, None) + logger.info("管理员 %s 设置凭证 %s 网页 Cookie: %s", + principal.username, credential_id, + "已设置" if cookie else "已清除") + services.audit.record(actor=principal.username, + action=ACTION_CREDENTIAL_WEB_COOKIE, + target=credential_id, + detail="已设置" if cookie else "已清除") + schedule_probe(credential_id) + return {"ok": True} + @router.post("/api/credentials/pin") async def pin_credential(payload: dict, _csrf: None = Depends(csrf_protected), principal=Depends(principal_from_request)): require_operator(principal) pin = payload.get("credential_id") - credentials.set_pinned(pin) + # 类型收口:非字符串直接 400。放任 dict/list 进 set_pinned 会走到 + # sqlite3 的参数绑定 → ProgrammingError → 500(客户端输入不该是 5xx)。 + # None = 取消指定(合法),其余必须是字符串。 + if pin is not None and not isinstance(pin, str): + raise InvalidRequest("credential_id must be a string or null") + # 存在性校验:不存在的 id 会被 set_pinned 的「先清全部 pin」顺带解除 + # 其它凭证的指定,而端点仍回 200——管理员以为已指定,实则把指定清空了。 + if not credentials.set_pinned(pin): + raise InvalidRequest("credential not found") logger.info("管理员 %s 固定凭证 %s", principal.username, pin) services.audit.record(actor=principal.username, action=ACTION_CREDENTIAL_PIN, - target=pin if isinstance(pin, str) else None, + target=pin, detail=f"pinned={pin!r}") return {"ok": True} @@ -391,7 +638,8 @@ async def checkin_credential(credential_id: str, require_operator(principal) provider, data = provider_for(credential_id, "checkin") return await checkin_credential_once(credentials, provider, data, - credential_id, schedule_probe) + credential_id, schedule_probe, + services.checkin_events) @router.post("/api/credentials/batch") async def batch_credentials(payload: dict, @@ -426,6 +674,8 @@ async def batch_credentials(payload: dict, if len(ids) > BATCH_MAX_IDS: raise InvalidRequest(f"too many credentials (max {BATCH_MAX_IDS})") enabled = bool(payload.get("enabled", True)) + # force 只对 revive 有效:清掉「上游点名恢复时刻」的冷却(见 repo.revive) + force_revive = bool(payload.get("force")) semaphore = asyncio.Semaphore(BATCH_CONCURRENCY) async def run_one(credential_id: str) -> dict: @@ -435,7 +685,7 @@ async def run_one(credential_id: str) -> dict: return {"id": credential_id, "ok": False, "error": "credential not found"} return {"id": credential_id, "ok": True} if action == "revive": - if not credentials.revive(credential_id): + if not credentials.revive(credential_id, force=force_revive): return {"id": credential_id, "ok": False, "error": "credential not found"} return {"id": credential_id, "ok": True} # probe / checkin 会真的出网:在途上限只约束这两类动作。 @@ -449,7 +699,8 @@ async def run_one(credential_id: str) -> dict: "detail": detail} provider, data = provider_for(credential_id, "checkin") detail = await checkin_credential_once( - credentials, provider, data, credential_id, schedule_probe) + credentials, provider, data, credential_id, schedule_probe, + services.checkin_events) return {"id": credential_id, "ok": bool(detail["ok"]), "detail": detail} except InvalidRequest as error: # 凭证不存在 / 渠道不支持该能力:可预期的逐条失败,原因直接可读 @@ -491,6 +742,45 @@ async def checkin_status(credential_id: str, error="credential does not support checkin status") return {"status": await provider.checkin_status(data)} + @router.get("/api/credentials/{credential_id}/checkin-events") + async def checkin_events_history(credential_id: str, limit: int = 20, + principal=Depends(principal_from_request)): + """签到审计日志:最近几次签到的时间/结果/领取积分/上游业务码(倒序)。 + + 与 `GET .../checkin`(实时查上游活动状态)不同:这里读的是**本地落库的 + 历史**,回答「这个号上次签到是什么时候、领了多少、上游回了什么」, + 不触发任何上游请求,故对已失效凭证也能查到过去记录。 + """ + require_operator(principal) + if services.credentials.provider_of(credential_id) is None: + raise InvalidRequest("credential not found") + return {"events": services.checkin_events.recent(credential_id, limit=limit)} + + @router.get("/api/credentials/{credential_id}/export") + async def export_credential(credential_id: str, + principal=Depends(principal_from_request)): + """单条凭证导出(JSON):与「批量导入」的条目同形,可原样再导入。 + + 导出**明文凭证**,因此只对 admin/operator 开放,并落一条 + `credential.export` 审计(谁把哪个号的令牌带出了系统)。响应带 + `Content-Disposition` 便于浏览器直接存文件,`no-store` 防中间层缓存。 + """ + require_operator(principal) + entry = services.credentials.export_entry(credential_id) + if entry is None: + raise InvalidRequest("credential not found") + services.audit.record(actor=principal.username, + action=ACTION_CREDENTIAL_EXPORT, + target=credential_id, + detail=f"上游 {entry['provider']}") + logger.info("管理员 %s 导出凭证 %s(上游 %s)", principal.username, + credential_id, entry["provider"]) + return JSONResponse(entry, headers={ + "Content-Disposition": + f'attachment; filename="coding2api-credential-{credential_id}.json"', + "Cache-Control": "no-store", + }) + @router.get("/api/credentials/{credential_id}/growth") async def growth_history(credential_id: str, limit: int = 20, principal=Depends(principal_from_request)): diff --git a/src/api/admin_keys.py b/src/api/admin_keys.py index 4d86ec83..e55a39e7 100644 --- a/src/api/admin_keys.py +++ b/src/api/admin_keys.py @@ -4,15 +4,19 @@ import time -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request -from ..audit.actions import ACTION_API_KEY_QUOTA +from ..audit.actions import ( + ACTION_API_KEY_CREATE, + ACTION_API_KEY_DELETE, + ACTION_API_KEY_QUOTA, +) from ..auth.access import normalize_allowed_ips, normalize_allowed_models from ..compat.openai.request import InvalidRequest from ..engine.limits import day_start from ..engine.model_resolver import KNOWN_PROVIDERS from ..key_quota import KeyQuota, parse_key_quota, quota_text -from .deps import Services, csrf_protected, principal_from_request +from .deps import Services, csrf_protected, principal_from_request, request_ip def _parse_binding(raw: object) -> str: @@ -70,6 +74,26 @@ def _quota_detail(quota: KeyQuota) -> str: return ";".join(parts) or "清除配额" +def _policy_detail(name: str, binding: str, allowed_ips: str, + allowed_models: str, expires_at: int | None, + quota: KeyQuota) -> str: + """创建审计明细:一行可读摘要,回答「这把 Key 是谁、能干什么」。 + + Key 明文只在创建响应里返回一次,事后追责只能靠审计行——不含策略摘要的 + `api_key.create` 记录回答不了「这把能刷无限制模型的 Key 是谁建的」。 + """ + parts = [f"名称 {name or '(未命名)'}"] + if binding: + parts.append(f"绑定渠道 {binding}") + if allowed_ips: + parts.append(f"来源 IP {allowed_ips}") + if allowed_models: + parts.append(f"模型白名单 {allowed_models}") + parts.append(f"到期 {expires_at}" if expires_at else "永不过期") + parts.append(_quota_detail(quota)) + return ";".join(parts) + + def create_router(services: Services) -> APIRouter: router = APIRouter() api_keys = services.api_keys @@ -96,16 +120,29 @@ async def list_keys(sort: str | None = None, order: str | None = None, "total": api_keys.count_for(principal.username)} @router.post("/api/api-keys") - async def create_key(payload: dict, + async def create_key(payload: dict, request: Request, _csrf: None = Depends(csrf_protected), principal=Depends(principal_from_request)): + # 先解析再创建:写库前校验全部通过,避免半成品行(create 是一次 INSERT, + # 但配额/白名单校验失败会让请求 400 而 Key 已经发出去)。 + binding = _parse_binding(payload.get("provider_binding")) + allowed_ips = _parse_allowed_ips(payload.get("allowed_ips")) + allowed_models = _parse_allowed_models(payload.get("allowed_models")) + expires_at = _parse_expires_at(payload.get("expires_at")) + quota = _parse_quota(payload.get("quota")) created = api_keys.create( principal.username, str(payload.get("name") or ""), - provider_binding=_parse_binding(payload.get("provider_binding")), - allowed_ips=_parse_allowed_ips(payload.get("allowed_ips")), - allowed_models=_parse_allowed_models(payload.get("allowed_models")), - expires_at=_parse_expires_at(payload.get("expires_at")), - quota=quota_text(_parse_quota(payload.get("quota")))) + provider_binding=binding, allowed_ips=allowed_ips, + allowed_models=allowed_models, expires_at=expires_at, + quota=quota_text(quota)) + # 发 Key 是「造了一把进网关的凭据」,必须留痕:明文只返回这一次, + # 事后只能靠审计行回答「这把能刷全模型的 Key 是谁建的、什么时候」。 + services.audit.record( + actor=principal.username, action=ACTION_API_KEY_CREATE, + target=created["id"], + detail=_policy_detail(str(payload.get("name") or ""), binding, + allowed_ips, allowed_models, expires_at, quota), + ip=request_ip(request, services.settings)) return created # 明文只在此返回一次 @router.put("/api/api-keys/{key_id}/quota") @@ -126,11 +163,21 @@ async def set_key_quota(key_id: str, payload: dict, return {"ok": True, "quota": quota.as_dict()} @router.delete("/api/api-keys/{key_id}") - async def delete_key(key_id: str, + async def delete_key(key_id: str, request: Request, _csrf: None = Depends(csrf_protected), principal=Depends(principal_from_request)): + # 先捞名字(删完就查不到了):审计行只留 id,事后对不上「被删的是哪把」。 + deleted_name = "" + for row in api_keys.list_for(principal.username): + if row["id"] == key_id: + deleted_name = row["name"] + break if not api_keys.delete(key_id, principal.username): raise InvalidRequest("api key not found") + services.audit.record( + actor=principal.username, action=ACTION_API_KEY_DELETE, target=key_id, + detail=f"名称 {deleted_name or '(未命名)'}", + ip=request_ip(request, services.settings)) return {"ok": True} return router diff --git a/src/api/admin_model_availability.py b/src/api/admin_model_availability.py index dfd00ef3..77b92b3e 100644 --- a/src/api/admin_model_availability.py +++ b/src/api/admin_model_availability.py @@ -10,10 +10,14 @@ `since` 绝对时刻限定,走 `idx_usage_ts`;`days` 默认 7、上限 30,`limit` 上限 200——聚合必须限窗口与行数,否则一张跑了一年的库会被这一个只读端点 拖垮。 -* 只认 `error_type in ('upstream_blocked', 'invalid_request')`:前者是协议 - 预留值(provider 直接上报时自动纳入),后者是当前 executor 对 - `ErrKind.BLOCKED` / `ErrKind.INVALID` 的统一归类——「上游不认这个模型」 - 目前就落在 `invalid_request` 里。 +* 只认 `error_type in ('upstream_blocked', 'model_not_found', + 'model_capability', 'invalid_request')`: + `model_not_found` 是 executor 对「上游明确说没有这个模型」(`ErrKind.BLOCKED`, + 2026-10-26 从 `invalid_request` 里独立出来)的归类,`model_capability` 是 + 「这个 (渠道, 模型) 服务不了这次请求形态」(CodeArts 001001020 not multimodal, + 2026-11-03 从 INVALID 里独立出来),`upstream_blocked` 是协议预留值 + (provider 直接上报时自动纳入),`invalid_request` 是请求无效 + (`ErrKind.INVALID` / `ErrKind.REQUEST`,上游 400 拒绝也落这里)。 * `blocked` 是**现算**的:拿 `MODEL_BLOCKLIST`(热更)与记录里的模型名 做 glob 匹配,与 `/v1/models` 的过滤同口径(归一键 / 展示名任一命中即算)。 不在服务端缓存,改完黑名单下一次请求立即反映。 @@ -25,8 +29,9 @@ 判定里 `inconclusive` **绝不**触发禁用。 * 探活与生产完全隔离:直连 provider,不写 `usage_events`、不动冷却表、不改 健康度,结论只进独立的 `model_probes` 表。唯一的出口影响是**列表展示**: - 判死(`failed`)且负缓存仍生效的 (渠道, 模型) 从 `/v1/models` 里摘掉, - 到期口径是那条负缓存的 `cooling_until`(见 `api.models.merged_entries`)。 + 判死(`failed`)的 (渠道, 模型) 从 `/v1/models` 里摘掉,探活成功即恢复 + (见 `api.models.merged_entries`)。判死行由后台周期一起复探——隐藏没有 + 到期时间,复探是唯一的恢复入口。 * 探活**成功**才是恢复入口:它清掉该 (渠道, 模型) 的 blocked 负缓存,模型 立即回到池子,不必等 6–24h 的 TTL 走完。 * 人工接管(`manual != ''`)优先于自动探活:管理员点过「恢复」之后不希望 @@ -64,7 +69,12 @@ MAX_LIMIT = 200 # 计入「模型级失败」的 error_type(见模块 docstring)。 -FAILURE_ERROR_TYPES = ("upstream_blocked", "invalid_request") +# `model_capability`(001001020 not multimodal)也是「这个 (渠道, 模型) 服务不了 +# 这次请求形态」——换号无用、换模型/渠道才有解,与 `model_not_found` 同属 +# 模型级可用性问题,纳入看板;`context_overflow` 是客户端上下文太长,与本视图 +# 的问题(上游不认这个模型)无关,不纳入。 +FAILURE_ERROR_TYPES = ("upstream_blocked", "model_not_found", "model_capability", + "invalid_request") def _blocked(model: str, patterns: tuple[str, ...]) -> bool: diff --git a/src/api/admin_settings.py b/src/api/admin_settings.py index fe49b671..033c8377 100644 --- a/src/api/admin_settings.py +++ b/src/api/admin_settings.py @@ -6,8 +6,9 @@ 同一页还展示 `/api/tasks` 的**后台任务运行态**(进程内):任务清单与周期来自 同一份 `HOT_SETTINGS.task` 归属,非任务项按 `HotSetting.group` 归入网关卡组 -(`GATEWAY_GROUPS`);页面把两者拼成「任务与配置」,每个任务 / 每个网关卡组 -各一个 tab——看周期的人就在看这个任务最近跑得怎么样,分开两页反而要来回跳。 +(`GATEWAY_GROUPS`);页面把两者拼成「任务与配置」,一级 tab 分「后台任务 / +网关配置」,任务再按业务分组(`TASK_GROUPS`)二级切换、组内每个任务一个 tab—— +看周期的人就在看这个任务最近跑得怎么样,分开两页反而要来回跳。 """ from __future__ import annotations @@ -22,6 +23,7 @@ from ..compat.openai.request import InvalidRequest from ..runtime_settings import GATEWAY_GROUPS, InvalidSetting from ..tasks.runner import UnknownTaskError +from ..tasks.status import TASK_GROUPS from .deps import Services, csrf_protected, principal_from_request, request_ip logger = logging.getLogger(__name__) @@ -59,6 +61,18 @@ async def update_settings(payload: dict, request: Request, raise InvalidRequest(str(error)) from error keys = ", ".join(sorted(values)) or "(空)" logger.info("管理员 %s 更新运行时配置 %s(DB 覆盖 .env)", principal.username, keys) + if "dump_request_bodies" in values: + # 这是全服务唯一「把用户对话落盘」的开关:开关动作既进审计(下面那条 + # settings.update 带 keys),也在日志里单独喊一声——排查完没关是运维 + # 事故,日志里必须一眼可见。 + logger.warning("管理员 %s 把「请求体诊断落盘」改为 %s(DATA_DIR/dumps 将保存" + "完整请求体,含对话内容)", + principal.username, runtime.get("dump_request_bodies")) + if "gateway_paused" in values: + # kill switch 的两个方向都要留痕:开启是止血动作,关闭同样要能追到人。 + logger.warning("管理员 %s 把「网关暂停」改为 %s(原因:%s)", + principal.username, runtime.get("gateway_paused"), + runtime.get("gateway_pause_reason") or "未填原因") # 配置写入本身就是一次「谁能改网关行为」的变更,必须留痕:模型黑名单 # (C3 的一键黑名单按钮也走这条接口)与告警 webhook 被改掉却不进审计, # 事后无法回答「是谁把它关的」。 @@ -80,6 +94,9 @@ async def list_tasks(request: Request, runner = getattr(request.app.state, "task_runner", None) return { "tasks": runner.task_status() if runner is not None else [], + # 任务业务分组随响应下发:前端据此把任务拆成二级 tab, + # 不在前端硬编码 key→分组映射(后端加任务/改归属不用动前端)。 + "groups": [{"value": key, "label": label} for key, label in TASK_GROUPS], "server_time": int(time.time()), } diff --git a/src/api/admin_stats.py b/src/api/admin_stats.py index 3c8b59cc..b3411d4d 100644 --- a/src/api/admin_stats.py +++ b/src/api/admin_stats.py @@ -11,17 +11,36 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse, StreamingResponse +from ..audit.actions import ACTION_DATA_EXPORT from ..auth.rbac import Principal, require_operator from ..compat.openai.errors import error_payload +from ..compat.openai.request import InvalidRequest from ..stats.csv import CSV_BOM, EXPORT_HEADERS, csv_line, event_cells from ..stats.live import LiveEventHub, Subscription, TooManySubscribers -from ..stats.query import ERROR_MAX_GROUPS, EXPORT_MAX_ROWS -from .deps import Services, principal_from_request +from ..stats.query import ERROR_MAX_GROUPS, EXPORT_MAX_ROWS, METRIC_KINDS +from .deps import Services, principal_from_request, request_ip # 心跳间隔:代理/浏览器对「长时间没数据」的连接会主动断(nginx 默认 60s)。 # 15s 一条注释帧,既保活又不至于把流量抬起来。 HEARTBEAT_SECONDS = 15.0 +# 分位时序只认这两个指标(读明细表按列取分位,其余列无分位语义)。 +PERCENTILE_METRICS = ("latency", "ttfb") + + +def _check_metric(metric: str, *, allowed: tuple[str, ...] | None = None) -> str: + """校验 `metric` 取值域,非法值显式 400。 + + PROPOSAL §5 列死了 `timeline` / `model-timeline` 的 metric 取值集合。 + 之前未知取值静默回落到 `requests`(`METRIC_KINDS.get(metric, "sum_requests")`), + 客户端拼错参数会拿到一份「看起来正常」的请求数图,错误被完全吞掉。 + """ + allowed = allowed or tuple(METRIC_KINDS) + if metric not in allowed: + raise InvalidRequest( + f"metric must be one of: {', '.join(allowed)}") + return metric + def _scope(principal: Principal, username: str | None) -> str | None: """admin/operator 传 username 可查全局/指定人;viewer 永远只能看自己。 @@ -147,7 +166,8 @@ async def stats_timeline(principal=Depends(principal_from_request), until: int | None = None, metric: str = "requests"): return {"points": stats_query.timeline( - username=_scope(principal, username), since=since, until=until, metric=metric)} + username=_scope(principal, username), since=since, until=until, + metric=_check_metric(metric))} @router.get("/api/stats/model-timeline") async def stats_model_timeline(principal=Depends(principal_from_request), @@ -155,7 +175,19 @@ async def stats_model_timeline(principal=Depends(principal_from_request), until: int | None = None, metric: str = "requests"): return stats_query.model_timeline( - username=_scope(principal, username), since=since, until=until, metric=metric) + username=_scope(principal, username), since=since, until=until, + metric=_check_metric(metric)) + + @router.get("/api/stats/percentile-timeline") + async def stats_percentile_timeline(principal=Depends(principal_from_request), + username: str | None = None, + since: int | None = None, + until: int | None = None, + metric: str = "latency"): + """延迟分位时序(P50 中位数 + P95 上界):延迟图的「典型值 + 长尾」两层。""" + return stats_query.percentile_timeline( + username=_scope(principal, username), since=since, until=until, + metric=_check_metric(metric, allowed=PERCENTILE_METRICS)) @router.get("/api/stats/by-user") async def stats_by_user(principal=Depends(principal_from_request), @@ -196,7 +228,8 @@ async def stats_errors(principal=Depends(principal_from_request), limit=max(1, min(limit, ERROR_MAX_GROUPS)))} @router.get("/api/stats/events") - async def stats_events(principal=Depends(principal_from_request), + async def stats_events(request: Request, + principal=Depends(principal_from_request), username: str | None = None, since: int | None = None, until: int | None = None, before: int | None = None, limit: int = 50, @@ -220,6 +253,14 @@ async def stats_events(principal=Depends(principal_from_request), truncated = len(rows) > EXPORT_MAX_ROWS rows = rows[:EXPORT_MAX_ROWS] now = int(datetime.now(tz=UTC).timestamp()) + # 整份明细导出的大窗口(`export_events` 不过滤用户名时能拿到全池 + # 记录)必须留痕:谁在什么时候把多少行拉走了。 + services.audit.record( + actor=principal.username, action=ACTION_DATA_EXPORT, + target="usage_events", + detail=(f"CSV 明细 {len(rows)} 行" + + ("(已截断)" if truncated else "")), + ip=request_ip(request, services.settings)) headers = { "Content-Disposition": f'attachment; filename="{_export_filename(now)}"', "X-Export-Row-Count": str(len(rows)), diff --git a/src/api/admin_users.py b/src/api/admin_users.py index a835c625..d5d8ba81 100644 --- a/src/api/admin_users.py +++ b/src/api/admin_users.py @@ -23,8 +23,10 @@ ACTION_USER_DISABLE, ACTION_USER_ENABLE, ACTION_USER_PASSWORD_RESET, + ACTION_USER_POLICY, ACTION_USER_ROLE_CHANGE, ) +from ..auth.access import normalize_allowed_models from ..auth.rbac import ( ROLE_ADMIN, ROLES, @@ -34,6 +36,7 @@ ) from ..auth.users import create_password_hash from ..compat.openai.request import InvalidRequest +from ..key_quota import KeyQuota, parse_key_quota, quota_text from .admin_auth import ACTIVATION_TTL_SECONDS from .deps import Services, csrf_protected, principal_from_request, request_ip @@ -57,9 +60,29 @@ def _public_view(row: dict) -> dict: "created_by": row["created_by"], # 是否有待激活令牌(不泄露摘要本身) "pending_activation": row["activation_digest"] is not None, + # 用户级策略(Playground):模型白名单与配额,界面直接可编 + "allowed_models": row.get("allowed_models") or "", + "quota": row.get("quota") or "", } +def _policy_detail(models: str, quota: KeyQuota) -> str: + """用户策略的审计明细:一行说清「这个人在 Playground 能碰什么」。 + + 不含密码/令牌——审计的 detail 只放可读策略,不放凭据。 + """ + parts = [f"模型白名单 {models}" if models else "模型不限"] + if quota.is_empty(): + parts.append("配额不限") + else: + labels = (("daily_requests", "每日请求"), ("daily_tokens", "每日 token"), + ("daily_cost_usd", "每日成本 USD"), ("rpm", "RPM"), + ("tpm", "TPM"), ("max_concurrency", "并发")) + parts.append(";".join(f"{label} {getattr(quota, name):g}" + for name, label in labels if getattr(quota, name))) + return ";".join(parts) + + def create_router(services: Services) -> APIRouter: router = APIRouter() @@ -193,6 +216,37 @@ async def reset_password(username: str, request: Request, return {"username": username, "activate_token": token, "expires_at": issued + ACTIVATION_TTL_SECONDS} + @router.put("/api/users/{username}/policy") + async def set_user_policy(username: str, request: Request, payload: dict, + _csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + """用户级策略:管理台 Playground 的模型白名单 + 配额(整体替换)。 + + 为什么策略挂在**用户**上而不是 Key 上:Playground 走会话 Cookie、不经过 + Key 鉴权,只按 Key 判策略等于给每个登录用户留一条无限直连通道。语义与 + Key 那套一致(白名单是 fnmatch glob,配额是同一份六项 JSON),但两者 + 独立:Key 的策略管外部调用,这里的策略管这个人在管理台里能干什么。 + + 配额用量按用户名聚合,该用户经由任何入口(含自己的 Key)的流量都计入。 + """ + _require(principal) + if services.user_repo.get(username) is None: + raise InvalidRequest(f"user {username!r} not found") + try: + models = normalize_allowed_models(str(payload.get("allowed_models") or "")) + except ValueError as error: + raise InvalidRequest(str(error)) from error + try: + quota = parse_key_quota(payload.get("quota")) + except ValueError as error: + raise InvalidRequest(str(error)) from error + services.user_repo.set_policy(username, allowed_models=models, + quota=quota_text(quota)) + services.audit.record(actor=principal.username, action=ACTION_USER_POLICY, + target=username, detail=_policy_detail(models, quota), + ip=request_ip(request, services.settings) or None) + return {"ok": True, "allowed_models": models, "quota": quota.as_dict()} + # 故意不提供 DELETE:硬删会让 usage_events 里留下查不到用户名的孤儿统计, # 而「停用账号」的实际需求已被 disable 完全覆盖。硬删只走 CLI。 return router diff --git a/src/api/authorize.py b/src/api/authorize.py index 650c38ee..b6bff111 100644 --- a/src/api/authorize.py +++ b/src/api/authorize.py @@ -3,14 +3,17 @@ from __future__ import annotations import time +from html import escape from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse +from ..audit.actions import ACTION_CREDENTIAL_IMPORT from ..auth.session import SESSION_COOKIE, verify_session_token from ..compat.openai.errors import error_payload from ..provider.trae.callback import login_trace_id from ..provider.trae.events import UpstreamProtocolViolation +from .credential_login import save_login_credential from .deps import Services # 待完成回调的有效期:/authorize 无鉴权(浏览器 302 不带 key),长期挂着 @@ -19,6 +22,70 @@ # 10 分钟足够走完一次浏览器登录。 PENDING_CALLBACK_TTL_SECONDS = 600 +# 浏览器回调落在 JSON 上会被当成「什么也没发生」:用户看不到成功也看不到 +# 失败原因(issue #21 的「不回调,也没报错」)。顶层导航带 `Accept: text/html` +# 时改回一张说明页,程序化客户端仍拿到原来的 JSON。 +_RESULT_PAGE = """ + + + + +{title} · Coding2API + + +

{title}

{message}

+""" + + +def _wants_html(request: Request) -> bool: + """浏览器顶层导航要人读的页面,程序化客户端(无 Accept 或 */*)继续拿 JSON。""" + return "text/html" in request.headers.get("accept", "") + + +def _page(status_code: int, title: str, message: str) -> HTMLResponse: + return HTMLResponse( + _RESULT_PAGE.format(title=escape(title), message=escape(message)), + status_code=status_code) + + +def _failure(request: Request, message: str, hint: str, *, + code: str = "invalid_request") -> JSONResponse | HTMLResponse: + """失败响应:JSON 契约给程序,中文原因给人(浏览器只看得到后者)。""" + if _wants_html(request): + return _page(400, "TRAE 登录未完成", hint) + return JSONResponse(status_code=400, content=error_payload(message, code, 400)) + + +def _pending_by_trace(pending: dict[str, tuple[float, str]], + trace: str) -> tuple[str, tuple[float, str]] | None: + """按 loginTraceID 反查发起人。 + + 管理台可能同时有 LAN 直连与 https 隧道两条入口:在 A 主机登录、回调 + 落在 B 主机时浏览器**不会**带 A 的会话 Cookie。`loginTraceID` 是 + `(machine_id + device_id)[-16:]`、只出现在发起那次登录的 auth_url 里, + 它本身就是一次性凭证,据此仍能把回调钉死在**那一次** pending 登录上。 + """ + if not trace: + return None + for username, entry in pending.items(): + machine_id, _, device_id = entry[1].partition(":") + if machine_id and device_id and login_trace_id(machine_id, device_id) == trace: + return username, entry + return None + def create_router(services: Services) -> APIRouter: router = APIRouter() @@ -41,7 +108,8 @@ async def authorize(request: Request): - **loginTraceID**(主):TRAE 原样回显 `login_trace_id`,值是 `(machine_id + device_id)[-16:]`,只有发起登录那次拿得到; - **发起人会话**(辅):回调是顶层浏览器导航,SameSite=Lax 会带上 - 管理台的会话 Cookie,据会话用户名找到其待完成登录。 + 管理台的会话 Cookie,据会话用户名找到其待完成登录;Cookie 缺失 + (例如 LAN 发起、隧道回调)时退回按 loginTraceID 反查发起人。 这样: - 两个管理员并发发起登录不会互相覆盖(各按用户名分键); @@ -60,8 +128,8 @@ async def authorize(request: Request): provider = services.registry.get("trae") pending: dict[str, tuple[float, str]] = request.app.state.pending_callbacks if provider is None: - return JSONResponse(status_code=400, content=error_payload( - "no pending TRAE login in progress", "invalid_request", 400)) + return _failure(request, "no pending TRAE login in progress", + "服务端没有启用 TRAE 渠道,无法完成登录。") # 会话 Cookie → 发起人(回调为顶层导航,Lax Cookie 会带上)。 # 与 deps.principal_from_request 同一套活跃性校验:签名有效还不够, # 发起人必须仍存在且未被禁用、会话 epoch 未被改密/禁用作废, @@ -70,42 +138,62 @@ async def authorize(request: Request): request.cookies.get(SESSION_COOKIE, ""), services.settings.app_secret) username = "" if verified is not None: - candidate, epoch = verified - if (services.users.is_active(candidate) - and services.users.session_epoch(candidate) == epoch): + candidate, epoch, issued_at = verified + if services.users.session_valid(candidate, epoch, issued_at): username = candidate entry = pending.get(username) if username else None if entry is None: - return JSONResponse(status_code=400, content=error_payload( - "no pending TRAE login in progress", "invalid_request", 400)) + # Cookie 不在回调这条入口上(LAN 发起 / 隧道回调):按 trace 反查。 + found = _pending_by_trace(pending, + request.query_params.get("loginTraceID") or "") + if found is None: + return _failure( + request, "no pending TRAE login in progress", + "没有进行中的 TRAE 登录(可能已过期,或不是在当前浏览器发起的)。" + "请回到管理台重新点击「登录 TRAE」。") + username, entry = found started_at, state = entry if time.monotonic() - started_at > PENDING_CALLBACK_TTL_SECONDS: pending.pop(username, None) - return JSONResponse(status_code=400, content=error_payload( - "pending TRAE login expired, start a new one", - "invalid_request", 400)) + return _failure( + request, "pending TRAE login expired, start a new one", + "这次登录已超过 10 分钟有效期,请回到管理台重新发起登录。") # 回调绑定:loginTraceID 必须等于本次 pending 登录的 trace id # (machine/device id 在 state 里)。缺失/不符一律拒绝——这才是 # 「只接受发起人本人那次登录的回调」,与 Referer 无关。 machine_id, _, device_id = state.partition(":") if request.query_params.get("loginTraceID") != login_trace_id(machine_id, device_id): - return JSONResponse(status_code=400, content=error_payload( - "callback does not match the pending login", "invalid_request", 400)) + return _failure( + request, "callback does not match the pending login", + "这次回调与发起登录的那一次不匹配,请回到管理台重新登录。") if not request.query_params.get("refreshToken") and not request.query_params.get("userJwt"): - return JSONResponse(status_code=400, content=error_payload( - "callback missing refreshToken", "invalid_request", 400)) + return _failure( + request, "callback missing refreshToken", + "回调参数里没有 refreshToken,TRAE 没有把登录结果带回来,请重新登录。") # 先消费 pending,防止并发回调重复兑换同一登录 pending.pop(username, None) try: credential_data = await provider.complete_callback(raw, state) except UpstreamProtocolViolation as error: - return JSONResponse(status_code=400, content=error_payload( - error.public_message, "invalid_credential", 400)) - credential_id = services.credentials.add( - provider="trae", credential_data=credential_data, + return _failure( + request, error.public_message, + f"换取 TRAE 凭证失败:{error.public_message}", + code="invalid_credential") + credential_id, merged = await save_login_credential( + services, provider_id="trae", credential_data=credential_data, nickname=str(credential_data.get("nickname") or ""), added_by=username) + # 审计:OAuth 落库的凭证与手工导入同 action,否则「谁什么时候把号 + # 塞进池子」在审计页上只剩手工导入那一条(见 admin_auth 的 + # `_record_oauth_import`,两边口径一致)。 + services.audit.record(actor=username, action=ACTION_CREDENTIAL_IMPORT, + target=credential_id, + detail=("上游 trae;来源 OAuth 回调(合并已有凭证)" + if merged else "上游 trae;来源 OAuth 回调")) services.schedule_probe(credential_id) + if _wants_html(request): + return _page(200, "TRAE 登录成功", + "凭证已保存,可以关闭此页面回到管理台。") return {"ok": True, "captured": True, "at": int(time.time())} return router diff --git a/src/api/chat.py b/src/api/chat.py index 35f71f80..8cac5fe7 100644 --- a/src/api/chat.py +++ b/src/api/chat.py @@ -4,6 +4,7 @@ import contextlib import json +import os import time from pathlib import Path @@ -15,25 +16,52 @@ # dump 目录最多保留的文件数:诊断开关长期开着时不能无限侵占磁盘 DUMP_KEEP_FILES = 200 +# 单个 dump 文件的最长保留时间:即使文件数没超,24h 前的请求体也该消失 +DUMP_MAX_AGE_SECONDS = 24 * 3600 +# 目录/文件权限:请求体含完整对话内容,同机其它用户不该读到 +DUMP_DIR_MODE = 0o700 +DUMP_FILE_MODE = 0o600 def dump_request_body(data_dir: Path, body: object) -> None: - """把原始请求体写到 data/dumps/(诊断用),并淘汰超量的旧文件。 + """把原始请求体写到 data/dumps/(诊断用),并淘汰超量/超期的旧文件。 请求体包含完整对话内容,因此这个开关只应用于排查客户端差异, - 不能长期开启;这里只做基本的有界保留。 + 不能长期开启;这里做三道收口: + + - 权限:目录 0700、文件 0600(含**已存在**目录的收紧,老版本建过 0755 的 + 目录也要拉回来); + - 条数上限:`DUMP_KEEP_FILES`; + - 时间上限:`DUMP_MAX_AGE_SECONDS`(按 mtime),条数没超也不会长期留存。 + + 开关本身是热更项(`dump_request_bodies`),改动进审计(`settings.update`)。 """ dump_dir = data_dir / "dumps" - dump_dir.mkdir(parents=True, exist_ok=True) try: - (dump_dir / f"{int(time.time() * 1000)}.json").write_text( - json.dumps(body, ensure_ascii=False, indent=1), encoding="utf-8") + dump_dir.mkdir(parents=True, exist_ok=True, mode=DUMP_DIR_MODE) + # mkdir 的 mode 只影响**新建**目录,且会被 umask 削;已存在的目录 + # (老版本建的、或数据目录搬迁带过来的)必须显式收紧。 + dump_dir.chmod(DUMP_DIR_MODE) + path = dump_dir / f"{int(time.time() * 1000)}.json" + # 用 os.open 建文件而不是 write_text:要先落权限再写内容,避免 + # 「先写后 chmod」窗口内同机其它用户能读到对话正文。 + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, DUMP_FILE_MODE) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(body, ensure_ascii=False, indent=1)) except OSError: # 磁盘只读/满:诊断失败绝不能影响请求 return - stale = sorted(dump_dir.glob("*.json"))[:-DUMP_KEEP_FILES] - for path in stale: + files = sorted(dump_dir.glob("*.json")) + # 条数超限的最旧那批先淘汰;再补上「条数没超但已过 24h」的 + stale = files[:-DUMP_KEEP_FILES] + for item in files: + if item in stale: + continue + with contextlib.suppress(OSError): + if item.stat().st_mtime < time.time() - DUMP_MAX_AGE_SECONDS: + stale.append(item) + for item in stale: with contextlib.suppress(OSError): - path.unlink() + item.unlink() def create_router(services: Services) -> APIRouter: diff --git a/src/api/context.py b/src/api/context.py index dcec97d9..eef21989 100644 --- a/src/api/context.py +++ b/src/api/context.py @@ -18,10 +18,16 @@ """ from __future__ import annotations +import logging from collections.abc import Callable from typing import Any -from ..engine.compress import compress_messages +from ..engine.compress import compress_messages, estimate_messages +from ..engine.prefix_debug import PrefixDebugTracker +from ..engine.sticky_trim import StickyTrimmer, conversation_key +from ..engine.tool_output import compress_tool_outputs + +logger = logging.getLogger(__name__) # 渠道实际窗口下限(只用于**压缩预算**,不影响 `/v1/models` 对外宣告)。 # @@ -80,7 +86,32 @@ def build_context_compressor( 请求现读,管理台热更即生效。装配侧(main.py)把它注入 `ExecutorDeps.context_compress`;executor 在选号后按实际渠道调用。 """ + # 粘性裁剪的边界记忆(进程内,按会话+渠道):压缩闭包每次请求都会调用, + # 记忆必须挂在闭包外才能跨轮生效 + trimmer = StickyTrimmer() + def compress(provider_id: str, model: str, payload: dict) -> dict: + if settings.tool_output_compress_enabled: + # tool 输出压缩与窗口无关(省的是 token 不是长度),必须在 + # 「未超限就原样返回」之前做,否则永远等不到触发条件。 + messages = payload.get("messages") + if isinstance(messages, list): + squashed = compress_tool_outputs( + messages, max_chars=int(settings.tool_output_compress_max_chars)) + if squashed is not messages: + before = sum(len(m["content"]) for m in messages + if isinstance(m, dict) + and isinstance(m.get("content"), str)) + after = sum(len(m["content"]) for m in squashed + if isinstance(m, dict) + and isinstance(m.get("content"), str)) + # 只在真的压了才记。DEBUG 而非 INFO:长会话里这是**常态** + # 而非异常,实测 1 小时内 749 行、占 err.log 的 19%,把真 + # WARNING 挤出视野;验收收益要数字时按需打开 DEBUG 即可。 + logger.debug( + "tool 输出压缩 provider=%s model=%s messages=%d 字符 %d→%d", + provider_id, model, len(messages), before, after) + payload = {**payload, "messages": squashed} if not settings.context_compress_enabled: return payload window = context_window_for(cache, aliases, provider_id, model) @@ -89,16 +120,63 @@ def compress(provider_id: str, model: str, payload: dict) -> dict: messages = payload.get("messages") if not isinstance(messages, list): return payload - compressed = compress_messages( - messages, - max_input_tokens=window, - reserve_for_output=int(settings.context_compress_reserve_tokens), - min_keep_messages=int(settings.context_compress_min_keep_messages), - safety_ratio=float(settings.context_compress_safety_ratio), - ) + options = { + "max_input_tokens": window, + "reserve_for_output": int(settings.context_compress_reserve_tokens), + "min_keep_messages": int(settings.context_compress_min_keep_messages), + "safety_ratio": float(settings.context_compress_safety_ratio), + } + if settings.context_compress_sticky_enabled: + # 粘性裁剪:裁剪边界跨轮保持,前缀不变 → 上游缓存能命中(见 + # engine/sticky_trim.py)。关闭即退回「每轮都按当轮预算重算」的 + # 旧行为(客户端每轮重发完整历史时,命中率只有 system 段) + outcome = trimmer.compress( + conversation_key(provider_id, model, payload), messages, **options) + compressed, trimmed = outcome.messages, outcome.trimmed + sticky_reason = outcome.reason + else: + compressed = compress_messages(messages, **options) + trimmed = compressed is not messages + sticky_reason = "off" if compressed is messages: return payload + # 每次裁剪都会让前缀边界前移一条消息 ⇒ 上游前缀缓存只剩 system 段。 + # 命中率异常时先看这里:同一条流连续多轮都在裁,就是它把命中打没了。 + if trimmed and settings.prefix_debug_enabled: + logger.info( + "前缀诊断·压缩 provider=%s model=%s window=%d messages=%d→%d " + "估算=%.0f→%.0f sticky=%s reason=%s", + provider_id, model, window, len(messages), len(compressed), + estimate_messages(messages), estimate_messages(compressed), + settings.context_compress_sticky_enabled, sticky_reason) # 不原地改 payload:同一请求可能在轮换/回退里再压一次(另一个渠道、 # 另一个窗口),必须始终从原文出发,且 affinity 指纹读的是原文 return {**payload, "messages": compressed} return compress + + +def build_prefix_observer( + settings: Any, + tracker: PrefixDebugTracker | None = None, +) -> Callable[[str, str, str, dict], None]: + """构造 `(credential_id, provider_id, model, payload) → None` 诊断闭包。 + + 开关现读运行态覆盖层(`prefix_debug_enabled`,默认关):关闭时每请求只 + 多一次属性读取,不计算任何摘要。`tracker` 可注入(测试固定 LRU 容量)。 + """ + state = tracker if tracker is not None else PrefixDebugTracker() + + def observe(credential_id: str, provider_id: str, model: str, + payload: dict) -> None: + if not settings.prefix_debug_enabled: + return + # 上游缓存按账号(凭证)归属,模型不同则模板/参数不同,分开记基准 + report = state.observe(f"{credential_id}\x00{model}", payload) + logger.info( + "前缀诊断 provider=%s model=%s cred=%s msgs=%d system=%s tools=%s " + "tools_changed=%s lcp=%s reusable=%s split=%s", + provider_id, model, credential_id, report["messages"], + report["system"], report["tools"], report["tools_changed"], + report["lcp"], report["reusable"], report["split"]) + + return observe diff --git a/src/api/credential_login.py b/src/api/credential_login.py new file mode 100644 index 00000000..d152cd16 --- /dev/null +++ b/src/api/credential_login.py @@ -0,0 +1,83 @@ +"""上游 OAuth 登录结果的落库策略:同一账号再次登录时合并,而不是新增一行。 + +为什么单独抽一层:三条登录路径(轮询 / 粘贴回调 `/upstream/complete` / +浏览器回调 `/authorize`)原来各写一份 `credentials.add(...)`,重新登录一律 +新增。CodeArts / OfficeAce 的凭证会因 refresh_token 一次性作废、STS 到期等 +原因需要重新登录;按旧行为每登一次池子里就多一条同账号凭证,选号、额度、 +统计都会被重复账号污染。 + +合并键由 `provider/login_identity.py` 统一给(只能比稳定身份——CodeArts +`uid`、OfficeAce `account_id`),不能比整份 JSON:重新登录后 token / DPoP +私钥必然全变。 +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from ..provider.login_identity import login_identities_for +from .deps import Services + +#: 登录落库的渠道锁:单进程内同一渠道的「查 → 合并 → 新增」串行化。 +#: +#: 为什么凭证级 `edit_lock` 不够:它按 `credential_id` 分键,而「第一次登录」 +#: 还没有 id 可键。两个并发登录(两个标签页 / 轮询超时后重试)都查到 None → +#: 都走 `add()`,池子里就是两条同账号凭证,正是本模块要消除的重复。 +#: +#: 为什么按渠道而不是按身份分键:同一次登录的别名集合可能不同(身份补全接口 +#: 偶发失败时只有兜底键),按集合分键会让「{uid}」与「{uid, domain|user}」 +#: 两把锁互不排斥,等于没锁。登录是低频人工动作(一天几次),渠道级串行 +#: 完全够用;选号/聊天热路径不经过这里。 +_login_locks: dict[str, asyncio.Lock] = {} + + +def _login_lock(provider_id: str) -> asyncio.Lock: + lock = _login_locks.get(provider_id) + if lock is None: + lock = asyncio.Lock() + _login_locks[provider_id] = lock + return lock + + +async def save_login_credential( + services: Services, *, + provider_id: str, + credential_data: dict[str, Any], + nickname: str, + added_by: str, +) -> tuple[str, bool]: + """登录结果落库,返回 `(credential_id, 是否为合并更新)`。 + + - 有稳定身份且命中同渠道既有凭证 → 就地更新(解除硬禁用/冷却),保留 + `enabled` / `pinned` / `tags` / `added_by` / `created_at`。 + - 没有身份、没有命中、或命中行在加锁前被删除 → 新增。 + + **查 → 合并 → 新增整段持渠道登录锁**:`find_login_credential` 读库与 + `credentials.add` 写库之间若被并发登录插进来,两边都会查到「没有既有 + 凭证」并各插一行。渠道级锁让同一渠道的并发登录串行化(登录是低频人工 + 动作);不同渠道互不阻塞。 + + 更新另走凭证级编辑锁:预刷新任务也是「解密 → await 上游 → 整份覆写」, + 不加锁时两边的快照会互相覆盖。 + """ + target = login_identities_for(provider_id, credential_data) + if not target: + # 没有稳定身份 = 不能安全合并,直接新增(不加锁也没人有东西可抢)。 + credential_id = services.credentials.add( + provider=provider_id, credential_data=credential_data, + nickname=nickname, added_by=added_by) + return credential_id, False + + async with _login_lock(provider_id): + existing_id = services.credentials.find_login_credential( + provider_id, lambda data: login_identities_for(provider_id, data), target) + if existing_id is not None: + async with services.credentials.edit_lock(existing_id): + if services.credentials.merge_login_credential( + existing_id, credential_data, nickname): + return existing_id, True + credential_id = services.credentials.add( + provider=provider_id, credential_data=credential_data, + nickname=nickname, added_by=added_by) + return credential_id, False diff --git a/src/api/deps.py b/src/api/deps.py index 06daf882..c2b3499b 100644 --- a/src/api/deps.py +++ b/src/api/deps.py @@ -9,6 +9,8 @@ from __future__ import annotations import json +import logging +import math from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -35,6 +37,7 @@ AlertRepository, ApiKeyRepository, AuditRepository, + CheckinEventRepository, CredentialRepository, CreditEventRepository, GrowthRepository, @@ -61,6 +64,8 @@ ("POST", "/api/auth/logout"), }) +logger = logging.getLogger(__name__) + @dataclass class Services: @@ -75,6 +80,8 @@ class Services: credentials: CredentialRepository growth_events: GrowthRepository credit_events: CreditEventRepository + # 签到审计日志(checkin_events):每轮签到落一行,/api/credentials/{id}/checkin-events 回看。 + checkin_events: CheckinEventRepository api_keys: ApiKeyRepository executor: Executor registry: dict[str, Any] @@ -98,13 +105,18 @@ class Services: alerts: AlertRepository # 凭证级限额护栏配置(/api/credentials/{id}/limits)。 limits: LimitsRepository - # 主动探活结论(`model_probes`):管理台展示 + 人工接管标记;`failed` 且 - # 负缓存仍生效时,`merged_entries` 会把该 (渠道, 模型) 从列表里抽掉。与 - # 调度侧的 `credential_model_cooldowns` 严格分开(见 src/tasks/model_probe.py)。 + # 主动探活结论(`model_probes`):管理台展示 + 人工接管标记;`failed` 时 + # `merged_entries` 会把该 (渠道, 模型) 从列表里抽掉(不看负缓存是否还在, + # 恢复靠复探成功)。与调度侧的 `credential_model_cooldowns` 严格分开 + # (见 src/tasks/model_probe.py)。 model_probes: ModelProbeRepository # Key 级配额:准入判定(当日用量注入 KeyUsageRepository)+ 自助用量共用。 # 进程内滑动窗口/在途计数在 guard 里,重启清零(单进程部署语义)。 key_quota: KeyQuotaGuard + # 用户级配额:只用于管理台 Playground 的会话直连(`users.quota`),用量按 + # **用户名**聚合(该用户经由任何入口的流量都算)。独立 guard 实例 = + # 独立的滑动窗口/在途计数,与 Key 的窗口互不干扰。 + user_quota: KeyQuotaGuard # 按 API Key 聚合用量(Key 配额判定与 GET /v1/usage 的数据源)。 key_usage: KeyUsageRepository # 同一个限流护栏实例(executor 也持它):管理台改完限额后 invalidate() @@ -157,17 +169,17 @@ async def principal_from_request(request: Request) -> Principal: 3. 会话 epoch 与 DB 一致——改密/禁用/改角色后旧 Cookie 立即失效。 缺 `ep` 的老 Cookie 按 0 处理(升级平滑),因此引导期签发的 Cookie 仍然有效。 - 4. 角色从 DB 现读,不进 Cookie——降级立即生效。 + 4. 会话签发时间不早于账号创建——整库重建(服务器重置 / 换机)后 epoch + 归零,只靠第 3 条会让上一个库签的 Cookie 重新生效(issue #37)。 + 5. 角色从 DB 现读,不进 Cookie——降级立即生效。 """ services = get_services(request) token = request.cookies.get(SESSION_COOKIE, "") verified = verify_session_token(token, services.settings.app_secret) if verified is None: raise UnauthorizedError("session missing or expired") - username, epoch = verified - if not services.users.is_active(username): - raise UnauthorizedError("session missing or expired") - if services.users.session_epoch(username) != epoch: + username, epoch, issued_at = verified + if not services.users.session_valid(username, epoch, issued_at): raise UnauthorizedError("session missing or expired") role = services.users.role_of(username) principal = Principal(username=username, is_admin=role == ROLE_ADMIN, role=role) @@ -197,13 +209,46 @@ class ApiKeyPrincipal: username: str provider_binding: str = "" # '' = 不限定渠道 allowed_models: str = "" # '' = 不限定模型(fnmatch glob,逗号分隔) + # 用户级模型白名单(`users.allowed_models`,admin 在用户策略里设): + # 与 Key 白名单**同时**生效(两个都要放行)。缺这层时用户自签一个空 + # 白名单的 Key 就能绕开管理员按人设的模型策略。 + user_allowed_models: str = "" + # 用户级配额(`users.quota`):与 Key 配额**同时**生效。缺这层时用户自签 + # 一把空配额的 Key 就能把管理员按人设的日配额整体绕过(Playground 已判, + # 而 /v1 原来只判 Key 自己的 quota)。 + user_quota: KeyQuota = field(default_factory=KeyQuota) key_id: str = "" # api_keys.id(Key 级配额与自助用量的归属) quota: KeyQuota = field(default_factory=KeyQuota) # 该 Key 的配额(全关=不受限) +class ServicePaused(Exception): + """全站暂停(kill switch,`gateway_paused`):/v1 一律 503。 + + 管理台走会话鉴权、不经过这里,因此**暂停期间运维仍能进去关掉它**。 + 对外只回受控文案,不回显运维写的原因(可能含事故编号等内部信息); + 原因进服务端日志。 + """ + + def __init__(self, reason: str = "") -> None: + super().__init__("service temporarily paused by operator") + self.reason = reason + + +def _ensure_not_paused(services: Services) -> None: + """暂停期拦掉 /v1:放在鉴权依赖里而不是各端点,避免新端点漏拦。""" + settings = services.settings + if not getattr(settings, "gateway_paused", False): + return + reason = str(getattr(settings, "gateway_pause_reason", "") or "") + logger.warning("网关暂停中(kill switch),拒绝 /v1 请求:%s", + reason or "(未填原因)") + raise ServicePaused(reason) + + def _api_key_principal(services: Services, request: Request, raw_key: str) -> ApiKeyPrincipal: """校验裸 Key 文本并组装 Principal(Bearer 与 x-api-key 两条入口共用)。""" + _ensure_not_paused(services) record = services.api_keys.authenticate(raw_key) # 用户被删除或禁用后旧 Key 必须立即失效(同会话 Cookie 的理由) if not record or not services.users.is_active(record["username"]): @@ -213,9 +258,17 @@ def _api_key_principal(services: Services, request: Request, source = request_ip(request, services.settings) if not ip_allowed(source, record.get("allowed_ips") or ""): raise ForbiddenError("source ip not allowed for this api key") + # 用户级策略现读(与角色/禁用同一口径,不进 Cookie):admin 按人设的模型 + # 白名单与配额都必须在 /v1 也生效,否则用户自签一把空策略的 Key 即可绕开。 + # 取不到策略行按不限制放行——与 Playground 的宽容读路径一致,脏数据不该 + # 把出口锁死。 + policy = services.user_repo.get(record["username"]) or {} principal = ApiKeyPrincipal(username=record["username"], provider_binding=record.get("provider_binding") or "", allowed_models=record.get("allowed_models") or "", + user_allowed_models=str( + policy.get("allowed_models") or ""), + user_quota=load_key_quota(policy.get("quota")), key_id=record["id"], quota=load_key_quota(record.get("quota"))) # 归属权只认鉴权结果(绝不读入站头):统计写入经 contextvar 现读, @@ -269,14 +322,33 @@ async def read_json_body(request: Request) -> Any: 但本项目的 /v1 与 playground 走原始 Request(需要透传任意字段), 裸调 `request.json()` 会让 json.JSONDecodeError 冒泡成 500。 - `parse_constant` 拒绝 `NaN` / `Infinity` / `-Infinity`(JSON 标准不允许, - 但 Python json 默认接受):这些值会经本服务重新序列化透传给上游,把 - 本应 400 的输入变成上游 4xx,污染凭证健康度与统计。 + 两道 JSON 层的数值把关: + + - `parse_constant` 拒绝 `NaN` / `Infinity` / `-Infinity`(JSON 标准不允许, + 但 Python json 默认接受):这些值会经本服务重新序列化透传给上游,把 + 本应 400 的输入变成上游 4xx,污染凭证健康度与统计。 + - `parse_float` 拒绝**溢出成 inf 的合法 JSON 数字**(`1e999`):字面量拦不住 + 它(`json.loads('{"x":1e999}')` → `inf`),而 `httpx` 序列化时抛 + `ValueError: Out of range float values are not JSON compliant` → 变 500。 + 逐字段校验(`compat/*/request.py`)只覆盖已知的几个键,chat 出口会把整个 + 请求体透传给上游,未知键里的 inf 会一路带到序列化那一步炸掉。 + + `parse_float` 拦在**解析层**而非逐字段:三个出口(chat / Anthropic / + Responses)共用这一个入口,一处生效全生效;逐字段补齐要各写一遍且仍会漏 + 掉 chat 出口的未知透传键。 """ def _reject_constant(token: str) -> Any: raise InvalidRequest(f"JSON value {token} is not allowed") + def _reject_non_finite(token: str) -> float: + # Python 的 json 在这里已把 1e999 解析成 inf(不抛 OverflowError) + value = float(token) + if not math.isfinite(value): + raise InvalidRequest(f"JSON number {token} is not finite") + return value + try: - return json.loads(await request.body(), parse_constant=_reject_constant) + return json.loads(await request.body(), parse_constant=_reject_constant, + parse_float=_reject_non_finite) except (json.JSONDecodeError, UnicodeDecodeError) as error: raise InvalidRequest("request body is not valid JSON") from error diff --git a/src/api/endpoints.py b/src/api/endpoints.py index f494318a..13c64b03 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -19,19 +19,41 @@ from ..config import Settings from ..engine.compress import estimate_messages from ..engine.executor import Executor, StreamSink +from ..engine.sse import SSE_COMMENT from ..key_quota import Lease, QuotaExceeded from ..runtime_settings import RuntimeSettings from .deps import ApiKeyPrincipal, get_services, read_json_body -from .streaming import with_keepalive +from .streaming import respond_with_keepalive, with_keepalive def ensure_model_allowed(chat_request: ChatRequest, principal: ApiKeyPrincipal, settings: Settings | RuntimeSettings) -> None: - """模型白名单校验:不通过即 400(三个出口与 count_tokens 同一口径)。""" - if not model_allowed(chat_request.model, principal.allowed_models, + """模型白名单校验:不通过即 400(三个出口与 count_tokens 同一口径)。 + + 两层策略**同时**生效,缺一不可: + - `api_keys.allowed_models`:这把 Key 自己被授予的范围; + - `users.allowed_models`:管理员按人设的范围(自助建 Key 时由本人填写, + 所以不能只看 Key 自己的白名单,否则设一个空列表即可越权)。 + """ + ensure_model_allowed_for(chat_request, principal.allowed_models, settings, + subject="this api key") + ensure_model_allowed_for(chat_request, principal.user_allowed_models, settings, + subject="this user") + + +def ensure_model_allowed_for(chat_request: ChatRequest, allowed_models: str, + settings: Settings | RuntimeSettings, *, + subject: str) -> None: + """白名单校验的共用实现:错误文案里的主体(Key / 用户)由调用方给。 + + 两种主体共用同一判据:`api_keys.allowed_models`(外部 Key)与 + `users.allowed_models`(管理台 Playground 的会话直连)。分开写两份实现必然 + 漂移——其中一份漏了「省略模型名即默认模型」的绕过口子,白名单就等于没有。 + """ + if not model_allowed(chat_request.model, allowed_models, default_model=settings.default_model): raise InvalidRequest( - f"model {chat_request.model!r} is not allowed for this api key") + f"model {chat_request.model!r} is not allowed for {subject}") def estimated_tokens(chat_request: ChatRequest) -> int: @@ -98,12 +120,26 @@ async def dispatch_compat( lease.release() # 400 不该占着并发名额 raise translator = translator_for(target.model) if translator_for else None + # 心跳帧随出口协议走:Anthropic 要 `event: ping` 具名事件(只解析 + # `event:` 行的客户端会忽略注释帧),chat / Responses 保持注释帧 + # (Responses 无 ping 事件,未知事件类型会让严格解析器报错)。 + keepalive_frame = (translator.keepalive() if translator is not None + else SSE_COMMENT) frames = with_keepalive(executor.stream_guarded(chat_request, username=principal.username, translator=translator, - provider_binding=binding)) + provider_binding=binding), + keepalive_frame=keepalive_frame) return StreamingResponse( _release_when_done(frames, lease), media_type="text/event-stream") + # 非流式保活(`nonstream_keepalive_seconds`,默认 0 = 关闭):宽限期内 + # 完成仍是逐字节相同的 JSONResponse,超时才转 chunked 吐空白心跳。 + keepalive = float(getattr(settings, "nonstream_keepalive_seconds", 0) or 0) + if keepalive > 0: + return await respond_with_keepalive( + lambda: executor.complete(chat_request, username=principal.username, + provider_binding=binding), + grace=keepalive, convert=convert, release=lease.release) try: completion = await executor.complete(chat_request, username=principal.username, provider_binding=binding) @@ -114,13 +150,47 @@ async def dispatch_compat( def _admit_quota(request: Request, principal: ApiKeyPrincipal, chat_request: ChatRequest) -> Lease: - """按 Key 配额准入;超限抛 `QuotaExceeded`,放行返回 Lease(不受限时不计数)。 + """按 Key 配额 + 用户配额准入;超限抛 `QuotaExceeded`。 + + 两层**同时**生效,缺一不可: + - `api_keys.quota`:这把 Key 自己被授予的上限; + - `users.quota`:管理员按人设的上限(自助建 Key 时由本人填写,所以不能 + 只看 Key 自己的配额,否则造一把空配额的 Key 即绕开人均配额)。 + + 与 Playground 同一判据(`services.user_quota`,用量按**用户名**聚合)。 + 先判 Key 再判用户:两者都通过才计数,任一失败立即释放已占名额。 不受限的 Key(配额全关)也返回 Lease——`counted=False`,`release()` 是 空操作,调用方不必到处判空。 """ - return get_services(request).key_quota.admit( - principal.key_id, principal.quota, tokens=estimated_tokens(chat_request)) + services = get_services(request) + tokens = estimated_tokens(chat_request) + lease = services.key_quota.admit( + principal.key_id, principal.quota, tokens=tokens) + # 用户级配额全关时 `admit` 直接返回 counted=False 的 Lease(不查库), + # 所以这里不必先判空——省一条分支,也省得两处口径分叉。 + try: + user_lease = services.user_quota.admit( + principal.username, principal.user_quota, tokens=tokens, subject="user") + except QuotaExceeded: + lease.release() # 用户级拒绝不该占着 Key 的并发名额 + raise + return _CombinedLease(lease, user_lease) + + +class _CombinedLease: + """Key 级 + 用户级两层配额的合成租约:`release()` 同时归还两层名额。 + + 只实现调用方用到的那一个方法(`release`)——`counted` 之类的内部分支 + 调用方从不读,实现了反而是没人测的死代码。 + """ + + def __init__(self, *leases) -> None: + self._leases = leases + + def release(self) -> None: + for lease in self._leases: + lease.release() async def _release_when_done(frames, lease: Lease): diff --git a/src/api/logs.py b/src/api/logs.py index 1f2bedf2..f5e3db10 100644 --- a/src/api/logs.py +++ b/src/api/logs.py @@ -40,7 +40,7 @@ # 非流式端点默认返回行数;上限由缓冲容量决定(不能超过实际保留的行)。 DEFAULT_LOG_LIMIT = 200 -_DISABLED = {"error": {"message": "log page disabled", "code": "not_found", "status": 404}} +_DISABLED = error_payload("log page disabled", "not_found", 404) def _sse_frame(line: dict[str, Any]) -> str: diff --git a/src/api/metrics.py b/src/api/metrics.py index df662f97..3634f1b4 100644 --- a/src/api/metrics.py +++ b/src/api/metrics.py @@ -20,6 +20,8 @@ from __future__ import annotations +import hmac + from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, Response @@ -107,17 +109,41 @@ def render_metrics( return "\n".join(lines) + "\n" +def _token_matches(header: str, expected: str) -> bool: + """Bearer 头校验(恒时比较;前缀大小写不敏感,Prometheus 发 `Bearer`)。 + + 编码成 bytes:`hmac.compare_digest` 对含非 ASCII 的 `str` 会抛 TypeError, + 而这里的输入完全来自外部请求头。 + """ + prefix = "bearer " + if not header.lower().startswith(prefix): + return False + supplied = header[len(prefix):].strip() + return hmac.compare_digest(supplied.encode("utf-8"), expected.encode("utf-8")) + + def create_router(services: Services) -> APIRouter: router = APIRouter() @router.get("/metrics", include_in_schema=False) async def metrics(request: Request): - """Prometheus 抓取端点。**不鉴权**——与同样裸暴露的 `/healthz` 同口径, - 且指标已禁用高基数标签(只有渠道 / 模型 / 状态等有限集合)。""" + """Prometheus 抓取端点。 + + 默认**不鉴权**——与同样裸暴露的 `/healthz` 同口径,且指标已禁用高基数 + 标签(只有渠道 / 模型 / 状态等有限集合)。但端点会暴露渠道与模型清单、 + 凭证池四态计数,对外部署可设 `METRICS_TOKEN` 收口:配置后必须带 + `Authorization: Bearer `(缺失或错误一律 401,不回显差在哪)。 + """ if not bool(services.settings.metrics_enabled): return JSONResponse(status_code=404, content=error_payload("metrics disabled", "invalid_request", 404)) + token = str(services.settings.metrics_token or "") + if token and not _token_matches( + request.headers.get("authorization", ""), token): + return JSONResponse(status_code=401, + content=error_payload("metrics unauthorized", + "unauthorized", 401)) stats = request.app.state.stats_collector runner = getattr(request.app.state, "task_runner", None) # 只报**已装配**的任务(与 /api/tasks 同源):未装配的 key 恒 0 会 diff --git a/src/api/model_merge.py b/src/api/model_merge.py index b8510c6f..8ce1693d 100644 --- a/src/api/model_merge.py +++ b/src/api/model_merge.py @@ -81,8 +81,9 @@ # 展示排序权重:CodeBuddy / TRAE 优先,其余渠道(zen / kilo 等)在后; # 多渠道模型取所有渠道里的最小权重(含 CB 即进第一段)。 _PROVIDER_RANK = {"codebuddy": 0, "codebuddy_intl": 1, "trae": 2, "qoder": 3, - "codearts": 4, "officeace": 5} -_UNRANKED_PROVIDER = 6 + "codearts": 4, "officeace": 5, "raccoon": 6, + "lingxi": 7} +_UNRANKED_PROVIDER = 8 # 哨兵名:各上游自己的「自动路由 / 默认模型」占位,语义只在本渠道内成立,不跨 # 渠道合并。kilo 的 `kilo-auto/free`(上游名 `Auto Free`)与 Qoder 的 `auto` @@ -458,20 +459,30 @@ def _register_aliases(entries: list[dict[str, Any]], """ for entry in entries: for provider_id in sorted(entry["providers"]): - provider_aliases = aliases.setdefault(provider_id, {}) - raw_id = entry["raw_ids"][provider_id] - provider_aliases[entry["id"].lower()] = raw_id - provider_aliases[raw_id.lower()] = raw_id - # 「按展示名入键」的渠道额外登记展示名键:用户按 `Kimi K3` 或 - # `kimi-k3` 直连都能落到该渠道。不登记同渠道重名项(CB 的 - # `hy4-preview` / `hy4-preview-x` 都叫「Hy4 Preview」,登记任一个 - # 都会指向错误模型)。 - display = entry["meta"].get("name") - if provider_id in entry["name_keyed"] and display: - provider_aliases[str(display).lower()] = raw_id - label_key = normalize_model_key(str(display)) - if label_key != str(display).lower(): - provider_aliases[label_key] = raw_id + _register_entry_alias(entry, provider_id, aliases) + + +def _register_entry_alias(entry: dict[str, Any], provider_id: str, + aliases: dict[str, dict[str, str]]) -> None: + """单个 (条目, 渠道) 的别名登记(`_register_aliases` 的单元)。 + + 单独成函数是为了让「探活判死但仍要保留归属」的补登记走**同一套**键推导 + (见 `api.models._hidden_aliases`):两处各写一份键推导,迟早漂移。 + """ + raw_id = entry["raw_ids"][provider_id] + provider_aliases = aliases.setdefault(provider_id, {}) + provider_aliases[entry["id"].lower()] = raw_id + provider_aliases[raw_id.lower()] = raw_id + # 「按展示名入键」的渠道额外登记展示名键:用户按 `Kimi K3` 或 + # `kimi-k3` 直连都能落到该渠道。不登记同渠道重名项(CB 的 + # `hy4-preview` / `hy4-preview-x` 都叫「Hy4 Preview」,登记任一个 + # 都会指向错误模型)。 + display = entry["meta"].get("name") + if provider_id in entry["name_keyed"] and display: + provider_aliases[str(display).lower()] = raw_id + label_key = normalize_model_key(str(display)) + if label_key != str(display).lower(): + provider_aliases[label_key] = raw_id def _build_aliases(entries: list[dict[str, Any]], @@ -565,7 +576,10 @@ def _entry_response(entry: dict[str, Any], if meta["credit_rate"] is not None: detail["credit_rate"] = meta["credit_rate"] by_provider[pid] = detail - if len(entry["providers"]) > 1 and by_provider: + # 单渠道也下发 by_provider:PROPOSAL §3.4「原代号进 by_provider.{渠道}.raw_id」 + # 没有「多渠道才给」这条例外。以前只给多渠道,单渠道模型的原代号对外完全 + # 不可见(前端 `providerRawId` 拿不到,排障时看不到实际请求的 key)。 + if by_provider: result["by_provider"] = by_provider score = lookup(benchmarks or {}, entry["id"], entry["meta"].get("name")) if score is not None: diff --git a/src/api/models.py b/src/api/models.py index 11225b2f..dc4f1f76 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -23,8 +23,11 @@ **缓存存的是未过滤列表,过滤在每个出口现做**:MODEL_BLOCKLIST 是可热更项 (「改完立即生效」),若把过滤结果存进缓存,改完黑名单要等 TTL(300s) 才反映到 Playground,且被滤掉的模型在 TTL 内会从「兜底缓存」里复活。 -「探活判死」同理现算(`model_probes.failed_and_blocked`),并**逐渠道**抽掉: +「探活判死」同理现算(`model_probes.failed`),并**逐渠道**抽掉: (渠道, 模型) 判死只让这一对从该渠道消失,别的渠道正常时同一模型照常展示。 +判死不看到期、只看结论:恢复入口是复探成功(`ModelProbeTask` 每轮探判死行), +不再挂在 blocked 负缓存的 `cooling_until` 上——负缓存过期后没人再探,旧口径 +等于永久隐藏。 """ from __future__ import annotations @@ -40,15 +43,21 @@ from ..benchmarks import BenchmarkTable from ..compat.openai.errors import error_payload +from ..db.crypto import CredentialDecryptError from ..engine.reasoning import parse_effort_map +from ..error_text import error_text from ..model_alias import parse_model_aliases from ..pricing import PriceTable +from ..provider.base import Model +from ..provider.naming import normalize_model_key from .deps import ApiKeyPrincipal, Services, api_key_user from .model_catalog import load_catalog, save_catalog from .model_merge import ( _build_aliases, _entry_response, _merge_provider, + _model_label, + _register_entry_alias, _sort_key, _visible, ) @@ -93,14 +102,16 @@ def merged_entries(services: Services, connected: set[str]) -> list[dict[str, An TTL 复用、落盘恢复三条路径共用同一段合并逻辑,行为一致。渠道过滤用 `connected`(有可用凭证),与 `list_models` 的拉取范围保持同口径。 - **再叠一层「探活判死」过滤**(`probed_out`):(渠道, 模型) 被探活判死且 - 仍被 blocked 负缓存锁着时,把这条模型**从该渠道的表里抽掉**——只抽该渠道, - 别的渠道正常时同一模型照常展示(合并键跨渠道,多渠道条目退化成单渠道条目)。 - 隐藏条目时同时跳过它,别名表里也就没有「渠道 → 原名」,执行侧 - `_narrow_providers` 自然不再选它。与黑名单同口径:点名直连指定模型不受影响。 + **再叠一层「探活判死」过滤**(`probed_out`):(渠道, 模型) 被探活判死时, + 把这条模型**从该渠道的表里抽掉**——只抽该渠道,别的渠道正常时同一模型照常 + 展示(合并键跨渠道,多渠道条目退化成单渠道条目)。与黑名单同口径:点名 + 直连指定模型不受影响。 + + **但别名登记不能跟着摘**(见 `_hidden_aliases`):摘掉后执行侧 + `_narrow_providers` 会因「没有任何渠道登记该模型」回退成全部渠道扇出。 """ patterns = services.settings.blocklist_patterns - probed_out = services.model_probes.failed_and_blocked() + probed_out = services.model_probes.failed() grouped: dict[str, dict[str, Any]] = {} # 归一键 → {providers, meta, raw_ids, ...} for provider_id, table in services.model_list_cache.items(): if provider_id in connected and table: @@ -116,19 +127,91 @@ def merged_entries(services: Services, connected: set[str]) -> list[dict[str, An return sorted(grouped.values(), key=_sort_key) -def _publish(services: Services, entries: list[dict[str, Any]]) -> None: +def _hidden_aliases(services: Services, + probed_out: set[tuple[str, str]], + connected: set[str]) -> dict[str, dict[str, str]]: + """探活判死的 (渠道, 模型) → 别名登记(**只影响执行侧收窄,不进展示**)。 + + 「探活判死」把该 (渠道, 模型) 从模型列表里摘掉(`merged_entries`),若别名 + 登记也跟着摘,执行侧 `_narrow_providers` 就找不到任何登记该模型的渠道 → + **回退成全部渠道扇出**:请求被发到一堆根本没有该模型的上游,各回「无此 + 模型」,并被写上 (凭证, 模型) 负缓存——污染的是从未提供该模型的渠道。 + + 实测 2026-10-26 `deepseek-v4.1-flash-1017`:officeAce 的 `/v2/models` 挂着 + 它(26 个模型之一)但推理端点直接 404 `ModelArts.81009`;人工探活判死后 + 别名表丢了 officeAce 的归属,请求扇出到 codebuddy / codearts / + codebuddy_intl,三家各回「无此模型」,8 条负缓存写在从没有该模型的渠道上。 + + 故这里把判死的 (渠道, 模型) 补登记回别名表:请求仍**收窄**到真正持有它 + 的渠道(多半有负缓存兜底 → 快速 503「该模型暂时不可用」),而不是打一轮 + 无关上游。判死不再要求挂负缓存(见 `ModelProbeRepository.failed`),故也可能 + 真打到该渠道并得到上游的原始错误——那仍比污染其它渠道好。 + + 键推导走 `_register_entry_alias`(与真实条目**同一套**):两处各写一份 + 键推导,迟早漂移——漂移的表现是「用户按展示名请求时收窄失效」。 + **不覆盖已有映射**:真实条目的登记优先。 + **只登记有可用凭证的渠道**(`connected`):与 `merged_entries` 的口径一致 + (没凭证的渠道既不拉取也不展示),否则会给一个根本打不通的渠道登记归属。 + """ + aliases: dict[str, dict[str, str]] = {} + for provider_id, lower in sorted(probed_out): + if provider_id not in connected: + continue + table = services.model_list_cache.get(provider_id) or {} + entry = _hidden_entry(table.get(lower), lower, provider_id) + _register_entry_alias(entry, provider_id, aliases) + return aliases + + +def _hidden_entry(model: Model | None, lower: str, + provider_id: str) -> dict[str, Any]: + """判死模型 → 一个只含它自己的最小条目(供别名登记用)。 + + 键的推导与真实条目同源(`_model_label` / `normalize_model_key`):对外 id + 取展示名的归一键,展示名取清洗后的文本;`name_keyed` 含该渠道(它按 + 展示名入的键,故展示名键也要登记)。 + + **目录里已经没有这个模型**(上游把它撤了,`model is None`):无从得知它 + 的对外 id 与展示名,退回按原 id 登记(`lower`)。此时用户按原 id 请求 + 仍能收窄到该渠道;按展示名请求则收窄不到——但那条路径本来就没有「这个 + 模型属于哪」的信息可言。 + """ + if model is None: + return {"id": lower, "meta": {"name": ""}, + "raw_ids": {provider_id: lower}, "name_keyed": set()} + label = _model_label(model) + return { + "id": normalize_model_key(label) or normalize_model_key(model.id), + "meta": {"name": label}, + "raw_ids": {provider_id: model.id}, + "name_keyed": {provider_id}, + } + + +def _publish(services: Services, entries: list[dict[str, Any]], + connected: set[str]) -> None: """定稿好的条目 → 就地更新别名表(executor 的闭包引用同一个 dict 对象)。 先算好再 clear+update:`_build_aliases` 若抛错,不能让别名表停在「已清空、 未写入」的中间态(那会让所有扁平名请求失去候选收窄)。对象身份必须保留, 故不能整体替换 dict。 + **判死的 (渠道, 模型) 补登记回别名表**(`_hidden_aliases`):它们已从 + 展示里摘掉,但执行侧的候选收窄必须仍指向真正持有它们的渠道,否则请求 + 扇出到一堆不认该模型的上游(见 `_hidden_aliases` 的 docstring)。 + 自定义别名(`MODEL_ALIASES`)**每次现读**运行态覆盖层再解析:这是热更 项,改完下一次列表 / 单查就生效,不需要重启或等 TTL。解析结果同时进 「条目表」(列表侧)与「别名表」(执行侧),两者天然是同一份。 """ custom = parse_model_aliases(str(getattr(services.settings, "model_aliases", ""))) aliases = _build_aliases(entries, custom) + for provider_id, provider_aliases in _hidden_aliases( + services, services.model_probes.failed(), + connected).items(): + target = aliases.setdefault(provider_id, {}) + for key, raw_id in provider_aliases.items(): + target.setdefault(key, raw_id) services.model_aliases.clear() services.model_aliases.update(aliases) @@ -140,7 +223,7 @@ def publish_aliases(services: Services, connected: set[str]) -> None: zen 探活(十几秒)——那段时间里已拉好的渠道本该已经能收窄候选,否则扁平名 请求会按「全部渠道」扇出,真实打一轮不认这个模型的上游。 """ - _publish(services, merged_entries(services, connected)) + _publish(services, merged_entries(services, connected), connected) async def _refresh_provider(services: Services, provider_id: str, provider: Any) -> None: @@ -152,10 +235,22 @@ async def _refresh_provider(services: Services, provider_id: str, provider: Any) # 用该上游的一个可用凭证拉取(凭证有归属,模型列表是账号级的) # selectable_only:硬禁用/用户关闭的凭证取不到数据,只会白失败 candidates = services.credentials.candidates([provider_id], selectable_only=True) - credential_data = ( - services.credentials.credential_data(candidates[0].credential_id) - if candidates else {} - ) + try: + credential_data = ( + services.credentials.credential_data(candidates[0].credential_id) + if candidates else {} + ) + except CredentialDecryptError: + # 解不开(APP_SECRET 换过 / 密文损坏)→ 本上游暂时拉不了清单,但 + # **不该让整页模型列表 500**(实测 2026-10-26 14:48:一条坏凭证 + # 连累所有渠道)。按「本次拉取失败」处理:保留旧缓存、记负缓存 + # (避免每次请求都重试同一个坏凭证),其余上游照常。 + logger.error("模型列表获取失败 %s: 凭证解密失败(APP_SECRET 是否被更换过?)", + provider_id) + services.model_list_failures[provider_id] = ( + services.model_list_failures.get(provider_id, 0) + 1) + services.model_list_fetched_at[provider_id] = time.monotonic() + return try: models = await provider.list_models(credential_data) except Exception as error: # noqa: BLE001 - 单上游失败不影响其他 @@ -276,7 +371,7 @@ async def run() -> None: try: await _refresh_providers(services, connected) except Exception as error: # noqa: BLE001 - 后台刷新失败只记日志 - logger.warning("后台模型列表刷新失败: %s", error) + logger.warning("后台模型列表刷新失败: %s", error_text(error)) finally: services.model_refreshing.difference_update(stale) @@ -293,22 +388,24 @@ def _done(_task: asyncio.Task) -> None: def _served_entries(services: Services, connected: set[str], - allowed_models: str = "") -> list[dict[str, Any]]: - """合并 + 定稿 + 就地 publish 别名表,并按 Key 白名单过滤。 + allowed_models: str = "", + user_allowed_models: str = "") -> list[dict[str, Any]]: + """合并 + 定稿 + 就地 publish 别名表,并按白名单过滤。 - `allowed_models` 非空时按该 Key 的模型白名单过滤展示:白名单是 - Key 级策略,执行时的权威判定在出口(`model_allowed`),这里只是让 - `/v1/models` 与 Key 实际能用的模型一致。 + `allowed_models`(Key 级)与 `user_allowed_models`(用户级,admin 按人设) + 同时生效:两者都要放行。执行时的权威判定在出口(`model_allowed`),这里 + 只是让 `/v1/models` 与实际能用的模型一致。 列表与单模型查询(`serve_model`)共用这一段,保证两条出口看到的是 同一份「定稿后」条目(`_publish` 会就地 finalize,id 撞车消歧只发生一次)。 """ entries = merged_entries(services, connected) - _publish(services, entries) - if allowed_models: - from ..auth.access import model_allowed - entries = [entry for entry in entries - if model_allowed(entry["id"], allowed_models)] + _publish(services, entries, connected) + for spec in (allowed_models, user_allowed_models): + if spec: + from ..auth.access import model_allowed + entries = [entry for entry in entries + if model_allowed(entry["id"], spec)] return entries @@ -316,7 +413,8 @@ def _build_response(services: Services, connected: set[str], allowed_models: str = "", benchmarks: BenchmarkTable | None = None, prices: PriceTable | None = None, - efforts: dict[str, dict[str, str]] | None = None) -> dict: + efforts: dict[str, dict[str, str]] | None = None, + user_allowed_models: str = "") -> dict: """按当前缓存合并出响应(不碰上游),并就地 publish 别名表。 `benchmarks` 为能力分表(`app.state.model_benchmarks`);None/空表时条目 @@ -324,7 +422,8 @@ def _build_response(services: Services, connected: set[str], `efforts`(生效中的档位映射)同理:缺省时条目不带 `cost` / `reasoning_efforts`。 """ - entries = _served_entries(services, connected, allowed_models) + entries = _served_entries(services, connected, allowed_models, + user_allowed_models) return {"object": "list", "data": [_entry_response(entry, benchmarks, prices, efforts) for entry in entries]} @@ -385,7 +484,8 @@ def _efforts_of(services: Services) -> dict[str, dict[str, str]] | None: async def serve_models(services: Services, allowed_models: str = "", - request: Request | None = None) -> dict: + request: Request | None = None, *, + user_allowed_models: str = "") -> dict: """HTTP 出口(`/v1/models`、Playground):先回旧列表,过期渠道后台刷。 stale-while-revalidate:TTL 到期不再把 zen 探活的十几秒压在请求上。只有某 @@ -405,11 +505,13 @@ async def serve_models(services: Services, allowed_models: str = "", connected = await _revalidated_providers(services) return _build_response(services, connected, allowed_models, _benchmarks_of(request), _prices_of(request), - _efforts_of(services)) + _efforts_of(services), + user_allowed_models=user_allowed_models) async def serve_model(services: Services, model_id: str, allowed_models: str = "", - request: Request | None = None) -> dict[str, Any] | None: + request: Request | None = None, *, + user_allowed_models: str = "") -> dict[str, Any] | None: """单模型查询(`GET /v1/models/{id}`):命中返回条目,否则 None。 刷新与合并完全复用列表那一段(`_revalidated_providers` + `_served_entries`): @@ -420,7 +522,8 @@ async def serve_model(services: Services, model_id: str, allowed_models: str = " 「池子里还有哪些模型」。 """ connected = await _revalidated_providers(services) - entry = _match_entry(_served_entries(services, connected, allowed_models), model_id) + entry = _match_entry(_served_entries(services, connected, allowed_models, + user_allowed_models), model_id) if entry is None: return None return _entry_response(entry, _benchmarks_of(request), _prices_of(request), @@ -461,7 +564,8 @@ def create_router(services: Services) -> APIRouter: @router.get("/v1/models") async def list_v1_models(request: Request, principal: ApiKeyPrincipal = Depends(api_key_user)): - return await serve_models(services, principal.allowed_models, request) + return await serve_models(services, principal.allowed_models, request, + user_allowed_models=principal.user_allowed_models) @router.get("/v1/models/{model_id:path}") async def get_v1_model(model_id: str, request: Request, @@ -475,7 +579,8 @@ async def get_v1_model(model_id: str, request: Request, 找不到(或不在该 Key 的白名单内)一律 404 + OpenAI 错误形状:白名单外 的模型不能靠单查探测出「池子里有它」。 """ - item = await serve_model(services, model_id, principal.allowed_models, request) + item = await serve_model(services, model_id, principal.allowed_models, request, + user_allowed_models=principal.user_allowed_models) if item is None: return JSONResponse( status_code=404, diff --git a/src/api/playground.py b/src/api/playground.py index cbff0cde..2bb7b9f1 100644 --- a/src/api/playground.py +++ b/src/api/playground.py @@ -1,4 +1,16 @@ -"""Playground:会话鉴权的调试端点(无需 API Key,与 /v1 走同一执行引擎)。""" +"""Playground:会话鉴权的调试端点(无需 API Key,与 /v1 走同一执行引擎)。 + +**按人再判一次出站策略**:会话 Cookie 只回答「你是谁」,不回答「你能用哪些 +模型、一天能用多少」。缺这一步时任何登录用户(含 viewer)都能直调任意模型, +把 Key 的模型白名单与配额整体绕过——管理台是「人能进门」的地方,进门之后的 +出站策略必须落在人身上(`users.allowed_models` / `users.quota`)。 + +判定与 `/v1` 共用同一份实现与口径:白名单走 +`endpoints.ensure_model_allowed_for`(与 Key 白名单同一判据,含「省略模型名 +即默认模型」的绕过口子),配额走独立的 `services.user_quota`(用量按**用户名** +聚合,该用户经任何入口的流量都算)。顺序也与 `/v1` 一致:先白名单(400), +再配额(429,不消耗任何上游调用)。 +""" from __future__ import annotations @@ -6,11 +18,26 @@ from fastapi.responses import JSONResponse, StreamingResponse from ..compat.openai.request import parse_chat_request +from ..key_quota import Lease, QuotaExceeded, load_key_quota from .deps import Services, csrf_protected, principal_from_request, read_json_body +from .endpoints import ( + ensure_model_allowed_for, + estimated_tokens, + quota_exceeded_response, +) from .models import serve_models from .streaming import with_keepalive +async def _release_when_done(frames, lease: Lease): + """流式收尾归还并发名额:正常结束 / 客户端断开 / 异常都要释放。""" + try: + async for frame in frames: + yield frame + finally: + lease.release() + + def create_router(services: Services) -> APIRouter: router = APIRouter() executor = services.executor @@ -29,16 +56,41 @@ async def playground_chat(request: Request, principal=Depends(principal_from_request)): """会话内直接调试:与外部 /v1 走同一执行引擎,用量记到当前用户。 - 不走 API Key 鉴权——调试是管理台自带能力,不应强迫用户先造一个 Key。 + 不走 API Key 鉴权——调试是管理台自带能力,不应强迫用户先造一个 Key; + 但**策略**照 Key 那套判:模型白名单不通过 → 400,配额超限 → 429。 + 用户被删/被禁用不在这里判:`principal_from_request` 已按 DB 现读校验。 + 取不到策略行时按「不限制」放行,与 Key 配额的宽容读路径一致——脏数据 + 不该把调试入口锁死。 """ body = await read_json_body(request) chat_request = parse_chat_request(body) + record = services.user_repo.get(principal.username) or {} + ensure_model_allowed_for(chat_request, + str(record.get("allowed_models") or ""), + services.settings, subject="this user") + try: + lease = services.user_quota.admit( + principal.username, load_key_quota(record.get("quota")), + tokens=estimated_tokens(chat_request), subject="user") + except QuotaExceeded as error: + return quota_exceeded_response(error) if chat_request.stream: - executor.preflight(chat_request) + # 前置校验:在 200 响应头发出前拒绝不可能成功的请求(否则只能断流) + try: + executor.preflight(chat_request) + except Exception: + lease.release() # 400 不该占着并发名额 + raise return StreamingResponse( - with_keepalive(executor.stream_guarded(chat_request, - username=principal.username)), + with_keepalive(_release_when_done( + executor.stream_guarded(chat_request, + username=principal.username), lease)), media_type="text/event-stream") - return JSONResponse(await executor.complete(chat_request, username=principal.username)) + try: + completion = await executor.complete(chat_request, + username=principal.username) + finally: + lease.release() + return JSONResponse(completion) - return router + return router \ No newline at end of file diff --git a/src/api/streaming.py b/src/api/streaming.py index 1c9f8d64..2c7c401d 100644 --- a/src/api/streaming.py +++ b/src/api/streaming.py @@ -3,24 +3,46 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator +import json +import logging +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +from starlette.responses import JSONResponse, Response, StreamingResponse from ..engine.sse import SSE_COMMENT +logger = logging.getLogger(__name__) + # 上游思考/排队时可能长时间不吐字节;nginx proxy_read_timeout 默认 60s, # 空闲超过它就会断开连接。SSE 注释帧被客户端忽略,只为保活。 KEEPALIVE_SECONDS = 15.0 +# 非流式保活字节:JSON 允许任意前导空白(RFC 8259 §2 `JSON-text = ws value ws`), +# 解析器(Python json / JS JSON.parse / Go encoding/json)都照常接受 +# `"\n\n{\"a\":1}"`。发换行比发空格更不容易被中间盒做行首嗅探改写。 +JSON_KEEPALIVE_BYTES = b"\n" + async def with_keepalive(frames: AsyncIterator[bytes], - interval: float = KEEPALIVE_SECONDS) -> AsyncIterator[bytes]: - """在上游无输出的空隙插入 SSE 注释帧。 + interval: float | None = None, + keepalive_frame: bytes = SSE_COMMENT) -> AsyncIterator[bytes]: + """在上游无输出的空隙插入心跳帧。 + + `keepalive_frame` 由出口协议决定:chat / Responses 用 SSE 注释帧(被客户端 + 忽略),Anthropic 用具名 `event: ping`(部分客户端只解析 `event:` 行,注释帧 + 对它们等于没有字节,长静默下仍会判超时)。 + + `interval=None` 读模块常量 `KEEPALIVE_SECONDS`(运行期读而非默认参数, + 否则 import 时就定死,测试无法缩短)。 实现要点:取下一帧用**可复用的 Task** 而不是 `wait_for(anext(...))`。 后者在超时时会 cancel 掉 `anext` 协程,取消会穿透进上游生成器并把 它关闭——心跳反而变成了断流。这里用 `asyncio.wait` 与定时器竞争, - 超时只发注释、待取帧的 Task 继续存活。 + 超时只发心跳、待取帧的 Task 继续存活。 """ + if interval is None: + interval = KEEPALIVE_SECONDS iterator = frames.__aiter__() pending: asyncio.Task[bytes] | None = None try: @@ -41,7 +63,7 @@ async def with_keepalive(frames: AsyncIterator[bytes], continue # 定时器先到:上游暂无输出 → 发心跳,保留 pending 继续等 ticker.cancel() - yield SSE_COMMENT + yield keepalive_frame finally: # 客户端断开 / 生成器关闭:清掉在途任务,避免 "Task was destroyed"。 # cancel() 只是「请求取消」,必须 await 到任务真正结束再退出——否则 @@ -49,3 +71,94 @@ async def with_keepalive(frames: AsyncIterator[bytes], if pending is not None and not pending.done(): pending.cancel() await asyncio.gather(pending, return_exceptions=True) + + +# 保活已提交 200 之后上游才失败时的正文:状态码已经发出去了,改不回来,只能 +# 给一个可解析的 JSON 错误体(比截断强:截断的 JSON 在客户端是解码异常, +# 完全看不出是上游问题)。文案固定,不回显上游正文。 +LATE_FAILURE_PAYLOAD: dict[str, Any] = { + "error": { + "message": "upstream request failed after the response had started", + "type": "api_error", + "code": "upstream_error", + "status": 502, + } +} + + +def _dumps(payload: Any) -> bytes: + """与 `JSONResponse.render` 逐字节一致(分隔符/allow_nan 都对齐)。""" + return json.dumps(payload, ensure_ascii=False, allow_nan=False, + indent=None, separators=(",", ":")).encode("utf-8") + + +async def respond_with_keepalive( + work: Callable[[], Awaitable[dict[str, Any]]], + *, grace: float, + convert: Callable[[dict[str, Any]], Any] | None = None, + release: Callable[[], None] | None = None) -> Response: + """非流式出口的保活(`nonstream_keepalive_seconds`,默认 0 关闭)。 + + **宽限期设计**:先等 `grace` 秒。 + + - 期间完成 → 返回普通 `JSONResponse`:状态码、`Content-Length`、字节内容 + 与不开保活时**完全一致**,异常也照常抛给上层异常处理器(400/503 不变)。 + - 超时 → 才提交 200 + chunked,先吐空白字节再补正文。 + + 为什么不一开始就用 `StreamingResponse`:那样 200 在聚合前就发出去了, + 之后任何失败(无可用凭证 503 / 请求无效 400)都改不回来。宽限期把 + 「改不回来」的窗口压到只有真正超过 `grace` 的请求。 + + 空白字节合法:RFC 8259 §2 规定 `JSON-text = ws value ws`,值前允许任意 + 空白,故 `"\\n\\n{...}"` 仍是合法 JSON(Python `json.loads` / JS + `JSON.parse` 均已实测接受)。 + """ + task = asyncio.ensure_future(work()) + try: + done, _ = await asyncio.wait({task}, timeout=grace) + except BaseException: + # 客户端断开 / 上层取消:必须把工作协程一起收掉,否则它继续跑完整个 + # 上游调用(真实计费)却没人接收结果。 + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if release is not None: + release() + raise + if task in done: + try: + completion = task.result() + finally: + if release is not None: + release() + return JSONResponse(convert(completion) if convert else completion) + return StreamingResponse( + _json_frames(task, grace, convert, release), + media_type="application/json", + headers={"X-Accel-Buffering": "no", "Cache-Control": "no-store"}) + + +async def _json_frames(task: asyncio.Task[dict[str, Any]], interval: float, + convert: Callable[[dict[str, Any]], Any] | None, + release: Callable[[], None] | None) -> AsyncIterator[bytes]: + """慢请求的正文生成器:空闲 `interval` 秒就吐一个空白字节。""" + try: + while not task.done(): + await asyncio.wait({task}, timeout=interval) + if not task.done(): + yield JSON_KEEPALIVE_BYTES + try: + completion = task.result() + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - 已提交 200,只能写进正文 + logger.warning("非流式保活:200 已提交后上游才失败,正文降级为错误体", + exc_info=True) + yield _dumps(LATE_FAILURE_PAYLOAD) + return + yield _dumps(convert(completion) if convert else completion) + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if release is not None: + release() diff --git a/src/audit/actions.py b/src/audit/actions.py index e757f9a5..481abb50 100644 --- a/src/audit/actions.py +++ b/src/audit/actions.py @@ -16,6 +16,7 @@ ACTION_USER_PASSWORD_CHANGE = "user.password_change" ACTION_USER_PASSWORD_RESET = "user.password_reset" ACTION_USER_DELETE = "user.delete" +ACTION_USER_POLICY = "user.policy" ACTION_CREDENTIAL_IMPORT = "credential.import" ACTION_CREDENTIAL_DELETE = "credential.delete" ACTION_CREDENTIAL_TOGGLE = "credential.toggle" @@ -24,11 +25,21 @@ ACTION_CREDENTIAL_SWITCH_ACCOUNT = "credential.switch_account" ACTION_CREDENTIAL_LIMITS = "credential.limits" ACTION_CREDENTIAL_TAGS = "credential.tags" +ACTION_CREDENTIAL_EXPORT = "credential.export" +ACTION_CREDENTIAL_WEB_COOKIE = "credential.web_cookie" +ACTION_CREDENTIAL_WEIGHT = "credential.weight" ACTION_API_KEY_QUOTA = "api_key.quota" +ACTION_API_KEY_CREATE = "api_key.create" +ACTION_API_KEY_DELETE = "api_key.delete" +ACTION_DATA_EXPORT = "data.export" ACTION_ALERT_RESOLVE = "alert.resolve" ACTION_MODEL_PROBE = "model.probe" ACTION_MODEL_PROBE_MANUAL = "model.probe_manual" ACTION_SETTINGS_UPDATE = "settings.update" +ACTION_BACKUP_CREATE = "backup.create" +ACTION_BACKUP_DOWNLOAD = "backup.download" +ACTION_BACKUP_DELETE = "backup.delete" +ACTION_BACKUP_RESTORE = "backup.restore" ACTIONS: tuple[str, ...] = ( ACTION_LOGIN_SUCCESS, ACTION_LOGIN_FAILURE, @@ -37,11 +48,18 @@ ACTION_USER_PASSWORD_CHANGE, ACTION_USER_PASSWORD_RESET, ACTION_USER_DELETE, ACTION_CREDENTIAL_IMPORT, ACTION_CREDENTIAL_DELETE, ACTION_CREDENTIAL_TOGGLE, ACTION_CREDENTIAL_PIN, ACTION_CREDENTIAL_REVIVE, ACTION_CREDENTIAL_SWITCH_ACCOUNT, - ACTION_CREDENTIAL_LIMITS, ACTION_CREDENTIAL_TAGS, + ACTION_CREDENTIAL_LIMITS, ACTION_CREDENTIAL_TAGS, ACTION_CREDENTIAL_EXPORT, + ACTION_CREDENTIAL_WEB_COOKIE, + ACTION_CREDENTIAL_WEIGHT, + ACTION_USER_POLICY, ACTION_API_KEY_QUOTA, + ACTION_API_KEY_CREATE, ACTION_API_KEY_DELETE, + ACTION_DATA_EXPORT, ACTION_ALERT_RESOLVE, ACTION_MODEL_PROBE, ACTION_MODEL_PROBE_MANUAL, ACTION_SETTINGS_UPDATE, + ACTION_BACKUP_CREATE, ACTION_BACKUP_DOWNLOAD, + ACTION_BACKUP_DELETE, ACTION_BACKUP_RESTORE, ) # 界面上的中文标签(审计页筛选下拉用) @@ -53,6 +71,7 @@ ACTION_USER_ROLE_CHANGE: "变更角色", ACTION_USER_DISABLE: "禁用用户", ACTION_USER_ENABLE: "启用用户", + ACTION_USER_POLICY: "设置用户策略", ACTION_USER_PASSWORD_CHANGE: "修改密码", ACTION_USER_PASSWORD_RESET: "重置密码", ACTION_USER_DELETE: "硬删用户", @@ -64,7 +83,13 @@ ACTION_CREDENTIAL_SWITCH_ACCOUNT: "切换账号", ACTION_CREDENTIAL_LIMITS: "设置限额", ACTION_CREDENTIAL_TAGS: "设置标签", + ACTION_CREDENTIAL_EXPORT: "导出凭证", + ACTION_CREDENTIAL_WEB_COOKIE: "设置网页 Cookie", + ACTION_CREDENTIAL_WEIGHT: "设置调度权重", ACTION_API_KEY_QUOTA: "设置 Key 配额", + ACTION_API_KEY_CREATE: "创建 API Key", + ACTION_API_KEY_DELETE: "删除 API Key", + ACTION_DATA_EXPORT: "导出数据", ACTION_ALERT_RESOLVE: "标记告警恢复", ACTION_MODEL_PROBE: "探活模型", ACTION_MODEL_PROBE_MANUAL: "接管模型探活", diff --git a/src/auth/bootstrap.py b/src/auth/bootstrap.py index d5496e90..e03a41bc 100644 --- a/src/auth/bootstrap.py +++ b/src/auth/bootstrap.py @@ -3,7 +3,8 @@ 四步,顺序有依赖: 1. 库为空 → 从 users.txt 导入(一律 viewer,不猜角色)。 -2. 按 `ADMIN_USERNAMES` 把命中者提权为 admin(幂等,已是 admin 不动)。 +2. 库里有**活跃 admin** 时跳过 env 提权;没有活跃 admin(引导期)才按 + `ADMIN_USERNAMES` 把命中者提权为 admin(幂等,已是 admin 不动)。 3. 库为空 → 报错。消息必须同时给出「放回 users.txt 重启」与「跑 CLI 建号」 两条恢复路径——用户这时候已经起不来了,只报「文件不存在」等于没说怎么办。 4. 无活跃 admin → 兑回 env 指名者;仍无 → 报错。 @@ -11,6 +12,13 @@ 第 4 步是防锁死的最后一层:只要有 active admin 就能在界面里处理其余情况, 所以启动不拦「admin 被降级」这类操作,只兜住「一个都没有」的死局。 +**第 2 步为什么只在引导期提权**:角色真源是 DB,不是 env。每次启动都按 +`ADMIN_USERNAMES` 改写角色,等于让管理台的降级在下一次重启后被撤销 +(「角色不进 Cookie,降级立即生效」只对当次会话有效,重启即失效)。 +判定用「有没有活跃 admin」而不是「库里有没有用户」:库里有用户但全是 +viewer/operator 时仍是引导期(老部署从 users.txt 升级的中间态),此时 +env 提权是唯一的出路;库里有活跃 admin 后 env 就不再插手。 + 写入幂等性来自 `upsert_imported`(已存在不覆盖)与 `update_role`(同值无害), 因此反复重启不会把在管理台改过的密码/角色冲回文件值。 @@ -40,17 +48,27 @@ def bootstrap_users(config, user_repo, *, file_store=None, log=None) -> None: imported = file_store.import_into(user_repo) # 2. env 指名的引导期 admin + # + # **只在引导期提权**:库里有活跃 admin 时,env 不再改写任何人的角色。 + # 之前每次启动都无条件提权,等于让 ADMIN_USERNAMES 成为角色真源—— + # 管理台刚降级的用户重启后又变回 admin,「降级立即生效」被重启撤销。 + # 引导期 = 库里还没有活跃 admin(首次部署、老部署从 users.txt 升级)。 admin_names = getattr(config, "admin_set", frozenset()) promoted: list[str] = [] - for username in sorted(admin_names): - row = user_repo.get(username) - if row is None: - # env 指了个库里没有的用户:不报错(可能只是残留配置),但也不算 - # 「有 admin」——第 4 步会兜住真正无 admin 的情况。 - continue - if row["role"] != ROLE_ADMIN: - user_repo.update_role(username, ROLE_ADMIN, bump_epoch=False) - promoted.append(username) + if user_repo.count_active_admins() > 0: + # 已有活跃 admin:角色只来自 DB,env 提权全部跳过(含被降级的)。 + if log is not None and admin_names: + log("账号引导:已有活跃 admin,跳过 ADMIN_USERNAMES 提权(角色只来自 DB)") + else: + for username in sorted(admin_names): + row = user_repo.get(username) + if row is None: + # env 指了个库里没有的用户:不报错(可能只是残留配置),但也不算 + # 「有 admin」——第 4 步会兜住真正无 admin 的情况。 + continue + if row["role"] != ROLE_ADMIN: + user_repo.update_role(username, ROLE_ADMIN, bump_epoch=False) + promoted.append(username) if log is not None: if imported: @@ -64,9 +82,9 @@ def bootstrap_users(config, user_repo, *, file_store=None, log=None) -> None: # 4. 必须至少一个活跃 admin。 # - # 这里不再重复「按 env 提权」:第 2 步已把 env 指名的**全部**用户提为 admin - # (含被禁用的那些),所以「活跃 admin 为 0」只可能是它们全被禁用—— - # 此时 env 帮不上忙,只能走恢复路径。刻意不自动启用:那会绕过一次 + # 第 2 步已在「无活跃 admin」时把 env 指名的**全部**用户提为 admin + # (含被禁用的那些),所以走到这里「活跃 admin 为 0」只可能是它们全被 + # 禁用——此时 env 帮不上忙,只能走恢复路径。刻意不自动启用:那会绕过一次 # 有意的禁用。 if user_repo.count_active_admins() == 0: raise UsersFileError(f"no active admin account; {_RECOVERY_HINT}") diff --git a/src/auth/session.py b/src/auth/session.py index a5eaa91b..79d4bb4a 100644 --- a/src/auth/session.py +++ b/src/auth/session.py @@ -6,6 +6,11 @@ 兼容性:老版本签的 Cookie 没有 `ep` 字段,一律按 0 处理而不是判非法—— 否则升级后所有已登录用户会被集体登出。新字段的值非法(类型错/负数)才拒绝。 + +`iat`(签发时间)供 `session_valid()` 判断会话是否早于账号创建:服务器重置 / +换机后整库重建,`session_epoch` 从 0 重来,而 `APP_SECRET` 没换时旧 Cookie +的 `ep=0` 会重新匹配新账号(issue #37)。老 Cookie 缺 `iat` 时返回 None, +调用方跳过这项校验以保持平滑升级。 """ from __future__ import annotations @@ -36,8 +41,13 @@ def create_session_token(username: str, secret: str, *, epoch: int = 0, def verify_session_token(token: str, secret: str, *, - now: int | None = None) -> tuple[str, int] | None: - """返回 `(用户名, 会话 epoch)`;签名无效、过期或格式错误返回 None。""" + now: int | None = None, + ) -> tuple[str, int, int | None] | None: + """返回 `(用户名, 会话 epoch, 签发时间)`;签名无效、过期或格式错误返回 None。 + + 签发时间对没有该字段的老 Cookie 返回 None(调用方据此跳过「早于账号创建」 + 校验),显式写了非法值才判非法。 + """ if not token or "." not in token: return None payload_b64, _, signature = token.partition(".") @@ -70,4 +80,10 @@ def verify_session_token(token: str, secret: str, *, epoch = payload.get("ep", 0) if not isinstance(epoch, int) or isinstance(epoch, bool) or epoch < 0: return None - return username, epoch + # iat 缺失 = 升级前的老 Cookie(跳过创建时间校验);显式非法值才拒绝 + issued_at = payload.get("iat") + if issued_at is not None and ( + not isinstance(issued_at, int) or isinstance(issued_at, bool) + or issued_at < 0): + return None + return username, epoch, issued_at diff --git a/src/auth/users.py b/src/auth/users.py index 7763bf2b..939aa446 100644 --- a/src/auth/users.py +++ b/src/auth/users.py @@ -25,6 +25,18 @@ PBKDF2_SALT_BYTES = 16 PBKDF2_DIGEST_BYTES = 32 +# 用户名里禁止的字符(PROPOSAL §6「拒绝含 `:` / 换行的用户名」)。 +# +# 冒号会截断 users.txt 的 `用户名:hash` 字段;换行/回车会注入额外记录。 +# 放在这里(而不是某个脚本里)是这套规则唯一的家:写 users.txt 的脚本 +# (hash_password.py)与写 DB 的脚本(create_user.py)都必须拒同一批字符, +# 否则「CLI 建号能过的名字」和「用户文件能写的名字」口径就不一致。 +FORBIDDEN_USERNAME_CHARS = (":", "\n", "\r") + +# 会话签发时间与账号创建时间的允许误差(见 `DbUserStore.session_valid`)。 +# 同秒精度 + 测试夹具「先签 Cookie 再启动建号」的正常时序都落在余量内。 +SESSION_CLOCK_SLACK_SECONDS = 60 + class UsersFileError(RuntimeError): """用户文件缺失、格式非法或无有效用户。""" @@ -135,6 +147,19 @@ def validate(self) -> None: self._load_if_needed() +#: 时序对齐用的 dummy 哈希缓存(见 `_dummy_password_hash`)。 +_DUMMY_HASH_CACHE: list[str] = [] + + +def _dummy_password_hash() -> str: + """进程内只造一次的 PBKDF2 哈希(随机口令,永远对不上)。""" + if not _DUMMY_HASH_CACHE: + # lazy 而非模块级:`create_password_hash` 是 600k 迭代(约 45ms), + # import 期每进程多付一次可接受,放 import 会拖累所有收集模块的测试。 + _DUMMY_HASH_CACHE.append(create_password_hash(secrets.token_urlsafe(32))) + return _DUMMY_HASH_CACHE[0] + + class DbUserStore: """SQLite 用户存储:DB 是唯一权威源。 @@ -149,6 +174,10 @@ def __init__(self, repo) -> None: def verify(self, username: str, password: str) -> bool: row = self._repo.get(username) if row is None or not row["enabled"]: + # 不存在 / 被禁用也要完整跑一遍 PBKDF2:600k 迭代实测量级 ~45ms, + # 直接 return False 的响应比「存在但密码错」快两个数量级,攻击者 + # 据此计时即可枚举有效用户名(实测时序差 ~7000×)。 + verify_password(password, _dummy_password_hash()) return False return verify_password(password, row["password_hash"]) @@ -165,6 +194,29 @@ def session_epoch(self, username: str) -> int | None: row = self._repo.get(username) return int(row["session_epoch"]) if row else None + def session_valid(self, username: str, epoch: int, + issued_at: int | None) -> bool: + """会话是否仍有效:存在且启用 + epoch 未作废 + 不早于账号创建。 + + 前两条与原来 `is_active` + `session_epoch ==` 等价。第三条补的是整库 + 重建场景:服务器重置 / 换机后 SQLite 是新的,`session_epoch` 从 0 + 重来;若 `APP_SECRET` 还是旧值,历史 Cookie 的 `ep=0` 会重新匹配新 + 账号,旧浏览器无需重新登录(issue #37)。`iat` 早于当前账号 `created_at` + 的会话必然是上一个库签发的,直接拒绝。 + + `issued_at=None`(升级前的老 Cookie 没有 `iat`):跳过该项,保持 + 与「缺 ep 按 0 放行」同一平滑口径。留 60s 余量吸收签发与建号同秒 / + 测试夹具先签 Cookie 再启动建号的正常时序。 + """ + row = self._repo.get(username) + if row is None or not row["enabled"]: + return False + if int(row["session_epoch"]) != epoch: + return False + if issued_at is None: + return True + return issued_at >= int(row["created_at"] or 0) - SESSION_CLOCK_SLACK_SECONDS + def must_change_password(self, username: str) -> bool: row = self._repo.get(username) return bool(row["must_change_password"]) if row else False diff --git a/src/compat/anthropic/request.py b/src/compat/anthropic/request.py index dc97c1b8..e57b3bad 100644 --- a/src/compat/anthropic/request.py +++ b/src/compat/anthropic/request.py @@ -28,7 +28,7 @@ import json from typing import Any -from ...engine.reasoning import thinking_effort +from ...engine.reasoning import output_config_effort, thinking_effort from ..openai.request import ( MAX_OUTPUT_TOKENS, MAX_TEMPERATURE, @@ -161,6 +161,30 @@ def _merge_parts(parts: list[dict[str, Any]]) -> str | list[dict[str, Any]]: return list(parts) +def _placeholder_text(kind: str, block: dict[str, Any]) -> str: + """本网关无法转成 chat 表达的内容块 → 一段中性占位文本。 + + Claude Code 2.1.31x 起会在会话里回灌两类块: + + * `tool_reference`(服务端 tool_search 命中的工具名,`{type, tool_name}`): + 它只是「这个工具可用」的提示,chat 没有对应语义,丢掉也不影响模型已 + 看到的工具表。 + * `document`(PDF / 纯文本附件):chat 只有 `image_url`,转不了文档。 + + 两者此前都显式 400,而客户端遇到 400 会**整轮重发一次**并永久关掉该特性 + (`tool_reference` 实测 28/30 次 400 都出自它)。改成占位文本后模型至少 + 知道「这里原本有个附件/工具引用」,比白打一轮往返好。 + """ + if kind == "tool_reference": + name = block.get("tool_name") + if isinstance(name, str) and name: + return f"[tool available: {name}]" + return "[tool available]" + title = block.get("title") + label = title if isinstance(title, str) and title else kind + return f"[{label} omitted: this gateway cannot forward {kind} content]" + + def _tool_result_text(content: Any) -> str | list[dict[str, Any]]: """`tool_result.content`:字符串或 text/image 块数组。 @@ -183,9 +207,9 @@ def _tool_result_text(content: Any) -> str | list[dict[str, Any]]: parts.append(_text_part(block, where)) elif kind == "image": parts.append(_image_part(block, where)) - elif kind == "document": - raise InvalidRequest( - f"tool_result.content type {kind!r} is not supported by this gateway") + elif kind in ("document", "tool_reference"): + parts.append({"type": "text", + "text": _placeholder_text(kind, block)}) else: raise InvalidRequest( f"unknown tool_result.content type {kind!r}") @@ -247,8 +271,11 @@ def flush() -> None: tool_message["cache_control"] = marker messages.append(tool_message) elif kind == "document": - raise InvalidRequest( - f"content block type {kind!r} is not supported by this gateway") + # user 消息里的 document(附件):chat 只有 image_url,见 + # `_placeholder_text`。此前显式 400 会打死整轮。 + pending.append({"type": "text", "text": _placeholder_text(kind, block)}) + elif kind == "tool_reference": + pending.append({"type": "text", "text": _placeholder_text(kind, block)}) else: raise InvalidRequest(f"unknown content block type {kind!r}") flush() @@ -294,6 +321,10 @@ def _assistant_message(blocks: list[Any]) -> dict[str, Any] | None: if marker is not None: tool_call["cache_control"] = marker tool_calls.append(tool_call) + elif kind == "tool_reference": + # 服务端 tool_search 的「该工具可用」提示:chat 无对应语义, + # assistant 侧直接丢(不占上下文,也不影响下一轮调用) + continue else: raise InvalidRequest(f"unknown content block type {kind!r}") if not content_parts and not reasoning_parts and not tool_calls: @@ -318,6 +349,15 @@ def _map_messages(raw_messages: list[Any]) -> list[dict[str, Any]]: raise InvalidRequest(f"messages[{index}] must be an object") role = item.get("role") content = item.get("content") + if role == "system": + # Claude Code 2.1.318 起在新会话首轮发 mid-conversation system + # 消息(`mid-conversation-system-2026-04-07` 特性)。chat 只在 + # 开头认 system,但**整轮 400 会让客户端白打一次往返**——它收到 + # 400 会去掉该消息重发并永久关掉特性。这里就地转成 system 消息 + # 按原位置插入:上游对此宽容,`role` 顺序本就是客户端的意图。 + messages.append({"role": "system", + "content": _system_block_content(content, index)}) + continue if isinstance(content, str): if role not in ("user", "assistant"): raise InvalidRequest(f"message role {role!r} is not supported") @@ -337,6 +377,37 @@ def _map_messages(raw_messages: list[Any]) -> list[dict[str, Any]]: return messages +def _system_block_content(content: Any, index: int) -> str: + """会话中途 system 消息的 content → 纯文本。 + + 只接受字符串与 text 块数组(与顶层 `system` 同形);其他块类型属于 + 客户端把非文本塞进 system 的畸形输入,显式 400 而不是猜。 + """ + if isinstance(content, str): + return content + if content is None: + return "" + if not isinstance(content, list): + raise InvalidRequest( + f"messages[{index}].content must be a string or an array") + parts: list[str] = [] + for block_index, block in enumerate(content): + if not isinstance(block, dict): + raise InvalidRequest( + f"messages[{index}].content[{block_index}] must be an object") + kind = block.get("type") + if kind != "text": + raise InvalidRequest( + f"messages[{index}].content[{block_index}] type {kind!r} is " + f"not supported in a system message") + text = block.get("text") + if not isinstance(text, str): + raise InvalidRequest( + f"messages[{index}].content[{block_index}].text must be a string") + parts.append(text) + return "".join(parts) + + def _map_tools(tools: Any) -> list[dict[str, Any]]: mapped: list[dict[str, Any]] = [] for index, tool in enumerate(tools): @@ -444,10 +515,16 @@ def parse_messages_request(body: Any, *, thinking_efforts: str = "") -> ChatRequ choice = _map_tool_choice(body.get("tool_choice")) if choice is not None: upstream["tool_choice"] = choice - # thinking → reasoning_effort(默认不映射):Anthropic 只规定 budget_tokens - # ≥1024、没给「预算 ↔ 档位」对应表,阈值只能由使用者显式配置;留空时这里 - # 一个字段都不写,`thinking` 与现状一样被忽略 - effort = thinking_effort(thinking_efforts, body.get("thinking")) + # 档位来源优先级: + # 1. `output_config.effort`(Claude Code 2.1.318 起 `/effort` 的真实落点, + # 与 `thinking:{type:"adaptive"}` 同时出现;此前整块被丢弃 → 用户选的 + # 档位静默失效,且不报错) + # 2. `thinking.budget_tokens` → 阈值表(默认不映射):Anthropic 只规定 + # budget_tokens ≥1024、没给「预算 ↔ 档位」对应表,阈值只能由使用者显式 + # 配置;留空时一个字段都不写,`thinking` 与现状一样被忽略 + effort = output_config_effort(body.get("output_config")) + if effort is None: + effort = thinking_effort(thinking_efforts, body.get("thinking")) if effort is not None: upstream["reasoning_effort"] = effort return ChatRequest(model=model or "", messages=messages, stream=stream, diff --git a/src/compat/anthropic/response.py b/src/compat/anthropic/response.py index 026cf77e..9b0aba68 100644 --- a/src/compat/anthropic/response.py +++ b/src/compat/anthropic/response.py @@ -157,10 +157,13 @@ def finish(self) -> Iterator[bytes]: yield from self._close("stop") def keepalive(self) -> bytes: - """SSE 注释帧心跳(与 chat / Responses 出口同一约定)。""" - from ...engine.sse import SSE_COMMENT + """Anthropic 心跳:具名 `ping` 事件(不是 SSE 注释帧)。 - return SSE_COMMENT + 官方流式规范里的 `ping` 是**具名事件**(`event: ping` + + `data: {"type": "ping"}`),客户端只解析 `event:` 行时注释帧等于没有 + 字节,长静默(推理模型 TTFT 可达 40s)下仍会判超时断开。 + """ + return b'event: ping\ndata: {"type": "ping"}\n\n' def error_frame(self, message: str, code: str) -> bytes: """出口错误帧:Anthropic 用 `event: error`(无 [DONE] 哨兵)。""" diff --git a/src/compat/openai/dsml.py b/src/compat/openai/dsml.py new file mode 100644 index 00000000..8a5363a8 --- /dev/null +++ b/src/compat/openai/dsml.py @@ -0,0 +1,244 @@ +"""DeepSeek DSML 工具调用泄漏的回读。 + +DeepSeek V3.2/V4 的推理引擎正常情况下会把 `DSML` 标记解析成结构化 +`tool_calls`,但 `tool_choice=auto` + 流式、或参数值含换行时**偶发**解析失败, +原始标记直接混进 assistant 正文: + +```text +<||DSML||tool_calls> +<||DSML||invoke name="shell"> +<||DSML||parameter name="command" string="true">Get-Content a.csv + + +``` + +对客户端来说这是纯文本:没有 tool_call、回合静默结束。本模块把这些块还原成 +结构化调用、并从可见正文里剥掉标记。 + +**方言**(2026-10 多来源实测): + +- 真实标记用**双**全角竖线 `|`(U+FF5C)包住 `DSML`,也见过单竖线形态; + 两种都接受(匹配单/双竖线,不是「纠正」成某一种)。 +- V4 Flash 会把 `invoke` 写成**自闭合**并携带 `string` 属性值: + `<||DSML||invoke name="cmd" string="echo alpha" />`。参数按**形状**读, + 不按标签名猜:自闭合的没有 body,值只能在属性里。 +- 可能缺 `<...tool_calls>` 外层包装,只剩裸 `invoke` 块(V4 长上下文实测)。 + 裸块同样回读。 + +**fail-closed**:回读出来的参数由本模块 `json.dumps` 生成,天然合法;而 +`aggregate` 对上游原始 `arguments` 做一次校验——空串归一成 `{}`,其余必须是 +合法 JSON **对象**,否则抛 `InvalidToolArguments`(协议级错误:不冷却凭证、 +不落账号级惩罚,由 executor 换号重试/按 400 结束)。宁可明确报错,也不把 +坏参数递给客户端去执行。 +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + +# U+FF5C 全角竖线;真实标记是双写,历史上也出现过单写 +_BAR = "\uff5c" +_OPEN = rf"<{_BAR}{{1,2}}DSML{_BAR}{{1,2}}" +_CLOSE = rf"") +_TC_BLOCK = re.compile(_OPEN + r"tool_calls>(.*?)" + _CLOSE + r"tool_calls>", re.S) +_INVOKE = re.compile( + _OPEN + r'invoke\b([^>]*?)(?:/>|>(.*?)' + _CLOSE + r"invoke>)", re.S) +_PARAM = re.compile( + _OPEN + r'parameter\b([^>]*?)(?:/>|>(.*?)' + _CLOSE + r"parameter>)", re.S) +_ATTR = re.compile(r'(\w+)\s*=\s*"([^"]*)"') +# 尾部「可能是标记前缀」的窗口:`<` + 竖线 / DSML 字母的任意前缀 +_PARTIAL = re.compile(rf"<{_BAR}{{0,2}}[DSML]{{0,4}}{_BAR}{{0,2}}$") + + +def looks_like_marker(text: str) -> bool: + """文本是否含 DSML 标记(或结尾停在标记前缀上)。""" + if "DSML" in text and _BAR in text: + return True + return bool(_PARTIAL.search(text)) + + +def _open_marker_tail(text: str) -> str | None: + """最后一个**未闭合**开标记之后的文本;没有未闭合标记返回 None。 + + 标记可能被切成多片(`<||DS` + `ML||invoke ...`),此时整段都要留在 + 缓冲里,否则半截标记会当正文发给客户端。 + """ + last = None + for match in re.finditer(_OPEN, text): + last = match.start() + if last is None: + return None + tail = text[last:] + if "/>" in tail or re.search(_CLOSE, tail): + return None + return tail + + +def _attrs(raw: str) -> dict[str, str]: + return {key: value for key, value in _ATTR.findall(raw)} + + +def _decode_value(value: str, is_string: bool) -> Any: + """参数值归一:`string="true"` 原样保留;否则尝试 JSON(数字/对象/布尔)。""" + if is_string: + return value + try: + return json.loads(value) + except ValueError: + return value + + +def _params(body: str) -> dict[str, Any]: + """解析一个 invoke 的 body:`parameter` 标签 + 自闭合 `invoke`(V4 方言)。""" + params: dict[str, Any] = {} + if not body: + return params + for param in _PARAM.finditer(body): + attrs = _attrs(param.group(1) or "") + key = attrs.get("name") + if not key: + continue + raw = param.group(2) + if raw is None: + # 自闭合参数:值在 string 属性里,语义上是字符串 + params[key] = attrs.get("string", "") + continue + params[key] = _decode_value(raw, attrs.get("string") == "true") + for nested in _INVOKE.finditer(body): + if nested.group(2) is not None: + # 不可达(正则结构决定):`_INVOKE` 的非贪婪 body 在**第一个** + # `` 前截断,有闭合标签的嵌套项会连同闭合一起被外层吃掉, + # body 里不可能出现完整的嵌套对。保留判断是防御 DSML 方言演进 + # (若将来上游改用不同闭合形态,这里能挡住把嵌套调用误当参数)。 + continue # pragma: no cover + attrs = _attrs(nested.group(1) or "") + key = attrs.get("name") + if key: + params[key] = attrs.get("string", "") + return params + + +def _parse_invokes(block: str) -> list[dict[str, Any]]: + """顺序扫描一个块里的顶层 invoke(嵌套自闭合项按参数处理,不重复计数)。""" + calls: list[dict[str, Any]] = [] + position = 0 + while True: + match = _INVOKE.search(block, position) + if match is None: + break + position = match.end() + attrs = _attrs(match.group(1) or "") + name = attrs.get("name") + if not name: + continue + body = match.group(2) + if body is None: + # 顶层自闭合 invoke:参数值在 string 属性里(没有参数名) + params = {"value": attrs["string"]} if "string" in attrs else {} + else: + params = _params(body) + calls.append({ + "type": "function", + "function": { + "name": name, + "arguments": json.dumps(params, ensure_ascii=False, sort_keys=True), + }, + }) + return calls + + +def extract(text: str) -> tuple[list[dict[str, Any]], str]: + """从正文里提取 DSML 工具调用,返回 `(calls, 可见文本)`。 + + 完整 `tool_calls` 块优先;剩下的裸 `invoke` 块同样回读。无法配对的 + `<...DSML...` 标记原样保留在文本里(宁可显示出来,也不吞掉用户正文)。 + 调用按出现顺序编号。 + """ + if not text or "DSML" not in text: + return [], text + calls: list[dict[str, Any]] = [] + visible = text + for match in _TC_BLOCK.finditer(text): + calls.extend(_parse_invokes(match.group(1))) + if calls: + visible = _TC_BLOCK.sub("", text) + # 裸 invoke 块(缺外层 tool_calls 包装):必须在剥完整块之后扫, + # 否则外层块里的 invoke 会被重复计入。 + while True: + match = _INVOKE.search(visible) + if match is None: + break + found = _parse_invokes(match.group(0)) + if found: + calls.extend(found) + visible = visible[:match.start()] + visible[match.end():] + if not calls: + return [], text + for index, call in enumerate(calls): + call["index"] = index + call.setdefault("id", f"call_dsml_{index}") + return calls, visible + + +class DsmlStreamFilter: + """流式 content 的增量过滤器。 + + 普通文本立刻放行(不缓冲、不延迟);只有出现标记(或结尾停在标记前缀上) + 才开始缓冲,等到完整块解析完再从缓冲里取可见文本。流结束时仍不完整的 + 标记原样放行(`flush`),绝不丢用户正文。 + """ + + def __init__(self) -> None: + self._buffer = "" + self.recovered = False + + def feed(self, text: str) -> tuple[str, list[dict[str, Any]]]: + """喂一个 content 分片,返回 `(可放行文本, 回读的调用)`。""" + if not text: + return "", [] + if not self._buffer and not looks_like_marker(text): + return text, [] + self._buffer += text + calls, visible = extract(self._buffer) + if calls: + self.recovered = True + self._buffer = visible + return visible, calls + # 还没有完整块:只有「结尾看不出是标记前缀」时才把前面的正文放行, + # 否则留到下一片(标记可能跨 chunk 断开) + if _open_marker_tail(self._buffer) is not None: + return "", [] + if _PARTIAL.search(self._buffer): + return "", [] + pending, self._buffer = self._buffer, "" + return pending, [] + + def flush(self) -> str: + """流结束:把缓冲里剩下的内容原样交出(不完整的标记也不吞)。""" + pending, self._buffer = self._buffer, "" + return pending + + +def is_valid_arguments(arguments: Any) -> bool: + """工具参数是否为合法 JSON 对象(`fail-closed` 的唯一判据)。 + + 空串 / `None` 视为无参数(无参函数的常见形态)。非对象(数组、裸标量) + 也算非法:OpenAI 契约要求 arguments 是 JSON **对象**。合法值原样保留—— + 重新序列化会改变字节(空格/键序),对按字节比对或签名校验的客户端不友好。 + """ + if arguments is None or arguments == "": + return True + if not isinstance(arguments, str): + return False + try: + parsed = json.loads(arguments) + except ValueError: + return False + return isinstance(parsed, dict) diff --git a/src/compat/openai/errors.py b/src/compat/openai/errors.py index 7a5968a0..91a4c316 100644 --- a/src/compat/openai/errors.py +++ b/src/compat/openai/errors.py @@ -9,6 +9,24 @@ from ...provider.base import ErrKind, Event, event_error_kind +class InvalidToolArguments(Exception): + """上游给出的工具调用参数不是合法 JSON 对象(协议级失败)。 + + **fail-closed**:不把坏 `arguments` 透传给客户端去执行,也不冷却凭证 + (不是账号的错)。分类为 INVALID:换号/换上游重试,全部失败时对外 400 + `invalid_request`,客户端能明确知道「这次调用不可用」而不是拿到半截 JSON。 + """ + + def __init__(self, function: str, arguments: Any) -> None: + super().__init__( + f"tool call {function!r} returned invalid JSON arguments: {arguments!r}") + self.function = function + self.arguments = arguments + + def kind(self) -> ErrKind: + return ErrKind.INVALID + + class UpstreamStreamError(Exception): """流内业务错误(聚合路径抛出,由 executor 转成冷却 + 轮换)。 diff --git a/src/compat/openai/request.py b/src/compat/openai/request.py index b2da3b8d..f5dceded 100644 --- a/src/compat/openai/request.py +++ b/src/compat/openai/request.py @@ -66,10 +66,14 @@ def validate_n(raw: dict[str, Any]) -> None: def validate_numeric_fields(raw: dict[str, Any]) -> None: """校验全部数值型顶层字段,拒绝 NaN/±Inf 与畸形值。 - 只在 `read_json_body` 的 `parse_constant` 之外补齐:`parse_constant` 只拦 - 字面量 `NaN`/`Infinity`,拦不住 JSON 溢出成 `inf` 的 `1e999` - (`json.loads('{"x":1e999}')` → `inf`)。这些值经本服务重新序列化会变成 - 非法 JSON `Infinity` 透传给上游,把本应 400 的输入变成上游 4xx。 + 已知键(temperature / top_p / 采样惩罚 / seed / logit_bias)的范围与 + 类型校验在这一层做:它们有业务边界(如 `temperature <= 1000`),不只是 + 「是不是有限数」。 + + **JSON 溢出成 inf 的 `1e999` 由更外层拦**(`api/deps.py::read_json_body` + 的 `parse_float`):那里是解析入口,三个出口共用,一处生效全生效; + 逐字段补齐要各写一遍,且 chat 出口会把整个请求体透传给上游,未知键里的 + inf 会一路带到 httpx 序列化那一步炸成 500。 """ for key in ("temperature", "top_p"): if key in raw: diff --git a/src/compat/openai/response.py b/src/compat/openai/response.py index 964bc769..eef59322 100644 --- a/src/compat/openai/response.py +++ b/src/compat/openai/response.py @@ -2,7 +2,8 @@ 约定(继承 codebuddy2api 的语义): - 首块补 role:assistant -- 上游缺失 index 的 tool_calls 补稳定 index,不重生成 id +- 上游缺失 index 的 tool_calls 补稳定 index,不重生成已有 id;`id` 缺失时 + 按 index 补一个稳定值(OpenAI 客户端按 id 聚合分片,缺 id 会丢工具调用) - reasonng 走 delta.reasoning_content - 流以 data: [DONE] 结束 """ @@ -17,7 +18,8 @@ from ...engine.sse import SSE_COMMENT, SSE_DONE, format_openai_frame from ...provider.base import Event, EventKind, Usage -from .errors import UpstreamStreamError, stream_error_frame +from . import dsml +from .errors import InvalidToolArguments, UpstreamStreamError, stream_error_frame ROLE_CHUNK = {"role": "assistant", "content": ""} @@ -58,10 +60,18 @@ def _completion_id() -> str: @dataclass class ToolIndexState: - """优先沿用上游 index,缺失时按出现顺序补稳定位置。""" + """优先沿用上游 index,缺失时按出现顺序补稳定位置;`id` 缺失时补稳定 id。 + + 上游(尤其聚合型中转)偶尔只发 `function.arguments` 分片而不发 `id`。 + OpenAI 客户端按 `id` 聚合流式工具调用分片,缺 id 的分片会被丢弃,表现为 + 「模型调了工具但客户端收不到」。故按 index 生成并记忆 id,后续同 index 的 + 分片沿用同一个值(每次现生成会让同一调用被拆成多个)。 + """ id_to_index: dict[str, int] = field(default_factory=dict) + index_to_id: dict[int, str] = field(default_factory=dict) used: set[int] = field(default_factory=set) + _seq: int = 0 def resolve(self, tool_call: dict[str, Any]) -> tuple[dict[str, Any], int]: item = dict(tool_call) @@ -69,8 +79,7 @@ def resolve(self, tool_call: dict[str, Any]) -> tuple[dict[str, Any], int]: tool_id = item.get("id") if isinstance(index, int) and not isinstance(index, bool): self.used.add(index) - if isinstance(tool_id, str) and tool_id: - self.id_to_index[tool_id] = index + self._bind(index, tool_id, item) return item, index if isinstance(tool_id, str) and tool_id and tool_id in self.id_to_index: resolved = self.id_to_index[tool_id] @@ -80,11 +89,27 @@ def resolve(self, tool_call: dict[str, Any]) -> tuple[dict[str, Any], int]: while candidate in self.used: candidate += 1 self.used.add(candidate) - if isinstance(tool_id, str) and tool_id: - self.id_to_index[tool_id] = candidate + self._bind(candidate, tool_id, item) item["index"] = candidate return item, candidate + def _bind(self, index: int, tool_id: Any, item: dict[str, Any]) -> None: + """登记 id↔index 双向映射;`id` 缺失时按 index 补一个稳定值。 + + 每片都补同一个 id(而不是只补首片):按 id 聚合的客户端若在续片上 + 拿到空 id,会把一次调用拆成两条;重复同一个 id 则各种聚合方式都安全。 + """ + if isinstance(tool_id, str) and tool_id: + self.id_to_index[tool_id] = index + self.index_to_id.setdefault(index, tool_id) + return + existing = self.index_to_id.get(index) + if existing is None: + self._seq += 1 + existing = f"call_{index}_{self._seq}" + self.index_to_id[index] = existing + item["id"] = existing + class StreamTranslator: """把 Event 序列翻译为 OpenAI SSE 帧序列。""" @@ -99,10 +124,8 @@ def __init__(self, model: str) -> None: self.usage: Usage | None = None # [DONE] 已产出:客户端此后断开属正常收尾(拿到回调即关连接) self.done_sent = False - @staticmethod - def _is_empty_payload(event: Event) -> bool: - """空内容不消耗首帧 role 标记,避免发出无意义 chunk。""" - return not (event.content or event.tool_calls) + # DSML 泄漏回读:普通正文零缓冲,只有出现标记才进入缓冲态 + self._dsml = dsml.DsmlStreamFilter() def translate(self, event: Event) -> Iterator[bytes]: if event.kind is EventKind.ERROR: @@ -121,19 +144,30 @@ def translate(self, event: Event) -> Iterator[bytes]: yield from self._close(event.finish_reason or "stop") return + text = event.content + recovered: list[dict[str, Any]] = [] + if event.kind is EventKind.CONTENT and text: + # DSML 泄漏回读:可见正文照常流式放行,回读出的调用另起一帧 + text, recovered = self._dsml.feed(text) + if not text and not recovered: + return delta: dict[str, Any] = {} - if not self._sent_role and not self._is_empty_payload(event): + if not self._sent_role and (text or event.tool_calls or recovered): delta.update(ROLE_CHUNK) self._sent_role = True - if event.kind is EventKind.CONTENT and event.content: - delta["content"] = event.content + if event.kind is EventKind.CONTENT and text: + delta["content"] = text elif event.kind is EventKind.REASONING and event.content: delta["reasoning_content"] = event.content elif event.kind is EventKind.TOOL_CALLS and event.tool_calls: delta["tool_calls"] = [self._tool_index.resolve(tc)[0] for tc in event.tool_calls] - if not delta: - return - yield format_openai_frame(json.dumps(_chunk(self.model, delta), ensure_ascii=False)) + if delta: + yield format_openai_frame( + json.dumps(_chunk(self.model, delta), ensure_ascii=False)) + if recovered: + calls = [self._tool_index.resolve(tc)[0] for tc in recovered] + yield format_openai_frame(json.dumps( + _chunk(self.model, {"tool_calls": calls}), ensure_ascii=False)) def finish(self) -> Iterator[bytes]: """上游未发 done 就断流:补一个结束帧,保证客户端不会挂住。""" @@ -156,6 +190,19 @@ def error_frame(self, message: str, code: str) -> bytes: return stream_error_frame(message, code) def _close(self, finish_reason: str) -> Iterator[bytes]: + # 缓冲里剩下的正文(不完整的 DSML 标记)原样交出,绝不吞用户内容 + pending = self._dsml.flush() + if pending: + delta: dict[str, Any] = {} + if not self._sent_role: + delta.update(ROLE_CHUNK) + self._sent_role = True + delta["content"] = pending + yield format_openai_frame( + json.dumps(_chunk(self.model, delta), ensure_ascii=False)) + if self._dsml.recovered and finish_reason == "stop": + # 回读出了工具调用:把 stop 改成 tool_calls,否则客户端不执行 + finish_reason = "tool_calls" yield format_openai_frame(json.dumps( _chunk(self.model, {}, finish_reason=finish_reason), ensure_ascii=False)) # OpenAI 标准形态:include_usage 时收尾帧之后、[DONE] 之前补一帧 @@ -208,12 +255,30 @@ def aggregate(events: Iterable[Event], model: str) -> dict[str, Any]: current["function"]["arguments"] = ( (current["function"].get("arguments") or "") + fragment) + # DSML 泄漏回读:模型把工具调用写成文本时还原成结构化调用,并从正文剥掉 + content_text = "".join(content_parts) + recovered, content_text = dsml.extract(content_text) + if recovered: + next_index = max(tool_calls) + 1 if tool_calls else 0 + for offset, call in enumerate(recovered): + call = dict(call) + call["index"] = next_index + offset + tool_calls[call["index"]] = call + if finish_reason == "stop": + finish_reason = "tool_calls" + ordered = [tool_calls[i] for i in sorted(tool_calls)] + for call in ordered: + function = call.get("function") or {} + if not dsml.is_valid_arguments(function.get("arguments")): + # fail-closed:坏参数不递交给客户端执行,按协议级失败处理 + raise InvalidToolArguments(function.get("name") or "-", + function.get("arguments")) message: dict[str, Any] = {"role": "assistant", - "content": "".join(content_parts) or None} + "content": content_text or None} if reasoning_parts: message["reasoning_content"] = "".join(reasoning_parts) - if tool_calls: - message["tool_calls"] = [tool_calls[i] for i in sorted(tool_calls)] + if ordered: + message["tool_calls"] = ordered result: dict[str, Any] = { "id": _completion_id(), "object": "chat.completion", diff --git a/src/compat/responses/request.py b/src/compat/responses/request.py index eb10fae9..24a4d252 100644 --- a/src/compat/responses/request.py +++ b/src/compat/responses/request.py @@ -13,7 +13,8 @@ → chat `image_url`(`image_url` 直传,上游已实测接受 data URL 与 http URL); `input_file` 需服务端文件存储,本网关无此能力 → 400 - `tools` 的 `{type:"function", name, description, parameters}` → chat - `{type:"function", function:{...}}`;非 function 工具(web_search 等)→ 400 + `{type:"function", function:{...}}`;**托管工具与非 function 类型一律跳过** + (`web_search` / `tool_search` / `namespace` / `custom`,见 `_map_tools`) - `reasoning.effort` → 顶层 `reasoning_effort`(出口侧字段名映射) - `text.format` → `response_format`(`text`/`json_object` 直通,`json_schema` 展开成 chat 的嵌套结构;无法表达的形状显式 400) @@ -27,6 +28,7 @@ from __future__ import annotations +import logging from typing import Any from ..openai.request import ( @@ -39,6 +41,8 @@ require_positive_int, ) +logger = logging.getLogger(__name__) + # Responses 里合法但本网关无法无损转成 chat 的 input item 类型。 # 依据 openai/codex(codex-rs/protocol/src/models.rs 的 ResponseItem 与 # codex-rs/app-server-protocol 的 TS 定义)实测:Codex CLI 实际会发 @@ -223,25 +227,101 @@ def _map_input_item(item: Any) -> dict[str, Any] | None: def _map_tools(tools: Any) -> list[dict[str, Any]]: + """Responses 工具表 → chat 工具表。 + + **只翻译 chat 表达得了的 function**,其余类型跳过并记 debug(不报错): + + * `function`:直译(历史上唯一被接受的类型)。 + * `namespace`:Codex 0.135+ 对自定义 responses provider **默认下发** + (Responses-Lite 的 `functions` 组、`multi_agent_v1` 子代理组、MCP 组)。 + **只展平默认命名空间 `functions`**:它等价于无命名空间,成员就是普通 + function,模型调用时客户端按 `functions` 查到同一个工具。 + 其余命名空间的成员不加前缀、直接丢弃——chat 出口只能给出扁平工具名, + 而客户端是按 `(namespace, name)` 二元组查登记表的(`registry.rs` + `ToolName::new(namespace, name)`):展平成 `{ns}__{name}` 在客户端看来 + 是一个「默认命名空间里不存在的新工具」,模型一旦调用就是 + `unsupported call`,比不提供更糟。 + * 其余(`web_search` / `tool_search` / `image_generation` / `file_search` / + `custom` …):托管工具由**推理服务端**执行,本网关没有该能力; + `custom` 是 freeform 工具(`apply_patch` 的 lark 语法),chat 的 + `function.parameters` 表达不了「整段自由文本」还不破坏 Codex 的 + `custom_tool_call` 配对。跳过是降级,但 400 会让**整轮请求失败**—— + Codex 默认配置必带 `web_search`,客户端也不会降级重试(实测 0.135–0.167)。 + """ mapped: list[dict[str, Any]] = [] + seen: set[str] = set() for index, tool in enumerate(tools): if not isinstance(tool, dict): raise InvalidRequest(f"tools[{index}] must be an object") - if tool.get("type") != "function": - raise InvalidRequest( - f"tool type {tool.get('type')!r} is not supported by this gateway") - name = tool.get("name") - if not isinstance(name, str) or not name: - raise InvalidRequest(f"tools[{index}].name must be a non-empty string") - function: dict[str, Any] = {"name": name} - if isinstance(tool.get("description"), str): - function["description"] = tool["description"] - if isinstance(tool.get("parameters"), dict): - function["parameters"] = tool["parameters"] + kind = tool.get("type") + if kind == "namespace": + for name, function in _namespace_functions(tool, f"tools[{index}]"): + if name in seen: + continue + seen.add(name) + mapped.append({"type": "function", "function": function}) + continue + if kind != "function": + logger.debug("responses: 跳过无法翻译的工具类型 %r(tools[%d])", + kind, index) + continue + function = _function_tool(tool, f"tools[{index}]") + if function["name"] in seen: + continue + seen.add(function["name"]) mapped.append({"type": "function", "function": function}) return mapped +def _function_tool(tool: dict[str, Any], where: str) -> dict[str, Any]: + """`{type:"function", name, description, parameters}` → chat `function`。""" + name = tool.get("name") + if not isinstance(name, str) or not name: + raise InvalidRequest(f"{where}.name must be a non-empty string") + function: dict[str, Any] = {"name": name} + if isinstance(tool.get("description"), str): + function["description"] = tool["description"] + if isinstance(tool.get("parameters"), dict): + function["parameters"] = tool["parameters"] + return function + + +def _namespace_functions(tool: dict[str, Any], + where: str) -> list[tuple[str, dict[str, Any]]]: + """`namespace` 工具组 → `[(chat 函数名, chat function)]`。 + + **只展开默认命名空间 `functions`**;其他命名空间返回空表(调用方据此 + 记 debug 日志)。原因见 `_map_tools`:chat 只有扁平名字,客户端却按 + `(namespace, name)` 查登记表,展平名会被当成「不存在的工具」。 + + 组内只认 `function` 成员;`custom`(freeform)成员跳过。 + """ + namespace = tool.get("name") + if not isinstance(namespace, str) or not namespace: + raise InvalidRequest(f"{where}.name must be a non-empty string") + members = tool.get("tools") + if members is None: + return [] + if not isinstance(members, list): + raise InvalidRequest(f"{where}.tools must be an array") + if namespace != "functions": + logger.debug("responses: 跳过非默认命名空间 %r(%s 共 %d 个成员)", + namespace, where, len(members)) + return [] + entries: list[tuple[str, dict[str, Any]]] = [] + for index, member in enumerate(members): + member_where = f"{where}.tools[{index}]" + if not isinstance(member, dict): + raise InvalidRequest(f"{member_where} must be an object") + if member.get("type") != "function": + logger.debug("responses: 跳过命名空间 %r 内的工具类型 %r", + namespace, member.get("type")) + continue + function = _function_tool(member, member_where) + entries.append((function["name"], function)) + return entries + + def _map_response_format(text_config: dict[str, Any]) -> dict[str, Any] | None: """Responses `text.format` → chat `response_format`。 diff --git a/src/compat/responses/response.py b/src/compat/responses/response.py index 7aebb726..65bdb1a2 100644 --- a/src/compat/responses/response.py +++ b/src/compat/responses/response.py @@ -190,7 +190,12 @@ def finish(self) -> Iterator[bytes]: yield from self._close("stop") def keepalive(self) -> bytes: - """SSE 注释帧心跳(与 chat 出口同一约定)。""" + """SSE 注释帧心跳(与 chat 出口同一约定)。 + + **不能改成 `event: ping`**:Responses API 没有 ping 事件类型,严格 + 解析器收到未知事件会直接报错(`MAXeaglet/commandcode-proxy` 的实测 + 结论,与 Anthropic 出口的处理相反)。 + """ from ...engine.sse import SSE_COMMENT return SSE_COMMENT diff --git a/src/config.py b/src/config.py index 041e4e3f..d6143304 100644 --- a/src/config.py +++ b/src/config.py @@ -60,10 +60,30 @@ class Settings(BaseSettings): zen_api_endpoint: str = "https://opencode.ai" zen_allowed_endpoints: str = "https://opencode.ai" zen_opencode_version: str = "1.18.0" + # 门禁的移动靶部分做成配置(改完重启即生效,无需改代码): + # - 根路径:`/zen/v1`(默认,本仓 2026-10-28 实测 200)或 `/inference/v1` + # (第三方实测称已迁移;两条路径当前都 200,保留可切); + # - 门禁工具名:上游 10-14 收紧后第三方实测要求 `bash,glob,grep,read` + # 四件套,本仓仍以两件套通过 —— 冲突未复验,默认不变; + # - UA 模板:`{version}` 占位符代入版本号;空 = `opencode/`。 + zen_api_root: str = "/zen/v1" + zen_gate_tools: str = "bash,read" + zen_user_agent: str = "" # Kilo Gateway 免费层:标准 OpenAI 协议、无门禁、无凭证,端点白名单同样 # 只为防误配。免费模型由上游 `isFree` 标记识别(见 provider/kilo/client.py)。 kilo_api_endpoint: str = "https://api.kilo.ai/api/gateway" kilo_allowed_endpoints: str = "https://api.kilo.ai/api/gateway" + # 小浣熊(商汤):标准 OpenAI 兼容网关,鉴权是网页会话 JWT(cookie + # `xhx_token`)。白名单只为防误配,避免把用户的会话 JWT 发往未授权主机。 + raccoon_api_endpoint: str = "https://xiaohuanxiong.com" + raccoon_allowed_endpoints: str = "https://xiaohuanxiong.com" + # WPS 灵犀:桌面端 agent-runtime 通路(三条请求一次对话),鉴权是 WPS 账号 + # cookie `wps_sid`。白名单同时收主域与 kdocs 回落域——对话面按这两个域 + # 顺序回落(kdocs 账号的集群在 lingxi.kdocs.cn),漏收会让回落直接 404。 + lingxi_api_endpoint: str = "https://lingxi.wps.cn" + lingxi_allowed_endpoints: str = ( + "https://lingxi.wps.cn,https://lingxi.kdocs.cn" + ) # Qoder(阿里,COSY 私有协议):真实账号渠道,端点白名单防止误把带 COSY # 签名的请求发往未授权主机。默认取国内版;国际版改这两个值并把国际版域名 # 一并加入白名单。推理走 gateway.*,额度/登录走 openapi.*。 @@ -96,6 +116,10 @@ class Settings(BaseSettings): # `渠道=代理URL;渠道2=代理URL2`,协议 http/https/socks5/socks5h;留空 = 直连。 # 代理作用于连接池,运行中改值需重启,故不做热更。 provider_proxies: str = "" + # 出口 IP 粘性(「一号一出口」):全局代理池,分号分隔;按凭证 id 稳定哈希 + # 分配,同一凭证重启后仍走同一出口。未配 = 沿用 `PROVIDER_PROXIES`/直连。 + # 注意:池成员增删会让部分凭证换桶(改池子本身就是要换出口)。 + credential_proxy_pool: str = "" # 路由与调度 # 默认模型:六条渠道都已上架 `deepseek-v4.1-flash`(CB / TRAE / CodeArts / @@ -137,10 +161,11 @@ class Settings(BaseSettings): # 缓存(30 分钟)对齐:再密也不会让 zen 多探活一次,只是白打其余渠道的 # /models。 model_catalog_minutes: int = 30 - # 模型可用性探活周期(分钟):只探仍被负缓存(「上游无此模型」)的 + # 模型可用性探活周期(分钟):探仍被负缓存(「上游无此模型」)与已判死的 # (渠道, 模型),一次最小对话。0 = 关闭(默认关闭):探活会真发上游请求、 # 消耗额度,是否开启应由部署者按额度余量决定。开启后建议 ≥60 分钟—— - # 负缓存本身 6h 起步,比它更密只是白耗额度。 + # 负缓存本身 6h 起步,比它更密只是白耗额度。判死行也靠这条周期复探恢复 + # (判死即从列表摘掉,没有到期时间)。 model_probe_minutes: int = 0 # 成长中心(仅 CodeBuddy 有):一轮领取的周期,以及是否允许不可逆动作 # (抽奖/连登兑换/开 Buddy 盲盒/消耗补登卡)。这些动作无法撤销, @@ -160,13 +185,21 @@ class Settings(BaseSettings): # 一堆部署在同一分钟齐刷刷打上游。按「日期 + 时点」记去重键,抖动只改 # 执行时刻、不改归属,不会让同一时点跑两次。 checkin_jitter_minutes: int = 0 + # 跳过签到的渠道(逗号分隔,如 `raccoon,lingxi`)。上游长期停发时用它止损: + # 签到任务照常跑、其它渠道不受影响,只把点名的渠道从签到里摘出去。 + # 与「暂停凭证」不同——`enabled=0` 会连聊天一起停掉,而这里只停签到。 + checkin_skip_providers: str = "" # 服务端日志页(「任务与配置」→ 日志):进程内环形缓冲的保留行数, # 0 = 关闭该页(端点返回 404)。只收本进程 logging 记录,不读文件。 log_buffer_lines: int = 500 - # 运维告警:周期评估四类风险,命中即落 alert_events 并可选推送 + # 运维告警:周期评估七类规则,命中即落 alert_events 并可选推送 # webhook。webhook 留空 = 只留站内记录、不外推。 alert_enabled: bool = True alert_webhook_url: str = "" + # 额外出口:飞书 / Slack 的入站 Webhook 地址(留空 = 不启用)。消息体 + # 按各自格式单独构造,通用 webhook 仍发完整 JSON + 签名。 + alert_feishu_webhook_url: str = "" + alert_slack_webhook_url: str = "" alert_interval_minutes: int = 5 # 评估周期(分钟),下限 1 alert_silence_minutes: int = 30 # 同一 (规则, 对象) 的静默窗(分钟) # 告警迟滞:连续命中 N 轮才告警、连续 M 轮未命中才确认恢复。 @@ -184,6 +217,30 @@ class Settings(BaseSettings): alert_error_rate_threshold: float = 0.5 alert_error_rate_min_requests: int = 20 alert_error_rate_window_minutes: int = 15 + # 单 Key 用量暴涨:窗口内某把 Key 的 token 总量越过阈值即告警(0 关闭)。 + # 这是滥用/泄露信号(Key 被贴到仓库、多人共用),不是上游错误。 + alert_key_burst_tokens: int = 0 + alert_key_burst_window_minutes: int = 15 + # 非工作时段仍有请求:`[start, end)` 为工作时间(本地时区,跨零点合法), + # start == end 关闭该规则;非工作时段请求数达到最小样本才告警(0 关闭)。 + # 刻意不做「多 IP 同 Key」:usage_events 不存 IP(零知识口径),审计里有 + # IP 但只覆盖登录/写操作,拿它做用量归因不可靠。 + alert_off_hours_start: int = 0 + alert_off_hours_end: int = 0 + alert_off_hours_min_requests: int = 0 + # 预算分层告警:当月(本地自然月)成本预算,达到 50/80/95/100% 分档告警。 + # 0 关闭该规则;成本只覆盖可定价明细(未匹配价表的不计入),detail 里给覆盖度。 + alert_budget_cny: float = 0.0 + alert_budget_levels: str = "50,80,95,100" + # 渠道死亡告警:统计窗内该渠道有流量但**一次都没成功**。0 关闭该规则。 + # 与错误率互补:officeace 积分耗尽整批回 401,会被 fallback 稀释掉。 + # 默认开启:20 次请求全失败已足够说明渠道挂了,再少容易误报。 + alert_channel_dead_min_requests: int = 20 + alert_channel_dead_window_minutes: int = 60 + # 签到连续失败告警:同一凭证最近连续 N 轮签到都失败(成功一次即清零)。 + # 0 关闭。补 task_failed 看不见的「任务跑成功、上游业务返回失败」。 + alert_checkin_fail_min_streak: int = 3 + alert_checkin_fail_window_hours: int = 24 # SQLite 备份后台任务:周期性把主库一致性快照(VACUUM INTO)写进 # DATA_DIR/backups/,保留最近 BACKUP_KEEP 份。默认每日一次、留 7 份。 # 备份周期取整日是因为库本身不大(几十 MB 量级),更密只是白占磁盘。 @@ -217,6 +274,16 @@ class Settings(BaseSettings): # Kilo 聊天最小间隔:同为匿名免费层(网关级 200 req/h/IP),默认 0。 # 与 zen 各自独立:两条免费渠道互不排队。 kilo_chat_min_interval: float = 0 + # 小浣熊聊天最小间隔:真实账号渠道,上游按**请求频率**限流(实测 6 并发 + # 即 429 `user_model_rate_limited`),并发上限由 pacer 的 max_concurrency + # 兜住(默认 2),不需要额外的最小间隔。 + raccoon_chat_min_interval: float = 0 + # WPS 灵犀聊天最小间隔:真实账号渠道,上游按 run 计费且并发未取证,默认 0; + # 需要给同一账号限速时调大(run 缓存与单飞已把重复计费压在客户端)。 + lingxi_chat_min_interval: float = 0 + # 小浣熊每账号并发上限:实测 5 并发放行、第 6 个 429。取 2 留余量,避免 + # 与用户其它客户端抢同一账号的并发额度。0 关闭上限。 + raccoon_max_concurrency: int = 2 # Qoder 聊天最小间隔:真实账号渠道,上游有账号级频率风控,默认 5s # (对齐 CodeBuddy)。与 CB/TRAE/zen/kilo 各自独立,互不排队。 qoder_chat_min_interval: float = 5 @@ -253,36 +320,60 @@ class Settings(BaseSettings): # 原代号的归一键、展示名、展示名的归一键四种写法任一命中即滤——用户照列表 # 里看到的 `kimi-k3` / `Kimi K3` 写就能生效,老规则按原代号写也仍生效。 # 覆盖此值时为完全替换(含默认噪音规则),增删请重写全量。 - # 默认口径(2026-10-19 与线上生效值对齐):**只展示当前在售的新模型**, - # 三类一律滤掉—— - # 1. 上游内部/代理模型:custom_model_* / *sub*agent* / summary / - # browser_use_* / file_search_agent(chat 报 3003 / 非用户模型); + # 默认口径(2026-10-31 与线上生效值等值,62 条;顺序按来源分组,fnmatch + # 无语义差别):**只展示当前在售的新模型**,四类一律滤掉—— + # 1. 上游内部/代理模型与自动路由占位:custom_model_* / *sub*agent* / + # summary / browser_use_* / file_search_agent(chat 报 3003 / 非用户模型); # default 与 codebuddy_intl 的 default-model/fast-model/balanced-model/ # primary-model/deep-model、Qoder 的 auto 是各上游的「自动路由」占位。 - # 2. 已停售/被新一代取代的老模型:glm-4.* / glm-5 / glm-5.0* / glm-5.1 / - # glm-5.2* / glm-5v-* / glm-5-turbo、kimi-k2.*(含 k2.5/2.6/k2-thinking)、 - # minimax-m2.5/m2.7/m3、deepseek-v3-* / deepseek-v4-*(v4.1 不受影响)、 - # qwen3.7-*、gpt-5.4/5.5、hy3* / hy4-preview、hunyuan-*、 - # seed-2.1* / seed-code-pro-* / seed-evolving、qwen3-vl-235b、 - # openpangu 之外的开源小模型(nemotron-3* / lfm2* / laguna* / - # north-mini-code / inkling-small / xing4.0-29b-a4 / openrouter-models-router)。 - # 3. 实测不可用的噪音:glyph-cluster(Kilo 两次 121s 后 408)、 - # hunyuan-*(含 hunyuan-image-* 的 400 11103 backend not supported)。 - # 刻意不加的:deepseek-v4.1-flash / kimi-k3 / glm-5.3* / mimo-v2.6* / - # qwen3.8-max / openpangu-2.0-*(在售主力,2026-10-19 实测正常 chat)。 + # 2. 已被新一代取代的老模型:glm-4.* / glm-5 / glm-5.0* / glm-5.1 / + # glm-5.2* / glm-5v-* / glm-5-turbo(→ glm-5.3)、kimi-k2.* / + # kimi-k3(→ kimi-k3.1-pro)、minimax-m2.5/m2.7/m3(→ m3.1-flash)、 + # deepseek-v3-* / deepseek-v4-*(→ v4.1-flash)、qwen3.7-* / qwen3-* / + # qwen3.6*(→ qwen3.8-max)、gpt-5.4/5.5/5.6*(→ gpt-6-sol / 6.1-sol)、 + # hy3* / hy4-preview / hunyuan-*、seed-2.1* / seed-code-pro-* / + # seed-evolving(豆包整族已停售,无后继)、qwen3-vl-235b。 + # 3. 已下架的免费池与开源小模型:space-bunny(Kilo 已退役)、 + # nemotron-3* / lfm2* / laguna* / north-mini-code / inkling-small / + # xing4.0-29b-a4 / glyph-cluster(Kilo 两次 121s 后 408)/ + # openrouter-models-router。 + # 4. 渠道侧测试号与区域/日期快照代号(同一模型已有主名,留着只会让用户 + # 选中另一个价位):deepseek-v4.1-flash-0930 / -bnt、glm-5.3-test / + # -zx、kimi-k3-test-1009 / -1019。**逐条登记,不用通用剥日期 / 剥区域 + # 尾缀**——`deepseek-v4.1-flash-1017` 是另一个价位的快照,仍在售。 + # 刻意不加的:deepseek-v4.1-flash(含 -1017 / -sg)/ kimi-k3.1-pro / + # glm-5.3*(含 flashx)/ mimo-v2.6* / qwen3.8-max / qwen3.8-flash / + # openpangu-2.0-* / gpt-6-* / grok-4.7 / gemini-3.8-flash(在售)。 + # `qwen3.8-flash`(Qoder qfmodel)2026-10-27 实测 chat 成功(63→117 token), + # 原先按「400 Execution failed」滤掉的判断已失效,故移出黑名单。 model_blocklist: str = ( + # ① 内部 / 代理模型与自动路由占位 "custom_model_*,*sub*agent*,summary,browser_use_*,file_search_agent," - "default,seed-code-pro-*,sagitta,aquila,glm-4.*,glm-5,glm-5-turbo," - "glm-5.0*,glm-5.1,glm-5v-*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking," - "minimax-m2.5,deepseek-v3-*,hunyuan-*,hy3*,deepseek-v4-*,qwen3.7-*,auto," - "nemotron-3-*,glm-5.2*,nemotron-3*,lfm2*,north-mini-code," - "openrouter-models-router,fast,balanced,deep,primary,hy4-preview," - "inkling-small,minimax-m2.7,kimi-k2.*,minimax-m3,gpt-5.4,gpt-5.5," - "laguna*,qwen3-vl-235b,seed-2.1*,xing4.0-29b-a4,glyph-cluster," - "qwen3.8-flash,seed-evolving" + "default,fast,balanced,deep,primary,auto,sagitta,aquila," + # ② 已被新一代取代的老模型 + "glm-4.*,glm-5,glm-5-turbo,glm-5.0*,glm-5.1,glm-5v-*,glm-5.2*,glm.5.2*," + "kimi-k2.*,kimi-k2.5,kimi-k2.6,kimi-k2-thinking,kimi-k3," + "minimax-m2.5,minimax-m2.7,minimax-m3,deepseek-v3-*,deepseek-v4-*," + "qwen3.7-*,qwen3-*,qwen3.6*,qwen3-vl-235b," + "gpt-5.4,gpt-5.5,gpt-5.6*,hy3*,hy4-preview,hunyuan-*," + "seed-2.1*,seed-2-1-turbo,doubao-seed-2.1-turbo,seed-code-pro-*," + "seed-evolving," + # ③ 已下架的免费池 / 开源小模型 + "space-bunny,nemotron-3-*,nemotron-3*,lfm2*,laguna*,north-mini-code," + "inkling-small,xing4.0-29b-a4,glyph-cluster,openrouter-models-router," + # ④ 渠道测试号与区域 / 日期快照代号 + "deepseek-v4.1-flash-0930,deepseek-v4.1-flash-bnt," + "glm-5.3-test,glm-5.3-zx,kimi-k3-test-1009,kimi-k3-1019" ) - # 诊断:把 /v1 入口的原始请求体落到 data/dumps/(排查客户端差异用) + # 诊断:把 /v1 入口的原始请求体落到 data/dumps/(排查客户端差异用)。 + # 热更项(管理台可开关,改动进审计);落盘目录 0700、文件 0600、 + # 保留 200 个且不超过 24h——这是全服务唯一把用户对话写进磁盘的开关。 dump_request_bodies: bool = False + # 全站暂停(kill switch):所有 API Key 鉴权的 /v1 端点立即返 503, + # 管理台与登录保留可用——异常消耗/上游事故时一键止血。热更项。 + gateway_paused: bool = False + # 暂停原因(只进日志与审计,不回显给调用方;留空按「未填原因」记) + gateway_pause_reason: str = "" # 截断续写:上游以 finish_reason=length 截断时,同凭证自动续写, # 最多该次数后收尾;0 关闭。仅 length 触发(其余"空正文/代码块未闭合" # 判据经实测无支撑,未实现,见 TECHNICAL.md §3.4) @@ -300,9 +391,19 @@ class Settings(BaseSettings): # 推荐 300。可热更。 stream_idle_timeout_seconds: float = 0 # 短冷却「等待续上」(秒):全池都在短冷却、没有立即可用候选时,若最短的 - # 剩余冷却不超过该值,则等待后重取一次(而不是直接 503)。0 = 关闭(快速 - # 失败)。等待期间不占在途租约,避免把并发请求堆成连接洪峰。可热更。 - short_cooldown_wait_seconds: float = 0 + # 剩余冷却不超过该值,则等待后重取一次(而不是直接 503)。等待期间不占 + # 在途租约,避免把并发请求堆成连接洪峰。默认 60:coding agent 收到 503 + # 只会立刻更密集地重试,原地等一轮比把失败甩给客户端更划算;上限 60 也 + # 保证慢失败不会超过多数客户端的读超时。0 = 关闭(快速失败)。可热更。 + short_cooldown_wait_seconds: float = 60 + # 非流式出口保活(秒):`stream=false` 的请求在聚合完成前一个字节都不发, + # 中间盒(CDN / 反代)的空闲超时会直接切连接。设为 N > 0 时,N 秒内没 + # 完成就改走 chunked,先吐空白字节再补 JSON 正文——空白是合法 JSON 前导 + # (RFC 8259 §2 `JSON-text = ws value ws`),解析器照常工作。 + # 默认 0 = 关闭:本部署 nginx `proxy_read_timeout 3600s` + frp TCP, + # 链路上没有短空闲超时,实测 30 天 >60s 的成功请求 1741 个全部成功。 + # 只有在 CDN 后面(Cloudflare 100s 等)才需要打开,推荐 15。可热更。 + nonstream_keepalive_seconds: float = 0 # 单账号在途请求上限(全局,跨渠道):同一凭证并发达到该值后不再参与选号, # 避免把单账号打进上游限流/封号;0 = 不限。租约在请求结束后释放,另有 # 600s TTL 兜底(见 src/engine/inflight.py)。 @@ -316,6 +417,19 @@ class Settings(BaseSettings): context_compress_reserve_tokens: int = 4096 context_compress_min_keep_messages: int = 4 context_compress_safety_ratio: float = 0.95 + # 粘性裁剪:一次裁到预算的 60% 并记住窗口起点,跨轮保持前缀不变,上游 + # 前缀缓存才能命中(见 engine/sticky_trim.py)。关闭 = 旧行为(每轮按当轮 + # 预算重算,客户端每轮重发完整历史时只有 system 段可复用)。 + context_compress_sticky_enabled: bool = True + # tool 输出压缩(默认关):只压 `role=tool` 且超过 max_chars 的纯文本, + # 去 ANSI + 折叠连续重复行 + 保留命中错误关键字的行。确定性:同输入同 + # 输出,不破坏上游前缀缓存。max_chars ≤ 0 视为关闭(防御)。 + tool_output_compress_enabled: bool = False + tool_output_compress_max_chars: int = 8000 + # 前缀缓存诊断:每个请求往日志写一行载荷指纹(消息数、system/tools 摘要、 + # 与同凭证同模型上一条请求的最长公共前缀序号、分叉处消息摘要)。只记摘要 + # 不记正文,用来定位命中率异常时前缀在哪条消息分叉。默认关(INFO 级噪音)。 + prefix_debug_enabled: bool = False # 跨渠道 fallback 链:请求模型在主渠道全部不可用时,按「兼容组」 # 依次回退到组内其他模型(组内成员自行带上 @渠道 或依赖目录收窄)。 # 格式 `组名=成员1,成员2;组名2=成员3`;留空 = 不启用(保持原行为)。 @@ -324,9 +438,16 @@ class Settings(BaseSettings): # 上游改 id 时只改这里。格式 `对外名=模型@渠道;...`;留空 = 不启用。 # 与真实对外 id 或自动别名撞车视为配置错误(不静默覆盖)。可热更。 model_aliases: str = "" - # Prometheus 指标出口(GET /metrics):默认开启。与 /healthz 同口径**不鉴权**, - # 指标已禁用高基数标签(不含凭证 ID / 用户名)。关闭后端点返回 404。可热更。 + # Prometheus 指标出口(GET /metrics):默认开启。默认与 /healthz 同口径 + # **不鉴权**,指标已禁用高基数标签(不含凭证 ID / 用户名)。关闭后端点返回 404。 + # 对外部署建议同时设置 METRICS_TOKEN(见下),否则任何人都能读到渠道/模型 + # 清单与凭证池规模。可热更。 metrics_enabled: bool = True + # /metrics 的可选 Bearer 令牌:留空 = 保持公开;配置后抓取方必须带 + # `Authorization: Bearer `(Prometheus `authorization.credentials`)。 + # **启动期项**,不进热更表:热更值会落 DB 并被 /api/settings 回显, + # 令牌不该出现在管理台页面上。 + metrics_token: str = "" # 请求级结构化日志(logfmt,见 src/webapp/access_log.py):默认开启。 # 只记 /api、/v1、/authorize 这些业务请求,每行含 request_id / 路由模板 / # 状态码 / 耗时;**不写 query string、请求体、用户名**(脱敏口径见 @@ -377,6 +498,16 @@ def kilo_allowed(self) -> tuple[str, ...]: return tuple(e.strip().rstrip("/") for e in self.kilo_allowed_endpoints.split(",") if e.strip()) + @cached_property + def raccoon_allowed(self) -> tuple[str, ...]: + return tuple(e.strip().rstrip("/") + for e in self.raccoon_allowed_endpoints.split(",") if e.strip()) + + @cached_property + def lingxi_allowed(self) -> tuple[str, ...]: + return tuple(e.strip().rstrip("/") + for e in self.lingxi_allowed_endpoints.split(",") if e.strip()) + @cached_property def qoder_allowed(self) -> tuple[str, ...]: return tuple(e.strip().rstrip("/") for e in self.qoder_allowed_endpoints.split(",") @@ -419,8 +550,31 @@ def validate_endpoint_allowed(endpoint: str, settings: Settings) -> bool: def validate_codebuddy_intl_endpoint_allowed(endpoint: str, settings: Settings) -> bool: - """国际站端点白名单校验(防把国内站 Token 发往国际站或反之)。""" - return endpoint.strip().rstrip("/") in settings.codebuddy_intl_allowed + """国际站端点白名单校验(防把国内站 Token 发往国际站或反之)。 + + **白名单本身也校验**:PROPOSAL §2.7「`CODEBUDDY_INTL_ALLOWED_ENDPOINTS` + 默认只有国际站主机,填国内站启动时报错(跨站发送即凭证泄露)」。 + 只校验「端点在白名单里」不够——把国内站主机**加进**国际站白名单后, + 端点校验会通过,Token 照样发往国内站(国际站账号的 Token 发到国内站 + 网关 = 跨站泄露)。故白名单里出现国内站主机时一律拒绝。 + """ + allowed = settings.codebuddy_intl_allowed + if any(_is_codebuddy_cn_host(entry) for entry in allowed): + return False + return endpoint.strip().rstrip("/") in allowed + + +def _is_codebuddy_cn_host(endpoint: str) -> bool: + """该地址是不是国内站主机(`copilot.tencent.com`)。 + + 比主机名而非整串:配置里可能带路径或端口(`https://copilot.tencent.com:443/`), + 整串比较会漏判。国际站白名单里出现国内站主机 = 跨站发送,必须拒绝。 + """ + text = endpoint.strip() + if text.startswith(("http://", "https://")): + text = text.split("://", 1)[1] + host = text.split("/", 1)[0].split(":", 1)[0].strip().lower() + return host == _CODEBUDDY_CN.split("://", 1)[1].lower() def validate_zen_endpoint_allowed(endpoint: str, settings: Settings) -> bool: @@ -433,6 +587,16 @@ def validate_kilo_endpoint_allowed(endpoint: str, settings: Settings) -> bool: return endpoint.strip().rstrip("/") in settings.kilo_allowed +def validate_raccoon_endpoint_allowed(endpoint: str, settings: Settings) -> bool: + """小浣熊端点白名单校验(防把用户的会话 JWT 发往未授权主机)。""" + return endpoint.strip().rstrip("/") in settings.raccoon_allowed + + +def validate_lingxi_endpoint_allowed(endpoint: str, settings: Settings) -> bool: + """灵犀端点白名单校验(防把 WPS 登录态 cookie 发往未授权主机)。""" + return endpoint.strip().rstrip("/") in settings.lingxi_allowed + + def validate_qoder_endpoint_allowed(endpoint: str, settings: Settings) -> bool: """Qoder 端点白名单校验(防把带 COSY 签名的请求发往未授权主机)。""" return endpoint.strip().rstrip("/") in settings.qoder_allowed diff --git a/src/db/backup.py b/src/db/backup.py index c5a82186..fd3166e0 100644 --- a/src/db/backup.py +++ b/src/db/backup.py @@ -19,6 +19,8 @@ from __future__ import annotations +import json +import logging import os import sqlite3 import time @@ -26,6 +28,8 @@ from datetime import UTC, datetime from pathlib import Path +logger = logging.getLogger(__name__) + # 默认保留份数(后台任务与 CLI 的默认值;可经 BACKUP_KEEP 覆盖)。 DEFAULT_KEEP = 7 # 备份子目录名(相对 DATA_DIR)。 @@ -174,3 +178,126 @@ def restore_database(db_path: str | Path, source: str | Path, *, for sidecar in (f"{target.name}-wal", f"{target.name}-shm"): Path(target.parent, sidecar).unlink(missing_ok=True) return safety + + +# ---------------------------------------------------------------- 管理台恢复 +# 运行中直接换库不安全:旧连接/未 checkpoint 的 WAL 会把新库拼坏。管理台上传 +# 的备份先落 `DATA_DIR/restore/pending.sqlite3`,**下次启动、建连接之前**由 +# `apply_pending_restore` 走 `restore_database` 原子替换;期间服务照常用老库。 + +PENDING_DIRNAME = "restore" +PENDING_NAME = "pending.sqlite3" +PENDING_META = "pending.json" +# 恢复要求的最小表集合:一份「随便什么 sqlite 文件」不能当备份恢复 +REQUIRED_TABLES = ("credentials", "api_keys", "users") + + +def pending_restore_path(db_path: str | Path) -> Path: + return Path(db_path).resolve().parent / PENDING_DIRNAME / PENDING_NAME + + +def verify_app_database(path: str | Path) -> None: + """完整性 + 「确实是本服务的库」双校验。 + + - `PRAGMA integrity_check`(同 `verify_database`); + - 必含核心表(防止把任意 sqlite 文件恢复进生产); + - `user_version` 不高于当前程序支持的版本(更高的库说明来自更新的版本, + 老程序不认识的列/迁移会直接把服务启动打挂)。 + """ + from .migrate import SCHEMA_VERSION # 延迟导入:migrate 也会 import 本模块 + + try: + with closing(sqlite3.connect(f"file:{Path(path)}?mode=ro", uri=True)) as conn: + row = conn.execute("PRAGMA integrity_check").fetchone() + if (row[0] if row else None) != "ok": + raise RuntimeError( + f"数据库完整性校验未通过: {path}: {row[0] if row else None!r}") + tables = {item[0] for item in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'")} + version = int(conn.execute("PRAGMA user_version").fetchone()[0]) + except sqlite3.Error as error: + raise RuntimeError(f"数据库无法读取: {path}: {error}") from error + missing = [name for name in REQUIRED_TABLES if name not in tables] + if missing: + raise RuntimeError(f"不是本服务的数据库(缺少表 {', '.join(missing)})") + if version > SCHEMA_VERSION: + raise RuntimeError( + f"备份的 schema 版本 {version} 高于本程序支持的 {SCHEMA_VERSION}," + "请先升级程序再恢复") + + +def stage_restore(db_path: str | Path, data: bytes, *, filename: str = "", + now: float | None = None) -> dict: + """把上传文件校验后落到待恢复位置;返回可展示的元信息。 + + 校验失败抛 `RuntimeError`(不留下任何文件);成功后旧待恢复文件被覆盖。 + """ + if not data: + raise RuntimeError("上传内容为空") + pending = pending_restore_path(db_path) + pending.parent.mkdir(parents=True, exist_ok=True) + _chmod(pending.parent, 0o700) + temp = pending.with_suffix(".uploading") + try: + temp.write_bytes(data) + _chmod(temp, 0o600) + verify_app_database(temp) + os.replace(temp, pending) + finally: + temp.unlink(missing_ok=True) + meta = { + "filename": Path(filename).name if filename else pending.name, + "size": len(data), + "uploaded_at": int(now if now is not None else time.time()), + } + meta_path = pending.with_name(PENDING_META) + meta_path.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8") + _chmod(meta_path, 0o600) + return meta + + +def pending_restore_info(db_path: str | Path) -> dict | None: + """待恢复文件元信息;没有待恢复文件返回 None(meta 缺失也能读大小/时间)。""" + pending = pending_restore_path(db_path) + if not pending.is_file(): + return None + meta_path = pending.with_name(PENDING_META) + meta: dict = {} + if meta_path.is_file(): + try: + loaded = json.loads(meta_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + meta = loaded + except (OSError, ValueError): + meta = {} + stat = pending.stat() + return { + "filename": str(meta.get("filename") or pending.name), + "size": int(meta.get("size") or stat.st_size), + "uploaded_at": int(meta.get("uploaded_at") or stat.st_mtime), + } + + +def apply_pending_restore(db_path: str | Path, *, + now: float | None = None) -> Path | None: + """启动时应用待恢复文件;返回安全副本路径(未恢复 / 主库原本不存在时为 None)。 + + 待恢复文件校验不过时**不阻断启动**:改名留档后返回 None,服务继续用当前库。 + 否则一个坏上传会让服务再也起不来,恰好与「备份恢复」的目的相反。 + """ + pending = pending_restore_path(db_path) + if not pending.is_file(): + return None + try: + safety = restore_database(db_path, pending, now=now) + except (RuntimeError, OSError, sqlite3.Error) as error: + rejected = pending.with_name(f"{PENDING_NAME}.rejected-{_stamp(now)}") + with suppress(OSError): + pending.replace(rejected) + pending.with_name(PENDING_META).unlink(missing_ok=True) + logger.warning("待恢复备份不可用,已留档为 %s:%s", rejected, error) + return None + pending.unlink(missing_ok=True) + pending.with_name(PENDING_META).unlink(missing_ok=True) + logger.warning("已应用待恢复备份;恢复前安全副本:%s", safety) + return safety diff --git a/src/db/crypto.py b/src/db/crypto.py index cb2fa530..f3101c5c 100644 --- a/src/db/crypto.py +++ b/src/db/crypto.py @@ -31,6 +31,22 @@ def derive_key(app_secret: str) -> bytes: # 无熵」的密钥同样能被秒破,单看长度拦不住。 MIN_SECRET_DISTINCT_CHARS = 8 +# 已知占位值 / 示例值 / 常见弱口令:`.env.example` 与文档里出现过,或 +# 「够长且字符够杂」刚好骗过上面两条启发式。实测 `local-dev-secret-change-me` +# (26 字符 / 15 种字符)能通过长度与熵检查,然后用它解开真实库的全部凭证。 +# 长度与熵是启发式,这里是**确定性的**兜底:命中即拒绝启动。 +KNOWN_WEAK_SECRETS = frozenset({ + "local-dev-secret-change-me", + "change-me-please-change-me", + "please-change-me-change-me", + "changeme-change-me-1234", + "password123456789", + "secretsecretsecret", + "your-secret-key-here", + "replace-with-a-real-secret", + "development-secret-key", +}) + def _looks_low_entropy(app_secret: str) -> bool: """长度够但熵极低:重复字符、整串是短周期重复、或字符集过小。""" @@ -45,18 +61,38 @@ def _looks_low_entropy(app_secret: str) -> bool: return False +def _is_known_weak(app_secret: str) -> bool: + """已知占位值 / 示例值(大小写与首尾空白不敏感)。 + + 与 `_looks_low_entropy` 分开:那条是**统计特征**,这条是**已知名单**。 + 名单拦截的是「够长、字符够杂、但公开可查」的占位值——它们在任何熵指标上 + 都像真密钥,只有枚举才能识别。 + """ + return app_secret.strip().lower() in KNOWN_WEAK_SECRETS + + class WeakSecretError(ValueError): """APP_SECRET 过短(占位值/手滑),拒绝启动。""" class CredentialCipher: - def __init__(self, app_secret: str) -> None: + def __init__(self, app_secret: str, *, allow_known_weak: bool = False) -> None: + """`allow_known_weak=True` 只给**轮换脚本**用:它必须能用旧(可能已被 + 列入黑名单的)密钥解密存量数据,否则永远修不了。业务路径一律用默认值。 + """ if not app_secret: raise ValueError("APP_SECRET must not be empty") if len(app_secret) < MIN_SECRET_LENGTH: raise WeakSecretError( f"APP_SECRET must be at least {MIN_SECRET_LENGTH} characters") - if _looks_low_entropy(app_secret): + if not allow_known_weak and _is_known_weak(app_secret): + # 已知占位值优先判:它比「低熵」更具体,文案能直接指向怎么修。 + # 与 `.env.example` / 文档里的占位值同名:长度与熵都够,但公开可查。 + # 用它加密等于把凭证交给任何读过本仓库的人。 + raise WeakSecretError( + "APP_SECRET is a known placeholder/example value; generate a " + "real one (python -c \"import secrets;print(secrets.token_urlsafe(48))\")") + if not allow_known_weak and _looks_low_entropy(app_secret): raise WeakSecretError( "APP_SECRET looks low-entropy (repeated/cyclic or too few " f"distinct characters, need >= {MIN_SECRET_DISTINCT_CHARS})") diff --git a/src/db/key_usage.py b/src/db/key_usage.py index 95e2fc3e..64c897b4 100644 --- a/src/db/key_usage.py +++ b/src/db/key_usage.py @@ -42,6 +42,27 @@ def for_key(self, key_id: str, *, since: int, (key_id, int(since), until, until)).fetchone() return _row_usage(row) + def for_user(self, username: str, *, since: int, + until: int | None = None) -> KeyUsage: + """聚合某个**用户**在 `[since, until)` 内的用量(用户级配额用)。 + + 与 `for_key` 的口径差别就是要按人而不是按 Key:该用户经由任何入口产生 + 的行都算(自己 Key 的请求 + 管理台 Playground 的会话直连请求——后者的 + `api_key_id` 为 NULL)。所以这里不按 `api_key_id IS NOT NULL` 过滤, + 否则 Playground 自己的流量不吃自己那份配额,配额形同虚设。 + """ + row = self._db.connect().execute( + "SELECT COUNT(*) AS requests, " + "COALESCE(SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END), 0) AS ok, " + "COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0) AS failed, " + "COALESCE(SUM(input_tokens), 0) AS input_tokens, " + "COALESCE(SUM(output_tokens), 0) AS output_tokens, " + "COALESCE(SUM(cost_usd), 0) AS cost_usd " + "FROM usage_events WHERE username = ? AND ts >= ? " + "AND (? IS NULL OR ts < ?)", + (username, int(since), until, until)).fetchone() + return _row_usage(row) + def map_for(self, key_ids: list[str], *, since: int) -> dict[str, KeyUsage]: """批量聚合(管理台列表「当日已用」一列):一次查询,避免逐行 N+1。""" ids = [key_id for key_id in key_ids if key_id] diff --git a/src/db/migrate.py b/src/db/migrate.py index 318c2b57..af4d2cf8 100644 --- a/src/db/migrate.py +++ b/src/db/migrate.py @@ -69,7 +69,25 @@ # (`quota_credits_remaining` / `quota_credits_total` / `quota_credits_expiry_ladder`)。 # 积分账成为 CodeArts 唯一的额度来源,直接落在通用列(`quota_remaining` 等)上, # 故这三列不再有读者;见 `_MIGRATION_COLUMN_DROPS`。 -SCHEMA_VERSION = 29 +# 30:users 新增 allowed_models / quota(用户级策略:约束管理台 Playground 的 +# 会话直连,补上「viewer 用 Cookie 直调任意模型」绕过 Key 白名单与配额的口子)。 +# 两列默认 '' = 不限制,老库补列后 Playground 行为与补列前完全一致。 +# 31:新增 checkin_events 表(每日签到的逐次审计日志:时间/结果/领取积分/上游 +# 业务码/文案/触发方式)。新增表只需进 schema.sql(CREATE TABLE IF NOT EXISTS +# 对老库同样生效),无需迁移动作;老库升级后表为空 =「升级前无签到日志」, +# 不影响签到本身(去重仍是 CheckinTask 当日作用域集合)。与已删除的 `checkins` +# 去重表无关(`_MIGRATION_DROPS` 里的那张)。 +# 32:credentials 新增 cooling_kind(冷却来源 ErrKind)。老库补 NULL = 来源未知, +# `revive` 按补列前行为清除冷却;新写入的冷却带上来源,`revive` 保留 +# 「上游点名恢复时刻」的那些(soft/credit/plan/restricted),避免人工恢复后 +# 立刻再撞一次上游 429(实测 2026-11-03 12:22–12:23 三次 revive + 三次 429)。 +# 33:credentials 新增 web_cookie_state(网页 Cookie 状态:NULL/ok/expired)。 +# 只在管理台提示「网页会话已失效,重贴 Cookie」,不参与调度;老库补 NULL = +# 未知(下次探测写入)。额度本身仍以 statistics 汇总口径为准。 +# 34:credentials 新增 routing_weight(调度权重)。NULL/0 = 默认,正数越大越先 +# 被选中,用于管理员的显式偏好(灰度、保留备用号)。老库补 NULL 后选号顺序与 +# 补列前完全一致(所有比较键相等时仍按原链 cid 排序)。 +SCHEMA_VERSION = 34 # (表, 列定义):历史库升级时逐条补列 _MIGRATION_COLUMNS: tuple[tuple[str, str], ...] = ( @@ -90,6 +108,9 @@ # 额度包明细(展示用,含包名):与选号指标 quota_expiry_ladder 分开, # 避免为管理台展示改动调度排序行为 ("credentials", "quota_packages TEXT"), + ("credentials", "web_cookie_state TEXT"), # 网页明细源状态:NULL/ok/expired + # 调度权重:NULL/0 = 默认;正数越大越优先(管理员显式偏好,见 Scheduler.select) + ("credentials", "routing_weight INTEGER"), # access token 签发/到期时间:上游显式 expires_at 缺失时回落 # JWT 的 iat/exp;NULL=老库未回填(列表读到按需派生),0=确实未知 ("credentials", "token_expires_at INTEGER"), @@ -127,6 +148,13 @@ ("usage_events", "api_key_id TEXT"), # Key 级配额(JSON 文本):老库补 '' =「不限制」,出口行为不变。 ("api_keys", "quota TEXT NOT NULL DEFAULT ''"), + # 用户级策略(仅约束 Playground 会话直连):老库补 '' =「不限制」, + # Playground 行为与补列前一致(原先不校验任何按人策略)。 + ("users", "allowed_models TEXT NOT NULL DEFAULT ''"), + ("users", "quota TEXT NOT NULL DEFAULT ''"), + # 冷却来源(ErrKind 值):老库补列后为 NULL = 未知来源,人工 revive 照旧 + # 清除冷却(与补列前行为一致),不把历史冷却误判成上游点名的那种。 + ("credentials", "cooling_kind TEXT"), ) # 已废弃的表:schema.sql 里已删定义,但老库里可能还留着,必须显式清理。 @@ -153,6 +181,9 @@ # 按 Key 计量 / 自助用量只查「某把 Key + 时间窗」,必须走索引不能全表扫; # api_key_id 是第 26 版才补的列,索引只能在这里建。 ("idx_usage_key", "usage_events", "api_key_id, ts"), + # 失败归因的「换号救回」按 request_id 关联同一请求的成功行(见 + # stats.query.errors):request_id 是第 19 版补的列,索引只能在这里建。 + ("idx_usage_request", "usage_events", "request_id"), ) diff --git a/src/db/paid_models.py b/src/db/paid_models.py new file mode 100644 index 00000000..80ad5522 --- /dev/null +++ b/src/db/paid_models.py @@ -0,0 +1,87 @@ +"""实测扣费反推「目录说免费、其实收费」的模型(免费优先档的校准数据源)。 + +**为什么需要它**:`credit_rate == 0` 来自模型目录(上游 `/v3/config` 的 +`credits: "x0 credits"`),而目录只是**上游自报**。上游改计费策略时常常不改 +目录——实测 2026-11-03 21:59 起 `codebuddy_intl/deepseek-v4.1-flash` 目录仍 +写 x0,每次请求却实扣 0.01–7.39 积分(`credit_estimated=0`,即上游真值)。 +调度器把 x0 当「免费渠道整体排前」(`scheduler._free_first_rank`),于是这一 +条目录脏数据压过了 36h / 7 天两级到期窗口:officeace 三条凭证 15.8 小时内 +到期的 100 积分包整夜闲置,流量全打到实际在扣费的 intl 上。 + +**判据只认硬证据**:`usage_events` 里 `credit > 0 AND credit_estimated = 0` +的行——上游明确报了一个正数扣费,不是本服务按价表推算的(TRAE 的推算值 +`credit_estimated=1` 不参与,否则价表一调就可能把真免费模型误判成收费)。 + +**按 (渠道, 归一键) 聚合**:`usage_events.model` 存的是**对外**模型名,目录 +存的是**上游原始 id**,两者大小写与 free 尾缀写法不同(`space-bunny-free` +vs `space-bunny`),故两边都过 `normalize_model_key` 再比。 +""" + +from __future__ import annotations + +import time +from collections.abc import Callable + +from ..provider.naming import normalize_model_key + +# 观测窗口:只认最近这段时间内的实扣。太短(分钟级)会让校准在低峰期掉回 +# 免费档(夜里没人用就没有观测行);太长会让「上游改回免费」之后仍被当收费 +# 晾着。24h 覆盖一整个用量周期,且与「免费额度」的日界同量级。 +OBSERVATION_WINDOW_SECONDS = 24 * 3600 +# 快照 TTL:选号是热路径,每次请求都做一次 `GROUP BY provider, model` 虽只 +# 2–7ms(17 万行),但没必要。计费策略是小时级变化的,10 分钟足够新鲜。 +SNAPSHOT_TTL_SECONDS = 600.0 + + +class PaidModelRepository: + """`usage_events` 里「上游明确扣过费」的 (渠道, 模型) → 实测均值。 + + 返回的均值供调度端覆盖目录里的 x0:它不参与排序比较(排序只区分 + `== 0` 与 `!= 0`),但把真实倍率回填比填一个哨兵值诚实——排查时 + 「这个模型被当成 x0.14」比「被当成非零」有用得多。 + """ + + def __init__(self, db, *, window: int = OBSERVATION_WINDOW_SECONDS, + ttl: float = SNAPSHOT_TTL_SECONDS, + now_fn: Callable[[], float] = time.monotonic) -> None: + self._db = db + self._window = window + self._ttl = ttl + self._now = now_fn + self._snapshot: tuple[float, dict[tuple[str, str], float]] | None = None + + def invalidate(self) -> None: + """丢弃快照(测试与「刚写完一条明细」的调用方用)。""" + self._snapshot = None + + def observed_rates(self) -> dict[tuple[str, str], float]: + """`{(provider, 归一键): 平均每次请求积分}`;窗口内无实扣时为空表。 + + 快照按 TTL 复用:过期才重新聚合。窗口起点用 `time.time()`(与 + `usage_events.ts` 同一时钟),TTL 用单调钟(不受系统改时间影响)。 + """ + now = self._now() + snapshot = self._snapshot + if snapshot is not None and now - snapshot[0] < self._ttl: + return snapshot[1] + rates = self._query() + self._snapshot = (now, rates) + return rates + + def _query(self) -> dict[tuple[str, str], float]: + since = int(time.time()) - self._window + rows = self._db.connect().execute( + "SELECT provider, model, COALESCE(SUM(credit), 0) AS total, " + "COUNT(*) AS count FROM usage_events " + "WHERE ts >= ? AND credit > 0 AND credit_estimated = 0 " + "GROUP BY provider, model", (since,)).fetchall() + # 同一归一键可能来自多个原始写法(大小写 / free 尾缀):累加后再平均, + # 否则后到的写法会覆盖先到的,均值取决于 SQL 的返回顺序。 + buckets: dict[tuple[str, str], tuple[float, int]] = {} + for row in rows: + key = (row["provider"], normalize_model_key(row["model"])) + total, count = buckets.get(key, (0.0, 0)) + buckets[key] = (total + float(row["total"] or 0.0), + count + int(row["count"] or 0)) + return {key: total / count + for key, (total, count) in buckets.items() if count > 0} diff --git a/src/db/repo.py b/src/db/repo.py index 45e1a00f..48e8b32c 100644 --- a/src/db/repo.py +++ b/src/db/repo.py @@ -15,18 +15,35 @@ from ..auth.api_key import digest_api_key, generate_api_key, preview_api_key from ..db.crypto import CredentialCipher, CredentialDecryptError from ..engine.scheduler import ( + UPSTREAM_COOLDOWN_KINDS, Candidate, ErrorOutcome, ModelCooldown, expiring_credits, expiry_windows, ) -from ..provider.base import Quota, health_score +from ..provider.base import ErrKind, Quota, health_score from ..provider.token_expiry import credential_token_times from ..sorting import sort_rows, sql_order from ..tags import tags_text, tags_value +def _now(now: int | None) -> int: + """调用方缺省时取当前 epoch 秒;显式传 0 时保留 0(不被当成缺省)。""" + return int(now if now is not None else time.time()) + + +def _clear_model_cooldowns(conn: Any, credential_id: str) -> None: + """清掉该凭证的全部 (凭证, 模型) 冷却行。 + + 模型级冷却没有外键级联,所以删除凭证 / 硬禁用 / 账号级冷却 / 显式恢复 + 都必须显式清理——否则重建同 id 凭证会继承旧的模型冷却,残留行还会拖慢 + 留存清理。 + """ + conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", + (credential_id,)) + + def _new_id(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex[:16]}" @@ -179,11 +196,12 @@ def quota_ledger(provider: str) -> str: """该渠道的额度记在哪本账(`credit_events.ledger`)。 CodeArts 的额度来自积分接口(`/snap-manager/v1/statistics/plugin`,内置模型 - 按积分计费),记 `credits`;其余渠道是套餐 / 每日池口径的余额,记 `pool`。 + 按积分计费),小浣熊的额度来自积分余额(每请求扣 1 分),两家都记 `credits`; + 其余渠道是套餐 / 每日池口径的余额,记 `pool`。 老库里 CodeArts 的 `pool` 行是已下线的每日 token 池口径,不再展示(量级与 语义都和积分不同,混读会被当成同一本账的连续读数)。 """ - return "credits" if provider == "codearts" else "pool" + return "credits" if provider in ("codearts", "raccoon", "lingxi") else "pool" class CredentialRepository: @@ -214,7 +232,7 @@ def add(self, *, provider: str, credential_data: dict, nickname: str = "", tags: list[str] | None = None) -> str: credential_id = _new_id("cred") payload = json.dumps(credential_data, ensure_ascii=False).encode("utf-8") - created = int(now if now is not None else time.time()) + created = _now(now) issued_at, expires_at = credential_token_times(credential_data) with self._db.transaction() as conn: conn.execute( @@ -232,13 +250,23 @@ def set_tags(self, credential_id: str, tags: list[str]) -> bool: (tags_text(tags), credential_id)) return cursor.rowcount > 0 + def set_routing_weight(self, credential_id: str, weight: int | None) -> bool: + """设置调度权重(None = 恢复默认)。返回是否命中该凭证。 + + 权重只影响**选号顺序**,不是准入条件:低于任一「可调度」前提(健康、 + 冷却、启用)不产生任何效果,也不会把低健康度的号顶上去。 + """ + with self._db.transaction() as conn: + cursor = conn.execute("UPDATE credentials SET routing_weight = ? WHERE id = ?", + (weight, credential_id)) + return cursor.rowcount > 0 + def delete(self, credential_id: str) -> bool: with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM credentials WHERE id = ?", (credential_id,)) # 模型级冷却没有外键级联,凭证删除后必须显式清理, # 否则重建同 id 凭证会继承旧的模型冷却(残留行还会拖慢留存清理) - conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", - (credential_id,)) + _clear_model_cooldowns(conn, credential_id) return cursor.rowcount > 0 def set_enabled(self, credential_id: str, enabled: bool) -> bool: @@ -248,26 +276,65 @@ def set_enabled(self, credential_id: str, enabled: bool) -> bool: (1 if enabled else 0, credential_id)) return cursor.rowcount > 0 - def set_pinned(self, credential_id: str | None) -> None: + def set_pinned(self, credential_id: str | None) -> bool: + """指定唯一被 pin 的凭证(`None` = 取消指定)。 + + 全库至多一条 pinned:先清全部再置目标行。 + + 返回值 = 指定是否生效。`credential_id` 非空但库里没有这一行时返回 + False,**且不改动任何既有 pin**:否则管理员会以为「已指定」生效了, + 实际把别人的指定解除了,界面却显示成功。 + + 查存在性与两次写入放在同一事务里:`BEGIN IMMEDIATE` 的写锁保证 + 「查到存在」到「清 pin + 置 pin」之间不被并发删除打断(否则目标行 + 在清完之后被删,pin 就落空了)。 + """ with self._db.transaction() as conn: + if credential_id is not None: + exists = conn.execute( + "SELECT 1 FROM credentials WHERE id = ?", + (credential_id,)).fetchone() + if exists is None: + return False conn.execute("UPDATE credentials SET pinned = 0") if credential_id is not None: - conn.execute("UPDATE credentials SET pinned = 1 WHERE id = ?", (credential_id,)) + conn.execute( + "UPDATE credentials SET pinned = 1 WHERE id = ?", + (credential_id,)) + return True - def revive(self, credential_id: str) -> bool: - """解除硬禁用(session 死亡)与冷却,允许凭证重新参与调度。 + def revive(self, credential_id: str, *, force: bool = False) -> bool: + """解除硬禁用(session 死亡);冷却按来源决定是否一并清除。 没有这个入口时,凭证一旦因 session 失效被硬禁用就只能删除重建, 重新登录后也无法复用同一条记录。模型级冷却一并清空:管理员显式 - 「恢复」的意图包含该凭证的全部冷却状态。 + 「恢复」的意图包含该凭证的全部模型级冷却状态。 + + **账号级冷却不再无条件清除**:`cooling_kind` 属于上游点名恢复时刻的 + 那几类(`UPSTREAM_COOLDOWN_KINDS`:限流/余额/权益/风控)时保留—— + 实测 2026-11-03 12:22–12:23 管理员连续三次批量恢复 zen 凭证,每次都 + 顺手解掉刚写入的 429 冷却(上游已点名到次日 08:00),下一次请求立刻 + 再撞一次同一个 429。要强制清除走 `force=True`(前端「强制恢复」)。 + `cooling_kind` 为 NULL(老库 / 本地 OTHER 冷却)时照旧清除,与补列前一致。 """ with self._db.transaction() as conn: - cursor = conn.execute( - "UPDATE credentials SET disabled = 0, disabled_reason = NULL, " - "cooling_until = NULL, err_count = 0 WHERE id = ?", (credential_id,)) - conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", - (credential_id,)) - return cursor.rowcount > 0 + row = conn.execute( + "SELECT cooling_kind FROM credentials WHERE id = ?", + (credential_id,)).fetchone() + if row is None: + return False + keep_cooldown = (not force) and row["cooling_kind"] in UPSTREAM_COOLDOWN_KINDS + if keep_cooldown: + conn.execute( + "UPDATE credentials SET disabled = 0, disabled_reason = NULL, " + "err_count = 0 WHERE id = ?", (credential_id,)) + else: + conn.execute( + "UPDATE credentials SET disabled = 0, disabled_reason = NULL, " + "cooling_until = NULL, cooling_kind = NULL, err_count = 0 " + "WHERE id = ?", (credential_id,)) + _clear_model_cooldowns(conn, credential_id) + return True def mark_relogin_required(self, credential_id: str, reason: str) -> None: """标记「需重新登录」:硬禁用 + 可操作原因,停止一切自动重试。 @@ -283,9 +350,8 @@ def mark_relogin_required(self, credential_id: str, reason: str) -> None: with self._db.transaction() as conn: conn.execute( "UPDATE credentials SET disabled = 1, disabled_reason = ?, err_count = 0, " - "cooling_until = NULL WHERE id = ?", (reason, credential_id)) - conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", - (credential_id,)) + "cooling_until = NULL, cooling_kind = NULL WHERE id = ?", (reason, credential_id)) + _clear_model_cooldowns(conn, credential_id) def save_error(self, credential_id: str, outcome: ErrorOutcome) -> None: """落库一次错误结果。 @@ -293,6 +359,13 @@ def save_error(self, credential_id: str, outcome: ErrorOutcome) -> None: `outcome.model_cooldowns` 非空表示模型级冷却:只写 (凭证, 模型) 表, 不动账号级 cooling_until;反之账号级冷却会清空该凭证的模型级条目 (账号级限流不允许被「切模型」绕过)。 + + hard禁用(DEAD)优先写 `outcome.disabled_reason`,为空才回落历史的 + "session dead":401/403 里有「账号被风控限制」这类重登解决不了的原因, + 管理台直接显示该字段,写错会把人引向错误的修复动作。 + + 写账号级冷却时同步写 `cooling_kind`(ErrKind 值):`revive` 据此保留 + 「上游点名恢复时刻」的冷却(见 `UPSTREAM_COOLDOWN_KINDS`)。 """ with self._db.transaction() as conn: if outcome.model_cooldowns: @@ -310,14 +383,46 @@ def save_error(self, credential_id: str, outcome: ErrorOutcome) -> None: if outcome.disabled: conn.execute( "UPDATE credentials SET disabled = 1, disabled_reason = ?, err_count = 0, " - "cooling_until = NULL WHERE id = ?", ("session dead", credential_id)) + "cooling_until = NULL, cooling_kind = NULL WHERE id = ?", + (outcome.disabled_reason or "session dead", credential_id)) else: + # `cooling_until = NULL` 的语义是「本次不动冷却」,不是「清掉冷却」: + # 低于阈值的 OTHER 类错误(`note_error` 返回 err_count=count、 + # cooling_until=None)原来会无条件覆盖,把并发请求刚写入的 + # RESTRICTED/SOFT 冷却(2h~7d)擦成 NULL,账号立刻回池继续撞。 + # 与 `executor._note_upstream_error` 里 REQUEST 分支的顾虑同源。 + # 要**主动解除**冷却走 `revive()` / `purge_expired_*`,不靠这里。 + # `cooling_kind` 同 COALESCE 语义:本次不动冷却时也不动来源标签。 conn.execute( - "UPDATE credentials SET cooling_until = ?, err_count = ? WHERE id = ?", - (outcome.cooling_until, outcome.err_count, credential_id)) + "UPDATE credentials SET cooling_until = COALESCE(?, cooling_until), " + "cooling_kind = COALESCE(?, cooling_kind), " + "err_count = ? WHERE id = ?", + (outcome.cooling_until, outcome.cooling_kind or None, + outcome.err_count, credential_id)) if outcome.cooling_until is not None: - conn.execute("DELETE FROM credential_model_cooldowns WHERE credential_id = ?", - (credential_id,)) + _clear_model_cooldowns(conn, credential_id) + + def cool_provider(self, provider: str, *, until: int) -> int: + """渠道级冷却:把该渠道所有凭证的账号级冷却一起推后,返回受影响行数。 + + 用于「整批同源账号被上游风控限制」(CodeBuddy 11140):逐个号冷却挡不住 + 下一个请求换同渠道另一个号继续撞,每次都在上游多留一条风控记录。只写 + `cooling_until`,不动 `disabled`——禁用是不可逆的人工状态,而这类限制 + 会由上游自行解除,到期必须能自动回到候选。 + + 只在比现有冷却更晚时覆盖(`cooling_until < until`):渠道级暂停不该 + 缩短某个号已经更晚的冷却。账号级冷却出现要清掉模型级条目,否则模型级 + 豁免会泄漏成「换模型绕过账号级限制」。 + """ + with self._db.transaction() as conn: + cursor = conn.execute( + "UPDATE credentials SET cooling_until = ?, cooling_kind = ?, err_count = 0 " + "WHERE provider = ? AND (cooling_until IS NULL OR cooling_until < ?)", + (until, ErrKind.RESTRICTED.value, provider, until)) + conn.execute( + "DELETE FROM credential_model_cooldowns WHERE credential_id IN " + "(SELECT id FROM credentials WHERE provider = ?)", (provider,)) + return cursor.rowcount def save_success(self, credential_id: str, *, model: str | None = None) -> None: """成功:清账号级错误累计;`model` 命中负缓存条目时一并清除。 @@ -338,7 +443,22 @@ def purge_expired_model_cooldowns(self, now: int | None = None) -> int: with self._db.transaction() as conn: cursor = conn.execute( "DELETE FROM credential_model_cooldowns WHERE cooling_until <= ?", - (int(now if now is not None else time.time()),)) + (_now(now),)) + return cursor.rowcount + + def purge_expired_account_cooldowns(self, now: int | None = None) -> int: + """清掉已过期的账号级 `cooling_until`(留存任务每轮调用)。 + + 选号本身用 `cooling_until > now` 判断,过期值不会挡住凭证,但它会 + 长期留在库里:管理台列表下发的是原始值,一条几周前的陈旧冷却会让 + 该行在「冷却」维度上排序失真,也让「谁在冷却」这类人工排查看到 + 幽灵记录。与模型级条目分开计数,便于区分两类回收。 + """ + with self._db.transaction() as conn: + cursor = conn.execute( + "UPDATE credentials SET cooling_until = NULL, cooling_kind = NULL " + "WHERE cooling_until IS NOT NULL AND cooling_until <= ?", + (_now(now),)) return cursor.rowcount def model_cooldowns_for(self, credential_id: str, @@ -347,7 +467,7 @@ def model_cooldowns_for(self, credential_id: str, rows = self._db.connect().execute( "SELECT model, cooling_until FROM credential_model_cooldowns " "WHERE credential_id = ? AND cooling_until > ?", - (credential_id, int(now if now is not None else time.time()))).fetchall() + (credential_id, _now(now))).fetchall() return {row["model"]: row["cooling_until"] for row in rows} def blocked_cooldowns(self, now: int | None = None) -> list[dict[str, Any]]: @@ -362,7 +482,7 @@ def blocked_cooldowns(self, now: int | None = None) -> list[dict[str, Any]]: "FROM credential_model_cooldowns c " "JOIN credentials cred ON cred.id = c.credential_id " "WHERE c.reason = 'blocked' AND c.cooling_until > ? " - "ORDER BY c.cooling_until", (int(now if now is not None else time.time()),) + "ORDER BY c.cooling_until", (_now(now),) ).fetchall() return [dict(row) for row in rows] @@ -398,6 +518,92 @@ def save_credential_data(self, credential_id: str, credential_data: dict) -> Non "token_issued_at = ? WHERE id = ?", (self._cipher.encrypt(payload), expires_at, issued_at, credential_id)) + def set_web_cookie_state(self, credential_id: str, state: str | None) -> None: + """写入/清除网页 Cookie 状态(管理台提示用,不参与调度)。""" + with self._db.transaction() as conn: + conn.execute("UPDATE credentials SET web_cookie_state = ? WHERE id = ?", + (state, credential_id)) + + def find_login_credential(self, provider: str, + identity: Callable[[dict[str, Any]], frozenset[str]], + target: frozenset[str]) -> str | None: + """同渠道里找出同一上游账号的既有凭证(重新登录时合并用)。 + + `identity` 由 provider 提供(CodeArts 用 `uid` + `domain_id|user_name`, + OfficeAce 用 `account_id` + `principal_id`):**不能比对整份 JSON**, + 重新登录后 access token / refresh_token / DPoP 私钥都会变,整份比对 + 永远不相等。两侧都给**别名集合**、按交集判定:身份补全接口偶发失败时 + 新登录只能给出兜底键,单键比较会与老行的首选键错开从而新增重复行。 + + 多行命中(历史遗留的重复凭证)时的优先级:先挑 `disabled=1`(正是这次 + 重新登录要救的那条),再挑 `pinned`,最后取最早创建的一条(统计流水与 + 变量的归属都挂在最早的 id 上)。没有身份或没有命中返回 None,调用方 + 退回新增,绝不猜。 + """ + if not target: + return None + rows = self._db.connect().execute( + "SELECT id, disabled, pinned, data_enc FROM credentials " + "WHERE provider = ? ORDER BY created_at ASC", (provider,)).fetchall() + matches: list[Any] = [] + for row in rows: + # 解密与身份解析放在同一个 try:两者都是「这一行坏了」,处置一致 + # ——跳过这一行,不能让一条坏数据挡住登录。 + try: + existing = json.loads(self._cipher.decrypt(row["data_enc"]).decode("utf-8")) + if identity(existing) & target: + matches.append(row) + except Exception: # noqa: BLE001 - 坏行跳过:一条坏数据不该挡住登录 + continue + for row in matches: + if row["disabled"]: + return row["id"] + for row in matches: + if row["pinned"]: + return row["id"] + return matches[0]["id"] if matches else None + + def merge_login_credential(self, credential_id: str, credential_data: dict, + nickname: str = "") -> bool: + """重新登录成功后就地更新既有凭证;返回是否命中该行。 + + 与 `save_credential_data` 的差别是语义:重新登录 = 上游重新签发了整份 + 凭证,理所当然要**解除**「需重新登录」硬禁用并清掉本地失败计数 + (`disabled` / `err_count` / 模型级负缓存),否则新凭证落库了却仍选不中。 + `enabled`(用户主动暂停)/ `pinned` / `tags` / `added_by` / + `created_at` 一概保留——合并只换凭证内容,不改运维状态。 + + **账号级冷却不走 `force`**:与 `revive(force=False)` 同口径保留 + `UPSTREAM_COOLDOWN_KINDS`(限流/余额/权益/风控)那几类——那是上游点名 + 的恢复时刻,重登换的是凭证不是配额。2026-11-03 的教训是「恢复顺手解掉 + 刚写入的 429 冷却 → 下一个请求立刻再撞一次」,重登是同一个动作的 + 另一种入口。其余来源(本地 OTHER 冷却 / `cooling_kind` 为 NULL 的老库) + 照旧清除,与 `revive` 保持一致。 + + 昵称为空时保留旧值(登录接口偶尔拿不到用户名,不能因此清空展示名)。 + """ + payload = json.dumps(credential_data, ensure_ascii=False).encode("utf-8") + issued_at, expires_at = credential_token_times(credential_data) + with self._db.transaction() as conn: + row = conn.execute( + "SELECT cooling_kind FROM credentials WHERE id = ?", + (credential_id,)).fetchone() + if row is None: + return False + keep_cooldown = row["cooling_kind"] in UPSTREAM_COOLDOWN_KINDS + cooling_sql = ("" if keep_cooldown + else "cooling_until = NULL, cooling_kind = NULL, ") + conn.execute( + "UPDATE credentials SET data_enc = ?, token_expires_at = ?, " + "token_issued_at = ?, " + f"nickname = CASE WHEN ? = '' THEN nickname ELSE ? END, " + f"disabled = 0, disabled_reason = NULL, {cooling_sql}" + "err_count = 0 WHERE id = ?", + (self._cipher.encrypt(payload), expires_at, issued_at, + nickname, nickname, credential_id)) + _clear_model_cooldowns(conn, credential_id) + return True + def save_quota(self, credential_id: str, quota: Quota) -> None: """写回额度,并顺带记一条变动流水。 @@ -414,10 +620,12 @@ def save_quota(self, credential_id: str, quota: Quota) -> None: conn.execute( "UPDATE credentials SET quota_remaining = ?, quota_total = ?, " "quota_cycle_end = ?, quota_expiry_ladder = ?, quota_packages = ?, " + "web_cookie_state = ?, " "quota_probed_at = ?, health = ? " "WHERE id = ?", (quota.remaining, quota.total, quota.cycle_end, _ladder_text(quota.expiry_ladder), _packages_text(quota.packages), + quota.web_cookie_state, quota.probed_at, health_score(quota), credential_id), ) @@ -475,7 +683,7 @@ def _record_credit_event(conn, credential_id: str, *, ledger: str, before, after def mark_probe_failed(self, credential_id: str, now: int | None = None) -> None: with self._db.transaction() as conn: conn.execute("UPDATE credentials SET quota_probed_at = ?, health = NULL WHERE id = ?", - (int(now if now is not None else time.time()), credential_id)) + (_now(now), credential_id)) def save_growth_result(self, credential_id: str, report: str, now: int | None = None) -> None: @@ -484,7 +692,7 @@ def save_growth_result(self, credential_id: str, report: str, conn.execute( "UPDATE credentials SET growth_last_run_at = ?, growth_last_result = ? " "WHERE id = ?", - (int(now if now is not None else time.time()), report, credential_id)) + (_now(now), report, credential_id)) # ------------------------------------------------------------- 读取 @@ -498,7 +706,8 @@ def candidates(self, providers: Iterable[str] | None = None, """ rows = self._db.connect().execute( "SELECT id, provider, health, cooling_until, disabled, enabled, err_count, pinned, " - "quota_remaining, quota_cycle_end, quota_expiry_ladder FROM credentials").fetchall() + "quota_remaining, quota_cycle_end, quota_expiry_ladder, routing_weight " + "FROM credentials").fetchall() allowed = set(providers) if providers is not None else None # 模型级冷却整表读一次(表很小:只在模型限流/负缓存时才有行), # 按凭证聚合后挂到 Candidate 上;每选一次号查一次库会抵消选号的开销优势。 @@ -521,6 +730,7 @@ def candidates(self, providers: Iterable[str] | None = None, pinned=bool(row["pinned"]), cycle_end=row["quota_cycle_end"], quota_remaining=row["quota_remaining"], expiry_ladder=_ladder_value(row["quota_expiry_ladder"]), + routing_weight=row["routing_weight"], model_cooldowns=by_credential.get(row["id"]), ) for row in rows if allowed is None or row["provider"] in allowed @@ -534,6 +744,24 @@ def provider_of(self, credential_id: str) -> str | None: "SELECT provider FROM credentials WHERE id = ?", (credential_id,)).fetchone() return row["provider"] if row else None + def has_same_credential(self, provider: str, credential_data: dict[str, Any]) -> bool: + """同渠道是否已有内容完全相同的凭证(批量导入去重用)。 + + 逐行解密比对是 O(渠道内凭证数):批量导入是低频操作,且只跑一次; + 为它加指纹列/索引不划算。某行密文损坏(换过 APP_SECRET 等)时跳过该行, + 不能让一条坏数据把整批导入挡住。 + """ + rows = self._db.connect().execute( + "SELECT data_enc FROM credentials WHERE provider = ?", (provider,)).fetchall() + for row in rows: + try: + existing = json.loads(self._cipher.decrypt(row["data_enc"]).decode("utf-8")) + except Exception: # noqa: BLE001 - 坏行跳过,见 docstring + continue + if existing == credential_data: + return True + return False + def pool_counts(self, now: int | None = None) -> dict[str, int]: """池健康计数(/healthz):四类互斥且合计等于 total。 @@ -542,7 +770,7 @@ def pool_counts(self, now: int | None = None) -> dict[str, int]: 实际选号不一致的「可用数」。判定优先级:disabled → paused → cooling → ready,与调度器一致(禁用/暂停优先于冷却)。 """ - moment = int(now if now is not None else time.time()) + moment = _now(now) counts = {"total": 0, "ready": 0, "cooling": 0, "paused": 0, "disabled": 0} for candidate in self.candidates(): counts["total"] += 1 @@ -575,6 +803,14 @@ def attention_counts(self) -> dict[str, int]: return {"disabled": int(row["disabled"]), "exhausted": int(row["exhausted"])} def credential_data(self, credential_id: str) -> dict[str, Any] | None: + """取凭证明文;不存在返回 None。 + + 解密失败**照旧抛** `CredentialDecryptError`:它表示 APP_SECRET 换过 + 或密文损坏,是需要人处理的状态(见 `webapp.handlers` 的可行动 500 + 映射)。「一条坏凭证不该连累整个请求」由**调用方**处理:引擎选号 + 与模型列表会捕获它并跳过该凭证(见 `executor._safe_credential_data` + / `api.models`),而单条探测端点把它如实报给管理员。 + """ row = self._db.connect().execute( "SELECT data_enc FROM credentials WHERE id = ?", (credential_id,)).fetchone() if row is None: @@ -582,6 +818,27 @@ def credential_data(self, credential_id: str) -> dict[str, Any] | None: plaintext = self._cipher.decrypt(row["data_enc"]) return json.loads(plaintext.decode("utf-8")) + def export_entry(self, credential_id: str) -> dict[str, Any] | None: + """单条凭证导出:与 `POST /api/credentials` 的导入项同形,可原样再导入。 + + 复用 `import_credential` 接受的结构(`provider` + `credential` + + `nickname` + `tags`),因此导出的文件既能备份,也能直接粘进「批量导入」。 + 解密失败照旧抛 `CredentialDecryptError`,与 `credential_data` 同一口径。 + """ + row = self._db.connect().execute( + "SELECT provider, nickname, tags, data_enc FROM credentials WHERE id = ?", + (credential_id,)).fetchone() + if row is None: + return None + plaintext = self._cipher.decrypt(row["data_enc"]) + credential = json.loads(plaintext.decode("utf-8")) + # 网页 Cookie 是浏览器会话凭据(能读账号页全部数据,权限比推理 token + # 更宽),导出件默认不带:少一处明文泄漏面,重装后重新粘贴即可。 + if row["provider"] in ("qoder", "codearts"): + credential.pop("web_cookie", None) + return {"provider": row["provider"], "nickname": row["nickname"], + "tags": tags_value(row["tags"]), "credential": credential} + def nickname_of(self, credential_id: str) -> str | None: """单条凭证昵称(供实时流按需补名字,避免每事件拉整份列表)。 @@ -620,12 +877,16 @@ def list_all( 服务端算好的「剩余秒数」不会随页面 tick 更新,两处口径还会漂移。 到期时间未知时为 0(展示层隐藏,绝不当成已过期)。 """ + # 注意:这里用 `or` 而非 `is not None`——list_all 的调用方从不传 0, + # 而 `now=0`(epoch 起点)对它等同于「取当前时刻」是可接受的。 now = int(now or time.time()) rows = self._db.connect().execute( "SELECT id, provider, nickname, enabled, disabled, disabled_reason, pinned, " - "health, cooling_until, err_count, quota_remaining, quota_total, quota_cycle_end, " + "health, cooling_until, cooling_kind, err_count, quota_remaining, " + "quota_total, quota_cycle_end, " "quota_probed_at, growth_last_run_at, growth_last_result, created_at, added_by, " "quota_expiry_ladder, quota_packages, data_enc, " + "web_cookie_state, routing_weight, " "token_expires_at, token_issued_at, " "tags " "FROM credentials ORDER BY created_at").fetchall() @@ -688,7 +949,7 @@ def expiring_tokens(self, *, within_seconds: int, now: int | None = None, """ if within_seconds <= 0: return [] - moment = int(now if now is not None else time.time()) + moment = _now(now) deadline = moment + within_seconds rows = self._db.connect().execute( "SELECT id, provider, nickname, data_enc, token_expires_at, token_issued_at " @@ -719,7 +980,7 @@ def create(self, username: str, name: str = "", now: int | None = None, quota: str = "") -> dict[str, Any]: plaintext = generate_api_key() key_id = _new_id("key") - created_at = int(now if now is not None else time.time()) + created_at = _now(now) with self._db.transaction() as conn: conn.execute( "INSERT INTO api_keys (id, username, name, key_digest, preview, created_at, " @@ -844,7 +1105,7 @@ def record(self, *, credential_id: str, result, trigger: str = "auto", conn.execute( "INSERT INTO growth_events (id, credential_id, ts, ok, session_dead, " "report, credit, energy, streak_days, trigger) VALUES (?,?,?,?,?,?,?,?,?,?)", - (event_id, credential_id, int(now if now is not None else time.time()), + (event_id, credential_id, _now(now), 1 if result.ok else 0, 1 if result.session_dead else 0, result.report, result.credit, result.energy, result.streak_days, trigger), ) @@ -856,7 +1117,7 @@ def prune(self, *, keep_days: int, now: int | None = None) -> int: 每轮探测都可能落一行,不回收会无界增长(读侧有 LIMIT,但表本身会 一直变大);保留期与请求明细一致,避免「成长中心记录比请求明细还久」。 """ - cutoff = int(now if now is not None else time.time()) - keep_days * 86400 + cutoff = _now(now) - keep_days * 86400 with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM growth_events WHERE ts < ?", (cutoff,)) return cursor.rowcount @@ -874,6 +1135,107 @@ def recent(self, credential_id: str, limit: int = 20) -> list[dict[str, Any]]: return [dict(row) for row in rows] +class CheckinEventRepository: + """每日签到审计日志(checkin_events)。 + + 每轮对一条凭证执行签到就落一行(成功/失败/已签都记),供管理台回看 + 「这个号昨天几点签的、领了多少、上游回了什么码」。 + + 与已删除的 `checkins` 去重表无关:去重仍由 `CheckinTask` 的当日作用域集合 + 承担,本表只增不改、不参与任何判定。只存汇总,不落上游原始响应体。 + """ + + def __init__(self, db) -> None: + self._db = db + + def record(self, *, credential_id: str, result, trigger: str = "auto", + now: int | None = None) -> str: + """记一行签到结果(`result` 为 provider 的 CheckinResult)。""" + event_id = _new_id("checkin") + with self._db.transaction() as conn: + conn.execute( + "INSERT INTO checkin_events (id, credential_id, ts, ok, already, " + "credit, code, message, trigger) VALUES (?,?,?,?,?,?,?,?,?)", + (event_id, credential_id, _now(now), + 1 if result.ok else 0, 1 if result.already_checked_in else 0, + result.credit, result.code, result.message, trigger), + ) + return event_id + + def prune(self, *, keep_days: int, now: int | None = None) -> int: + """删除超过保留期的签到日志(与 usage_events 同一保留策略入口)。 + + 每次签到都可能落一行,不回收会无界增长(读侧有 LIMIT,但表本身会 + 一直变大);保留期与请求明细一致,避免「签到日志比请求明细还久」。 + """ + cutoff = _now(now) - keep_days * 86400 + with self._db.transaction() as conn: + cursor = conn.execute("DELETE FROM checkin_events WHERE ts < ?", (cutoff,)) + return cursor.rowcount + + def recent(self, credential_id: str, limit: int = 20) -> list[dict[str, Any]]: + """默认倒序返回最近记录;limit 收敛到 [1, 200] 防止一次拉爆前端。""" + rows = self._db.connect().execute( + "SELECT * FROM checkin_events WHERE credential_id = ? ORDER BY ts DESC LIMIT ?", + (credential_id, max(1, min(200, limit)))).fetchall() + return [dict(row) for row in rows] + + def latest_since(self, since: int) -> dict[str, dict[str, Any]]: + """某时刻之后每条凭证**最新一条**记录,供管理台「今日签到」汇总。 + + 返回 `{credential_id: 行}`;没有记录的凭证**缺席**——缺席 ≠ 失败 + (今日任务还没轮到、凭证被暂停、渠道本就不支持签到都可能),调用方 + 据此区分「未签」与「签挂」。跨渠道的「今日」口径由调用方给 `since` + (北京时间 0 点,见 `engine.limits.day_start`),本方法不做时区判断。 + + 一次取全量再在内存里按凭证取首行(整体 `ts DESC`,首行即最新), + 而不是逐凭证一条 SQL:池子几十上百条时往返次数才是瓶颈,行数很小。 + """ + rows = self._db.connect().execute( + "SELECT * FROM checkin_events WHERE ts >= ? ORDER BY ts DESC", + (int(since),)).fetchall() + latest: dict[str, dict[str, Any]] = {} + for row in rows: + latest.setdefault(row["credential_id"], dict(row)) + return latest + + def consecutive_failures(self, *, min_streak: int, + since: int) -> list[dict[str, Any]]: + """近窗口内**连续失败**达到阈值的凭证,供签到失败告警。 + + 只数**最近一段**连续 `ok=0`:中间只要成功过一次就清零(第二次成功 + 即修复,不该继续挂着告警)。`since` 之外的行不参与,避免长期失败 + 凭证在保留期边缘反复进出。返回 `[{credential_id, streak, last_ts, + message}]`,按失败次数降序(最严重的排前面)。 + """ + if min_streak <= 0: + return [] + rows = self._db.connect().execute( + "SELECT credential_id, ts, ok, message FROM checkin_events " + "WHERE ts >= ? ORDER BY credential_id, ts DESC", (since,)).fetchall() + streaks: dict[str, dict[str, Any]] = {} + for row in rows: + credential_id = row["credential_id"] + state = streaks.get(credential_id) + if state is not None and state.get("done"): + continue # 该凭证已遇到成功,后面更早的行无关 + if row["ok"]: + streaks[credential_id] = {"done": True} + continue + if state is None: + state = streaks[credential_id] = { + "streak": 0, "last_ts": int(row["ts"]), + "message": row["message"] or ""} + state["streak"] += 1 + result = [ + {"credential_id": credential_id, "streak": state["streak"], + "last_ts": state["last_ts"], "message": state["message"]} + for credential_id, state in streaks.items() + if not state.get("done") and state["streak"] >= min_streak + ] + return sorted(result, key=lambda item: item["streak"], reverse=True) + + class CreditEventRepository: """积分变动流水(credit_events):只读查询,写入在 save_quota 事务内完成。 @@ -909,7 +1271,7 @@ def recent(self, credential_id: str, limit: int = 20, sort: str | None = None, def prune(self, *, keep_days: int, now: int | None = None) -> int: """删除超过保留期的流水(与 usage_events 同一保留策略入口)。""" - cutoff = int(now if now is not None else time.time()) - keep_days * 86400 + cutoff = _now(now) - keep_days * 86400 with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM credit_events WHERE ts < ?", (cutoff,)) return cursor.rowcount @@ -936,7 +1298,7 @@ def record(self, *, rule: str, severity: str, scope: str, message: str, conn.execute( "INSERT INTO alert_events (id, ts, rule, severity, scope, message, " "detail, delivered, delivery_error) VALUES (?,?,?,?,?,?,?,?,?)", - (alert_id, int(now if now is not None else time.time()), rule, + (alert_id, _now(now), rule, severity, scope, message, detail, 1 if delivered else 0, delivery_error), ) @@ -1003,7 +1365,7 @@ def resolve_by_id(self, alert_id: str, now: int | None = None) -> int: 只动这一行——不做「按 (rule, scope) 批量」,免得手滑一次把仍在告警的 同组记录一起收掉。 """ - resolved_at = int(now if now is not None else time.time()) + resolved_at = _now(now) with self._db.transaction() as conn: cursor = conn.execute( "UPDATE alert_events SET resolved_at = ? " @@ -1020,7 +1382,7 @@ def resolve(self, rule: str, scope: str, now: int | None = None) -> int: 返回受影响行数(0 = 没有待恢复的行,例如告警已被清理)。 """ - resolved_at = int(now if now is not None else time.time()) + resolved_at = _now(now) with self._db.transaction() as conn: cursor = conn.execute( "UPDATE alert_events SET resolved_at = ? " @@ -1030,7 +1392,7 @@ def resolve(self, rule: str, scope: str, now: int | None = None) -> int: def prune(self, *, keep_days: int, now: int | None = None) -> int: """删除超过保留期的告警(与 usage_events / 审计同一保留策略入口)。""" - cutoff = int(now if now is not None else time.time()) - keep_days * 86400 + cutoff = _now(now) - keep_days * 86400 with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM alert_events WHERE ts < ?", (cutoff,)) return cursor.rowcount @@ -1058,7 +1420,7 @@ def set(self, key: str, value: str, now: int | None = None) -> None: "INSERT INTO runtime_settings (key, value, updated_at) VALUES (?,?,?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value, " "updated_at = excluded.updated_at", - (key, value, int(now if now is not None else time.time())), + (key, value, _now(now)), ) def delete(self, key: str) -> None: @@ -1138,7 +1500,7 @@ def create(self, username: str, password_hash: str, *, role: str = "viewer", enabled: bool = True, must_change_password: bool = False, created_by: str | None = None, activation_digest: str | None = None, activation_expires_at: int | None = None, now: int | None = None) -> None: - timestamp = int(now if now is not None else time.time()) + timestamp = _now(now) with self._db.transaction() as conn: conn.execute( "INSERT INTO users (username, password_hash, role, enabled, " @@ -1170,7 +1532,7 @@ def update_role(self, username: str, role: str, *, bump_epoch: bool = True, "UPDATE users SET role = ?, session_epoch = session_epoch + ?, " "updated_at = ? WHERE username = ?", (role, 1 if bump_epoch else 0, - int(now if now is not None else time.time()), username), + _now(now), username), ) return cursor.rowcount > 0 @@ -1182,7 +1544,7 @@ def set_enabled(self, username: str, enabled: bool, *, "UPDATE users SET enabled = ?, session_epoch = session_epoch + 1, " "updated_at = ? WHERE username = ?", (1 if enabled else 0, - int(now if now is not None else time.time()), username), + _now(now), username), ) return cursor.rowcount > 0 @@ -1195,7 +1557,7 @@ def set_password(self, username: str, password_hash: str, *, "session_epoch = session_epoch + 1, activation_digest = NULL, " "activation_expires_at = NULL, updated_at = ? WHERE username = ?", (password_hash, 1 if must_change_password else 0, - int(now if now is not None else time.time()), username), + _now(now), username), ) return cursor.rowcount > 0 @@ -1206,7 +1568,23 @@ def set_activation_token(self, username: str, digest: str, expires_at: int, *, cursor = conn.execute( "UPDATE users SET activation_digest = ?, activation_expires_at = ?, " "updated_at = ? WHERE username = ?", - (digest, expires_at, int(now if now is not None else time.time()), username), + (digest, expires_at, _now(now), username), + ) + return cursor.rowcount > 0 + + def set_policy(self, username: str, *, allowed_models: str, quota: str, + now: int | None = None) -> bool: + """整体替换用户级策略(模型白名单 + 配额),空串 = 不限制。 + + 只影响该用户在管理台 Playground 的会话直连;其 API Key 的策略在 + `api_keys` 行上,是另一套(Key 可单独设白名单/配额,两者互不覆盖)。 + 返回 False 表示用户不存在。 + """ + with self._db.transaction() as conn: + cursor = conn.execute( + "UPDATE users SET allowed_models = ?, quota = ?, updated_at = ? " + "WHERE username = ?", + (allowed_models, quota, _now(now), username), ) return cursor.rowcount > 0 @@ -1247,7 +1625,7 @@ def record(self, *, actor: str, action: str, target: str | None = None, conn.execute( "INSERT INTO audit_events (id, ts, actor, action, target, detail, ip, ok) " "VALUES (?,?,?,?,?,?,?,?)", - (event_id, int(now if now is not None else time.time()), actor, action, + (event_id, _now(now), actor, action, target, detail, ip, 1 if ok else 0), ) return event_id @@ -1301,21 +1679,27 @@ def _filters(*, actor: str | None, action: str | None, return where, params def prune(self, *, keep_days: int, now: int | None = None) -> int: - cutoff = int(now if now is not None else time.time()) - keep_days * 86400 + cutoff = _now(now) - keep_days * 86400 with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM audit_events WHERE ts < ?", (cutoff,)) return cursor.rowcount class ModelProbeRepository: """主动探活结论(`model_probes`):管理台展示 + 「探活成功清负缓存」+ - 判死期间从模型列表暂时摘除。 + 判死期间从模型列表摘除。 与 `credential_model_cooldowns` 严格分工:那张表是调度**权威**状态(真实 请求的失败驱动,直接决定选号),本表只是观测记录。把探测结论写进冷却表 会让一次探测直接改变选号结果,而「探测失败」未必是模型本身的问题(额度 - 耗尽、限流、网络抖动都会失败)。唯一的出口影响是 `failed_and_blocked()`: - 判死**且**负缓存仍生效的 (渠道, 模型) 从模型列表与别名表里整对抽掉, - 到期口径仍是负缓存的 `cooling_until`,不引入第二套时间基准。 + 耗尽、限流、网络抖动都会失败)。唯一的出口影响是 `failed()`:判死的 + (渠道, 模型) 从模型列表与别名表里整对抽掉,**不看负缓存是否仍在**—— + 上游目录撒谎(officeAce 的 `/v2/models` 挂着推理端点 404 的模型)时, + 判死往往没有负缓存兜底,旧口径下这些模型会一直留在 Playground 里。 + + 隐藏**没有到期时间**:恢复入口只有「再探一次成功」(`failed()` 的行由 + `ModelProbeTask.run_once` 每轮复探,人工也可点「立即探活」)。到期口径 + 若挂在负缓存上,负缓存过期后没人再探,隐藏就变成了永久隐藏——现在两者 + 解耦:判死即隐藏,复探即恢复。 """ def __init__(self, db) -> None: @@ -1349,7 +1733,7 @@ def record(self, *, provider: str, model: str, state: str, 证据,继续累计会让一串无关故障凑成「连续失败」,把好模型判死。 `last_ok_at` 只在 `ok` 时前移,其余状态保留上一次成功时刻。 """ - stamp = int(now if now is not None else time.time()) + stamp = _now(now) with self._db.transaction() as conn: conn.execute( f"INSERT INTO model_probes ({self._RECORD_COLUMNS}) " @@ -1370,7 +1754,7 @@ def record(self, *, provider: str, model: str, state: str, def set_manual(self, *, provider: str, model: str, manual: str, now: int | None = None) -> None: """人工接管标记(`''` = 交还自动探活)。行不存在时先建一条空结论行。""" - stamp = int(now if now is not None else time.time()) + stamp = _now(now) with self._db.transaction() as conn: conn.execute( "INSERT INTO model_probes (provider, model, manual, updated_at) " @@ -1378,31 +1762,35 @@ def set_manual(self, *, provider: str, model: str, manual: str, "manual = excluded.manual, updated_at = excluded.updated_at", (provider, model, manual, stamp)) - def failed_and_blocked(self, now: int | None = None - ) -> set[tuple[str, str]]: - """「探活判死」且**仍被 blocked 负缓存锁着**的 `(渠道, 模型小写原名)`。 - - 模型列表据此把该 (渠道, 模型) 整对抽掉(见 `api.models.merged_entries`)。 - 两个条件缺一不可: + def failed(self) -> set[tuple[str, str]]: + """「探活判死」的 `(渠道, 模型小写原名)`:模型列表据此整对抽掉。 - * 只有 `state='failed'`:探活判死是观测结论,光它一个不会随时间恢复。 - * 还必须**仍被负缓存锁着**:`cooling_until` 是这套隐藏的到期口径(6–24h - 起步的退避),与调度侧的避让同一块表、同一个到期时刻。负缓存过期后 - 没人再探(`ModelProbeTask` 只探被锁的行),若隐藏只看探活结论,该模型 - 会永久消失——它其实只是还没被重新验证。 + 判据只有 `state='failed'`(上游明确 `BLOCKED` / `INVALID`)。**不再** + 要求同时挂着 blocked 负缓存:人工「立即探活」判死时通常没有负缓存 + (负缓存只由真实请求写入),旧口径下这些模型会一直留在 Playground / + `/v1/models` 里——上游目录撒谎(officeAce `ModelArts.81009`)时尤其 + 明显。隐藏的到期由复探负责:`ModelProbeTask.run_once` 会把这些行一起 + 探,探活成功(`state='ok'`)即恢复展示。 - 模型名取两表原样比对(`model_probes` 的行本来就来自 - `blocked_cooldowns`),输出统一小写以匹配模型列表缓存的键。 + 模型名输出统一小写以匹配模型列表缓存的键(`model_probes.model` 存的是 + 上游原名)。 """ rows = self._db.connect().execute( - "SELECT DISTINCT cred.provider AS provider, LOWER(c.model) AS model " - "FROM credential_model_cooldowns c " - "JOIN credentials cred ON cred.id = c.credential_id " - "JOIN model_probes p ON p.provider = cred.provider AND p.model = c.model " - "WHERE c.reason = 'blocked' AND c.cooling_until > ? AND p.state = 'failed'", - (int(now if now is not None else time.time()),)).fetchall() + "SELECT DISTINCT provider, LOWER(model) AS model " + "FROM model_probes WHERE state = 'failed'").fetchall() return {(row["provider"], row["model"]) for row in rows} + def failed_rows(self) -> list[dict[str, Any]]: + """判死行的 `(渠道, 模型原名)`:供探活任务复探(探活成功即恢复展示)。 + + 与 `failed()` 同一判据、不同用途:那个给小写集合做展示过滤,这个给 + 探活任务回放上游原名(上游只认原名,小写可能打不通)。 + """ + rows = self._db.connect().execute( + "SELECT provider, model FROM model_probes WHERE state = 'failed' " + "ORDER BY provider, model").fetchall() + return [dict(row) for row in rows] + def manual_marked(self) -> set[tuple[str, str]]: """被人工接管的 (渠道, 模型):自动探活整行跳过。""" rows = self._db.connect().execute( diff --git a/src/db/schema.sql b/src/db/schema.sql index ba6411c6..6cf915cb 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -9,6 +9,7 @@ -- 注:不建 checkins / model_cache 表——签到去重由 CheckinTask 的当日作用域 -- 集合实现(上游 status 为准);模型列表是「进程内 TTL 缓存 + DATA_DIR 落盘 -- 快照(model_catalog.json)」,不占表(见 src/api/model_catalog.py)。 +-- 签到**审计日志**是另一回事,见 `checkin_events`(只增不改、不参与去重)。 -- 用户建表:users.txt(PBKDF2)降级为「引导输入」,启动时一次性导入, -- 之后 DB 是唯一权威源。ADMIN_USERNAMES env 降级为「引导期角色来源 + 防锁死 @@ -23,7 +24,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( preview TEXT NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER, - -- 多 Key 出口:绑定渠道(见 KNOWN_PROVIDERS 八渠道 | ''=自动)与来源 IP + -- 多 Key 出口:绑定渠道(见 KNOWN_PROVIDERS 九渠道 | ''=自动)与来源 IP -- 白名单(逗号分隔 IP/CIDR,''=不限制)。空值即老库/未设置时的原行为。 provider_binding TEXT NOT NULL DEFAULT '', allowed_ips TEXT NOT NULL DEFAULT '', @@ -41,7 +42,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS credentials ( id TEXT PRIMARY KEY, - provider TEXT NOT NULL, -- 见 KNOWN_PROVIDERS(八渠道) + provider TEXT NOT NULL, -- 见 KNOWN_PROVIDERS(九渠道) nickname TEXT NOT NULL DEFAULT '', data_enc BLOB NOT NULL, -- Fernet 加密的凭证 JSON enabled INTEGER NOT NULL DEFAULT 1, -- 用户软开关 @@ -50,12 +51,18 @@ CREATE TABLE IF NOT EXISTS credentials ( pinned INTEGER NOT NULL DEFAULT 0, -- 手动指定当前凭证 health INTEGER, -- NULL=unknown;0-100=known;-1=exhausted cooling_until INTEGER, + -- 冷却来源(ErrKind 值:soft / credit / plan / restricted / other / ...): + -- 上游点名的恢复时刻属于「等一会就好」,人工 revive 不该顺手解掉。 + -- NULL = 无冷却或老库未回填(按「本地冷却」处理,revive 照旧清除)。 + cooling_kind TEXT, err_count INTEGER NOT NULL DEFAULT 0, quota_remaining REAL, quota_total REAL, quota_cycle_end INTEGER, -- 最早到期 epoch;渠道无此信息为 NULL quota_expiry_ladder TEXT, -- 到期阶梯 JSON [[epoch, 剩余积分]];无到期信息为 NULL - quota_packages TEXT, -- 额度包明细 JSON [{"name","total","used","end"}],仅展示 + quota_packages TEXT, -- 额度包明细 JSON [{"name","total","used","end","expiring"}],仅展示 + web_cookie_state TEXT, -- 网页明细源状态:NULL=未配置/未知,ok=正常,expired=会话失效(仅提示) + routing_weight INTEGER, -- 调度权重:NULL/0=默认;正数越大越先被选中(仅管理员显式偏好) quota_probed_at INTEGER, token_expires_at INTEGER, -- access token 到期 epoch;NULL=老库未回填,0=未知 token_issued_at INTEGER, -- access token 签发 epoch(JWT iat):进度条满量程与「最后续期」 @@ -121,6 +128,9 @@ CREATE TABLE IF NOT EXISTS usage_events ( CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_events(ts); CREATE INDEX IF NOT EXISTS idx_usage_user ON usage_events(username, ts); +-- `idx_usage_request`(失败归因按 request_id 关联成功行)不在这里建: +-- request_id 是老库后补的列,`executescript` 先于补列执行会报 +-- "no such column",只能走 `migrate.py` 的 `_MIGRATION_INDEXES`。 CREATE TABLE IF NOT EXISTS usage_hourly ( hour_utc INTEGER NOT NULL, @@ -165,6 +175,29 @@ CREATE TABLE IF NOT EXISTS growth_events ( CREATE INDEX IF NOT EXISTS idx_growth_cred_ts ON growth_events(credential_id, ts); +-- 每日签到运行记录(checkin_events):每次对一条凭证执行签到就落一行。 +-- +-- 为什么落库:签到「界面显示成功却加不到积分」是实测过的用户反馈,而任务报告 +-- 只有聚合计数(attempted/succeeded/failed/skipped),事后无法回答「这个号昨天 +-- 几点签的、领了多少、上游回了什么码」。故按凭证逐次记一行,保留期与请求明细 +-- 一致(RetentionTask 清理)。 +-- 只存汇总:不落上游原始响应体(可能回显凭证片段)。 +-- 与已删除的 `checkins` 表无关:那张表是**去重**用(现由 CheckinTask 当日作用域 +-- 集合承担),本表是**审计日志**,只增不改、不参与去重。 +CREATE TABLE IF NOT EXISTS checkin_events ( + id TEXT PRIMARY KEY, + credential_id TEXT NOT NULL, + ts INTEGER NOT NULL, + ok INTEGER NOT NULL, + already INTEGER NOT NULL DEFAULT 0, -- 上游判「今日已签」(已签不算错误) + credit REAL, -- 本次领取积分(可缺) + code INTEGER, -- 上游业务码(可缺) + message TEXT NOT NULL DEFAULT '', -- 一行中文/上游文案 + trigger TEXT NOT NULL DEFAULT 'auto' -- auto=定时 | manual=管理台手动 +); + +CREATE INDEX IF NOT EXISTS idx_checkin_cred_ts ON checkin_events(credential_id, ts); + -- 变动流水:额度探测写回时比对余额,每本账各增记一条。 -- -- 为什么靠 diff:签到 / 成长中心的上游接口普遍不打日志,拿不到「这次动作 @@ -224,9 +257,9 @@ CREATE INDEX IF NOT EXISTS idx_model_cooling_until -- `state`:ok(探活成功)/ failed(上游明确不认这个模型)/ inconclusive -- (额度、限流、网络等与模型无关的失败——**绝不能**据此禁用)。 -- `manual`:人工接管标记(非空 = 跳过自动探活),见 src/api/admin_model_availability.py。 --- `failed` + 同 (渠道, 模型) 仍有生效中的 `credential_model_cooldowns` --- (reason='blocked')时,该 (渠道, 模型) 会被模型列表暂时摘掉;到期口径 --- 就是那条负缓存的 cooling_until,见 src/api/models.py::merged_entries。 +-- `failed` 时该 (渠道, 模型) 会被模型列表摘掉(**不看**同渠道的 +-- `credential_model_cooldowns` 是否仍生效);恢复靠复探成功(`ModelProbeTask` +-- 每轮探 failed 行),隐藏没有到期时间,见 src/api/models.py::merged_entries。 CREATE TABLE IF NOT EXISTS model_probes ( provider TEXT NOT NULL, model TEXT NOT NULL, @@ -277,7 +310,16 @@ CREATE TABLE IF NOT EXISTS users ( activation_expires_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - created_by TEXT -- 建号者用户名;引导导入为 NULL + created_by TEXT, -- 建号者用户名;引导导入为 NULL + -- 用户级策略(仅约束**管理台 Playground** 的会话直连,不影响该用户的 API Key): + -- 没有这两列时,viewer 用会话 Cookie 直调任意模型就绕过了 Key 的模型白名单与 + -- 配额——管理台是「人能进门」的地方,出站策略必须按人再判一次。 + -- allowed_models:fnmatch glob,逗号分隔,'' = 不限制(与 api_keys 同语法)。 + allowed_models TEXT NOT NULL DEFAULT '', + -- quota:JSON 文本,'' = 不限制;结构见 src/key_quota.py(当日请求/token/成本、 + -- rpm/tpm/并发)。用量按**用户名**聚合:该用户经由任何入口(含自己的 Key) + -- 产生的用量都计入,配额语义是「这个人一天能用多少」,不是「这把 Key」。 + quota TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_users_role_enabled ON users(role, enabled); @@ -302,8 +344,9 @@ CREATE TABLE IF NOT EXISTS audit_events ( CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(ts); CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor, ts); --- 运维告警事件:后台周期评估四类风险(池耗尽 / 任务连续失败 / token --- 临近到期 / 上游错误率骤升),命中即落一行,供管理台「站内告警记录」回看。 +-- 运维告警事件:后台周期评估七类规则(池耗尽 / 任务连续失败 / token +-- 临近到期 / 上游错误率骤升 / 单 Key 用量暴涨 / 非工作时段 / 预算分层), +-- 命中即落一行,供管理台「站内告警记录」回看。 -- -- 为什么落库而不是像 TaskStatusStore 只留内存:告警的价值恰在「错过的那段 -- 时间发生了什么」——夜里池子耗尽、某任务连挂几轮,运维醒来要能看见,而 diff --git a/src/engine/affinity.py b/src/engine/affinity.py index 018c05b2..ebe7364b 100644 --- a/src/engine/affinity.py +++ b/src/engine/affinity.py @@ -4,14 +4,14 @@ 三种可识别形态,按可靠性依次取用: 1. **请求体显式会话标识**:`conversation_id` / `conversationId` / - `prompt_cache_key`(`metadata` 对象内或请求体顶层)。客户端直接给出 + `prompt_cache_key` / `session_id`(`metadata` 对象内或请求体顶层)。客户端直接给出 会话身份,最可靠——即便消息数组被裁剪/压缩也能粘住。 2. **请求头会话标识**(E1):`x-session-id` / `x-opencode-session` / - `x-session-affinity` / `conversation-id` / `conversation_id`。由请求入口 - 中间件校验后放进 contextvar(`request_context.set_session_id`),这里经 - `get_session_id()` 读——**不给 `pin_for`/`remember` 加 headers 参数**, - 否则参数要一路穿过路由 → executor → 调度等中间层,签名被污染且新增调用 - 点容易漏传(与 `request_id` 同一范式)。 + `x-session-affinity` / `thread-id` / `session-id` / `conversation-id` / + `conversation_id`。由请求入口中间件校验后放进 contextvar + (`request_context.set_session_id`),这里经 `get_session_id()` 读——**不给 + `pin_for`/`remember` 加 headers 参数**,否则参数要一路穿过路由 → executor → + 调度等中间层,签名被污染且新增调用点容易漏传(与 `request_id` 同一范式)。 3. **回落:消息数组的增量前缀指纹**:下一轮的 messages 以上一轮的完整 messages 为前缀再追加,据此匹配。仅当请求体**没有** `user_id`(顶层或 `metadata` 内)时才使用——同一用户的并行对话消息前缀可能相同,派生 @@ -24,9 +24,9 @@ - `prompt_cache_key`:OpenAI 官方顶层参数,**已核实**(chat/completions 与 responses 均支持) - `metadata.user_id`:Anthropic Messages API 官方字段,**已核实** -- `conversation_id` / `conversationId` / 顶层 `user_id`:非 OpenAI/Anthropic - 标准键,属客户端惯用约定,**未在真实流量中观测到**;作为网关兼容性 - 探测一并接受(命中即用,未命中无害) +- `conversation_id` / `conversationId` / 顶层 `user_id` / `session_id`:非 + OpenAI/Anthropic 标准键,属客户端惯用约定,**未在真实流量中观测到**; + 作为网关兼容性探测一并接受(命中即用,未命中无害) 键里掺入用户名,防止不同 API 用户的相同标识/消息数组串到同一凭证。 TTL 内没有后续轮次即视为对话结束,条目过期;条目数有上限,超量先淘汰 @@ -57,8 +57,11 @@ from ..config import live from ..request_context import get_session_id, sanitize_session_id -# 请求体里的显式会话标识键名,按优先级排列(前两个等价,客户端实现不一) -_EXPLICIT_KEYS: tuple[str, ...] = ("conversation_id", "conversationId", "prompt_cache_key") +# 请求体里的显式会话标识键名,按优先级排列(前两个等价,客户端实现不一)。 +# `session_id` 排最后:它是部分客户端(jasonxu 系)惯用的通用键,前三个是 +# 官方/生态已核实的键,同值时优先按更具体的语义取。 +_EXPLICIT_KEYS: tuple[str, ...] = ("conversation_id", "conversationId", + "prompt_cache_key", "session_id") # 请求头来源键的前缀:与 body 键同值也不撞键(`header:abc` ≠ `conversation_id:abc`) _HEADER_SOURCE = "header" diff --git a/src/engine/compress.py b/src/engine/compress.py index 06902271..a83c01c5 100644 --- a/src/engine/compress.py +++ b/src/engine/compress.py @@ -13,7 +13,9 @@ 其实装不下」,压缩根本不触发。 2. 超过「模型上限 × safety_ratio − reserve_for_output」时从**最老**的消息 开始丢弃;`system` 永远保留(丢了会改变模型行为),至少保留最近 N 条 - (保证当前这轮对话完整)。回填从最新往最老,装不下就跳过继续往前看。 + (保证当前这轮对话完整)。保留集合**必须是连续后缀**:跳着留(捞更老的 + 小消息)会在集合里留洞,下一轮重算时洞变了,发往上游的前缀整段失效, + 上游前缀缓存只能命中 system 段。 3. `assistant.tool_calls` 与其 `tool` 结果同生共死:按「组」处理,只删一半 会让上游报 tool_call_id 找不到。 4. 裁剪后仍超限(system / 最近 N 条本身太大)→ 截断最长的那条消息内容 @@ -93,8 +95,46 @@ def _content_text(content: Any) -> str: return str(content) +def _part_tokens(part: Any) -> float: + """单个 content part 的 token 估算。 + + 图片 part 按 base64 正文长度线性计入:早期实现对非文本 part 一律记 100 + 字符,而一张 1MB 的图 base64 后约 1.4M 字符,按 100 估等于「永远不触发 + 压缩」——图片会话会一路撞上游输入上限。上游按图片的实际 token 计费, + 这里用 base64 长度作代理。 + + **按长度一次算完,不逐字符加权**:请求体上限 16MB + (`BodySizeLimitMiddleware`),逐字符跑 `estimate_tokens` 是千万级 Python + 迭代,会在事件循环里阻塞整个进程。base64 字符集只有字母数字 `+/=`, + 按 `_OTHER_WEIGHT` 记与逐字符加权(中文/数字权重)的偏差在纯启发式估算 + 里可忽略。 + """ + if not isinstance(part, dict): + return estimate_tokens(part) if isinstance(part, str) else 0.0 + text = part.get("text") + if isinstance(text, str): + return estimate_tokens(text) + if part.get("type") == "image_url": + image = part.get("image_url") + url = image.get("url") if isinstance(image, dict) else None + if isinstance(url, str): + return _OTHER_WEIGHT * len(url) + return 0.0 + + +def _content_tokens(content: Any) -> float: + """一条 message 的 content(字符串或 part 数组)→ 估算 token 数。""" + if isinstance(content, str): + return estimate_tokens(content) + if isinstance(content, list): + return sum(_part_tokens(part) for part in content) + if content is None: + return 0.0 + return estimate_tokens(str(content)) + + def _message_tokens(message: dict[str, Any]) -> float: - total = estimate_tokens(_content_text(message.get("content"))) + total = _content_tokens(message.get("content")) reasoning = message.get("reasoning_content") if isinstance(reasoning, str): total += estimate_tokens(reasoning) @@ -189,6 +229,56 @@ def _group_atomic(messages: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: return groups +def trim_to_budget( + messages: list[dict[str, Any]], + *, + budget: float, + min_keep_messages: int = DEFAULT_MIN_KEEP_MESSAGES, +) -> tuple[list[dict[str, Any]], int]: + """按预算保留尾部;返回 `(结果, 第一条被保留正文消息在 body 中的绝对序号)`。 + + `system` 全部保留且不计入 body 序号;`assistant.tool_calls` 与其后连续 + `tool` 结果同生共死;至少保留最近 `min_keep_messages` 条;保留集合是**连续 + 后缀**(装不下就停,不跳着捞更老的小消息,否则前缀带洞、缓存无法复用)。 + 仍超预算时按单条内容截断(`_truncate_to_budget`)。 + + 序号是给**粘性裁剪**记窗口起点用的(见 `engine/sticky_trim.py`):下一轮 + 只要该位置的消息没变、且裁后总量仍在预算内,就能复用同一前缀,上游前缀 + 缓存才命中得上。调用方保证 `budget > 0`。 + """ + system_messages = [m for m in messages if m.get("role") == "system"] + body = [m for m in messages if m.get("role") != "system"] + + groups = _group_atomic(body) + starts: list[int] = [] + cursor = 0 + for group in groups: + starts.append(cursor) + cursor += len(group) + + running = estimate_messages(system_messages) + kept_groups: list[list[dict[str, Any]]] = [] + kept_count = 0 + start_index = len(body) + # 从最新往最老回填,**只保留连续后缀**:装不下就停,不再跳过去捞更老的 + # 小消息。跳着留会让「保留集合」带洞,下一轮重算时洞的位置一变,发往上游 + # 的前缀就整段失效(前缀缓存只能命中 system 段);连续后缀则只会在尾部 + # 增长,配合粘性裁剪可以长期复用同一前缀。 + # 最近 min_keep_messages 条无条件保留(保证当前这轮对话完整)。 + for position in range(len(groups) - 1, -1, -1): + group = groups[position] + cost = estimate_messages(group) + if kept_count >= min_keep_messages and running + cost > budget: + break + kept_groups.append(group) + running += cost + kept_count += len(group) + start_index = starts[position] + kept_groups.reverse() + result = [*system_messages, *[m for group in kept_groups for m in group]] + return _truncate_to_budget(result, budget), start_index + + def compress_messages( messages: list[dict[str, Any]], *, @@ -207,20 +297,6 @@ def compress_messages( if budget <= 0 or estimate_messages(messages) <= budget: return messages - system_messages = [m for m in messages if m.get("role") == "system"] - body = [m for m in messages if m.get("role") != "system"] - - running = estimate_messages(system_messages) - kept_groups: list[list[dict[str, Any]]] = [] - kept_count = 0 - # 从最新往最老回填:最近 min_keep_messages 条无条件保留(保证当前这轮对话 - # 完整),其后再装得下就继续往前留;装不下就跳过看更老的(贪心) - for group in reversed(_group_atomic(body)): - cost = estimate_messages(group) - if kept_count < min_keep_messages or running + cost <= budget: - kept_groups.append(group) - running += cost - kept_count += len(group) - kept_groups.reverse() - result = [*system_messages, *[m for group in kept_groups for m in group]] - return _truncate_to_budget(result, budget) \ No newline at end of file + trimmed, _start = trim_to_budget(messages, budget=budget, + min_keep_messages=min_keep_messages) + return trimmed \ No newline at end of file diff --git a/src/engine/executor.py b/src/engine/executor.py index 8e7fcaee..15cead7b 100644 --- a/src/engine/executor.py +++ b/src/engine/executor.py @@ -5,8 +5,10 @@ import asyncio import contextlib import logging +import socket +import ssl import time -from collections.abc import AsyncIterator, Iterable, Iterator +from collections.abc import AsyncIterator, Callable, Iterable, Iterator from dataclasses import dataclass, replace from typing import Any, Protocol @@ -19,15 +21,25 @@ aggregate, ) from ..config import live +from ..db.crypto import CredentialDecryptError from ..db.repo import CredentialRepository from ..provider.base import ( + EXHAUSTED, + NO_PENALTY_KINDS, + QUEUE_DEFAULT_WAIT_SECONDS, + QUEUE_MAX_ROUNDS, + QUEUE_MAX_WAIT_SECONDS, + REJECT_KINDS, ErrKind, Event, EventKind, + UpstreamTransportError, Usage, aclose_stream, event_error_kind, ) +from ..provider.naming import normalize_model_key +from ..request_context import credential_scope, set_credential_id from .continuation import ContinuationStream from .model_resolver import ( ModelTarget, @@ -35,11 +47,39 @@ ordered_fallback_chain, resolve, ) -from .reasoning import map_effort, parse_effort_map -from .scheduler import Scheduler +from .reasoning import ( + downgrade_effort, + is_effort_rejection, + map_effort, + parse_effort_map, +) +from .scheduler import Candidate, Scheduler logger = logging.getLogger(__name__) +# 用量对账:正文少于该字符数不判定(短回复的 token/字符比噪声极大) +USAGE_MIN_CHARS = 200 +# 正常中英文混合约 1–4 字符/token;超过该比值视为 usage 低估或静默替换 +USAGE_UNDERREPORT_RATIO = 12.0 + +# 同一渠道两次「账号被上游风控限制」的间隔窗口:窗口内再次命中说明不是单号 +# 偶发,而是整批同源账号(同出口 IP / 同设备)被上游拉黑,此时继续放剩余号 +# 去撞只会扩大标记面。实测一次请求连打 3 个号全 403,新号加入 3 分钟即封。 +RESTRICTED_BURST_WINDOW_SECONDS = 10 * 60 +# 窗口内**不同凭证**命中受限的最少个数:同一个号连撞两次不算「整批同源账号」 +# (单号 2h 冷却已经挡住重复撞),必须是第二个号也中,才说明是整批被拉黑。 +# 用「不同凭证数」而不是「命中次数」,是为了不让单一凭证/单一租户的连续两个 +# 请求直接升级成整渠道 24h 暂停。 +RESTRICTED_BURST_CREDENTIALS = 2 +# 渠道级暂停时长:只冷却、不禁用,到期自动恢复。比单号 2h 长一个量级, +# 给运维留出换出口 / 申诉的时间。 +RESTRICTED_PROVIDER_PAUSE_SECONDS = 24 * 3600 +# DEAD 现场复探额度的节流窗口(秒):重负载下同一凭证会**同时**收到多个 401 +# (正是积分被烧光的场景),不去重就会对同一账号并发打 N 次订阅接口。 +# 只影响「复探失败 / 复探仍是正数」两种情形(结论都是 DEAD,去重安全): +# 复探出 0 会立刻写回,后续请求在 `_quota_exhausted` 处就短路,不会进这里。 +DEAD_QUOTA_PROBE_TTL_SECONDS = 60.0 + class StreamSink(Protocol): """出口形状的注入缝:把中立 `Event` 翻成某个协议的 SSE 帧。 @@ -57,6 +97,8 @@ def finish(self) -> Iterator[bytes]: ... def error_frame(self, message: str, code: str) -> bytes: ... + def keepalive(self) -> bytes: ... + class NoHealthyCredential(Exception): pass @@ -90,6 +132,32 @@ def __init__(self, limit: int) -> None: self.limit = limit +class _Queued(Exception): + """上游排队中(`ErrKind.QUEUE`):内部信号,等 `wait` 秒后原号重开。 + + 用异常而不是 `break`+标志位,是因为非流式的读流包在 `try/except/else` 里, + `break` 出来会落到 `else` 分支把**半截** events 拿去聚合。异常能干净地 + 跳过 `else`,走 finally 释放租约后再等待。 + + 携带完整的 `plan`(而不只是秒数):计数与 pinned 在抛出点已经算好, + `except` 分支直接落账,避免两处各算一遍而漂移。 + """ + + def __init__(self, plan: _QueueRestart) -> None: + super().__init__(f"upstream queued {plan.wait}s") + self.plan = plan + + +@dataclass(slots=True) +class _QueueRestart: + """一次排队重开的计划(四个入口共用,见 `_plan_queue_restart`)。""" + + wait: float + rounds: int + attempts: int + pinned: tuple[str, dict[str, Any]] + + class _StreamTimeout(Exception): """流式取下一事件的计时超时(A1)。 @@ -170,6 +238,11 @@ class ExecutorDeps: # 免费优先档(候选中存在 x0 渠道时优先选它)注入 credit_rate;None / 空 # 表示目录未就绪,倍率未知,调度退回原排序(向后兼容)。 model_list_cache: dict[str, dict[str, Any]] | None = None + # 实测扣费校准(`db/paid_models.py`):() → {(渠道, 归一键): 均值}。 + # 目录写 x0 但最近 24h 有上游明确扣费(`credit_estimated=0` 且 credit>0) + # 的模型,注入时按实测均值覆盖——否则一条过期的目录数据就会让该渠道 + # 永远占住免费档,压过所有到期积分。None = 不校准(测试与旧装配兼容)。 + paid_models: Callable[[], dict[tuple[str, str], float]] | None = None # 会话粘性(ConversationAffinity);None 表示关闭。对话进行中固定用原 # 凭证,出错才轮换,成功后重新粘定实际服务的凭证 affinity: Any | None = None @@ -203,6 +276,10 @@ class ExecutorDeps: # 必须返回新对象或原样返回,不得原地改入参——同一请求可能在轮换/回退里 # 对不同渠道各压一次,且 affinity 指纹读的是未压缩原文 context_compress: Any | None = None + # 前缀缓存诊断(src/engine/prefix_debug.py):(凭证, 渠道, 模型, payload) → + # None,写一行指纹日志。None 表示未装配(默认,零开销);实现在闭包里现读 + # 运行态开关,关时连摘要都不算。 + prefix_debug: Any | None = None # 凭证级限额护栏(src/engine/limits.py 的 LimitGuard):选号前过滤掉 # 「当日用量/余额触顶」的候选;None 表示不启用(向后兼容)。 limits: Any | None = None @@ -211,6 +288,10 @@ class ExecutorDeps: # 在途计数同时注入候选快照供调度器做「最少连接」负载均衡。None 表示 # 不启用(向后兼容,此时无过滤也无负载键)。 inflight: Any | None = None + # 冷却到期半开探测(src/engine/half_open.py 的 HalfOpenGuard):冷却刚 + # 到期的凭证先只放 1 个在途请求,成功后再全量回池;None 表示不启用 + # (向后兼容,届时到期即全量回池)。 + half_open: Any | None = None # 出站剥离思考内容:真值 → 丢掉 `EventKind.REASONING` 事件(三个出口 # 一致生效,流式/非流式都覆盖)。**只过滤响应、不改请求体**:上游仍照常 # 思考(reasoning_effort 原样透传),只是不交给客户端。零参 callable 时 @@ -238,14 +319,29 @@ def __init__(self, deps: ExecutorDeps) -> None: # 流式超时的时钟:默认真实单调钟,测试可换成假时钟以精确断言 # 「只有数据事件重置计时」而不必真的等满窗口(见 test_executor_stream_timeout) self._clock = time.monotonic + # provider → (窗口起点, 窗口内命中受限的凭证集合):窗口内**第二个不同 + # 凭证**命中时升级为渠道级长暂停(见 `_note_upstream_error`)。按凭证 + # 集合而不是计数:同一个号重复命中不该升级(单号 2h 冷却已挡住重复撞), + # 否则单一租户/单一凭证的两个请求就能把整渠道暂停 24h。只在进程内, + # 重启丢失无所谓——它只是「别让剩余号继续撞」的加速器。 + self._restricted_hits: dict[str, tuple[int, set[str]]] = {} + # 半开探测:`_select` 选中后、`_lease` 登记前暂存 key(同一事件循环 + # 内同步传递,不会有并发交错);`_probe_leases` 是请求存活期间的 + # 探测位(按凭证分组,先进后出释放)。 + self._pending_probe: dict[str, tuple[str, str]] = {} + self._probe_leases: dict[str, list[tuple[str, str]]] = {} + # DEAD 现场复探额度的在途/最近一次结果:{credential_id: (完成时刻, 是否耗尽)}。 + # 同号并发 401 共用一次探测(见 `DEAD_QUOTA_PROBE_TTL_SECONDS`)。 + self._dead_quota_probes: dict[str, tuple[float, bool]] = {} def _record_invalid(self, username: str, provider_id: str, credential_id: str, - model: str, started: float, error: object) -> None: - """请求无效:只记统计(invalid_request),绝不冷却/禁用凭证。""" + model: str, started: float, error: object, + kind: ErrKind = ErrKind.INVALID) -> None: + """请求无效:只记统计(invalid_request / model_capability 等),绝不冷却/禁用凭证。""" self._deps.record( username=username, provider=provider_id, credential_id=credential_id, - model=model, ok=False, error_type="invalid_request", - latency_ms=int((time.monotonic() - started) * 1000)) + model=model, ok=False, error_type=_error_type_for(kind), + latency_ms=_elapsed_ms(started)) def _suggestions(self, model: str) -> list[str] | None: """400 时给用户的相近模型建议(未注入或无候选则 None)。""" @@ -269,19 +365,28 @@ def _unavailable_text(self, target: ModelTarget, last_error: Exception | None) - self._suggestions(target.model)) return _unavailable_message(last_error) - def _exhaust_error_type(self, target: ModelTarget) -> str: + def _exhaust_error_type(self, target: ModelTarget, + last_kind: ErrKind | None = None, + last_error: Exception | None = None) -> str: """轮换耗尽时落统计的受控 error_type(与 503 文案分流同源)。 所有候选都只因**该模型的模型级冷却**被排除时(`_all_model_cooled`, - 与 `_unavailable_text` 同一判据)记 `model_unavailable`,否则记 - `no_healthy_credential`——统计页此前一律显示「无可用凭证」,把 - 「换个模型就好」的上游限流误读成整池凭证挂了(实测 2026-10-13 - `ling-3.1-flash`:kilo 唯一凭证被 Novita 429 写模型级冷却后, - 明细每条都标「无可用凭证」)。对外错误码不变(仍 - `no_healthy_credential`,兼容层映射依赖它)。 + 与 `_unavailable_text` 同一判据)记 `model_unavailable`,否则按**导致 + 耗尽的真实原因**归类:`last_kind` 有值时走 `_failure_type`(SOFT → + rate_limit、传输异常 → upstream_timeout、本机网络失败 → network_error、 + CONTEXT → context_overflow…), + 一次都没选中凭证(`last_kind is None`)才回落 `no_healthy_credential`。 + + 为什么不再一律 `no_healthy_credential`:该标签读起来是「整池凭证挂了」, + 而实测 2026-11-03 zen 单凭证被 429 冷却到次日 08:00 后,每一条明细都标 + 「无可用凭证」,运维去查凭证池只会白折腾——真实原因是上游限流等额度。 + 对外错误码不变(仍 `no_healthy_credential`,兼容层映射依赖它)。 """ - return ("model_unavailable" if self._all_model_cooled(target) - else "no_healthy_credential") + if self._all_model_cooled(target): + return "model_unavailable" + if last_kind is None: + return "no_healthy_credential" + return _failure_type(last_kind, last_error) def _model_cooled_only(self, providers: Iterable[str], target: ModelTarget, now: int) -> bool: @@ -314,15 +419,29 @@ def _all_model_cooled(self, target: ModelTarget) -> bool: 调用点在 `_pick` 返回 None 之后,此时 `_select` 已保证至少注册了一个 上游(全无注册时它会先抛 NoProviderForModel),故无需再判空上游。 """ - registered = [pid for pid in self._narrow_providers(target) - if pid in self._deps.providers] - return self._model_cooled_only(registered, target, int(time.time())) + return self._model_cooled_only(self._registered(target), target, + int(time.time())) def _skip_provider(self, provider_id: str, tried: set[str]) -> None: """INVALID 后跳过该上游:把它的全部凭证都标记为已试。""" for candidate in self._deps.credentials.candidates([provider_id]): tried.add(candidate.credential_id) + def _attribution_provider(self, target: ModelTarget, observed: str) -> str: + """失败明细里的渠道名:真打过就用实际渠道,一次都没选中就用候选渠道。 + + `_pick` **首轮**返回 None(候选全在冷却 / 一个凭证都没有)时,渠道名 + 还停在占位 `"-"`:归因面板只剩一个横杠,看不出是哪个渠道挂的(实测 + 2026-10-29 10:18 `deepseek-v4.1-flash-sg`——唯一持有它的 + codebuddy_intl 两个号刚因 ConnectTimeout 进 SOFT 冷却,明细落成 + `provider="-"`)。收窄后的候选按声明顺序取第一个:那是「本该服务它」 + 的渠道,也是运维该去查的那个。 + + 候选必然非空:`_select` 在无注册上游时先抛 `NoProviderForModel`, + 走到 `_pick` 返回 None 的分支时注册集已校验过。 + """ + return observed if observed != "-" else self._registered(target)[0] + def _upstream_model(self, provider_id: str, model: str) -> str: """归一模型名 → 该上游注册的原始 id(大小写变体映射,未知则原样)。""" if self._deps.upstream_model_name is None: @@ -447,8 +566,7 @@ def preflight(self, request: ChatRequest, """ target = self.resolve_target(request, provider_binding) for attempt in self._fallback_chain(target): - if [pid for pid in self._narrow_providers(attempt) - if pid in self._deps.providers]: + if self._registered(attempt): return target raise NoProviderForModel(f"no provider registered for model {target.model!r}") @@ -522,8 +640,10 @@ async def stream(self, request: ChatRequest, *, username: str = "unknown", # [DONE] 已产出:客户端收尾断开,按成功记账(tokens 如实记录) if state.credential_id is not None: self._deps.credentials.save_success(state.credential_id) + self._confirm_probe(state.credential_id) self._record_success(attempt, state, state.provider, - state.credential_id or "-") + state.credential_id or "-", + truncated=not state.saw_finish) else: self._record_disconnect(attempt, state) raise @@ -549,6 +669,9 @@ async def _stream_loop(self, request: ChatRequest, target: ModelTarget, attempts = 0 last_error: Exception | None = None last_kind: ErrKind | None = None + # 本链项是否已落过尝试级明细(DEAD):是则选号耗尽时不再落收尾行, + # 否则同一请求在 requests 里算两次、Key 配额扣两次。 + attributed = False # 本链项是否已向客户端出过响应帧:出了就绝不再换模型**或换号** # (半截输出不可回滚,换号会让两个凭证的内容拼进同一条回复) emitted = False @@ -558,6 +681,17 @@ async def _stream_loop(self, request: ChatRequest, target: ModelTarget, # 外层既保住续写质量,又对三个出口(chat/Anthropic/Responses)一致。 hide_reasoning = self._thinking_disabled() + # 排队重开(F3):上游回「排队中 + retryAfterSeconds」时,等它点名的 + # 秒数后在**原号**上重开,不冷却、不换号、不占轮换预算。 + # `pinned` 是被点名的凭证(下一轮跳过 `_pick` 直接用), + # `queued_wait` 是本轮要等的秒数(等完 `continue`)。 + queue_rounds = 0 + pinned: tuple[str, dict[str, Any]] | None = None + # 推理档位降级(默认从不触发):只有上游明确拒绝该档位时才降一档, + # 原号重试一次;`effort_cap` 覆盖后续轮次的出站档位。 + effort_cap: str | None = None + effort_retried = False + def terminal(message: str, code: str) -> bytes: """终帧:本项未出帧时抛信号让外层试下一链项;已出帧则写错误帧收尾。""" if not emitted: @@ -565,50 +699,73 @@ def terminal(message: str, code: str) -> bytes: return state.translator.error_frame(message, code) while True: - pick = await self._pick(target, tried, state.affinity_id) - if pick is None: - # 走到这里 emitted 必为 False:已出帧的路径在上面 `if emitted or - # not should_rotate` 就 return 了,所以 `terminal()` 在这里**必定** - # 抛 `_ModelExhausted`(交给回退链下一项)。直接抛,不留永远 - # 走不到的 `yield`+`return`。 - if last_kind is ErrKind.INVALID: - # 所有候选上游都拒绝了该模型:400 语义而非 503 + if pinned is not None: + # 排队重开:复用被点名的凭证,不走 `_pick`(否则可能选到别的号, + # 排队语义就丢了)。凭证可能已被并发删除,删了就退回正常选号。 + # **不计入 attempts**:排队不是一次「换号尝试」,吃掉轮换预算 + # 会让三次排队就把整个请求打成 503。 + credential_id, credential_data = pinned + pinned = None + if self._safe_credential_data(credential_id) is None: + continue + provider_id = self._deps.credentials.provider_of(credential_id) + state.provider, state.credential_id = provider_id or "-", credential_id + else: + pick = await self._pick(target, tried, state.affinity_id) + if pick is None: + # 走到这里 emitted 必为 False:已出帧的路径在上面 `if emitted or + # not should_rotate` 就 return 了,所以 `terminal()` 在这里**必定** + # 抛 `_ModelExhausted`(交给回退链下一项)。直接抛,不留永远 + # 走不到的 `yield`+`return`。 + if last_kind in REJECT_KINDS: + # 所有候选上游都拒绝了该模型 / 这次请求形态:400 语义而非 503 + raise _ModelExhausted( + _reject_message(target.model, last_error, + self._suggestions(target.model)), + "invalid_request") + if not attributed: + self._deps.record( + username=state.username, + provider=self._attribution_provider(target, state.provider), + credential_id=state.credential_id, model=target.model, ok=False, + error_type=self._exhaust_error_type( + target, last_kind, last_error), + latency_ms=_elapsed_ms(state.started)) raise _ModelExhausted( - _reject_message(target.model, last_error, - self._suggestions(target.model)), - "invalid_request") - self._deps.record( - username=state.username, provider=state.provider, - credential_id=state.credential_id, model=target.model, ok=False, - error_type=self._exhaust_error_type(target), - latency_ms=_elapsed_ms(state.started)) - raise _ModelExhausted( - self._unavailable_text(target, last_error), - "no_healthy_credential") - credential_id, credential_data = pick - provider_id = self._deps.credentials.provider_of(credential_id) - state.provider, state.credential_id = provider_id or "-", credential_id - tried.add(credential_id) - attempts += 1 + self._unavailable_text(target, last_error), + "no_healthy_credential") + credential_id, credential_data = pick + provider_id = self._deps.credentials.provider_of(credential_id) + state.provider, state.credential_id = provider_id or "-", credential_id + tried.add(credential_id) + attempts += 1 # 按实际服务渠道压缩(未启用/未知窗口/未超限时原样返回):窗口 # 取该渠道自己的上限,不再被更小窗口的兄弟渠道拖累误裁 payload = self._compress_payload(request, provider_id, target.model) # 档位映射放在选号之后:目标档位依赖**实际服务渠道**(同名模型 # 跨渠道接受的档位不同)。返回值可能是新 dict,不改 `request.raw` payload = self._map_effort(provider_id, payload) + payload = self._clamp_effort(payload, effort_cap) + # 前缀诊断报告压缩后的载荷(见 _debug_prefix):压缩每轮裁一条 + # 就会让上游只能命中 system 段,这里记录的就是上游实际看到的前缀 + self._debug_prefix(credential_id, provider_id, target.model, payload) # 显式持有迭代器并在 finally 里关闭:下面两处 `break`(流内错误) # 只跳出 `async for`,不会关闭上游 async generator,provider 的 # 节流名额归还(pacer.release 在其 finally 里)会推迟到 GC。 # 轮换重试每次都要重新 wait_turn,泄漏累积到 max_concurrency 后 # 新请求永久阻塞——「用了三次就限制」。见 aclose_stream 注释。 iterator = self._stream_source( - provider_id, credential_data, payload, target.model).__aiter__() + provider_id, credential_data, payload, target.model, + credential_id).__aiter__() # 在途登记放在创建迭代器之后、`try` 之前:选号到此全同步,不会与 # 并发请求交错超额;释放统一走下面的 finally,覆盖成功/轮换/断开。 self._lease(credential_id) reader = _TimedReader(iterator) state.deadline = None + # 终态标记按尝试重置:上一次尝试的 FINISH 不能替这一次背书 + state.saw_finish = False broken = False + queued_wait: float | None = None try: while True: event = await self._next_event(reader, state, emitted) @@ -617,9 +774,45 @@ def terminal(message: str, code: str) -> bytes: if hide_reasoning and event.kind is EventKind.REASONING: # 出站剥离思考:上游仍照常生成,只是不交给客户端 continue + if event.model and state.observed_model is None: + state.observed_model = event.model + self._check_model_substitution( + target.model, event.model, provider_id, credential_id) if event.kind is EventKind.ERROR: - kind = _event_kind(event) - if kind is ErrKind.INVALID: + kind = await self._credit_if_exhausted( + _event_kind(event), credential_id, provider_id) + if kind is ErrKind.QUEUE: + # 排队:上游点名「等 N 秒再来」。未出帧时在原号上 + # 重开,不冷却、不换号、不占轮换预算;已出帧则 + # 无法重开(半截输出不可回滚),按 SOFT 收尾。 + plan = self._plan_queue_restart( + event.retry_after_seconds, emitted=emitted, + rounds=queue_rounds, attempts=attempts, + provider_id=provider_id, credential_id=credential_id, + credential_data=credential_data) + if plan is not None: + queue_rounds = plan.rounds + attempts = plan.attempts + pinned = plan.pinned + queued_wait = plan.wait + broken = True + break + kind = ErrKind.SOFT + if kind in REJECT_KINDS: + # 推理档位被拒:原号降一档重试一次(不换号、不冷却)。 + # 上游拒绝的是档位而不是凭证或模型,换号再撞只会 + # 让同一条请求在多个号上各失败一次。 + lower = (None if emitted else self._downgrade_effort( + payload, event.error_message, effort_retried)) + if lower is not None: + logger.warning( + "上游 %s 不认推理档位 %s(凭证 %s)→ 降到 %s 原号重试", + provider_id, payload.get("reasoning_effort"), + credential_id, lower) + effort_cap, effort_retried = lower, True + pinned = (credential_id, credential_data) + broken = True + break # 流内 4001 等参数/模型错误:换凭证没用, # 跳过该上游继续试其他上游;全部拒绝才以 400 结束 logger.warning( @@ -627,21 +820,38 @@ def terminal(message: str, code: str) -> bytes: provider_id, target.model, credential_id, event.error_code, event.error_message) self._record_invalid(state.username, provider_id, credential_id, - target.model, state.started, event) + target.model, state.started, event, kind) last_error = UpstreamStreamError(event) - last_kind = ErrKind.INVALID + last_kind = kind self._skip_provider(provider_id, tried) broken = True break + if kind is ErrKind.RESTRICTED: + # 账号被上游风控限制:同渠道其余号大概率同样被限, + # 逐个换号只会在上游多留几条记录(见 _handle_restricted) + attributed = self._handle_restricted( + provider_id=provider_id, credential_id=credential_id, + model=target.model, error=event, + username=state.username, started=state.started, + attributed=attributed, tried=tried) + last_error = UpstreamStreamError(event) + last_kind = kind + broken = True + break logger.warning( "上游 %s 流内错误(凭证 %s,kind=%s): code=%s %s", provider_id, credential_id, kind, event.error_code, event.error_message) - self._note_upstream_error(credential_id, kind, provider_id, - target.model, event) + attributed = self._note_upstream_error( + credential_id, kind, provider_id, target.model, event, + username=state.username, started=state.started) or attributed last_error = UpstreamStreamError(event) broken = True break + if event.kind is EventKind.FINISH: + state.saw_finish = True + if event.kind is EventKind.CONTENT and event.content: + state.content_chars += len(event.content) for frame in state.translator.translate(event): state.mark_first_byte() emitted = True @@ -650,30 +860,88 @@ def terminal(message: str, code: str) -> bytes: # 上游正常结束:清错误累计、重新粘定、按成功记账并收尾 self._deps.credentials.save_success( credential_id, model=self._model_scope(provider_id, target.model)) + self._confirm_probe(credential_id) self._remember(request, state.username, credential_id) - self._record_success(target, state, provider_id, credential_id) + truncated = not state.saw_finish + if truncated: + # 连接正常结束但没走到终态事件:响应不完整,但上游没报错, + # 凭证照常算健康(不冷却、不换号),只在统计里留痕。 + logger.warning( + "上游 %s 流未走到终态即结束(凭证 %s,模型 %s)" + "——按截断记账,不冷却凭证", + provider_id, credential_id, target.model) + self._record_success(target, state, provider_id, credential_id, + truncated=truncated) for frame in state.translator.finish(): emitted = True yield frame return except Exception as error: # noqa: BLE001 - 统一转为冷却或上抛 - kind = _classify(error) + kind = await self._credit_if_exhausted( + _classify(error), credential_id, provider_id) if kind is None: raise - if kind is ErrKind.INVALID: - # 请求无效(如该上游不认识模型):不冷却凭证, - # 跳过该上游继续试其他上游;全部拒绝才以 400 结束 + if kind is ErrKind.QUEUE: + # 排队也可能以 HTTP 403 + 排队 body 的形式抛出(见 + # qoder.events.classify_status):同样在原号上等后重开。 + plan = self._plan_queue_restart( + getattr(error, "retry_after_seconds", None), + emitted=emitted, rounds=queue_rounds, attempts=attempts, + provider_id=provider_id, credential_id=credential_id, + credential_data=credential_data) + if plan is not None: + queue_rounds = plan.rounds + attempts = plan.attempts + pinned = plan.pinned + queued_wait = plan.wait + else: + kind = ErrKind.SOFT + logger.warning("上游 %s 排队但无法重开(凭证 %s): %s", + provider_id, credential_id, _error_detail(error)) + self._note_upstream_error(credential_id, kind, provider_id, + target.model, error, + username=state.username, + started=state.started) + last_error, last_kind = error, kind + elif kind in REJECT_KINDS: + # 请求无效(如该上游不认识模型 / 模型不支持该请求形态): + # 不冷却凭证,跳过该上游继续试其他上游;全部拒绝才以 400 结束 + # 推理档位被拒:原号降一档重试一次(HTTP 400 形态, + # 与流内 error 事件同一套语义) + lower = (None if emitted else self._downgrade_effort( + payload, _error_detail(error), effort_retried)) + if lower is not None: + logger.warning( + "上游 %s 不认推理档位 %s(凭证 %s)→ 降到 %s 原号重试", + provider_id, payload.get("reasoning_effort"), + credential_id, lower) + effort_cap, effort_retried = lower, True + pinned = (credential_id, credential_data) + continue logger.warning("上游 %s 拒绝模型 %s(凭证 %s 跳过): %s", - provider_id, target.model, credential_id, error) + provider_id, target.model, credential_id, + _error_detail(error)) self._record_invalid(state.username, provider_id, credential_id, - target.model, state.started, error) - last_error, last_kind = error, ErrKind.INVALID + target.model, state.started, error, kind) + last_error, last_kind = error, kind self._skip_provider(provider_id, tried) + elif kind is ErrKind.RESTRICTED: + # 账号被上游风控限制:跳过该渠道,别让同渠道其它号继续撞 + attributed = self._handle_restricted( + provider_id=provider_id, credential_id=credential_id, + model=target.model, error=error, + username=state.username, started=state.started, + attributed=attributed, tried=tried) + last_error, last_kind = error, kind else: logger.warning("上游 %s 错误(凭证 %s,kind=%s): %s", - provider_id, credential_id, kind, error) - self._note_upstream_error(credential_id, kind, provider_id, target.model, error) - last_error = error + provider_id, credential_id, kind, + _error_detail(error)) + attributed = self._note_upstream_error( + credential_id, kind, provider_id, target.model, + error, username=state.username, + started=state.started) or attributed + last_error, last_kind = error, kind finally: # break / 异常 / 客户端断开都走到这里:先清掉在途的取帧任务 # (超时后它可能还在等上游),再关闭上游流归还节流名额。 @@ -682,8 +950,13 @@ def terminal(message: str, code: str) -> bytes: await reader.aclose() await aclose_stream(iterator) self._release(credential_id) + if queued_wait is not None: + # 在租约与上游流都释放**之后**才等:等待期间不占并发名额, + # 也不让上游连接白挂着。等完回到 while 顶部用 `pinned` 重开。 + await asyncio.sleep(queued_wait) + continue if not self._deps.scheduler.should_rotate(attempts) or emitted: - if last_kind is ErrKind.INVALID: + if last_kind in REJECT_KINDS: # 流已开始(200 已发出),以 invalid_request 错误帧结束 yield terminal( _reject_message(target.model, last_error, @@ -691,15 +964,22 @@ def terminal(message: str, code: str) -> bytes: "invalid_request") return # 已出帧后收尾:上游可能已生成内容(真实计费),按已知用量记账, - # 否则这次尝试的 token 全部漏记(与断开路径同一理由) - usage = state.translator.usage - self._deps.record( - username=state.username, provider=provider_id, - credential_id=credential_id, model=target.model, ok=False, - error_type=_stream_error_type(last_error), - input_tokens=_usage_field(usage, "input_tokens"), - output_tokens=_usage_field(usage, "output_tokens"), - latency_ms=_elapsed_ms(state.started)) + # 否则这次尝试的 token 全部漏记(与断开路径同一理由)。 + # 未出帧且 DEAD 已落尝试行时不再落收尾行:一次请求两行会让 + # usage_hourly.requests 虚高、成功率虚低,按 Key 计的日请求 + # 额度被扣两次(与 _complete 的 should_rotate 分支同一口径)。 + if emitted or not attributed: + usage = state.translator.usage + self._deps.record( + username=state.username, provider=provider_id, + credential_id=credential_id, model=target.model, ok=False, + error_type=_stream_error_type( + last_error, + quota_exhausted=self._quota_exhausted( + credential_id, provider_id)), + input_tokens=_usage_field(usage, "input_tokens"), + output_tokens=_usage_field(usage, "output_tokens"), + latency_ms=_elapsed_ms(state.started)) if isinstance(last_error, _StreamTimeout): # 空闲超时(已出帧):半截输出不可回滚,不换号,直接以错误帧 # 收尾;对外文案带上受控标识(A1 验收口径) @@ -766,6 +1046,17 @@ def _compress_payload(self, request: ChatRequest, provider_id: str | None, return request.raw return self._deps.context_compress(provider_id or "-", model, request.raw) + def _debug_prefix(self, credential_id: str, provider_id: str | None, + model: str, payload: dict[str, Any]) -> None: + """前缀缓存诊断(默认关):未装配时零开销,装配后由闭包现读热更开关。 + + 报告**压缩后**、即将发往上游的那份载荷——诊断要回答的是「上游看到了 + 什么前缀」,而不是客户端发了什么。 + """ + if self._deps.prefix_debug is None: + return + self._deps.prefix_debug(credential_id, provider_id or "-", model, payload) + def _thinking_disabled(self) -> bool: """出站是否剥离思考内容(热更)。未装配 / 标量 False 时即关闭。""" return bool(live(self._deps.force_disable_thinking)()) @@ -797,13 +1088,58 @@ def _map_effort(self, provider_id: str | None, updated["reasoning_effort"] = mapped return updated + def _check_model_substitution(self, requested: str, observed: str, + provider_id: str, credential_id: str) -> None: + """上游自报模型 ≠ 请求模型:只告警(不改响应、不冷却、不写 error_type)。 + + 与 `_check_usage_consistency` 同口径:这是观测信号,不是确定失败。 + 乱写失败归因会让成功率虚低,告警日志足够定位。 + """ + if not _model_mismatch(requested, observed): + return + logger.warning( + "上游 %s 返回模型 %s 与请求模型 %s 不一致(凭证 %s)" + "——疑似静默替换,仅告警不改响应", + provider_id, observed, requested, credential_id) + + def _clamp_effort(self, payload: dict[str, Any], + cap: str | None) -> dict[str, Any]: + """把出站档位压到 `cap`(降级重试后的上限);未设置时原样返回。""" + if cap is None: + return payload + effort = payload.get("reasoning_effort") + if not isinstance(effort, str) or not effort or effort == cap: + return payload + # 只可能由 `_downgrade_effort` 设值,cap 一定低于当时档位: + # 直接覆盖,不做二次排序比较(未知档位不参与推断)。 + updated = dict(payload) + updated["reasoning_effort"] = cap + return updated + + def _downgrade_effort(self, payload: dict[str, Any], message: str | None, + already: bool) -> str | None: + """上游拒收档位时给出降一档的目标;不该降(已降过 / 无档位 / 非档位错)→ None。""" + if already: + return None + effort = payload.get("reasoning_effort") + if not isinstance(effort, str) or not effort: + return None + if not is_effort_rejection(message): + return None + return downgrade_effort(effort) + def _stream_source(self, provider_id: str, credential_data: dict[str, Any], - payload: dict[str, Any], model: str) -> AsyncIterator[Event]: + payload: dict[str, Any], model: str, + credential_id: str | None = None) -> AsyncIterator[Event]: """上游事件流入口;开启截断续写时包一层。 续写在包装器内固定用同一凭证续发请求,因此凭证轮换/记账/统计 逻辑完全不需要感知它——executor 只会看到一条更长的流。 + + `credential_id` 写进 contextvar 供 provider 选出口代理(一号一出口): + 换号重试时每次调用都会重设,不会沿用上一个凭证的出口。 """ + set_credential_id(credential_id) upstream_model = self._upstream_model(provider_id, model) stream = self._deps.providers[provider_id].stream_chat( credential_data, payload, upstream_model) @@ -816,12 +1152,19 @@ def _stream_source(self, provider_id: str, credential_data: dict[str, Any], upstream_model, max_continues=max_continues) def _record_success(self, target: ModelTarget, state: _StreamState, - provider_id: str, credential_id: str) -> None: + provider_id: str, credential_id: str, *, + truncated: bool = False) -> None: state.recorded = True usage = state.translator.usage + fields: dict[str, Any] = {} + if truncated: + # 只有截断才带 error_type:正常成功保持「无该键」(统计落库为 NULL), + # 与历史上成功行的形状一致。 + fields["error_type"] = "truncated_stream" self._deps.record( username=state.username, provider=provider_id, credential_id=credential_id, - model=target.model, ok=True, + model=target.model, ok=not truncated, + **fields, input_tokens=_usage_field(usage, "input_tokens"), output_tokens=_usage_field(usage, "output_tokens"), reasoning_tokens=_usage_field(usage, "reasoning_tokens"), @@ -829,6 +1172,44 @@ def _record_success(self, target: ModelTarget, state: _StreamState, credit=_usage_field(usage, "credit"), credit_estimated=bool(_usage_field(usage, "credit_estimated")), ttfb_ms=state.ttfb_ms(), latency_ms=_elapsed_ms(state.started)) + self._check_usage_consistency( + provider_id=provider_id, credential_id=credential_id, model=target.model, + content_chars=state.content_chars, + output_tokens=_usage_field(usage, "output_tokens")) + + def _check_usage_consistency(self, *, provider_id: str, credential_id: str, + model: str, content_chars: int, + output_tokens: int | None) -> None: + """成功请求的「用量 ↔ 正文体量」对账(只告警,不改响应)。 + + **上游静默替换/截断**没有可靠的直接信号(中立事件层不带上游返回的 + `model`,各渠道的 SSE 字段也各不相同),但两条间接特征足够明显: + + - 正文很长却**一个输出 token 都没报**:上游把 usage 吞了,或只回了一 + 小段就收尾(截断); + - 上报的 token 数远小于正文长度(> `USAGE_UNDERREPORT_RATIO` 字符/token): + 正常中英文混合约 1–4 字符/token,超过 12 基本只能解释为「usage 被 + 低估」或「响应被上游换成了更小的模型再截断」。 + + 刻意**不**写 `error_type`、不改响应、不冷却:这是观测信号,不是确定的 + 失败(乱报会让归因面板和成功率失真)。正文 < `USAGE_MIN_CHARS` 时不判定 + ——短回复的 token/字符比噪声极大。 + """ + if content_chars < USAGE_MIN_CHARS: + return + if not output_tokens: + logger.warning( + "上游 %s 输出 %d 字符但未上报输出 token(凭证 %s,模型 %s)" + "——疑似 usage 被吞或响应被截断", + provider_id, content_chars, credential_id, model) + return + ratio = content_chars / output_tokens + if ratio > USAGE_UNDERREPORT_RATIO: + logger.warning( + "上游 %s 输出 %d 字符仅上报 %d 输出 token(%.1f 字符/token," + "凭证 %s,模型 %s)——疑似 usage 低估或模型被静默替换", + provider_id, content_chars, output_tokens, ratio, + credential_id, model) def _record_disconnect(self, target: ModelTarget, state: _StreamState) -> None: """客户端断开:按已知用量记账,标记 client_disconnect。""" @@ -871,6 +1252,9 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, attempts = 0 last_error: Exception | None = None last_kind: ErrKind | None = None + # 本次请求是否已落过尝试级明细(DEAD):是则选号耗尽时不再落收尾行, + # 否则同一请求在 requests 里算两次、Key 配额扣两次。 + attributed = False started = time.monotonic() last_provider = "-" last_credential: str | None = None @@ -884,36 +1268,63 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, # `reasoning_content`;三个出口共用 complete 路径,故一致生效。 hide_reasoning = self._thinking_disabled() + # 排队重开:与 `_stream_loop` 同一套语义(不冷却、不换号、不占轮换预算)。 + # 非流式同样要处理:排队信号在上游流里,聚合前不拦就会被 `aggregate` + # 当成流内错误,直接冷却换号。 + queue_rounds = 0 + pinned: tuple[str, dict[str, Any]] | None = None + # 推理档位降级:与 _stream_loop 同口径(上游拒档 → 原号降一档重试一次) + effort_cap: str | None = None + effort_retried = False + while True: - pick = await self._pick(target, tried, affinity_id) - if pick is None: - if last_kind is ErrKind.INVALID: - # 所有候选上游都拒绝了该模型:400 而非 503 - raise InvalidRequest(_reject_message( - target.model, last_error, self._suggestions(target.model))) - self._deps.record( - username=username, provider=last_provider, credential_id=last_credential, - model=target.model, ok=False, - error_type=self._exhaust_error_type(target), - latency_ms=int((time.monotonic() - started) * 1000)) - raise NoHealthyCredential( - self._unavailable_text(target, last_error)) - credential_id, credential_data = pick - tried.add(credential_id) - attempts += 1 - provider_id = self._deps.credentials.provider_of(credential_id) - last_provider, last_credential = provider_id or "-", credential_id + if pinned is not None: + credential_id, credential_data = pinned + pinned = None + if self._safe_credential_data(credential_id) is None: + continue + provider_id = self._deps.credentials.provider_of(credential_id) + last_provider, last_credential = provider_id or "-", credential_id + else: + pick = await self._pick(target, tried, affinity_id) + if pick is None: + if last_kind in REJECT_KINDS: + # 所有候选上游都拒绝了该模型:400 而非 503 + raise InvalidRequest(_reject_message( + target.model, last_error, self._suggestions(target.model))) + if not attributed: + self._deps.record( + username=username, + provider=self._attribution_provider(target, last_provider), + credential_id=last_credential, + model=target.model, ok=False, + error_type=self._exhaust_error_type( + target, last_kind, last_error), + latency_ms=_elapsed_ms(started)) + raise NoHealthyCredential( + self._unavailable_text(target, last_error)) + credential_id, credential_data = pick + tried.add(credential_id) + attempts += 1 + provider_id = self._deps.credentials.provider_of(credential_id) + last_provider, last_credential = provider_id or "-", credential_id # 与 _stream_loop 同理:按实际服务渠道压缩(对同一请求可能压多次, # 但实现必须无副作用——每轮都从原文出发),再按实际服务渠道映射档位 payload = self._compress_payload(request, provider_id, target.model) payload = self._map_effort(provider_id, payload) + payload = self._clamp_effort(payload, effort_cap) + self._debug_prefix(credential_id, provider_id, target.model, payload) events: list[Event] = [] + # 上游自报模型名(首个带 model 的事件):只用于静默替换观测 + observed_model: str | None = None # 与 _stream_loop 同理:显式关闭上游流,让节流名额在每次尝试 # 结束时同步归还,不依赖 GC 的 asyncgen finalize iterator = self._stream_source( - provider_id, credential_data, payload, target.model).__aiter__() + provider_id, credential_data, payload, target.model, + credential_id).__aiter__() # 与 _stream_loop 同理:在途登记 + finally 释放(含超时/断开) self._lease(credential_id) + queued_wait: float | None = None try: # 聚合整体超时兜底(见 ExecutorDeps.complete_timeout_seconds) async with (asyncio.timeout(self._deps.complete_timeout_seconds) @@ -925,6 +1336,23 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, if hide_reasoning and event.kind is EventKind.REASONING: # 出站剥离思考:上游仍照常生成,只是不交给客户端 continue + if event.model and observed_model is None: + observed_model = event.model + self._check_model_substitution( + target.model, event.model, provider_id, credential_id) + if (event.kind is EventKind.ERROR + and _event_kind(event) is ErrKind.QUEUE): + # 排队信号必须在入 events **之前**拦下:进了 events + # 就会被 `aggregate` 当成流内错误,直接冷却换号。 + plan = self._plan_queue_restart( + event.retry_after_seconds, emitted=False, + rounds=queue_rounds, attempts=attempts, + provider_id=provider_id, credential_id=credential_id, + credential_data=credential_data) + if plan is not None: + raise _Queued(plan) + # 轮次用尽:降级为普通瞬态错误,交给既有路径处理 + event = replace(event, error_kind=ErrKind.SOFT) events.append(event) # 事件条数上限:超长/死循环输出下 events 会无界 # 增长直到 OOM,超时兜不住「快而无穷」。按瞬态错误换号。 @@ -932,12 +1360,18 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, and len(events) > self._deps.complete_max_events): raise _AggregateOverflow( self._deps.complete_max_events) + except _Queued as queued: + queue_rounds = queued.plan.rounds + attempts = queued.plan.attempts + pinned = queued.plan.pinned + queued_wait = queued.plan.wait except TimeoutError: logger.warning( "上游 %s 非流式聚合超过 %ss(凭证 %s,按瞬态错误换号)", provider_id, self._deps.complete_timeout_seconds, credential_id) self._note_upstream_error(credential_id, ErrKind.SOFT, - provider_id, target.model) + provider_id, target.model, + username=username, started=started) last_error = UpstreamStreamError(Event( kind=EventKind.ERROR, error_message=(f"upstream complete timed out after " @@ -947,51 +1381,117 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, "上游 %s 非流式聚合事件超过 %s 条(凭证 %s,按瞬态错误换号)", provider_id, overflow.limit, credential_id) self._note_upstream_error(credential_id, ErrKind.SOFT, - provider_id, target.model) + provider_id, target.model, + username=username, started=started) last_error = UpstreamStreamError(Event( kind=EventKind.ERROR, error_message=(f"upstream complete exceeded " f"{overflow.limit} events"))) except Exception as error: # noqa: BLE001 - kind = _classify(error) + kind = await self._credit_if_exhausted( + _classify(error), credential_id, provider_id) if kind is None: raise - if kind is ErrKind.INVALID: - # 请求无效(如该上游不认识模型):不冷却凭证, - # 跳过该上游继续试其他上游;全部拒绝才以 400 结束 + if kind is ErrKind.QUEUE: + # HTTP 级排队(403 + 排队 body):与流内排队同一套语义 + plan = self._plan_queue_restart( + getattr(error, "retry_after_seconds", None), + emitted=False, rounds=queue_rounds, attempts=attempts, + provider_id=provider_id, credential_id=credential_id, + credential_data=credential_data) + if plan is not None: + queue_rounds = plan.rounds + attempts = plan.attempts + pinned = plan.pinned + queued_wait = plan.wait + else: + logger.warning("上游 %s 排队但无法重开(凭证 %s): %s", + provider_id, credential_id, _error_detail(error)) + self._note_upstream_error(credential_id, ErrKind.SOFT, + provider_id, target.model, error, + username=username, started=started) + last_error, last_kind = error, ErrKind.SOFT + elif kind in REJECT_KINDS: + # 推理档位被拒:原号降一档重试一次(非流式同一口径) + lower = self._downgrade_effort( + payload, _error_detail(error), effort_retried) + if lower is not None: + logger.warning( + "上游 %s 不认推理档位 %s(凭证 %s)→ 降到 %s 原号重试", + provider_id, payload.get("reasoning_effort"), + credential_id, lower) + effort_cap, effort_retried = lower, True + pinned = (credential_id, credential_data) + continue + # 请求无效(如该上游不认识模型 / 模型不支持该请求形态): + # 不冷却凭证,跳过该上游继续试其他上游;全部拒绝才以 400 结束 logger.warning("上游 %s 拒绝模型 %s(凭证 %s 跳过): %s", - provider_id, target.model, credential_id, error) + provider_id, target.model, credential_id, + _error_detail(error)) self._record_invalid(username, provider_id, credential_id, - target.model, started, error) - last_error, last_kind = error, ErrKind.INVALID + target.model, started, error, kind) + last_error, last_kind = error, kind self._skip_provider(provider_id, tried) + elif kind is ErrKind.RESTRICTED: + # 账号被上游风控限制:跳过该渠道,别让同渠道其它号继续撞 + attributed = self._handle_restricted( + provider_id=provider_id, credential_id=credential_id, + model=target.model, error=error, + username=username, started=started, + attributed=attributed, tried=tried) + last_error, last_kind = error, kind else: logger.warning("上游 %s 错误(凭证 %s,kind=%s): %s", - provider_id, credential_id, kind, error) - self._note_upstream_error(credential_id, kind, provider_id, target.model, error) - last_error = error + provider_id, credential_id, kind, + _error_detail(error)) + attributed = self._note_upstream_error( + credential_id, kind, provider_id, target.model, + error, username=username, started=started) or attributed + last_error, last_kind = error, kind else: try: result = aggregate(events, target.model) except UpstreamStreamError as error: - kind = _event_kind(error.event) - if kind is ErrKind.INVALID: + kind = await self._credit_if_exhausted( + _event_kind(error.event), credential_id, provider_id) + if kind in REJECT_KINDS: + # 推理档位被拒(流内 error 事件形态):原号降一档重试一次 + lower = self._downgrade_effort( + payload, error.event.error_message, effort_retried) + if lower is not None: + logger.warning( + "上游 %s 不认推理档位 %s(凭证 %s)→ 降到 %s 原号重试", + provider_id, payload.get("reasoning_effort"), + credential_id, lower) + effort_cap, effort_retried = lower, True + pinned = (credential_id, credential_data) + continue # 流内参数/模型错误:跳过该上游继续轮换,不冷却 logger.warning( "上游 %s 流内拒绝模型 %s(凭证 %s 跳过): code=%s %s", provider_id, target.model, credential_id, error.event.error_code, error.event.error_message) self._record_invalid(username, provider_id, credential_id, - target.model, started, error) - last_error, last_kind = error, ErrKind.INVALID + target.model, started, error, kind) + last_error, last_kind = error, kind self._skip_provider(provider_id, tried) + elif kind is ErrKind.RESTRICTED: + # 流内账号限制:跳过该渠道,别让其它号继续撞 + attributed = self._handle_restricted( + provider_id=provider_id, credential_id=credential_id, + model=target.model, error=error, + username=username, started=started, + attributed=attributed, tried=tried) + last_error, last_kind = error, kind else: - self._note_upstream_error(credential_id, kind, provider_id, - target.model, error) - last_error = error + attributed = self._note_upstream_error( + credential_id, kind, provider_id, target.model, error, + username=username, started=started) or attributed + last_error, last_kind = error, kind else: self._deps.credentials.save_success( credential_id, model=self._model_scope(provider_id, target.model)) + self._confirm_probe(credential_id) self._remember(request, username, credential_id) usage = result.get("usage") or {} # credit 不在对外响应里(客户端不需要,见 compat 出口), @@ -1017,21 +1517,40 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget, if usage_event else False, ttfb_ms=(int((first_event_at - started) * 1000) if first_event_at is not None else None), - latency_ms=int((time.monotonic() - started) * 1000)) + latency_ms=_elapsed_ms(started)) + # 与流式路径同一口径:成功请求的用量 ↔ 正文体量对账 + self._check_usage_consistency( + provider_id=provider_id, credential_id=credential_id, + model=target.model, + content_chars=sum( + len(e.content) for e in events + if e.kind is EventKind.CONTENT and e.content), + output_tokens=usage.get("completion_tokens")) return result finally: await aclose_stream(iterator) self._release(credential_id) + if queued_wait is not None: + # 租约与上游流都释放**之后**才等(同 _stream_loop) + await asyncio.sleep(queued_wait) + continue if not self._deps.scheduler.should_rotate(attempts): - if last_kind is ErrKind.INVALID: + if last_kind in REJECT_KINDS: raise InvalidRequest(_reject_message( target.model, last_error, self._suggestions(target.model))) - kind = _classify(last_error) if last_error is not None else None - self._deps.record( - username=username, provider=provider_id, credential_id=credential_id, - model=target.model, ok=False, - error_type=_error_type_for(kind) if kind else "upstream_protocol", - latency_ms=int((time.monotonic() - started) * 1000)) + kind = (await self._credit_if_exhausted( + _classify(last_error), credential_id, provider_id) + if last_error is not None else None) + # DEAD 尝试行已落过明细时不再落收尾行:一次请求两行会让 + # usage_hourly.requests 虚高、成功率虚低,按 Key 计的日请求 + # 额度被扣两次(与 _stream_loop / _complete 的 pick-is-None + # 分支同一口径)。 + if not attributed: + self._deps.record( + username=username, provider=provider_id, credential_id=credential_id, + model=target.model, ok=False, + error_type=_failure_type(kind, last_error), + latency_ms=_elapsed_ms(started)) raise NoHealthyCredential( self._unavailable_text(target, last_error)) @@ -1065,46 +1584,83 @@ async def _pick(self, target: ModelTarget, tried: set[str], 按真正打过的凭证数计,这里不碰。 - 只重取一次:到期后仍有别的请求抢走名额就照常返回 None(503), 不做「等第二轮」,否则慢请求会在池子打满时层层排队。 + - 等到的号**从 `tried` 解禁**再重取(B1):轮换刚把唯一/最后一个号 + 打进软冷却时,不解禁的话重取仍按「本轮试过」排除它,等于白等一轮 + 再 503。 """ waited = False while True: credential_id = self._select(target, tried, affinity_id) if credential_id is None: # 没有立即可用候选:短冷却「等待续上」只允许一次(见 docstring) - if waited or not await self._wait_short_cooldown(target, tried): + retry_id = None if waited else await self._wait_short_cooldown(target) + if retry_id is None: return None waited = True + # 等待前被轮换拉黑的号到期后必须解禁,否则重取还是选不到它; + # 它若已不在候选池(并发删除)则 discard 是空操作。 + tried.discard(retry_id) continue - credential_data = self._deps.credentials.credential_data(credential_id) + credential_data = self._safe_credential_data(credential_id) if credential_data is not None: return credential_id, credential_data - tried.add(credential_id) # 并发删除:标记后重选 + # 选中的号在真正使用前被淘汰:半开探测位不能跟着卡住 + self._drop_probe(credential_id) + tried.add(credential_id) # 并发删除 / 解不开:标记后重选 + + def _safe_credential_data(self, credential_id: str) -> dict[str, Any] | None: + """取凭证明文;**解不开**时跳过该凭证而不是让整个请求失败。 + + 解密失败(APP_SECRET 换过 / 密文损坏)早先直接冒泡 → 一条坏凭证 + 就让**整个聊天请求**回 500(实测 2026-10-26 14:48: + `/v1/chat/completions` 500,栈顶「凭证解密失败:APP_SECRET 是否被 + 更换过?」),其余好凭证白白连坐。 + + 这里吞掉它并返回 None(= 「这条凭证用不了」,与并发删除同义)→ + 选号继续找下一个号。单条探测端点仍把该错误如实报给管理员 + (见 `webapp.handlers` 的可行动 500),两处口径不冲突:一个是 + 「这个号不能用,换一个」,一个是「请处理这个号」。 + + 走 `credential_data`(而不是仓储的 `credential_data_or_skip`): + 测试替身只实现前者,后者是仓储层的具体能力。 + """ + try: + return self._deps.credentials.credential_data(credential_id) + except CredentialDecryptError: + logger.error("凭证解密失败(凭证 %s):APP_SECRET 是否被更换过?" + "该凭证已跳过,其余凭证不受影响", credential_id) + return None - async def _wait_short_cooldown(self, target: ModelTarget, - tried: set[str]) -> bool: - """没有立即可用候选时,若最短剩余冷却够短则等它到期;返回是否等待过。 + async def _wait_short_cooldown(self, target: ModelTarget) -> str | None: + """没有立即可用候选时,若最短剩余冷却够短则等它到期;返回可复用凭证。 - `remaining` 取**所有候选里最短的**剩余冷却(账号级与模型级取小): + `remaining` 取**所有候选里最短的**剩余冷却(账号级与模型级取较晚者): 池子里只要有一个号 N 秒后可用,等 N 秒就能续上;上限是热更的 `short_cooldown_wait_seconds`(≤0 关闭,直接快速失败)。 - 候选来源与 `_select` 完全一致(`_selectable` 过滤掉限额/在途之后), - 但这里刻意**不套 `_selectable`**:被在途占满的号不是「等几秒就好」 - (它要等一个完整请求跑完,可能几分钟),把它算进等待会放大排队。 - 因此只统计「冷却中 / 启用中」的候选,并按 `tried` 排除本轮已试过的。 + 候选来源与 `_select` 一致(限额/在途过滤相同),但刻意**不套 + `_selectable`**:被在途占满的号不是「等几秒就好」(它要等一个完整 + 请求跑完,可能几分钟),把它算进等待会放大排队。 + + **本轮试过的号同样参与**(B1):轮换把每个 429 过的号塞进 `tried` + 后,旧的「按 `tried` 排除」口径会得出「没有可等的号」直接 503—— + 而「一轮试完、全池都在短冷却」恰恰是最需要等待续上的场景。真正 + 不该等的是 DEAD / INVALID / 额度耗尽这类长冷却,它们的剩余时间 + 远超上限,由 `remaining > limit` 自然挡掉,不额外特判错误类型。 + + 返回要复用的凭证 id(调用方须先从 `tried` 解禁再重取);不值得等 + 时返回 None。 """ limit = float(live(self._deps.short_cooldown_wait_seconds)() or 0) if limit <= 0: - return False - registered = [pid for pid in self._narrow_providers(target) - if pid in self._deps.providers] + return None + registered = self._registered(target) if not registered: - return False + return None now = int(time.time()) remaining: int | None = None + retry_id: str | None = None for candidate in self._deps.credentials.candidates(registered): - if candidate.credential_id in tried: - continue if candidate.disabled or not candidate.enabled: continue if (self._deps.limits is not None @@ -1126,12 +1682,13 @@ async def _wait_short_cooldown(self, target: ModelTarget, wait = until - now if remaining is None or wait < remaining: remaining = wait + retry_id = candidate.credential_id if remaining is None or remaining > limit: - return False + return None logger.info("全部候选处于短冷却(最短剩余 %ss ≤ %.0fs),等待续上", remaining, limit) await asyncio.sleep(remaining) - return True + return retry_id def _sticky(self, candidates: list, tried: set[str], affinity_id: str | None) -> str | None: @@ -1160,6 +1717,11 @@ def _sticky(self, candidates: list, tried: set[str], return None return affinity_id + def _registered(self, target: ModelTarget) -> list[str]: + """收窄后的候选渠道 ∩ 已注册渠道(决定「这个模型能不能发出去」)。""" + return [pid for pid in self._narrow_providers(target) + if pid in self._deps.providers] + def _narrow_providers(self, target: ModelTarget) -> tuple[str, ...]: """模型目录能证明归属时,把候选上游收窄到登记了该模型的上游。 @@ -1185,6 +1747,11 @@ def _model_rates(self, target: ModelTarget) -> dict[str, float | None]: 别名表换回)。目录里查不到的渠道(该模型不在其登记表 / 目录未就绪 / 上游没给倍率)记 None——调度端未知倍率不参与免费优先,与 0(确认 免费)严格区分。免费渠道(zen / kilo)的条目显式标 0.0。 + + **目录的 x0 会被实测扣费覆盖**(`_observed_paid_rate`):上游改计费 + 策略时不改目录,x0 就一直留在快照里,而免费档压过全部到期积分—— + 实测 2026-11-03 起 intl 的 `deepseek-v4.1-flash` 目录写 x0、每次实扣 + 0.14 积分,officeace 15.8 小时内到期的 100 积分包整夜闲置。 """ aliases = self._deps.model_aliases cache = self._deps.model_list_cache @@ -1197,15 +1764,129 @@ def _model_rates(self, target: ModelTarget) -> dict[str, float | None]: if raw is None: continue model = cache.get(pid, {}).get(raw.lower()) - if model is not None: - rates[pid] = model.credit_rate + if model is None: + continue + rate = model.credit_rate + if rate == 0: + rate = self._observed_paid_rate(pid, target.model, rate) + rates[pid] = rate return rates - def _candidate(self, credential_id: str): + def _observed_paid_rate(self, provider_id: str, model: str, + declared: float | None) -> float | None: + """目录说 x0 时,用最近窗口内的实测扣费把它纠正成真实倍率。 + + 只对 `declared == 0` 生效:目录给的正数已经是上游自报的倍率,实测 + 均值反而更粗(受缓存命中率影响);None(目录未给倍率)也**不能**被 + 实测值补成「免费」或「收费」——未知就该继续未知。 + + 校准数据源缺失(未装配)或窗口内没有该 (渠道, 模型) 的实扣记录时 + 原样返回 0:宁可信目录,也不凭「没观测到扣费」判免费(夜里没人用 + 就没有观测行,那不是免费的证据)。 + """ + source = self._deps.paid_models + if declared != 0 or source is None: + return declared + observed = source().get((provider_id, normalize_model_key(model))) + return observed if observed is not None else declared + + def _candidate_or_none(self, credential_id: str, provider_id: str = "") -> Candidate: + """同 `_candidate`,但凭证已被并发删除时用占位候选而不是抛异常。 + + 用于「记错误」这类旁路:凭证在请求过程中被管理员删掉是正常竞态, + 此时再抛 `NoHealthyCredential` 会把这个信息性异常冒泡成 503 + (流式路径更糟:响应头已发出,只能断流),而真正原因是一次删除。 + 占位候选只承载「哪个渠道、哪条凭证」——`note_error` 用它算冷却时长 + 与命中次数,写库那一步本来就按 credential_id 定位(行已不存在时 + 写入是 no-op)。 + """ for candidate in self._deps.credentials.candidates(): if candidate.credential_id == credential_id: return candidate - raise NoHealthyCredential(f"credential {credential_id} disappeared") + return Candidate(credential_id=credential_id, provider=provider_id) + + def _quota_exhausted(self, credential_id: str, + provider_id: str = "") -> bool: + """该凭证额度是否**已探明**耗尽(`health == -1` 或 `quota_remaining <= 0`)。 + + 没探测过(`quota_remaining is None`)=未知,返回 False:未知不等于 + 耗尽,不能拿「没测过」当「用完了」的证据(与 `health_score` 的三态 + 口径一致:None 未知 / -1 耗尽 / 0-100 已知)。 + """ + candidate = self._candidate_or_none(credential_id, provider_id) + if candidate.health == EXHAUSTED: + return True + return (candidate.quota_remaining is not None + and candidate.quota_remaining <= 0) + + async def _credit_if_exhausted(self, kind: ErrKind | None, credential_id: str, + provider_id: str) -> ErrKind | None: + """DEAD 且额度证据成立 → CREDIT;证据不成立时以现场复探收口。 + + 非 DEAD 直接返回,不做候选查询:这条判据只在「凭据失效」这一分类上 + 需要额度证据(见 `_dead_as_credit`),其它分类的冷却口径不受影响。 + + 本地已探明耗尽(`_quota_exhausted`)就直接降级,不再出网。**证据陈旧** + (`quota_remaining` 还是正数或未探测)时必须现场复探一次:积分是分钟级 + 烧完的,而周期探额 60 分钟一轮,401 往往早于下一次探测(见 + `_probe_quota_now`)。复探失败=证据仍不明 → 保持 DEAD。 + """ + if kind is not ErrKind.DEAD: + return kind + if self._quota_exhausted(credential_id, provider_id): + return ErrKind.CREDIT + return _dead_as_credit( + quota_exhausted=await self._probe_quota_now(credential_id, provider_id)) + + async def _probe_quota_now(self, credential_id: str, + provider_id: str) -> bool: + """DEAD 现场复探该凭证额度,写回后按**新鲜证据**再判是否耗尽。 + + 为什么必须现场探:officeAce 积分耗尽写成 `401 APIG.1009`(与 AppKey + 真坏同文案),判据只能是额度证据;而周期探额 60 分钟一轮,重负载下几十 + 个请求几分钟就能把剩余积分烧光——401 到达时库里往往还是正数, + `_dead_as_credit` 收不了口(实测 2026-11-03:三条凭证 07:39/08:00/08:25 + 被硬禁用,401 前最后一次探额分别是 56.66 / 100 / 51.37,复探立刻为 0)。 + + 探测能力缺失(provider 没有 `probe_quota`)、凭证取不到(并发删除 / + 解不开)、或探测本身失败,都返回 False=保持 DEAD:宁可硬禁用一条额度 + 不明的凭证,也不能把「AppKey 真坏了」静默成「等签到」。`probe_failed` + 的结果不入库,免得把已探明的正数被一次网络抖动抹成未知。 + + **同号并发去重**:积分烧光时同一凭证会同时收到多个 401,逐个去探等于 + 对同一账号并发打 N 次订阅接口。`DEAD_QUOTA_PROBE_TTL_SECONDS` 内复用 + 最近一次结论;复探出 0 时立刻写回额度,后续请求在 `_quota_exhausted` + 就短路,不会走到这里。 + """ + now = time.monotonic() + cached = self._dead_quota_probes.get(credential_id) + if cached is not None and now - cached[0] < DEAD_QUOTA_PROBE_TTL_SECONDS: + return cached[1] + exhausted = await self._probe_quota_uncached(credential_id, provider_id) + self._dead_quota_probes[credential_id] = (time.monotonic(), exhausted) + return exhausted + + async def _probe_quota_uncached(self, credential_id: str, + provider_id: str) -> bool: + """`_probe_quota_now` 的实际探测体(不看去重缓存)。""" + provider = self._deps.providers.get(provider_id) + probe = getattr(provider, "probe_quota", None) + if probe is None: + return False + data = self._safe_credential_data(credential_id) + if data is None: + return False + try: + # 出口 IP 粘性:复探与推理必须走同一出口(见 provider/proxy.py) + with credential_scope(credential_id): + quota = await probe(data) + except Exception as error: # noqa: BLE001 - 探测失败=证据仍不明 + logger.warning("DEAD 现场复探额度失败(凭证 %s): %s", + credential_id, _error_detail(error)) + return False + if not quota.probe_failed: + self._deps.credentials.save_quota(credential_id, quota) + return self._quota_exhausted(credential_id, provider_id) def _select(self, target: ModelTarget, tried: set[str], affinity_id: str | None = None) -> str | None: @@ -1226,8 +1907,7 @@ def _select(self, target: ModelTarget, tried: set[str], 的登记表里)`_narrow_providers` 本就放行全部候选,不存在「漏试真正 持有它的新渠道」——模型列表 TTL 300s + 后台兜底刷新即新鲜度上限。 """ - registered = [pid for pid in self._narrow_providers(target) - if pid in self._deps.providers] + registered = self._registered(target) if not registered: raise NoProviderForModel(f"no provider registered for model {target.model!r}") now = int(time.time()) @@ -1235,8 +1915,55 @@ def _select(self, target: ModelTarget, tried: set[str], target, now) if not usable: return None - return (self._sticky(usable, tried, affinity_id) - or self._deps.scheduler.select(usable, tried, now)) + return self._choose(usable, target, tried, affinity_id, now) + + def _choose(self, usable: list, target: ModelTarget, tried: set[str], + affinity_id: str | None, now: int) -> str | None: + """在可用的候选里选号,并处理冷却到期的**半开探测**。 + + 候选集里「冷却刚过期」的凭证(`cooling_until <= now`,库里那一行还没 + 被留存任务清掉)不直接回池,而是先只放 1 个请求:探测位被别的请求 + 占着就换下一个号;没有别的号时按「无可用」返回(客户端重试)。探通 + 后的 `RECOVERED_TTL_SECONDS` 内正常放行,避免刚恢复的号被逐个排队。 + 只有被选中的候选才会占用探测位——未选中的请求不占,也就不会泄漏。 + """ + guard = self._deps.half_open + while usable: + credential_id = (self._sticky(usable, tried, affinity_id) + or self._deps.scheduler.select(usable, tried, now)) + if credential_id is None: + return None + if guard is None: + return credential_id + candidate = next(c for c in usable + if c.credential_id == credential_id) + key = self._half_open_key(candidate, target, now) + if key is None or guard.recovered(key): + return credential_id + if guard.acquire(key): + self._pending_probe[credential_id] = key + return credential_id + # 探测位被占:本次请求换别的号,别跟着一起撞刚解冻的账号 + usable = [c for c in usable + if c.credential_id != credential_id] + return None + + def _half_open_key(self, candidate, target: ModelTarget, + now: int) -> tuple[str, str] | None: + """该候选是否处在「冷却已过期」的半开窗口;是则返回探测键。 + + 账号级优先于模型级:账号级冷却覆盖全部模型,先恢复它。`scope` 用 + 上游原始模型名,与 `credential_model_cooldowns` 的登记键一致。 + """ + credential_id = candidate.credential_id + cooling = candidate.cooling_until + if cooling is not None and cooling <= now: + return (credential_id, "") + scope = self._model_scope(candidate.provider, target.model) + model_until = candidate.model_cooling_until(scope) + if model_until is not None and model_until <= now: + return (credential_id, scope) + return None def _selectable(self, candidates: list, target: ModelTarget, now: int) -> list: """按「该凭证所属上游的原始模型名」过滤出当前可选的候选。 @@ -1277,39 +2004,213 @@ def _lease(self, credential_id: str) -> None: """请求开始:登记一个在途租约(未装配护栏则跳过)。""" if self._deps.inflight is not None: self._deps.inflight.acquire(credential_id) + key = self._pending_probe.pop(credential_id, None) + if key is not None: + self._probe_leases.setdefault(credential_id, []).append(key) def _release(self, credential_id: str) -> None: """请求结束(成功/轮换/异常/断开):释放一个在途租约(幂等)。""" if self._deps.inflight is not None: self._deps.inflight.release(credential_id) + keys = self._probe_leases.get(credential_id) + if keys: + self._deps.half_open.release(keys.pop()) + if not keys: + self._probe_leases.pop(credential_id, None) + + def _drop_probe(self, credential_id: str) -> None: + """选中的凭证在真正使用前被淘汰(并发删除/解不开):归还探测位。""" + key = self._pending_probe.pop(credential_id, None) + if key is not None: + self._deps.half_open.release(key) + + def _confirm_probe(self, credential_id: str | None) -> None: + """请求成功:该号若在探测中则标记「已恢复」,后续请求不再排队。""" + if credential_id is None: + return + keys = self._probe_leases.get(credential_id) + if keys: + self._deps.half_open.confirm(keys[-1]) def _note_upstream_error(self, credential_id: str, kind: ErrKind, provider_id: str, model: str, - error: Exception | None = None) -> None: - """对一次上游错误作出反应:记账 + 落库,REQUEST 类零动作。 + error: Exception | None = None, *, + username: str, started: float) -> bool: + """对一次上游错误作出反应:记账 + 落库(DEAD/RESTRICTED 另落一条统计)。 + REQUEST / CONTEXT / NETWORK 类零动作。 - 请求级错误(11101 请求体坏 / 11115 上下文超限 / 11128 渠道风控 / + 返回值 = **本次是否落了尝试级明细**(仅 DEAD/RESTRICTED 为真)。调用方 + 据此在轮换耗尽时跳过收尾行,避免同一个请求在 `requests` 里被算两次、 + Key 配额被扣两次。 + + 请求级/上下文错误(11101 请求体坏 / 11115 上下文超限 / 11128 渠道风控 / 11135 图片无效)不是账号的问题:冷却或累计错误数都会在阈值处把健康 凭证踢掉。 也绝不落库——save_error 会把已有 cooling_until 写成 NULL, 等于顺手解掉别的错误留下的冷却。调用方只换号重试。 + **DEAD / RESTRICTED 额外落一条明细**:硬禁用只写 `credentials.disabled`, + 归因卡片读的是 `usage_events`,不落明细就完全看不到(实测 2026-10-23 + officeace 两次 401 `AppKey or AppSecret is invalid` 在统计页零痕迹)。 + **每次尝试**落一行,换号成功后由 `errors()` 的 `rescued` 标为「已救回」。 + 其他分类(SOFT / CREDIT / PLAN / MODEL)不在此落行——它们每次请求可能 + 触发多次,落行会把明细表灌满噪声;这些请求的失败由轮换耗尽时的收尾行 + 覆盖,收尾行的类型取**最后一次错误的真实归因**(见 `_exhaust_error_type`)。 + + **换号耗尽时收尾行不再落**(调用方看返回值):否则一次请求两行, + `usage_hourly.requests` 虚高、成功率虚低,按 Key 计的日请求额度也会 + 被扣两次。DEAD 尝试行已经是这次失败的完整归因。 + `error` 带 `retry_at` 时(上游 `Retry-After`,见 provider.base)交给 调度器在**该错误分类的作用域**内覆盖冷却时长;缺失/过去时刻回落常量。 """ - if kind is ErrKind.REQUEST: - return + if kind in NO_PENALTY_KINDS: + # REQUEST / CONTEXT / NETWORK 不是账号的问题:不冷却、不累计、 + # 不落错写库。NETWORK 是本机 DNS/连接失败,惩罚凭证会把一次网络 + # 抖动放大成整池「无可用凭证」。也绝不落库——save_error 会把已有 + # cooling_until 写成 NULL,等于顺手解掉别的错误留下的冷却。 + return False + if self._deps.half_open is not None and kind is not ErrKind.QUEUE: + # 又记了一次错误 → 之前「已探通」的结论作废,下次到期重新半开。 + # QUEUE 是「等几秒原号重开」,不是凭证故障,不必重置。 + scope = self._model_scope(provider_id, model) + self._deps.half_open.reset(credential_id, "") + self._deps.half_open.reset(credential_id, scope) outcome = self._deps.scheduler.note_error( - self._candidate(credential_id), kind, int(time.time()), + self._candidate_or_none(credential_id, provider_id), + kind, int(time.time()), model=self._model_scope(provider_id, model), retry_at=getattr(error, "retry_at", None)) + if kind is ErrKind.DEAD: + # 401/403 不都是 session 过期:11140 是账号被上游风控限制,管理台 + # 看到 "session dead" 会误以为是重登能解决的事。原因由 provider 识别。 + outcome = replace(outcome, disabled_reason=self._disabled_reason(provider_id, error)) self._deps.credentials.save_error(credential_id, outcome) + if kind is ErrKind.RESTRICTED: + # 同渠道短时间**第二个不同凭证**受限 → 整渠道暂停 + # (见 `_pause_provider_on_burst`)。必须在这里做而不是调用方: + # 流式/非流式两条路径都汇到这里。 + self._pause_provider_on_burst(provider_id, credential_id) + if kind not in (ErrKind.DEAD, ErrKind.RESTRICTED): + return False + self._deps.record( + username=username, provider=provider_id, credential_id=credential_id, + model=model, ok=False, error_type=_error_type_for(kind), + latency_ms=_elapsed_ms(started)) + return True + + def _pause_provider_on_burst(self, provider_id: str, credential_id: str) -> None: + """同渠道窗口内第二个**不同凭证**被上游限制 → 整渠道冷却。 + + 单号 2h 冷却只挡住这一个号:下一个请求会挑同渠道另一个号继续撞, + 每次都在上游留一条风控记录(实测 08:21 一次请求连打 3 个号、08:38 + 再来一轮,08:41 新号 3 分钟就被封)。**另一个号**也在窗口内中招, + 才说明是整批同源账号,此时该渠道整体退避 24h 才是止损。 + + 判据是「窗口内不同凭证的个数」而不是「命中次数」:同一个号重复中招 + 不算整批同源(它已被 2h 单号冷却挡住),只按命中次数升级会让单一 + 凭证/单一租户的两个连续请求把整渠道暂停 24h——那是跨用户的停服面。 + """ + now = int(time.time()) + window_start, hit = self._restricted_hits.get(provider_id, (now, set())) + if now - window_start > RESTRICTED_BURST_WINDOW_SECONDS: + # 窗口过期:重新开窗,别把上一轮的号算进本轮 + window_start, hit = now, set() + hit.add(credential_id) + self._restricted_hits[provider_id] = (window_start, hit) + if len(hit) < RESTRICTED_BURST_CREDENTIALS: + return + until = now + RESTRICTED_PROVIDER_PAUSE_SECONDS + paused = self._deps.credentials.cool_provider(provider_id, until=until) + logger.warning( + "渠道 %s 在 %d 秒内被 %d 个凭证相继受限:整渠道暂停 %d 秒(%d 条凭证)", + provider_id, now - window_start, len(hit), + RESTRICTED_PROVIDER_PAUSE_SECONDS, paused) + + def _handle_restricted(self, *, provider_id: str, credential_id: str, + model: str, error: Exception, username: str, + started: float, attributed: bool, + tried: set[str]) -> bool: + """账号被上游风控限制的统一处置:记错误 + 跳过整个渠道。 + + 四个调用点(流式 HTTP / 流式信封 / 非流式 HTTP / 非流式聚合)共用: + 处置必须一致,散着写很容易只改一处(历史教训:信封路径跳渠道而 HTTP + 路径继续换号,同一个号被反复拿去撞上游)。首号 2h 冷却、同渠道第二个 + 不同凭证也中招时升级整渠道 24h 暂停,都在 `_note_upstream_error` 内。 + + 日志只打**受控标识**(业务码 / 状态码 / 异常类名,见 + `_restricted_summary`):上游 body 可能回显用户内容或请求回显,与 + prefix_debug「只记摘要」的口径一致。返回调用方新的 `attributed`。 + """ + logger.warning("上游 %s 限制账号(凭证 %s 跳过该渠道): %s", + provider_id, credential_id, _restricted_summary(error)) + self._skip_provider(provider_id, tried) + return self._note_upstream_error( + credential_id, ErrKind.RESTRICTED, provider_id, model, error, + username=username, started=started) or attributed + + def _disabled_reason(self, provider_id: str, error: Exception | None) -> str: + """DEAD 的可操作原因:交由 provider 识别上游点名的账号级限制。 + + 识别不到返回空串,落库回落到历史的 "session dead"。只接受 `bytes` + 的 `body`(`UpstreamHTTPError.body`):流内错误 / 传输异常没有 body, + 自然回落,不会把上游正文写进管理台字段。 + """ + body = getattr(error, "body", None) + if not isinstance(body, bytes): + return "" + resolver = getattr(self._deps.providers.get(provider_id), + "disabled_reason", None) + if not callable(resolver): + return "" + return str(resolver(body) or "") + + def _queue_wait(self, retry_after: float | None, emitted: bool, + rounds: int) -> float | None: + """排队信号 → 本轮等待秒数;不该重开时返回 None(调用方降级为 SOFT)。 + + 三条约束: + + - **已出帧不重开**:半截输出不可回滚,重开会把两个号的内容拼进同一条 + 回复。此时返回 None,让调用方按 SOFT 收尾。 + - **轮次上限**:`QUEUE_MAX_ROUNDS` 用尽后返回 None,否则持续排队的 + 上游会把客户端连接吊死到它自己的超时。 + - **单轮封顶**:上游可能点名「2 小时后再来」,照等等于把请求挂死; + 按 `QUEUE_MAX_WAIT_SECONDS` 封顶,超了先等一轮再说。 + """ + if emitted or rounds >= QUEUE_MAX_ROUNDS: + return None + wait = QUEUE_DEFAULT_WAIT_SECONDS if retry_after is None else float(retry_after) + return min(max(wait, 0.0), QUEUE_MAX_WAIT_SECONDS) + + def _plan_queue_restart( + self, retry_after: float | None, *, emitted: bool, rounds: int, + attempts: int, provider_id: str, credential_id: str, + credential_data: dict[str, Any], + ) -> _QueueRestart | None: + """排队信号 → 重开计划;不该重开时返回 None(调用方降级为 SOFT)。 + + 四个入口(流式事件 / 流式异常 / 非流式 `_Queued` / 非流式异常)原本各 + 写一份「计数 + 记 pinned + 打日志」,收敛到这里保证四处的预算口径一致: + + - `attempts -= 1`:排队不是「换号尝试」。不归还的话三次排队就把 + `max_rotate` 吃满,请求直接 503。 + - `pinned` 记下被点名的凭证,下一轮跳过 `_pick` 直接用——否则可能选到 + 别的号,排队语义就丢了。 + """ + wait = self._queue_wait(retry_after, emitted, rounds) + if wait is None: + return None + rounds += 1 + logger.info("上游 %s 排队中(凭证 %s,第 %s/%s 轮),%.1fs 后原号重开", + provider_id, credential_id, rounds, QUEUE_MAX_ROUNDS, wait) + return _QueueRestart(wait=wait, rounds=rounds, attempts=attempts - 1, + pinned=(credential_id, credential_data)) def _model_scope(self, provider_id: str, model: str) -> str: """冷却表登记的模型名:用上游原始名(与 provider 实际调用的名字一致)。""" return self._upstream_model(provider_id, model) - @dataclass(slots=True) class _StreamState: """流式轮换过程中需要跨尝试保留的状态(统计用)。""" @@ -1326,6 +2227,14 @@ class _StreamState: # 流式超时的截止时刻(monotonic):None = 下一次取事件时重新计时。 # 只被数据事件重置(见 `_is_data_event`),跨尝试由 _stream_loop 显式清空 deadline: float | None = None + # 本次尝试输出的正文字符数:成功时与上游上报的 output_tokens 对账, + # 比例离谱说明上游静默换了模型/截断(见 `_check_usage_consistency`) + content_chars: int = 0 + # 本次尝试是否见过终态事件(FINISH):正常结束必须见过;没见过说明上游 + # 连接正常关闭但响应不完整(见 `_record_success` 的 truncated 口径) + saw_finish: bool = False + # 上游自报的模型名(首个带 model 的事件):只用于「静默替换」观测 + observed_model: str | None = None def mark_first_byte(self) -> None: if self._first_byte_at is None: @@ -1366,12 +2275,34 @@ def _event_kind(event: Event) -> ErrKind: return event_error_kind(event) +# 上游把「未指定/自动路由」回显成这些占位名(实测 Qoder 每帧都回 `auto`): +# 占位名不构成「替换」证据,忽略,否则每条请求都刷一条假告警。 +_MODEL_PLACEHOLDERS = frozenset({"auto", "default", "unknown", "none"}) + + +def _model_mismatch(requested: str, observed: str) -> bool: + """上游自报模型与请求模型是否「明显不是同一个」。 + + 容忍大小写与版本/日期后缀(`glm-5.2` vs `glm-5.2-20261001`):双向都不 + 构成前缀关系才判为不一致——真正的静默替换(换成别的模型)才会命中。 + """ + left = requested.strip().lower() + right = observed.strip().lower() + if not left or not right or left == right: + return False + if right in _MODEL_PLACEHOLDERS: + return False + return not (left.startswith(right) or right.startswith(left)) + + def _classify(error: Exception) -> ErrKind | None: """上游错误 → ErrKind;不认识的原样上抛。 - `kind()` 优先(provider 自己按上游错误码语义分类)。之后兜住 - `httpx.TransportError`:连接超时 / DNS 失败 / 写请求体超时 / 读超时 - 都是链路瞬态,与具体账号无关,按 SOFT 短冷却换号重试。 + 本机网络/DNS 失败(`ConnectError` / `gaierror`)先单独归 `NETWORK`:它不是 + 凭证或上游服务的问题,冷却凭证只会把网络抖动放大成「整池无可用凭证」。 + `kind()` 次之(provider 自己按上游错误码语义分类)。之后兜住 + `httpx.TransportError`:连接超时 / 写请求体超时 / 读超时是链路瞬态, + 与具体账号无关,按 SOFT 短冷却换号重试。 **为什么统一放在这里,而不是让每个 provider 各自包装**:httpx 的传输 异常会从 `client.stream()` 的 `__aenter__`、`aiter_bytes()`、写请求体 @@ -1382,15 +2313,63 @@ def _classify(error: Exception) -> ErrKind | None: 刻意**不**泛化到 `OSError` / `ConnectionError`:文件系统类错误(磁盘满、 句柄耗尽)也是它们的子类,误判成上游故障会去冷却一个完全健康的凭证。 `UpstreamTransportError` 自带 `kind()`,已被上面第一段覆盖。 + + **`ssl.SSLError` 单独列出**:TLS 层的 record layer failure / 证书链握手 + 失败在 anyio 的 `_call_sslobject_method` 里只有 `SSLEOFError` 被转成 + `BrokenResourceError`,其余 `SSLError` 原样上抛(见 anyio/streams/tls.py)。 + 它是 `OSError` 子类但**不是** `httpx.TransportError`,落不进上面的判断—— + 实测 2026-10-25 15:50 两次 codebuddy_intl 走 7897 代理时 `[SSL] record + layer failure` 直接逃到 `stream_guarded`,表现为一帧 `internal_error`: + 不换号、不冷却、不记统计,失败归因面板里连痕迹都没有。TLS 中断与读中断 + 同属链路瞬态,归 SOFT。仍然只认**具体类**,不放开到 `OSError` 整体。 """ kind_method = getattr(error, "kind", None) + if _is_network_error(error): + # DNS / TCP 连接失败发生在拿到响应之前:本机代理、DNS、网络切换都可能 + # 造成,与账号额度、上游限流无关。优先于 provider 的 kind(),否则被 + # 包成 `UpstreamTransportError`(固定 SOFT)时仍会冷却健康凭证。 + return ErrKind.NETWORK if callable(kind_method): return kind_method() - if isinstance(error, httpx.TransportError): + if isinstance(error, (httpx.TransportError, ssl.SSLError)): return ErrKind.SOFT return None +def _restricted_summary(error: object) -> str: + """受限错误的**受控标识**:上游业务码 / HTTP 状态码 / 异常类名。 + + 不给上游正文:`error_message` / `body_hint` 可能是回显的用户内容或被上游 + 回显的请求片段,与 `_error_summary`(对外文案)同一口径,只是这条进服务端 + 日志。正常输入必然命中前两支(信封事件带 `error_code`,`UpstreamStreamError` + 经 `.event` 取码,HTTP 错误带 `status`),末支只兜住测试桩等无码对象。 + """ + code = getattr(getattr(error, "event", error), "error_code", None) + if code: + return f"code={code}" + status = getattr(error, "status", None) + if isinstance(status, int) and status > 0: + return f"status={status}" + return f"kind={type(error).__name__}" + + +def _error_detail(error: Exception | None) -> str: + """服务端日志用的错误描述:`异常类名: 消息`。 + + 为什么不能只 `%s` 格式化异常:`httpx` 的传输异常经常 `str()` 为空 + (`Server disconnected without sending a response.` 之外的大量读超时 / + 写超时都是空串),日志只剩「kind=soft): 」,排障时分不清是读断、写超时 + 还是连接重置(实测 2026-10-23 codebuddy_intl 两次 158s/162s 的 SOFT 错误 + 都是空正文)。类名本身不含用户数据与上游正文,与既有 `body_hint` 截断 + 正文的口径一致,只进服务端日志。 + """ + if error is None: + return "-" + name = type(error).__name__ + text = str(error) + return f"{name}: {text}" if text else name + + def _error_summary(error: Exception | None) -> str: """对外错误文案里的错误标识:**只给受控分类,不回显上游正文**。 @@ -1463,28 +2442,137 @@ def _reject_message(model: str, last_error: Exception | None, return message -def _stream_error_type(error: Exception | None) -> str: +def _dead_as_credit(*, quota_exhausted: bool) -> ErrKind: + """DEAD 的额度证据成立 → 改判 CREDIT(不硬禁用)。 + + 上游把「额度用完」写成了凭据失效:officeace 积分耗尽时回 `HTTP 401 + APIG.1009 AppKey or AppSecret is invalid`(实测 2026-11-02:三条凭证 + `quota_remaining = 0 / quota_total = 4100`,同一条错误同时出现在模型列表 + 接口上),而 `APIG.1009` 正是 officeace 的「凭据真坏了」标记 → 判 DEAD。 + DEAD 的处置是**硬禁用**(`scheduler.note_error`)+归因落 + `credential_unavailable`:面板读成「凭证不可用」,运维于是 revive/重登 + ——积分仍是 0,重登无解(当天实测 revive 5 次 + OAuth 重登换号 1 次, + 401 照旧)。判 CREDIT 的处置是「冷却到次日 04:00 等签到恢复」,与真实 + 恢复路径一致,也不会把一条额度会回来的凭证永久踢出池子。 + + `quota_exhausted` 由调用方给**新鲜**证据:`_credit_if_exhausted` 先看本地 + 额度,证据陈旧(正数 / 未探测)时现场复探一次再传进来。没探测过(None) + 且复探失败时保持 DEAD——宁可硬禁用一条额度不明的凭证,也不能把「AppKey + 真坏了」静默成「等签到」。判据只看额度证据,不看渠道名,任何渠道 + 「DEAD 与额度耗尽同时成立」都按额度处理。 + """ + return ErrKind.CREDIT if quota_exhausted else ErrKind.DEAD + + +def _stream_error_type(error: Exception | None, *, + quota_exhausted: bool = False) -> str: """流已开始后的失败类型:流式超时单独成类,其余沿用通用映射。 `_StreamTimeout.kind()` 是 SOFT(冷却口径),但统计页要把「上游首字超时」 与「上游流中断」分开看(A1 验收),所以这里先按异常类型分流,再落到 `_error_type_for`。三个取值都在 collector 的受控白名单里。 + + `quota_exhausted` 与 `_credit_if_exhausted` 同一判据:收尾行(调用方在 + DEAD/CREDIT 未落尝试行时补的失败明细)必须与尝试行的归因一致,否则同一次 + 请求会出现「第一次记 credential_unavailable、收尾记 rate_limit」的矛盾 + (见 `_dead_as_credit`)。 """ if isinstance(error, _StreamTimeout): return error.error_code kind = _classify(error) if error is not None else None + return _failure_type(kind, error, quota_exhausted=quota_exhausted) + + +def _failure_type(kind: ErrKind | None, error: Exception | None = None, *, + quota_exhausted: bool = False) -> str: + """一次失败的受控统计类型:先按**异常形态**分流,再落 ErrKind 映射。 + + 本机网络/DNS 失败与链路层瞬态(`httpx.TransportError` / TLS 中断)必须先 + 分开:都表现为「连不上」,但一个该查本机 DNS/代理,一个该查上游链路。 + 归因面板若都记 `upstream_timeout`,运维会去排查上游(实测 2026-11-05 + 睡眠唤醒后六渠道同时 ENOTFOUND,面板全显示「上游超时」)。传输异常随后 + 在 SOFT 之前兜住:`upstream_timeout` 而不是 `rate_limit`。 + """ + if _is_network_error(error): + return "network_error" + if _is_transport_error(error): + return "upstream_timeout" + if kind is ErrKind.DEAD: + kind = _dead_as_credit(quota_exhausted=quota_exhausted) return _error_type_for(kind) if kind else "upstream_protocol" +def _is_network_error(error: Exception | None) -> bool: + """是不是本机 DNS / 连接建立失败(而非上游返回错误或读超时)。 + + 只认具体类:`httpx.ConnectError`(TCP/DNS 建连失败)、`httpx.ProxyError` + (本机代理通道不可用——2026-11 实测代理进程挂掉时走的是这一类,不是 + `ConnectError`,漏掉它会让健康凭证按 `upstream_timeout` 冷却)与底层 + `socket.gaierror`(解析失败)。`ConnectTimeout` 不在此列——它可能是上游慢, + 沿用 `upstream_timeout`。`__cause__` 也看一层:provider 把原始异常包成 + `UpstreamTransportError` 时仍要识别出网络根因。 + """ + if error is None: + return False + kinds = (httpx.ConnectError, httpx.ProxyError, socket.gaierror) + if isinstance(error, kinds): + return True + return isinstance(error.__cause__, kinds) + + +def _is_transport_error(error: Exception | None) -> bool: + """是不是链路层瞬态(连接超时 / DNS / 读超时 / TLS 中断)。 + + 只认**具体类型**,不放开到 `OSError` 整体:文件系统类错误(磁盘满、句柄 + 耗尽)也是它的子类,误判成上游故障会去冷却一条完全健康的凭证(与 + `_classify` 同一纪律)。 + """ + if error is None: + return False + return isinstance(error, (UpstreamTransportError, httpx.TransportError, ssl.SSLError)) + + def _error_type_for(kind: ErrKind) -> str: - """ErrKind → 受控的统计失败类型(web/src/api/display.ts 同步维护)。""" - if kind in (ErrKind.PLAN, ErrKind.CREDIT, ErrKind.MODEL, ErrKind.CONCURRENCY): + """ErrKind → 受控的统计失败类型(web/src/api/display.ts 同步维护)。 + + 本机网络/DNS 失败(NETWORK)独立成 `network_error`:它发生在连上游之前, + 不能混进「上游超时」——后者指向渠道,前者指向本机代理/DNS。链路层瞬态 + (`httpx.TransportError` / TLS 中断)不在这里分流——由 `_failure_type` + 先按异常形态判成 `upstream_timeout`,否则会被误读成「上游限流」。 + """ + if kind is ErrKind.NETWORK: + return "network_error" + if kind in (ErrKind.PLAN, ErrKind.CREDIT, ErrKind.MODEL, + ErrKind.CONCURRENCY, ErrKind.QUEUE, ErrKind.SOFT): # MODEL / CONCURRENCY 都是模型级限流,展示口径同为额度类; - # 冷却作用域与时长差异在 schema 层 + # QUEUE 是「上游排队中」,同属容量不足,与额度类同档展示。 + # SOFT 是上游限流/软限流(429 等)——比笼统的「渠道错误」更可行动: + # 运维看到「额度耗尽」知道要等冷却或换号,看到「渠道错误」只会去查 + # 渠道配置(实测 2026-11-03 zen 免费层 429 走 SOFT,归因面板此前 + # 记 `upstream_error`,读不出「等额度」)。冷却作用域与时长差异在 schema 层。 return "rate_limit" if kind is ErrKind.DEAD: return "credential_unavailable" - if kind in (ErrKind.INVALID, ErrKind.BLOCKED, ErrKind.REQUEST): + if kind is ErrKind.RESTRICTED: + # 账号被上游风控限制:与「凭证失效」分开——前者换号/重登都无效, + # 归到 credential_unavailable 会让运维去重新登录一个被拉黑的账号。 + return "account_restricted" + if kind is ErrKind.BLOCKED: + # 「该后端无此模型」(11102 / 81004 / 81009 / InferHub 002002009): + # **不是**请求无效。归到 `invalid_request` 会让归因面板把「上游没这个 + # 模型」读成「客户端参数写错了」(实测 2026-10-26 officeAce + # `deepseek-v4.1-flash-1017` 404 `ModelArts.81009` 就是被标成 + # 「请求无效」)。独立成类才能让运维一眼看出是模型下线而非请求错。 + return "model_not_found" + if kind is ErrKind.CONTEXT: + # 上下文超限(11115):动作是「精简上下文 / 换长窗口模型」, + # 与「请求写错了」分开,否则运维会去查参数写法。 + return "context_overflow" + if kind is ErrKind.CAPABILITY: + # 模型能力不匹配(001001020 not multimodal):动作是「换支持该形态的 + # 模型 / 渠道」,同样不能混进「请求无效」。 + return "model_capability" + if kind in (ErrKind.INVALID, ErrKind.REQUEST): return "invalid_request" return "upstream_error" diff --git a/src/engine/half_open.py b/src/engine/half_open.py new file mode 100644 index 00000000..362bc141 --- /dev/null +++ b/src/engine/half_open.py @@ -0,0 +1,101 @@ +"""冷却到期半开探测:到期后先放行**一个**请求,成功再全量回池。 + +原先冷却一到期,`Candidate.is_selectable` 立刻为真,所有并发请求会同时压到 +同一个刚解冻的号上。上游刚解除的往往只是「限流窗口过了」——账号本身仍然 +脆弱,一整波并发会把它再次打进限流,形成「冷却 60s → 到期 → 再撞 → 再冷却」 +的锯齿。 + +半开把恢复过程分成两段: + +- **探测中**:只允许 1 个在途请求打这个号;其余请求看见它被占用就换别的号 + (或在该号是唯一候选时按「无可用」返回,由客户端重试)。 +- **已恢复**:探测请求成功 → 本进程内记住「这个 key 已经探通」,后续请求 + 直接放行,不再逐个排队。失败的话上游会写一条新的冷却(`note_error`), + 探测位立即释放,下次到期再探。 + +**为什么是进程内集合**:冷却值本身在库里,但「谁正在探测」是瞬时状态。 +单进程部署下内存即准;多进程各自探一次也不过是 1 次/进程,仍远低于全量 +回池。重启丢失只会让每个号多探一次。 + +**TTL 兜底**:探测请求若在 `finally` 之前被 GC(生成器泄漏),探测位会滞留。 +超过 `PROBE_TTL_SECONDS` 的占用视为过期——宁可偶尔多放一个请求,也不能让 +号被永久卡在「探测中」。 +""" + +from __future__ import annotations + +import time +from collections.abc import Callable + +__all__ = ["PROBE_TTL_SECONDS", "HalfOpenGuard"] + +# 探测位兜底存活时间(秒):与请求整体超时同量级,正常路径必然更早释放。 +PROBE_TTL_SECONDS = 600.0 +# 「已探通」结论的存活时间(秒):过了这段时间重新按半开恢复。探测成功只 +# 证明「这一刻上游接受」,不能永久免除半开——冷却过 2h 的号探通一次就永久 +# 免检,等于把半开关掉了。取 120s 覆盖「到期瞬间的并发洪峰」这一核心场景, +# 又不会把刚恢复的号长期排除在保护之外。 +RECOVERED_TTL_SECONDS = 120.0 + + +class HalfOpenGuard: + """`(credential_id, scope)` 探测位:同一冷却作用域同时只有一个探测。 + + `scope` 为空串表示账号级冷却;非空表示模型级冷却(用上游原始模型名, + 与 `credential_model_cooldowns` 的键一致)——两者是独立的作用域, + 账号级探测进行中不该挡住另一个模型的探测。 + """ + + def __init__(self, *, ttl: float = PROBE_TTL_SECONDS, + recovered_ttl: float = RECOVERED_TTL_SECONDS, + now: Callable[[], float] = time.monotonic) -> None: + self._ttl = ttl + self._recovered_ttl = recovered_ttl + self._now = now + # key → 探测开始时刻 + self._probing: dict[tuple[str, str], float] = {} + # 已探通的 key → 探通时刻(见 RECOVERED_TTL_SECONDS) + self._recovered: dict[tuple[str, str], float] = {} + + def _live(self, key: tuple[str, str]) -> bool: + started = self._probing.get(key) + if started is None: + return False + if self._now() - started >= self._ttl: + self._probing.pop(key, None) + return False + return True + + def acquired(self, key: tuple[str, str]) -> bool: + """是否已有探测在途(含未过 TTL 的占用)。""" + return self._live(key) + + def recovered(self, key: tuple[str, str]) -> bool: + """本进程内是否已探通该作用域(探通后不再逐个排队)。""" + at = self._recovered.get(key) + if at is None: + return False + if self._now() - at >= self._recovered_ttl: + self._recovered.pop(key, None) + return False + return True + + def acquire(self, key: tuple[str, str]) -> bool: + """占用探测位;已占用返回 False(调用方应换号或视为无可用)。""" + if self._live(key): + return False + self._probing[key] = self._now() + return True + + def confirm(self, key: tuple[str, str]) -> None: + """探测成功:标记已恢复并释放探测位。""" + self._probing.pop(key, None) + self._recovered[key] = self._now() + + def release(self, key: tuple[str, str]) -> None: + """探测失败/中断:只释放探测位,不标记恢复(上游已写新冷却)。""" + self._probing.pop(key, None) + + def reset(self, credential_id: str, scope: str) -> None: + """该作用域又记了一次错误:清掉「已恢复」结论,下次到期重新半开。""" + self._recovered.pop((credential_id, scope), None) diff --git a/src/engine/model_resolver.py b/src/engine/model_resolver.py index 24f4a042..1dbaa1f7 100644 --- a/src/engine/model_resolver.py +++ b/src/engine/model_resolver.py @@ -5,7 +5,7 @@ from dataclasses import dataclass KNOWN_PROVIDERS = ("codebuddy", "codebuddy_intl", "trae", "zen", "kilo", "qoder", - "codearts", "officeace") + "codearts", "officeace", "raccoon", "lingxi") class UnknownModelError(ValueError): diff --git a/src/engine/prefix_debug.py b/src/engine/prefix_debug.py new file mode 100644 index 00000000..764d0406 --- /dev/null +++ b/src/engine/prefix_debug.py @@ -0,0 +1,105 @@ +"""前缀缓存诊断:记录每次请求的载荷指纹,定位「命中率为什么掉」。 + +为什么需要它:把单号命中率拆到单请求后能看到两种形态——连续轮次命中 ~99%, +以及每次只命中一段固定长度(如 14208/14464)的请求。后者说明前缀在会话正文 +处就分叉了,但网关只落 `input_tokens` / `cached_tokens` 两个数字,看不到**在 +哪条消息**分叉——上游前缀缓存按 token 对齐、按消息边界推理并不成立。 + +只产出**摘要**(sha256 前 16 位)与条数,不落正文:日志里不该出现用户代码。 +开关由运行态 `prefix_debug_enabled` 控制(默认关),调用方先判开关再调用, +关闭时本模块一次都不执行。 +""" +from __future__ import annotations + +import hashlib +import json +from collections import OrderedDict +from collections.abc import Sequence +from typing import Any + +_DIGEST_LEN = 16 +# 同一进程里同时在跑的会话数远小于这个值;LRU 只是防止凭证反复增删时无界增长 +_DEFAULT_CAPACITY = 64 +# 缺省 tools 的固定摘要:不参与比较的哨兵值,避免和真实摘要撞车。 +# 与 `_EMPTY` **必须不同**:两者同值时日志里分不清「本次没带 tools」与 +# 「没有 system 消息」(前者 tools_changed 恒 false 会被静默吞掉)。 +_NO_TOOLS = "no-tools" +# 无 system / 无分叉点时的占位 +_EMPTY = "-" + + +def _digest(value: Any) -> str: + """稳定摘要:键排序 + 紧凑分隔符 ⇒ 同内容同摘要,跨进程可比。""" + raw = json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), default=str) + # usedforsecurity=False:这里不是密码学用途,只是内容指纹 + return hashlib.sha256(raw.encode("utf-8"), + usedforsecurity=False).hexdigest()[:_DIGEST_LEN] + + +def message_digests(messages: Any) -> tuple[str, ...]: + """逐条消息摘要;非 list 形状(畸形请求)视为无消息。""" + if not isinstance(messages, list): + return () + return tuple(_digest(message) for message in messages) + + +def tools_digest(payload: dict[str, Any]) -> str: + """tools 定义的摘要。缺省 / 空列表都记哨兵值:两者对上游等价。""" + tools = payload.get("tools") + return _digest(tools) if tools else _NO_TOOLS + + +def longest_common_prefix(previous: Sequence[str], current: Sequence[str]) -> int: + """两条指纹序列从头部起连续相同的条数。""" + limit = min(len(previous), len(current)) + index = 0 + while index < limit and previous[index] == current[index]: + index += 1 + return index + + +class PrefixDebugTracker: + """按 key(凭证 + 模型)保存上一条请求的指纹,进程内有界 LRU。""" + + def __init__(self, capacity: int = _DEFAULT_CAPACITY) -> None: + if capacity < 1: + raise ValueError("prefix debug capacity must be >= 1") + self._capacity = capacity + self._seen: OrderedDict[str, tuple[tuple[str, ...], str]] = OrderedDict() + + def observe(self, key: str, payload: dict[str, Any]) -> dict[str, Any]: + """记录本次载荷并返回与上一次(同 key)的比较结果。 + + 首次见到该 key 时 `lcp` / `reusable` / `split` 均为 None:没有基准就 + 不猜「命中多少」,避免把「第一条」误报成「前缀完全变了」。 + """ + current = message_digests(payload.get("messages")) + tools = tools_digest(payload) + previous = self._seen.get(key) + self._seen.pop(key, None) + self._seen[key] = (current, tools) + if len(self._seen) > self._capacity: + self._seen.popitem(last=False) + + report: dict[str, Any] = { + "messages": len(current), + "system": current[0] if current else _EMPTY, + "tools": tools, + } + if previous is None: + report.update(lcp=None, reusable=None, tools_changed=None, split=None) + return report + lcp = longest_common_prefix(previous[0], current) + tools_changed = previous[1] != tools + # tools 定义排在 system 之后:它变了 ⇒ system 之后的 token 全部失效, + # 可复用前缀塌到 system 为止(system 本身变了则连 1 条都不剩) + reusable = min(lcp, 1) if tools_changed else lcp + report.update( + lcp=lcp, + reusable=reusable, + tools_changed=tools_changed, + # 分叉点那条消息的摘要:同一条反复出现在这里 ⇒ 就是它在每轮改写 + split=current[lcp] if lcp < len(current) else _EMPTY, + ) + return report diff --git a/src/engine/reasoning.py b/src/engine/reasoning.py index 8d06fb68..1db573b5 100644 --- a/src/engine/reasoning.py +++ b/src/engine/reasoning.py @@ -105,6 +105,25 @@ def parse_thinking_efforts(raw: str) -> tuple[str | None, tuple[tuple[int, str], return disabled, tuple(sorted(thresholds.items(), reverse=True)) +def output_config_effort(output_config: object) -> str | None: + """Anthropic `output_config` → `reasoning_effort`;`None` = 不映射。 + + Claude Code 2.1.318 起「`/effort` 选了哪档」落在 `output_config.effort` + (取值 `low|medium|high|xhigh|max`),并与 `thinking:{type:"adaptive"}` + 一起发送。此前本网关整块丢弃 `output_config`,用户改档位**静默无效**且 + 没有任何报错——比 400 更难排查,故这里原样透传取值。 + + 只认 `effort` 一个键:`output_config` 的其他成员(格式等)chat 无对应 + 表达,仍按原样忽略,避免把未核实的语义猜成上游字段。 + """ + if not isinstance(output_config, dict): + return None + effort = output_config.get("effort") + if not isinstance(effort, str) or not effort.strip(): + return None + return effort.strip().lower() + + def thinking_effort(raw: str, thinking: object) -> str | None: """Anthropic `thinking` 对象 → `reasoning_effort`;`None` = 不映射。 @@ -132,3 +151,45 @@ def thinking_effort(raw: str, thinking: object) -> str | None: if budget >= threshold: return None if effort == DELETE_EFFORT else effort return None + + +# 档位从高到低(只覆盖已确认存在的档位)。降级链只在这条链上走一步: +# 未知档位(渠道私有写法)不猜,直接放弃重试——猜错会把「高」降成别的语义。 +# `max` 是 Codex 0.16x 起的新顶档(`codex-rs/protocol/src/openai_models.rs` +# 的 `ReasoningEffort::Max`),必须排在 `xhigh` 之上,否则「max 被拒」时 +# 降级链直接放弃重试,用户只看到 400。 +EFFORT_ORDER: tuple[str, ...] = ("max", "xhigh", "high", "medium", "low", "minimal") + +# 客户端新写法 → 线上档位。只收**语义明确**的别名: +# `ultra`(Codex 的 Ultra)是「最高档」的另一种叫法,落到 `max`; +# `persistent` 在 Codex 里序列化成线上值 `"disabled"`(见 +# `client_tests.rs::reasoning_effort_for_requests_preserves_non_ultra_and_persistent_behavior`), +# 它不是「更高」的档位,无法参与降级链,故不映射(保留 None = 放弃重试)。 +_EFFORT_ALIASES: dict[str, str] = {"ultra": "max"} + +# 「上游拒收该推理档位」的错误文案特征。保守到只认点名 effort / budget 的写法: +# 泛化到「invalid」会把普通的参数错误也当成档位问题,白白多打一次上游。 +_EFFORT_REJECTION_MARKERS: tuple[str, ...] = ( + "reasoning_effort", "reasoning effort", "unsupported effort", + "invalid effort", "effort level", "budget_tokens", "thinking budget", + "思考强度", "推理强度", "思考档位", "推理档位", +) + + +def downgrade_effort(effort: str) -> str | None: + """降一档;已是最低档 / 未知档位返回 None(调用方据此放弃重试)。""" + normalized = effort.strip().lower() + normalized = _EFFORT_ALIASES.get(normalized, normalized) + try: + index = EFFORT_ORDER.index(normalized) + except ValueError: + return None + return EFFORT_ORDER[index + 1] if index + 1 < len(EFFORT_ORDER) else None + + +def is_effort_rejection(message: str | None) -> bool: + """错误文案是否「不认这个推理档位」(大小写不敏感,None 视为否)。""" + if not isinstance(message, str) or not message: + return False + text = message.lower() + return any(marker in text for marker in _EFFORT_REJECTION_MARKERS) diff --git a/src/engine/scheduler.py b/src/engine/scheduler.py index b80ac7c1..601cfa24 100644 --- a/src/engine/scheduler.py +++ b/src/engine/scheduler.py @@ -10,17 +10,42 @@ import time from collections.abc import Iterable, Mapping from dataclasses import dataclass +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo from ..config import live -from ..provider.base import MODEL_SCOPED_KINDS, ErrKind +from ..provider.base import MODEL_SCOPED_KINDS, NO_PENALTY_KINDS, ErrKind logger = logging.getLogger(__name__) +# 与签到、限额护栏同一时区:上游的「每日」按北京时间切(见 tasks/checkin)。 +_CN_TZ = ZoneInfo("Asia/Shanghai") + PLAN_COOLDOWN_SECONDS = 12 * 3600 SOFT_COOLDOWN_SECONDS = 60 OTHER_COOLDOWN_SECONDS = 10 * 60 +# 账号被上游风控限制(CodeBuddy 11140):2 小时起步。取 2h 而不是永久禁用—— +# 上游解除时间不可控、也不会通知,永久 disabled 只能人工 revive,反而逼着人 +# 去反复撞(每次 revive 都可能再留一条风控记录)。到期自然回到候选,试探一次 +# 的成本远低于长期静默。 +RESTRICTED_COOLDOWN_SECONDS = 2 * 3600 +# 11140 是「账号被上游定性」而不是定时冷却:同一账号反复被限说明整批账号同源, +# 每次到期原样再撞正是上游点名的自动化重试模式(2026-10-27 实测一天撞了 4 轮)。 +# 按命中次数指数退避,封顶 7 天;上游真的给了更晚的 Retry-After 仍按 24h 上限采纳。 +RESTRICTED_COOLDOWN_MAX_SECONDS = 7 * 86400 ERR_THRESHOLD = 3 MAX_ROTATE = 3 + +# 冷却来源属于「上游点名恢复时刻」的分类:人工 `revive` **保留**这些冷却 +# (清掉只会立刻再撞一次上游),要强制清除须显式 `force=True`。 +# 不含 MODEL/BLOCKED/CONCURRENCY(模型级条目,revive 一律清)、REQUEST/CONTEXT/ +# NETWORK/CAPABILITY/QUEUE(零冷却)、OTHER(本地累计冷却,清掉无害)。 +# 与 `note_error` 里写 `cooling_kind` 的分支必须同步:漏一处,该类的冷却 +# 就会被 revive 静默解掉。 +UPSTREAM_COOLDOWN_KINDS = frozenset({ + ErrKind.SOFT.value, ErrKind.CREDIT.value, + ErrKind.PLAN.value, ErrKind.RESTRICTED.value, +}) # 模型级限流(6004)软冷却基数与封顶(有界指数退避) MODEL_COOLDOWN_SECONDS = 600 MODEL_COOLDOWN_MAX_SECONDS = 2 * 3600 @@ -95,17 +120,21 @@ class ModelCooldown: def next_credit_reset(now: int) -> int: - """距现在最近的下一个本地 04:00(余额耗尽等签到恢复)。 + """距现在最近的下一个**北京时间** 04:00(余额耗尽等签到恢复)。 凌晨 00:00–04:00 之间返回当天 04:00(此时签到尚未跑,等当天签到即可恢复); - 04:00 及之后返回次日 04:00。用本地时区(与签到任务的日边界一致)。 + 04:00 及之后返回次日 04:00。 + + 时区固定 Asia/Shanghai,**不看进程本地时区**:签到任务的日界是北京时间 + (见 `tasks/checkin`),两者必须同源。此前用 `time.localtime/mktime`, + 容器 TZ≠CN 时冷却会比签到早或晚几小时解除,出现「刚重置又撞额度」或 + 「签到已恢复但号还在冷却」。 """ - local = time.localtime(now) - reset = time.mktime((local.tm_year, local.tm_mon, local.tm_mday, - CREDIT_RESET_HOUR, 0, 0, 0, 0, -1)) - if local.tm_hour >= CREDIT_RESET_HOUR: - reset += 86400 - return int(reset) + local = datetime.fromtimestamp(now, _CN_TZ) + reset = local.replace(hour=CREDIT_RESET_HOUR, minute=0, second=0, microsecond=0) + if local.hour >= CREDIT_RESET_HOUR: + reset += timedelta(days=1) + return int(reset.timestamp()) def model_cooldown_duration(hits: int) -> int: @@ -170,6 +199,11 @@ class Candidate: # 键:并发请求摊到空闲号,避免全部压在同一凭证上(见 Scheduler.select)。 # 0 = 空闲(含未装配在途护栏的部署,此时恒 0,排序退回原链)。 inflight: int = 0 + # 管理员的显式调度权重(credentials.routing_weight):NULL/0 = 默认, + # 正数越优先、负数越靠后。排在健康分档之后、其余偏好之前(见 select), + # 因此能压过免费优先 / 在途分摊 / 到期额度,但永远不能把 exhausted(-1) + # 或 unknown 的号顶到 known 前面。 + routing_weight: int | None = None # (凭证, 模型) 冷却表:model → ModelCooldown。模型级限流只写这里, # 不写 cooling_until,因此同账号的其他模型仍然可选 model_cooldowns: Mapping[str, ModelCooldown] | None = None @@ -202,7 +236,15 @@ class ErrorOutcome: """note_error 的结果:调用方据此持久化。""" disabled: bool = False + # 硬禁用时写进 `credentials.disabled_reason` 的**可操作原因**;空串表示 + # 调用方没有更精确的说法,落库回落到历史的 "session dead"。由 executor + # 在 DEAD 时按 provider 识别(如 CodeBuddy 11140 = 账号被风控限制,需申诉)。 + disabled_reason: str = "" cooling_until: int | None = None + # 冷却来源(ErrKind 值),写入 `credentials.cooling_kind`:`revive` 据此 + # 区分「上游点名恢复时刻」的冷却(soft/credit/plan/restricted,保留)与 + # 其余本地冷却(清除)。空串 = 本次不写该列。 + cooling_kind: str = "" err_count: int = 0 # 模型级条目:model → ModelCooldown(仅 MODEL/BLOCKED 产生), # 由调用方按该映射写库;账号级冷却出现时这里恒为空(互斥) @@ -277,6 +319,11 @@ def select(self, candidates: Iterable[Candidate], tried: set[str], 模型级冷却的过滤由调用方在候选集上完成(executor._select): 每个候选要按**自己所属上游**的原始模型名查冷却表,选号器不掌握 provider→模型名 的映射,硬塞进来只会造成两处各查一半。 + + **管理员权重**(`Candidate.routing_weight`):只在**同一健康分档内**做 + 稳定重排(权重高者优先),再接回其余档。因此高权重能压过免费优先 / + 在途分摊 / 到期额度,但永远不能让 exhausted 或 unknown 的号越过 known + 档——权重是偏好,不是准入条件。全 0(默认)时排序与无该字段时一致。 """ pool = [ c for c in candidates @@ -297,6 +344,15 @@ def select(self, candidates: Iterable[Candidate], tried: set[str], _rank(c.health), -(_health_value(c.health)), -_remaining_value(c.quota_remaining), c.credential_id), ) + # 管理员权重只调整**同一健康分档内**的次序:先把该档按权重稳定重排, + # 再接回其余档。这样默认(全 0)时排序与补该列前逐字节一致,也永远 + # 不会让 exhausted / unknown 的号借权重越过 known 档(权重是偏好, + # 不是准入条件,见 Candidate.routing_weight)。 + if any(c.routing_weight for c in chosen): + lead = _rank(chosen[0].health) + same = [c for c in chosen if _rank(c.health) == lead] + same.sort(key=lambda c: -_weight_value(c.routing_weight)) + chosen = same + [c for c in chosen if _rank(c.health) != lead] return chosen[0].credential_id def should_rotate(self, attempts: int) -> bool: @@ -316,7 +372,11 @@ def note_error(self, candidate: Candidate, kind: ErrKind, now: int, *, retry_at: int | None = None) -> ErrorOutcome: """按错误分类更新冷却/禁用状态。 - DEAD → 硬禁用 + DEAD → 硬禁用(额度耗尽的 DEAD 由 executor 先降级为 CREDIT, + 见 `executor._credit_if_exhausted`——被积分耗尽误判成凭据失效时, + 重登/换号都救不回来,硬禁用只会逼运维做无效动作) + RESTRICTED → 账号级指数冷却(2h 起按命中次数翻倍,封顶 7 天; + executor 另外跳过该渠道其余凭证,不逐个号去撞) PLAN → 12h 长冷却,清空 err_count(权益耗尽) CREDIT → 冷却到次日 04:00(余额不足,等签到恢复) SOFT → 短冷却,不累计 err_count(防雪崩) @@ -325,6 +385,8 @@ def note_error(self, candidate: Candidate, kind: ErrKind, now: int, *, 其中 CONCURRENCY 用固定短冷却,不翻倍 账号级冷却出现时清空模型级条目(防豁免泄漏) REQUEST → 零动作:不是账号的问题,换号但绝不惩罚凭证 + QUEUE → 零动作:上游只是排队,等 `retryAfterSeconds` 后原号重开; + 写冷却会让「等 5 秒」变成「锁 60 秒并换号」 `retry_at` 是上游 `Retry-After` 解析出的恢复时刻(epoch 秒,见 `provider.base.parse_retry_after`)。命中时用 `clamp_retry_at` 双端 @@ -333,20 +395,42 @@ def note_error(self, candidate: Candidate, kind: ErrKind, now: int, *, 历史版本逐字节一致。DEAD / REQUEST 不看它:硬禁用与请求级错误都不是 「等一会就恢复」。 """ - if kind is ErrKind.REQUEST: + if kind in NO_PENALTY_KINDS: + # REQUEST / CONTEXT 不是账号的问题:不冷却、不累计、只换号重试。 + # NETWORK(本机 DNS/连接失败)同理,见 NO_PENALTY_KINDS。 + return ErrorOutcome(err_count=candidate.err_count) + if kind is ErrKind.QUEUE: + # 排队不是失败:不冷却、不累计。真正的「等多久再打」由 executor + # 按上游点名的秒数在原号上等,调度器只负责别惩罚这个号。 return ErrorOutcome(err_count=candidate.err_count) if kind is ErrKind.DEAD: return ErrorOutcome(disabled=True, err_count=0) + if kind is ErrKind.RESTRICTED: + # 账号级风控限制:只冷却、不禁用。禁用是不可逆的人工状态,而这类 + # 限制会由上游自行解除;但**冷却时长按命中次数指数退避**(2h 起, + # 翻倍封顶 7 天):固定 2h 会让整批同源账号每 2h 被重试一遍, + # 而重复撞击已经被封的账号正是上游判「自动化」的证据。 + # 上游给 `Retry-After` 时仍夹在 [本次时长, 24h]。 + level = max(candidate.err_count, 0) + duration = min(RESTRICTED_COOLDOWN_SECONDS * (2 ** min(level, 8)), + RESTRICTED_COOLDOWN_MAX_SECONDS) + return ErrorOutcome( + cooling_until=self._cooling_until( + candidate, kind, now, retry_at, duration, None), + cooling_kind=kind.value, + err_count=level + 1) if kind is ErrKind.CREDIT: return ErrorOutcome( cooling_until=self._cooling_until( candidate, kind, now, retry_at, max(next_credit_reset(now) - now, 0), model), + cooling_kind=kind.value, err_count=0) if kind is ErrKind.PLAN: return ErrorOutcome( cooling_until=self._cooling_until( candidate, kind, now, retry_at, self._cooldowns[ErrKind.PLAN], model), + cooling_kind=kind.value, err_count=0) if kind in MODEL_SCOPED_KINDS: if not model: @@ -356,6 +440,7 @@ def note_error(self, candidate: Candidate, kind: ErrKind, now: int, *, cooling_until=self._cooling_until( candidate, kind, now, retry_at, self._cooldowns[ErrKind.SOFT], None), + cooling_kind=kind.value, err_count=candidate.err_count) if kind is ErrKind.BLOCKED: reason = "blocked" @@ -384,6 +469,7 @@ def note_error(self, candidate: Candidate, kind: ErrKind, now: int, *, cooling_until=self._cooling_until( candidate, kind, now, retry_at, self._cooldowns[ErrKind.SOFT], model), + cooling_kind=kind.value, err_count=candidate.err_count) count = candidate.err_count + 1 if count >= self.err_threshold: @@ -391,6 +477,7 @@ def note_error(self, candidate: Candidate, kind: ErrKind, now: int, *, cooling_until=self._cooling_until( candidate, kind, now, retry_at, self._cooldowns[ErrKind.OTHER], model), + cooling_kind=kind.value, err_count=0) return ErrorOutcome(err_count=count) @@ -403,12 +490,28 @@ def _cooling_until(candidate: Candidate, kind: ErrKind, now: int, if adopted: # 只在真的采用上游时刻时打一行:普通错误每个都打会把日志刷满 logger.info( - "采用上游时刻 %s(原常量 %s):凭证 %s 模型 %s 分类 %s 冷却 %ss", + "采用上游时刻 %s(原常量 %s):凭证 %s %s 分类 %s 冷却 %ss", _format_moment(until), _format_moment(now + duration), - candidate.credential_id, model or "-", kind.value, until - now) + candidate.credential_id, _scope_label(kind, model), kind.value, + until - now) return until +def _scope_label(kind: ErrKind, model: str | None) -> str: + """日志里的冷却作用域:模型级写模型名,账号级明确写「账号」。 + + 不能一律打 `model`:账号级分类(SOFT / PLAN / CREDIT / OTHER)的调用方 + 同样把请求模型名传进来,日志于是读成「只锁了这个模型」,与 + `credential_model_cooldowns` 的实际写入位置矛盾——实测 zen 免费层 429 + 落的是账号级 `cooling_until`,日志却写「模型 glm-5.3-flash-free」,排查时 + 极易误判成模型级限流。`MODEL` 缺模型名时已退化为账号级软冷却 + (见 `note_error`),故无模型的模型级分类也按账号打。 + """ + if kind in MODEL_SCOPED_KINDS and model: + return f"模型 {model}" + return "作用域 账号" + + def _format_moment(moment: int) -> str: """日志里的时刻:本地时间可读串(epoch 只适合机读,排查时难对表)。""" return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(moment)) @@ -439,6 +542,11 @@ def _remaining_value(remaining: float | None) -> float: return remaining if remaining is not None else 0.0 +def _weight_value(weight: int | None) -> int: + """管理员权重:NULL/0 = 默认。只用于同健康档内的稳定重排。""" + return weight or 0 + + def _free_first_rank(candidate: Candidate, free_present: bool) -> int: """免费优先档:0 = 免费渠道,1 = 其余。 diff --git a/src/engine/sticky_trim.py b/src/engine/sticky_trim.py new file mode 100644 index 00000000..21718130 --- /dev/null +++ b/src/engine/sticky_trim.py @@ -0,0 +1,192 @@ +"""粘性裁剪:让压缩后的前缀在多轮之间保持稳定。 + +**问题**:客户端每轮重发完整历史(实测一次 453 条 / 估算 1.88M token,而 +qoder 窗口只有 180K)时,`compress_messages` 每轮都按当轮预算重算「保留最近 +N 条」。多一轮消息,窗口边界就前移一条,发给上游的前缀从第 2 条消息起就变 +了——上游前缀缓存只能命中 system 段。实测同一凭证 32 条请求里 21 条 +`reusable=1`(只有 system 可复用),单号命中率 25%,而此刻同渠道其他形态 +的会话在 90%+。 + +**做法**:只在**超预算**时裁,且一次裁到 `budget × low_watermark`(默认 +0.6,留 40% 余量),把「保留窗口的起点」(正文消息的绝对序号)与该条消息 +的摘要一起记住。之后每轮只要: + +1. 起点消息内容没变(摘要一致), +2. 从该起点裁出来的总量仍在预算内, + +就直接复用同一起点——前缀逐字相同,缓存正常命中;直到新增消息把总量顶回 +预算,才再裁一次并下移起点。裁剪从「每轮一次」变成「每几十轮一次」。 + +**安全性**:起点必须仍是消息组的边界(`assistant.tool_calls` 与其 tool 结果 +不能被拆开),摘要对不上或分组变了就丢弃记忆重新裁——宁可多裁一次,也不把 +一段错的上下文发给上游。未超限时整份发出,记忆一并清除。 + +前提是保留窗口**连续**(`compress.trim_to_budget` 已改成「装不下就停」): +带洞的保留集合即使记住起点也复用不了,因为下一轮重算时洞的位置会变。 + +状态是进程内 LRU(按「渠道 + 模型 + 会话身份」)。重启后丢记忆只意味着多裁 +一次,不影响正确性。 +""" + +from __future__ import annotations + +import hashlib +import json +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any + +from .affinity import resolve_explicit +from .compress import ( + DEFAULT_MIN_KEEP_MESSAGES, + DEFAULT_RESERVE_FOR_OUTPUT, + DEFAULT_SAFETY_RATIO, + _group_atomic, + estimate_messages, + trim_to_budget, +) + +# 裁剪目标:预算的 60%。留出的余量决定「裁一次能撑多少轮」——余量越大, +# 两次裁剪间隔越长、缓存命中越稳,但一次性丢掉的旧上下文也越多。 +DEFAULT_LOW_WATERMARK = 0.6 +# 同时在跑的会话数上限(LRU):超出后淘汰最久未用的会话记忆 +_DEFAULT_CAPACITY = 256 + + +def _digest(value: Any) -> str: + """内容摘要(键排序紧凑 JSON):只用于判断「还是不是那条消息」。""" + raw = json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), default=str) + return hashlib.sha256(raw.encode("utf-8"), + usedforsecurity=False).hexdigest() + + +def conversation_key(provider_id: str, model: str, raw: dict[str, Any]) -> str: + """会话身份:显式会话标识(body/头)与头几条消息摘要一起做键。 + + 撞键的后果只是「白裁一次」——起点摘要校验会失败并重裁,不会把 A 会话的 + 上下文发给 B;所以这里宁可比需要的更细。 + """ + explicit = resolve_explicit(raw) or "" + messages = raw.get("messages") + head = _digest(messages[:3]) if isinstance(messages, list) and messages else "" + return f"{provider_id}|{model}|{explicit}|{head}" + + +@dataclass(frozen=True, slots=True) +class _Boundary: + """上一次裁剪保留窗口的起点。""" + + start: int # 正文消息(去掉 system)中的绝对序号 + digest: str # 起点那条消息的摘要 + + +@dataclass(frozen=True, slots=True) +class TrimOutcome: + """一次压缩的结果。""" + + messages: list[dict[str, Any]] + trimmed: bool # 本轮是否真的重新裁过;False = 原样或复用旧边界 + reason: str = "" # 为什么(没)裁:诊断用,见 _reuse / compress 的分支注释 + + +class StickyTrimmer: + """按会话记住裁剪边界,避免每轮都改写前缀。""" + + def __init__(self, *, capacity: int = _DEFAULT_CAPACITY, + low_watermark: float = DEFAULT_LOW_WATERMARK) -> None: + if capacity < 1: + raise ValueError("sticky trim capacity must be >= 1") + if not 0.0 < low_watermark <= 1.0: + raise ValueError("sticky trim low_watermark must be in (0, 1]") + self._capacity = capacity + self._low_watermark = low_watermark + self._seen: OrderedDict[str, _Boundary] = OrderedDict() + + def compress( + self, + key: str, + messages: list[dict[str, Any]], + *, + max_input_tokens: int, + reserve_for_output: float = DEFAULT_RESERVE_FOR_OUTPUT, + min_keep_messages: int = DEFAULT_MIN_KEEP_MESSAGES, + safety_ratio: float = DEFAULT_SAFETY_RATIO, + ) -> TrimOutcome: + """压缩入参消息;未超预算时原样返回(零拷贝,并清掉旧边界)。""" + if max_input_tokens <= 0: + self._seen.pop(key, None) + return TrimOutcome(messages, trimmed=False, reason="no_limit") + budget = max_input_tokens * safety_ratio - reserve_for_output + if budget <= 0: + self._seen.pop(key, None) + return TrimOutcome(messages, trimmed=False, reason="no_budget") + if estimate_messages(messages) <= budget: + # 整份都发得下:上一次的起点已无意义,清掉免得历史缩短后误用 + self._seen.pop(key, None) + return TrimOutcome(messages, trimmed=False, reason="fits") + + body = [m for m in messages if m.get("role") != "system"] + boundary = self._seen.get(key) + if boundary is not None: + reused, reason = self._reuse(messages, body, boundary, budget) + if reused is not None: + self._touch(key, boundary) + return TrimOutcome(reused, trimmed=False, reason="reused") + else: + reason = "no_memory" + + # 需要(重新)裁:一次裁到低水位,给后续若干轮留出前缀稳定空间 + trimmed, start = trim_to_budget( + messages, budget=budget * self._low_watermark, + min_keep_messages=min_keep_messages) + self._remember(key, body, start) + return TrimOutcome(trimmed, trimmed=True, reason=reason) + + # -------------------------------------------------------------- 内部 + + def _reuse(self, messages: list[dict[str, Any]], body: list[dict[str, Any]], + boundary: _Boundary, budget: float + ) -> tuple[list[dict[str, Any]] | None, str]: + """沿用旧起点:起点消息未变、仍在组边界、且裁后不超预算才算命中。""" + start = boundary.start + if start >= len(body): + return None, "start_out_of_range" + if _digest(body[start]) != boundary.digest: + return None, "digest_changed" + if start not in _group_starts(body): + return None, "not_group_start" + candidate = [*(m for m in messages if m.get("role") == "system"), + *body[start:]] + if estimate_messages(candidate) > budget: + return None, "over_budget" + return candidate, "reused" + + def _remember(self, key: str, body: list[dict[str, Any]], start: int) -> None: + if start < len(body): + boundary = _Boundary(start=start, digest=_digest(body[start])) + else: + # 没有正文可留(或全部被截):不记起点,下一轮重新裁 + self._seen.pop(key, None) + return + self._seen.pop(key, None) + self._seen[key] = boundary + if len(self._seen) > self._capacity: + self._seen.popitem(last=False) + + def _touch(self, key: str, boundary: _Boundary) -> None: + self._seen.pop(key, None) + self._seen[key] = boundary + + def __len__(self) -> int: + return len(self._seen) + + +def _group_starts(body: list[dict[str, Any]]) -> set[int]: + """正文各消息组的起始序号(`assistant.tool_calls` + 其后 tool 为一组)。""" + starts: set[int] = set() + cursor = 0 + for group in _group_atomic(body): + starts.add(cursor) + cursor += len(group) + return starts diff --git a/src/engine/tool_output.py b/src/engine/tool_output.py new file mode 100644 index 00000000..35388227 --- /dev/null +++ b/src/engine/tool_output.py @@ -0,0 +1,112 @@ +"""tool 输出确定性压缩(默认关,热更开关 `tool_output_compress_enabled`)。 + +只动 `role=tool` 的纯文本 `content`: + +1. 去掉 ANSI 转义序列(进度条 / 彩色日志,无信息量却照样占 token); +2. 折叠**连续重复行**(`xxx (×12)`),日志刷屏时这一步最省; +3. 超长时保留「头 N 行 + 尾 N 行 + 任何命中错误关键字的行」,中间标注 + `[... 省略 K 行 ...]`——错误行永远不丢,避免把「为什么失败」压没了。 + +**确定性**:同样输入必得同样输出。客户端每轮重发完整历史时,同一段 tool +输出每轮都压成同一份文本,上游前缀缓存才能继续命中(同 `compress.py` 的 +连续后缀理由)。不做模型化摘要:网关不引入第二个模型依赖,也不改写错误行原文。 + +只压缩超过 `max_chars` 的文本:阈值以下原样返回(含 ANSI),保证默认配置下 +未超限的输出逐字节不变。 +""" + +from __future__ import annotations + +import re +from typing import Any + +# 默认阈值:约 2K token 的纯 ASCII,足以覆盖正常命令输出,又把「刷屏日志」 +# 挡在门外。热更层可改(`tool_output_compress_max_chars`)。 +DEFAULT_MAX_CHARS = 8000 +DEFAULT_HEAD_LINES = 40 +DEFAULT_TAIL_LINES = 40 + +# CSI / OSC 之类的 ANSI 转义:`ESC [ ... m` 是常见形态,按宽松正则收口。 +_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") + + +def _is_error_line(line: str) -> bool: + """是否值得无条件保留的行(错误 / 异常 / 超时等定位信息)。""" + lowered = line.lower() + return any(marker in lowered for marker in _ERROR_MARKERS) + + +_ERROR_MARKERS: tuple[str, ...] = ( + "error", "fail", "fatal", "exception", "traceback", "panic", + "denied", "timeout", "timed out", "错误", "失败", "异常", "超时", "拒绝", +) + + +def _collapse_runs(lines: list[str]) -> list[str]: + """连续重复行折叠成一行 + `(×N)` 计数;单行不影响。""" + collapsed: list[str] = [] + index = 0 + total = len(lines) + while index < total: + line = lines[index] + repeat = 1 + while index + repeat < total and lines[index + repeat] == line: + repeat += 1 + collapsed.append(line if repeat == 1 else f"{line} (×{repeat})") + index += repeat + return collapsed + + +def _hard_trim(text: str, max_chars: int) -> str: + """最后兜底:按字符数留头尾,中间用标记代替(保证不超阈值)。""" + marker = "\n[... 省略 ...]\n" + if max_chars <= len(marker): + return text[:max_chars] + keep = max_chars - len(marker) + head = keep // 2 + return text[:head] + marker + text[-(keep - head):] + + +def compress_tool_text(text: str, *, max_chars: int = DEFAULT_MAX_CHARS, + head_lines: int = DEFAULT_HEAD_LINES, + tail_lines: int = DEFAULT_TAIL_LINES) -> str: + """单段 tool 输出 → 压缩后文本;未超阈值时**原样**返回(不碰 ANSI)。""" + if max_chars <= 0 or len(text) <= max_chars: + return text + lines = _ANSI_RE.sub("", text).splitlines() + collapsed = _collapse_runs(lines) + head = max(head_lines, 0) + tail = max(tail_lines, 0) + if len(collapsed) > head + tail: + middle = collapsed[head:len(collapsed) - tail] if tail else collapsed[head:] + kept = [line for line in middle if _is_error_line(line)] + omitted = len(middle) - len(kept) + parts = [*collapsed[:head], f"[... 省略 {omitted} 行 ...]", *kept] + if tail: + parts.extend(collapsed[-tail:]) + text = "\n".join(parts) + else: + text = "\n".join(collapsed) + return _hard_trim(text, max_chars) if len(text) > max_chars else text + + +def compress_tool_outputs(messages: list[dict[str, Any]], *, + max_chars: int = DEFAULT_MAX_CHARS, + head_lines: int = DEFAULT_HEAD_LINES, + tail_lines: int = DEFAULT_TAIL_LINES) -> list[dict[str, Any]]: + """压缩消息列表里的 tool 结果;无改动时返回**原列表**(零拷贝)。""" + changed = False + result: list[dict[str, Any]] = [] + for message in messages: + content = message.get("content") if isinstance(message, dict) else None + if (isinstance(message, dict) and message.get("role") == "tool" + and isinstance(content, str)): + compressed = compress_tool_text( + content, max_chars=max_chars, + head_lines=head_lines, tail_lines=tail_lines) + if compressed != content: + result.append({**message, "content": compressed}) + changed = True + continue + result.append(message) + return result if changed else messages diff --git a/src/error_text.py b/src/error_text.py new file mode 100644 index 00000000..99b465ab --- /dev/null +++ b/src/error_text.py @@ -0,0 +1,43 @@ +"""异常 → 一行日志/展示文本。 + +`str(error)` 对部分异常为空(`TimeoutError`、某些 `httpx.ConnectError`), +直接 `"%s" % error` 会打出「quota probe failed for cred_x: 」这种无因日志 +(2026-11 实测 89/91 条额度探测失败、54/58 条动态模型拉取失败都是空原因), +排障时只能看到「失败了」却不知道是超时、DNS 还是协议错误。 + +统一带上异常类名,保证至少能区分大类;有消息时保留消息(压缩空白、限长)。 + +**出口统一过日志脱敏**:`UpstreamHTTPError` 的消息里带 `body_hint`(上游错误 +响应体,最长 2 KB)。上游把请求原样回显、或在错误体里带上鉴权字段时, +这些文本会原样进 logger → 管理台日志页。`webapp.log_buffer` 的 +`redact_or_drop` 是最后一道门,但它的标识符正则只覆盖 token/secret/password/ +cookie/authorization/api_key,漏了 `jwt` / `ticket` / `code` / `openId` 与 +邮箱/账号。所以在**产生日志文本的这一处**先过同一套判定:命中敏感形态就 +只留类名,宁可少一条排障细节,也不把凭证推给浏览器。 +""" + +from __future__ import annotations + +import re + +from .webapp.log_buffer import redact_or_drop + +_WHITESPACE = re.compile(r"\s+") + +#: 消息上限:够放上游业务码与一句文案,又不至于把一段响应体灌进日志页。 +_LIMIT = 200 + + +def error_text(error: BaseException) -> str: + """异常 → `类名: 消息`;消息为空时只有类名(如 `TimeoutError`)。 + + 消息命中日志脱敏规则(疑似凭证/PII)时退化成只有类名:类名已经能区分 + 超时/协议/传输大类,具体响应体不值得冒泄露风险。 + """ + message = _WHITESPACE.sub(" ", str(error)).strip() + if not message: + return type(error).__name__ + if len(message) > _LIMIT: + message = message[:_LIMIT] + "…" + text = f"{type(error).__name__}: {message}" + return text if redact_or_drop(text) is not None else type(error).__name__ diff --git a/src/instance_lock.py b/src/instance_lock.py new file mode 100644 index 00000000..e7caddfe --- /dev/null +++ b/src/instance_lock.py @@ -0,0 +1,115 @@ +"""单实例保护:同一 `DATA_DIR` 只允许一个进程写库。 + +**为什么需要**:SQLite 允许多进程并发写(WAL + 忙等),所以「起了两个实例」 +不会报错,只会安静地互相覆盖——两个进程各自跑后台任务(额度探测、签到、 +模型目录刷新),同一凭证被探两次、同一小时汇总被写两遍。更常见的形态是 +launchd 的崩溃循环:端口被占 → uvicorn 绑定失败 → 退出 → KeepAlive 每 10s +重拉,2026-09-19~10-17 实测刷了 2377 次 `[Errno 48]`。 + +**为什么用文件锁而不是端口探测**: +- 端口探测有 TOCTOU 窗口(探测时空闲、绑定前被抢),而且只能保护「同端口」 + 的实例——两个实例配不同 PORT 时照样双写同一个库。 +- `flock` 由内核在进程退出(含 SIGKILL)时自动释放,不会留下需要人工清理 + 的陈旧锁文件。 +- 锁的是**数据目录**而不是库文件:后台任务还会写 `data/backups/`、 + `model_catalog.json` 等旁路文件,只锁 `.sqlite3` 挡不住。 + +**为什么锁文件不删**:unlink 一个正被 flock 的文件会让第二个进程创建一个新 +inode 并成功加锁——经典的 flock 陷阱。所以锁文件常驻,只锁内容不锁存在性。 +""" + +from __future__ import annotations + +import fcntl +import logging +import os +from pathlib import Path + +logger = logging.getLogger(__name__) + +#: 锁文件名(落在 DATA_DIR 下,与库、备份同目录,便于一起备份/迁移)。 +LOCK_NAME = ".instance.lock" + + +class AlreadyRunning(RuntimeError): + """同一 DATA_DIR 已有实例在运行。""" + + def __init__(self, lock_path: Path, holder: str) -> None: + self.lock_path = lock_path + self.holder = holder + super().__init__( + f"另一个实例已在使用 {lock_path.parent}(PID {holder})。" + f"同一数据目录只能起一个进程:两个实例会各自跑后台任务、" + f"重复探测凭证并互相覆盖统计。若确认没有实例在跑,删掉 " + f"{lock_path} 后重试。") + + +class InstanceLock: + """持有 `DATA_DIR/.instance.lock` 的独占 flock。 + + 用 `LOCK_EX | LOCK_NB`:拿不到立即失败,绝不等待——启动路径上等待只会 + 让 launchd 的 10s 重拉堆成一排等锁的进程。 + + 用法: + + lock = InstanceLock(data_dir) # 拿不到抛 AlreadyRunning + try: + ... # 跑服务 + finally: + lock.release() + """ + + def __init__(self, data_dir: str | Path) -> None: + self.path = Path(data_dir) / LOCK_NAME + self._fd: int | None = None + + def acquire(self) -> None: + """取独占锁;已被占用时抛 `AlreadyRunning`(含持有者 PID)。""" + self.path.parent.mkdir(parents=True, exist_ok=True) + # O_CLOEXEC:不把锁 fd 漏给子进程(uvicorn 的 reload/worker 模型下 + # 子进程继承 fd 会让锁在主进程退出后仍被持有)。 + fd = os.open(self.path, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + holder = self._read_holder() + os.close(fd) + raise AlreadyRunning(self.path, holder) from None + # 写入自己的 PID:锁文件内容只用于报错文案,权威判据永远是 flock。 + # 截断后写,避免上次更长的 PID 留下尾巴。 + os.ftruncate(fd, 0) + os.write(fd, f"{os.getpid()}\n".encode()) + self._fd = fd + + def release(self) -> None: + """释放锁(幂等)。""" + if self._fd is None: + return + try: + fcntl.flock(self._fd, fcntl.LOCK_UN) + finally: + os.close(self._fd) + self._fd = None + + def _read_holder(self) -> str: + """读锁文件里的 PID;读不到给 `unknown`(内容仅供参考,不参与判定)。""" + try: + text = self.path.read_text(encoding="utf-8").strip() + except OSError: + return "unknown" + return text or "unknown" + + def __enter__(self) -> InstanceLock: + self.acquire() + return self + + def __exit__(self, *exc_info: object) -> None: + self.release() + + +def hold_instance_lock(data_dir: str | Path) -> InstanceLock: + """取锁并记日志;失败抛 `AlreadyRunning`(调用方决定退出码)。""" + lock = InstanceLock(data_dir) + lock.acquire() + logger.info("已获取实例锁 %s(PID %d)", lock.path, os.getpid()) + return lock diff --git a/src/key_quota.py b/src/key_quota.py index 237906ac..75b03ca1 100644 --- a/src/key_quota.py +++ b/src/key_quota.py @@ -202,43 +202,48 @@ def inflight(self, key_id: str) -> int: # ------------------------------------------------------------ 准入 def admit(self, key_id: str, quota: KeyQuota, *, - tokens: int = 0) -> Lease: + tokens: int = 0, subject: str = "key") -> Lease: """判定并登记一次请求;超限抛 `QuotaExceeded`,放行返回 `Lease`。 `tokens` 是本次请求的**预估** token 数(输入估算 + 输出上限),因为 真实用量要等响应结束才知道,而 TPM 必须在转发前判定。预估偏大时 略保守、偏小时略宽松,都不影响「同一分钟内不超发」的语义。 + `subject` 只进 429 文案(`key` / `user`):同一个 guard 同时服务 + Key 级配额与用户级配额(Playground),调用方能一眼看出超的是哪一层。 + 判定过程中的任何异常(当日聚合查库失败、脏配置等)一律**放行**并记 warning:配额是流量治理而非安全边界,判定挂了不能把出口锁死。 """ try: - return self._admit(key_id, quota, tokens=max(0, tokens)) + return self._admit(key_id, quota, tokens=max(0, tokens), + subject=subject) except QuotaExceeded: raise except Exception as error: # noqa: BLE001 - 配额判定失败必须放行 logger.warning("Key 配额判定失败,本次放行: %s", error) return Lease(self, key_id, counted=False) - def _admit(self, key_id: str, quota: KeyQuota, *, tokens: int) -> Lease: + def _admit(self, key_id: str, quota: KeyQuota, *, tokens: int, + subject: str) -> Lease: moment = self._now() if quota.is_empty(): return Lease(self, key_id, counted=False) - self._check_daily(key_id, quota, moment) + self._check_daily(key_id, quota, moment, subject) events = self._prune(key_id, moment) if quota.rpm and len(events) >= quota.rpm: raise QuotaExceeded( - f"key quota exceeded: {quota.rpm} requests per minute", + f"{subject} quota exceeded: {quota.rpm} requests per minute", _window_retry(events, moment, self._window)) used_tokens = sum(count for _, count in events) if quota.tpm and used_tokens + tokens > quota.tpm: raise QuotaExceeded( - f"key quota exceeded: {quota.tpm} tokens per minute", + f"{subject} quota exceeded: {quota.tpm} tokens per minute", _window_retry(events, moment, self._window)) if quota.max_concurrency and self.inflight(key_id) >= quota.max_concurrency: # 并发名额随请求结束释放,正常几秒内就有空位;给 1 秒让客户端退避。 raise QuotaExceeded( - f"key quota exceeded: {quota.max_concurrency} concurrent requests", + f"{subject} quota exceeded: {quota.max_concurrency} concurrent requests", 1) self._events.setdefault(key_id, deque()).append((moment, tokens)) self._inflight[key_id] = self.inflight(key_id) + 1 @@ -253,7 +258,8 @@ def release(self, key_id: str) -> None: # ------------------------------------------------------------ 内部 - def _check_daily(self, key_id: str, quota: KeyQuota, moment: float) -> None: + def _check_daily(self, key_id: str, quota: KeyQuota, moment: float, + subject: str) -> None: """当日累计配额:查一次聚合(只有配了当日类限额才查)。""" if not (quota.daily_requests or quota.daily_tokens or quota.daily_cost_usd): return @@ -263,13 +269,13 @@ def _check_daily(self, key_id: str, quota: KeyQuota, moment: float) -> None: retry = int(day_start(int(moment)) + 86400 - moment) if quota.daily_requests and usage.requests >= quota.daily_requests: raise QuotaExceeded( - f"key quota exceeded: {quota.daily_requests} requests per day", retry) + f"{subject} quota exceeded: {quota.daily_requests} requests per day", retry) if quota.daily_tokens and usage.tokens >= quota.daily_tokens: raise QuotaExceeded( - f"key quota exceeded: {quota.daily_tokens} tokens per day", retry) + f"{subject} quota exceeded: {quota.daily_tokens} tokens per day", retry) if quota.daily_cost_usd and usage.cost_usd >= quota.daily_cost_usd: raise QuotaExceeded( - f"key quota exceeded: ${quota.daily_cost_usd:g} per day", retry) + f"{subject} quota exceeded: ${quota.daily_cost_usd:g} per day", retry) def _prune(self, key_id: str, moment: float) -> deque[tuple[float, int]]: """丢弃窗口外的事件;窗口空了就把该 Key 的条目一起删掉(防无界增长)。""" diff --git a/src/main.py b/src/main.py index 8e4dde83..694c021b 100644 --- a/src/main.py +++ b/src/main.py @@ -23,7 +23,9 @@ admin_alerts, admin_audit, admin_auth, + admin_backups, admin_badges, + admin_billing, admin_credentials, admin_keys, admin_model_availability, @@ -43,7 +45,7 @@ responses, usage, ) -from .api.context import build_context_compressor +from .api.context import build_context_compressor, build_prefix_observer from .api.deps import Services from .auth.throttle import LoginThrottle from .benchmarks import ( @@ -62,19 +64,24 @@ validate_codebuddy_intl_endpoint_allowed, validate_endpoint_allowed, validate_kilo_endpoint_allowed, + validate_lingxi_endpoint_allowed, validate_officeace_endpoint_allowed, validate_qoder_endpoint_allowed, + validate_raccoon_endpoint_allowed, validate_zen_endpoint_allowed, ) +from .db.backup import apply_pending_restore from .db.conn import Database -from .db.crypto import CredentialCipher +from .db.crypto import CredentialCipher, CredentialDecryptError from .db.key_usage import KeyUsageRepository from .db.limits import LimitsRepository, day_usage from .db.migrate import apply_schema +from .db.paid_models import PaidModelRepository from .db.repo import ( AlertRepository, ApiKeyRepository, AuditRepository, + CheckinEventRepository, CredentialRepository, CreditEventRepository, GrowthRepository, @@ -84,26 +91,40 @@ ) from .engine.affinity import ConversationAffinity from .engine.executor import Executor, ExecutorDeps +from .engine.half_open import HalfOpenGuard from .engine.inflight import InflightGuard from .engine.limits import LimitGuard from .engine.model_resolver import KNOWN_PROVIDERS, parse_fallback_groups from .engine.scheduler import Scheduler +from .instance_lock import AlreadyRunning, hold_instance_lock from .key_quota import KeyQuotaGuard from .provider.codearts import CodeArtsProvider from .provider.codearts.client import CodeArtsClient from .provider.codearts.oauth import CodeArtsOAuth from .provider.codebuddy.client import CodeBuddyClient, CodeBuddyProvider from .provider.codebuddy.intl import CodeBuddyIntlProvider +from .provider.codebuddy.model_access_store import ( + cache_path as model_access_cache_path, +) from .provider.codebuddy.oauth import CodeBuddyOAuth from .provider.kilo.client import KiloClient, KiloProvider +from .provider.lingxi import LingxiProvider +from .provider.lingxi.client import LingxiClient from .provider.officeace import OfficeAceProvider from .provider.officeace.client import OfficeAceClient from .provider.officeace.oauth import OfficeAceOAuth -from .provider.proxy import parse_provider_proxies +from .provider.probe_failure import relogin_reason +from .provider.proxy import ( + CredentialProxyPool, + parse_provider_proxies, + parse_proxy_pool, + set_proxy_pool, +) from .provider.qoder import QoderProvider from .provider.qoder.auth import QoderOAuth from .provider.qoder.client import QoderClient from .provider.qoder.events import detect_realm_from_domain +from .provider.raccoon.client import RaccoonClient, RaccoonProvider from .provider.trae.client import TraeClient, TraeProvider from .provider.zen.client import ZenClient, ZenProvider from .runtime_settings import load_runtime_settings @@ -129,10 +150,17 @@ logger = logging.getLogger(__name__) -# SIGTERM 后允许在途请求(含长流)收尾的最长秒数。三处必须一致: -# 本常量、deploy/systemd 的 TimeoutStopSec、docker-compose 的 stop_grace_period -# (后者是容器给进程的停机窗口,必须 >= 本值,否则 SIGKILL 会打断排空)。 -GRACEFUL_SHUTDOWN_SECONDS = 30 +# SIGTERM 后允许在途请求(含长流)收尾的最长秒数。四处必须一致: +# 本常量、deploy/launchd 的 ExitTimeOut、deploy/systemd 的 TimeoutStopSec、 +# docker-compose 的 stop_grace_period(后三者是「平台给进程的停机窗口」, +# 必须 >= 本值,否则 SIGKILL 会打断排空)。 +# +# 30s 的来历已不成立:实测 p99 延迟 57.7s、7 天内 27 次请求超过 120s +# (`usage_events.latency_ms`),30s 窗口下日志留下 38 次 +# `Cancel N running task(s), timeout graceful shutdown exceeded` —— 每次重启 +# 都在硬砍长流请求,统计也落不下来。90s 覆盖到 p99 以上,超过这个量级的 +# 只剩「客户端本来就会断开」的极长会话。 +GRACEFUL_SHUTDOWN_SECONDS = 90 # --------------------------------------------------------------- 向后兼容再导出 # 这些名字历史上住在 main.py,测试与外部脚本直接 import。真实定义已移到 @@ -215,6 +243,27 @@ def _kilo_endpoint(config: Settings) -> str: allowed_name="KILO_ALLOWED_ENDPOINTS") +def _raccoon_endpoint(config: Settings) -> str: + """解析小浣熊端点并强制白名单校验(防把用户会话 JWT 发往未授权主机)。""" + return _resolve_endpoint(config, attr="raccoon_api_endpoint", + validator=validate_raccoon_endpoint_allowed, + env_name="RACCOON_API_ENDPOINT", + allowed_name="RACCOON_ALLOWED_ENDPOINTS") + + +def _lingxi_endpoint(config: Settings) -> str: + """解析灵犀端点并强制白名单校验(防把 WPS 登录态发往未授权主机)。 + + 回落域(`lingxi.kdocs.cn`)不在这里解析:它由客户端的 `bases()` 固定追加, + 同一份白名单校验(漏收会让回落直接 404)。 + """ + return _resolve_endpoint(config, attr="lingxi_api_endpoint", + validator=validate_lingxi_endpoint_allowed, + env_name="LINGXI_API_ENDPOINT", + allowed_name="LINGXI_ALLOWED_ENDPOINTS", + strip_slash=True) + + def _qoder_host(config: Settings) -> str: """解析 Qoder openapi 端点并强制白名单校验。""" return _resolve_endpoint(config, attr="qoder_api_endpoint", @@ -355,6 +404,8 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None # 容器 / systemd 均如此),不经过 run(),否则审计等 INFO 日志仍被丢弃。 # 幂等,测试反复调用 build_app 不会叠加 handler。 configure_logging(config.log_level) + # 管理台上传的待恢复备份在**建连接之前**应用;坏文件只留档、不阻断启动 + apply_pending_restore(config.db_path) db = Database(config.db_path) apply_schema(db.connect()) # 运行时配置覆盖层:热更键读 DB 覆盖值,其余透明委托给 env 快照。 @@ -364,6 +415,7 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None credentials = CredentialRepository(db, cipher) growth_events = GrowthRepository(db) credit_events = CreditEventRepository(db) + checkin_events = CheckinEventRepository(db) api_keys = ApiKeyRepository(db) user_repo = UserRepository(db) audit = AuditRepository(db) @@ -396,6 +448,16 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None kilo_pacer = Pacer(lambda: runtime.kilo_chat_min_interval, lambda: runtime.kilo_chat_min_interval, allow_concurrent=True) + # 小浣熊独立 pacer:真实账号渠道,上游并发上限约 5(实测第 6 个 429), + # 用 max_concurrency 兜住;与其余渠道互不排队。 + raccoon_pacer = Pacer(lambda: runtime.raccoon_chat_min_interval, + lambda: runtime.raccoon_chat_min_interval, + allow_concurrent=True, + max_concurrency=lambda: runtime.raccoon_max_concurrency) + # 灵犀独立 pacer:真实账号渠道(按 run 计费),默认不节流;与其余渠道互不排队。 + lingxi_pacer = Pacer(lambda: runtime.lingxi_chat_min_interval, + lambda: runtime.lingxi_chat_min_interval, + allow_concurrent=True) # Qoder / CodeArts 是真实账号渠道,上游按账号频控;各自独立 pacer, # 与其余渠道互不排队(同渠道同凭证共桶、桶内允许并发)。 qoder_pacer = Pacer(lambda: runtime.qoder_chat_min_interval, @@ -414,6 +476,8 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None # 按渠道出站代理:启动期解析一次,装配时注入到各 provider 的 # httpx client;运行中改值需重启(连接池已建立,见 provider/proxy.py)。 proxies = parse_provider_proxies(config.provider_proxies, KNOWN_PROVIDERS) + # 出口 IP 粘性:池按凭证 id 稳定分配,装配期一次设定(连接池已建,改值需重启) + set_proxy_pool(CredentialProxyPool(parse_proxy_pool(config.credential_proxy_pool))) registry = providers if providers is not None else { "trae": TraeProvider( client=TraeClient(proxy=proxies.get("trae")), pacer=chat_pacer), @@ -430,10 +494,20 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None # 国际站网关硬性要求首条 system(实测 400/11128),国内站无此要求 require_leading_system=True, proxy=proxies.get("codebuddy_intl"), + # 国际站指纹:稳定会话 id + 官方版本常量 + 会话/模型头 + JWT uid; + # 体走 gzip(官方 CLI 形态);探活结果落盘,避免重启后 20 连发 + intl=True, + gzip_body=True, + access_cache_path=model_access_cache_path(config.data_dir), ), pacer=codebuddy_intl_pacer), "zen": ZenProvider( client=ZenClient(host=_zen_endpoint(config), version=config.zen_opencode_version, + api_root=config.zen_api_root, + gate_tools=tuple( + t.strip() for t in config.zen_gate_tools.split(",") + if t.strip()) or ("bash", "read"), + user_agent=config.zen_user_agent, proxy=proxies.get("zen")), pacer=zen_pacer), "kilo": KiloProvider( @@ -449,6 +523,14 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None client=CodeArtsClient(endpoint=_codearts_endpoint(config), proxy=proxies.get("codearts")), pacer=codearts_pacer), + "raccoon": RaccoonProvider( + client=RaccoonClient(host=_raccoon_endpoint(config), + proxy=proxies.get("raccoon")), + pacer=raccoon_pacer), + "lingxi": LingxiProvider( + client=LingxiClient(host=_lingxi_endpoint(config), + proxy=proxies.get("lingxi")), + pacer=lingxi_pacer), "officeace": OfficeAceProvider( client=OfficeAceClient(cloud_base=_officeace_endpoint(config), proxy=proxies.get("officeace")), @@ -492,9 +574,15 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None load_catalog_snapshot(config.data_dir)) except Exception as error: # noqa: BLE001 - 价表是加速手段,失败不阻断服务 logger.warning("恢复落盘模型目录失败: %s", error) + # 目录 x0 的实测校准:上游改计费不改目录时,用 `usage_events` 里 + # `credit_estimated=0` 的真实扣费把「免费」纠正成实际倍率,否则免费档 + # 会一直压过到期积分(见 `db/paid_models.py` 的模块 docstring)。 + # 先于 StatsCollector 创建:写入回调要 `invalidate()` 它,让「刚观测到的 + # 真扣费」立刻参与选号,而不是等 600s 快照 TTL。 + paid_models = PaidModelRepository(db) stats_collector = StatsCollector( db, prices=lambda: price_table, usd_cny_rate=lambda: runtime.usd_cny_rate, - on_record=live_events.publish) + on_record=live_events.publish, on_write=paid_models.invalidate) # 凭证级限额护栏:配置表 + 当日用量聚合注入 LimitGuard(快照缓存), # executor 选号前用它过滤触顶候选;管理台改完 invalidate() 立即生效。 limits_repo = LimitsRepository(db) @@ -507,9 +595,15 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None # 滑动窗口——不落库、重启清零,单进程部署下即精确。 key_usage = KeyUsageRepository(db) key_quota = KeyQuotaGuard(key_usage.for_key) + # 用户级配额(Playground 会话直连):按用户名聚合当日用量——该用户经任何 + # 入口的流量都计入,否则「你自己 Key 刷爆了但仍能用 Playground 无限调」。 + user_quota = KeyQuotaGuard(key_usage.for_user) # 凭证在途租约:跨渠道的「单账号并发上限」,选号前过滤 + 请求结束释放。 # 上限现读运行态覆盖层,管理台改完下一请求即生效(无快照,无需 invalidate)。 inflight_guard = InflightGuard(lambda: runtime.max_inflight_per_credential) + # 冷却到期半开探测:到期先放 1 个请求,成功再全量回池(进程内状态, + # 重启丢失只会让每个号多探一次)。 + half_open_guard = HalfOpenGuard() executor = Executor(ExecutorDeps(providers=registry, credentials=credentials, scheduler=Scheduler( expiry_window=lambda: runtime.quota_expiry_window_seconds, @@ -549,13 +643,62 @@ def build_app(settings: Settings | None = None, *, providers: dict | None = None # 同一份 dict 的引用:模型目录恢复 / 拉取后 # executor 立即可见(倍率查询走它) model_list_cache=model_cache, + # 实测扣费校准:目录写 x0 但最近 24h 有真实 + # 扣费的 (渠道, 模型) 按实测均值覆盖,见 + # `Executor._observed_paid_rate` + paid_models=paid_models.observed_rates, # 上下文压缩(修订):选号后按实际服务 # 渠道的窗口裁剪;settings 传运行态覆盖层, # 开关与四个参数每请求现读(热更) context_compress=build_context_compressor( model_cache, model_aliases, runtime), + # 前缀缓存诊断(默认关):命中率异常时 + # 在管理台打开,日志里就能看到前缀在哪条 + # 消息分叉、压缩是否每轮都在裁 + prefix_debug=build_prefix_observer(runtime), limits=limit_guard, - inflight=inflight_guard)) + inflight=inflight_guard, + half_open=half_open_guard)) + + # 即时额度探测:新增凭证 / OAuth 保存 / 账号切换 / 签到成功后重探测。 + # 定义在 lifespan 之前:签到任务要在装配时拿到它(签到=积分变化的那一刻, + # 等下一轮周期探测会让「套餐明细」和额度合计最多旧一小时)。 + def schedule_probe(credential_id: str) -> None: + """新增凭证 / OAuth 保存 / 账号切换 / 签到后立即重探测(不阻塞响应)。 + + 周期扫描是 60 分钟一轮,若不等这一轮,刚加进来的凭证在界面上会一直 + 显示「未探测到额度」,调度器也只能把它排在 known 之后。 + """ + provider_id = credentials.provider_of(credential_id) + provider = registry.get(provider_id or "") + # 解不开(APP_SECRET 换过 / 密文损坏)→ 跳过这条凭证,不让后台任务 + # 因一条坏凭证整轮失败 + try: + data = credentials.credential_data(credential_id) + except CredentialDecryptError: + return + if provider is None or data is None: + return + + async def probe() -> None: + try: + quota = await provider.probe_quota(data) + except Exception as error: # noqa: BLE001 - 探测失败标记为未探测 + logger.warning("即时额度探测失败 %s: %s", credential_id, error) + reason = relogin_reason(provider_id or "", error) + if reason: + # 刚导入 / 刚签到的号若已 401,立即停用:否则下一轮调度 + # 还会挑中它再撞一次,管理台也要等聊天失败才知道要重贴。 + credentials.mark_relogin_required(credential_id, reason) + else: + credentials.mark_probe_failed(credential_id) + return + credentials.save_quota(credential_id, quota) + + # 完成后从列表里摘除:否则长时间运行会无限累积已完成的 Task 对象 + task = asyncio.create_task(probe()) + app.state.pending_probes.append(task) + task.add_done_callback(lambda done: _forget_task(done, app.state.pending_probes)) @asynccontextmanager async def lifespan(app_: FastAPI): @@ -621,6 +764,7 @@ async def _refresh_model_catalog() -> dict[str, int]: runner = build_runner(credentials, registry, app_.state.stats_collector, runtime, growth_events=app_.state.growth_events, credit_events=credit_events, + checkin_events=checkin_events, audit=audit, alerts=alerts, backup=BackupTask(config.db_path, @@ -630,10 +774,18 @@ async def _refresh_model_catalog() -> dict[str, int]: model_probe_minutes=lambda: runtime.model_probe_minutes, model_catalog=_refresh_channel_models, openrouter_catalog=_refresh_model_catalog, + on_checkin_success=schedule_probe, openrouter_catalog_minutes=( lambda: runtime.openrouter_catalog_minutes)) app_.state.task_runner = runner - await runner.start() + # **首轮探测/刷新放后台**:`start()` 内联 await 的是两个真实的上游网络 + # 调用(quota_probe 每凭证一个请求、token 预刷新同样出网),最慢十几秒; + # 内联会让 lifespan startup 一直不结束 → uvicorn 还没开始 accept, + # 容器/launchd 的健康探针在窗口内判 unhealthy(重启循环)。这也与上面 + # 预热三兄弟「不内联 await」的口径一致。 + # 关闭期先收尾这个 task 再停循环(见 finally):否则 stop 先跑, + # start 又把周期循环建出来,那些循环没人 cancel。 + app_.state.task_runner_start_task = asyncio.create_task(runner.start()) # 预热模型别名表:放后台跑(force 绕过 TTL)。 # 不内联 await 的原因:zen 免费层探活最慢的模型可占十几秒,内联会让应用 # 在这段时间里不响应 /health,容器存活探针可能误判;动态拉取失败仅记日志。 @@ -651,6 +803,14 @@ async def _refresh_model_catalog() -> dict[str, int]: try: yield finally: + # **先等 start 收尾**:start 未完成时周期循环多半还没建出来, + # 先 stop 是 no-op,随后 start 把循环建出来 → 无人 cancel 的孤儿 + # 任务带着事件循环跑。start 里的两个 run_once 均在 `_guarded` + # 内(异常已吞),这里即使抛 CancelledError 也要继续 stop。 + start_task = getattr(app_.state, "task_runner_start_task", None) + if start_task is not None and not start_task.done(): + with contextlib.suppress(Exception): + await start_task await runner.stop() warmup = getattr(app_.state, "model_warmup_task", None) if warmup is not None and not warmup.done(): @@ -713,6 +873,7 @@ async def _refresh_model_catalog() -> dict[str, int]: app.state.executor = executor app.state.stats_collector = stats_collector app.state.growth_events = growth_events + app.state.checkin_events = checkin_events app.state.stats_query = StatsQuery(db) # SSE 实时流的订阅中心(与 StatsCollector 的 on_record 同源) app.state.live_events = live_events @@ -736,37 +897,12 @@ async def _refresh_model_catalog() -> dict[str, int]: app.state.upstream_status = upstream_status_store # 路由层共享依赖容器(TECHNICAL §2 deps.py) - def schedule_probe(credential_id: str) -> None: - """新增凭证 / OAuth 保存 / 账号切换 / 签到后立即重探测(不阻塞响应)。 - - 周期扫描是 60 分钟一轮,若不等这一轮,刚加进来的凭证在界面上会一直 - 显示「未探测到额度」,调度器也只能把它排在 known 之后。 - """ - provider_id = credentials.provider_of(credential_id) - provider = registry.get(provider_id or "") - data = credentials.credential_data(credential_id) - if provider is None or data is None: - return - - async def probe() -> None: - try: - quota = await provider.probe_quota(data) - except Exception as error: # noqa: BLE001 - 探测失败标记为未探测 - logger.warning("即时额度探测失败 %s: %s", credential_id, error) - credentials.mark_probe_failed(credential_id) - return - credentials.save_quota(credential_id, quota) - - # 完成后从列表里摘除:否则长时间运行会无限累积已完成的 Task 对象 - task = asyncio.create_task(probe()) - app.state.pending_probes.append(task) - task.add_done_callback(lambda done: _forget_task(done, app.state.pending_probes)) - services = Services( settings=runtime, credentials=credentials, growth_events=growth_events, credit_events=credit_events, + checkin_events=checkin_events, api_keys=api_keys, executor=executor, registry=registry, @@ -779,6 +915,7 @@ async def probe() -> None: limit_guard=limit_guard, key_quota=key_quota, key_usage=key_usage, + user_quota=user_quota, stats_query=app.state.stats_query, login_throttle=app.state.login_throttle, live_events=live_events, @@ -837,12 +974,14 @@ async def healthz(): app.include_router(admin_credentials.create_router(services)) app.include_router(admin_keys.create_router(services)) app.include_router(admin_settings.create_router(services)) + app.include_router(admin_backups.create_router(services)) app.include_router(admin_model_catalog.create_router(services)) app.include_router(admin_model_availability.create_router(services)) app.include_router(admin_alerts.create_router(services)) app.include_router(admin_badges.create_router(services)) app.include_router(admin_upstream.create_router(services)) app.include_router(admin_stats.create_router(services)) + app.include_router(admin_billing.create_router(services)) app.include_router(admin_users.create_router(services)) app.include_router(admin_audit.create_router(services)) # 服务端日志页(E3):admin-only 只读视图;`log_buffer_lines=0` 时回 404。 @@ -948,24 +1087,52 @@ def _load_users(*, settings: Settings, user_repo): def run() -> None: """本地启动入口:python -m src.main 或 coding2api 命令。""" + import sys + import uvicorn config = load_settings() # 应用日志(审计、上游错误等)在此之前无 handler 会被丢弃 configure_logging(config.log_level) - uvicorn.run( - build_app(config), host=config.host, port=config.port, - log_level=config.log_level.lower(), - # 关掉 uvicorn 自带 access log:请求级留痕已由 RequestLogMiddleware - # 覆盖(logfmt,带时间戳与 request_id,可与 usage_events 关联),而 - # uvicorn 的裸行无时间戳、含完整 query string,只是把同样的请求再写满 - # 另一个轮转文件。 - access_log=False, - # 收到 SIGTERM 后先停止接受新连接,给在途请求(含长流)最多 - # GRACEFUL_SHUTDOWN_SECONDS 秒收尾,再强关。默认 None 是无限等, - # 配合容器/launchd 的停机超时会变成「卡住直到被 SIGKILL」。 - timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_SECONDS, - ) + # 单实例保护必须在 build_app 之前:后者会建库、跑迁移、应用待恢复备份, + # 这些写操作在第二个实例上重复执行就是双写(见 src/instance_lock.py)。 + try: + instance_lock = hold_instance_lock(config.data_dir) + except AlreadyRunning as error: + # **退出码 0 而不是非零**:这是「已经有人在服务」,不是故障。 + # 三条启动路径的守护策略都是「非零退出才重拉」 + # (launchd KeepAlive.SuccessfulExit=false、systemd Restart=on-failure、 + # compose restart),用非零退出会变回每 10s 一次的重启循环—— + # 实测 2026-09-19~10-17 刷了 2377 条 `[Errno 48]`,完整 traceback + # 把真正的故障挤出视野。真崩溃仍然是非零退出,照样被拉起来。 + logger.error("%s", error) + print(f"启动中止(这不是故障,已有实例在服务):{error}", file=sys.stderr) + return + try: + uvicorn.run( + build_app(config), host=config.host, port=config.port, + log_level=config.log_level.lower(), + # 关掉 uvicorn 自带 access log:请求级留痕已由 RequestLogMiddleware + # 覆盖(logfmt,带时间戳与 request_id,可与 usage_events 关联),而 + # uvicorn 的裸行无时间戳、含完整 query string,只是把同样的请求再写满 + # 另一个轮转文件。 + access_log=False, + # 收到 SIGTERM 后先停止接受新连接,给在途请求(含长流)最多 + # GRACEFUL_SHUTDOWN_SECONDS 秒收尾,再强关。默认 None 是无限等, + # 配合容器/launchd 的停机超时会变成「卡住直到被 SIGKILL」。 + timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_SECONDS, + # **必须关**:uvicorn 自带的 proxy_headers=True 会把对端 ∈ + # forwarded_allow_ips(默认 127.0.0.1)请求的 `scope["client"]` + # 改写成 `X-Forwarded-For` 尾条目——于是「采信不采信 XFF」就轮不到 + # 应用层的 `TRUST_PROXY` 判定了(nginx/frpc 回源、容器 healthcheck、 + # 本机 curl 全命中回环)。后果:`TRUST_PROXY=false` 部署下,能连进 + # 回环端口的调用方自带一个 XFF 头即可伪造来源 IP,绕过 api_keys 的 + # IP 白名单与登录限流桶。来源解析统一收归 `access.client_ip`: + # `TRUST_PROXY` 决定一切,uvicorn 只做搬运。 + proxy_headers=False, + ) + finally: + instance_lock.release() if __name__ == "__main__": # pragma: no cover diff --git a/src/provider/auth_state.py b/src/provider/auth_state.py new file mode 100644 index 00000000..2b82bc37 --- /dev/null +++ b/src/provider/auth_state.py @@ -0,0 +1,113 @@ +"""OAuth pending 登录会话的共享登记表。 + +四个渠道(CodeBuddy / Qoder / CodeArts / OfficeAce)的 `AuthStateStore` 曾各写 +一份,其中 `owner` / `consume` / `cancel` / `cleanup` 逐字相同。差异只在 +`begin` 的载荷(上游 state / verifier+nonce / LoginSession)与取值方法名, +故基类只做「登记 + 归属校验 + 消费 + 过期清理」,载荷由泛型承载。 + +对外 `state` 一律是**本地生成的 reservation**,不是上游签发的 state:上游 +state 会写进授权链接(浏览器可见),拿它当会话键等于让人凭 URL 消费别人的 +登录。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +import time +from dataclasses import dataclass + + +@dataclass(slots=True) +class Reservation[T]: + """一条 pending 登录:归属人 + 创建时刻 + 渠道私有载荷。""" + + username: str + created_at: int + payload: T + + +class AuthStateStore[T]: + """state 的归属校验、消费与过期清理。 + + `max_pending` 非 None 时在 `begin` 里顶掉最老的会话:攒了太多没登完的 + 就拒绝会让人以为功能坏了,而每条 pending 会话都在轮询上游,留一堆只是 + 浪费(OfficeAce 的实测需求)。 + """ + + def __init__(self, ttl_seconds: int, *, + max_pending: int | None = None) -> None: + self._ttl = ttl_seconds + self._max_pending = max_pending + self._entries: dict[str, Reservation[T]] = {} + + def begin(self, username: str, payload: T, now: int | None = None) -> str: + self.cleanup(now) + if self._max_pending is not None: + while len(self._entries) >= self._max_pending: + oldest = min(self._entries, + key=lambda key: self._entries[key].created_at) + del self._entries[oldest] + reservation = secrets.token_urlsafe(18) + self._entries[reservation] = Reservation( + username=username, + created_at=int(now if now is not None else time.time()), + payload=payload) + return reservation + + def owner(self, auth_state: str, username: str, now: int | None = None) -> bool: + """归属校验,**含 TTL**:过期 reservation 不得再轮询/重放。 + + `cleanup()` 只在 `begin()` 时触发;若 owner 不判过期,一个过期的 + state 在下次 begin 之前会一直可被轮询(每次轮询都打上游)。 + """ + entry = self._entries.get(auth_state) + if entry is None or entry.username != username: + return False + current = int(now if now is not None else time.time()) + if current - entry.created_at >= self._ttl: + del self._entries[auth_state] # 惰性清理,避免字典堆积 + return False + return True + + def payload(self, auth_state: str, username: str, + now: int | None = None) -> T | None: + """归属校验通过才返回载荷(调用方据此轮询上游 / 取私钥)。""" + if not self.owner(auth_state, username, now): + return None + return self._entries[auth_state].payload + + def reservation(self, auth_state: str, username: str, + now: int | None = None) -> Reservation[T] | None: + """归属校验通过才返回整条登记(含归属人 / 创建时刻 / 载荷)。""" + if not self.owner(auth_state, username, now): + return None + return self._entries[auth_state] + + def consume(self, auth_state: str, username: str, now: int | None = None) -> bool: + """消费:成功后该 state 不可再次轮询或重放。""" + if not self.owner(auth_state, username, now): + return False + del self._entries[auth_state] + return True + + def cancel(self, auth_state: str, username: str, now: int | None = None) -> bool: + return self.consume(auth_state, username, now) + + def cleanup(self, now: int | None = None) -> None: + current = int(now if now is not None else time.time()) + for key in [k for k, v in self._entries.items() + if current - v.created_at >= self._ttl]: + del self._entries[key] + + def __len__(self) -> int: + return len(self._entries) + + +def pkce_pair(verifier: str | None = None) -> tuple[str, str]: + """PKCE `(code_verifier, code_challenge)`;challenge = BASE64URL(SHA256(v))。""" + value = verifier or secrets.token_urlsafe(32) + digest = hashlib.sha256(value.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return value, challenge diff --git a/src/provider/base.py b/src/provider/base.py index 07dbd9c7..46720dbe 100644 --- a/src/provider/base.py +++ b/src/provider/base.py @@ -6,12 +6,13 @@ from __future__ import annotations +import json import math import re import time from collections.abc import AsyncIterator, Callable from dataclasses import dataclass, field -from datetime import UTC +from datetime import UTC, datetime, timedelta, timezone from email.utils import parsedate_to_datetime from enum import StrEnum from typing import Any, ClassVar, Protocol, runtime_checkable @@ -29,7 +30,7 @@ class EventKind(StrEnum): class ErrKind(StrEnum): """错误分类 → 冷却时长。 - 分类的粒度决定冷却的作用域:账号级(PLAN/SOFT/DEAD/OTHER)与 + 分类的粒度决定冷却的作用域:账号级(PLAN/SOFT/DEAD/OTHER/RESTRICTED)与 模型级(MODEL/BLOCKED)必须分开,否则单个模型打满会连累同账号的 其他模型(实测 6004 只冷却触发的那个模型)。 """ @@ -39,6 +40,11 @@ class ErrKind(StrEnum): CREDIT = "credit" SOFT = "soft" # 限流/404 → 60s,不累计错误数 DEAD = "dead" # session 失效 → 硬禁用 + # 账号被上游风控限制(CodeBuddy 11140「request illegal」):账号级长冷却、 + # **当前请求不再换号**。与 DEAD 的区别:DEAD 是凭据本身失效(重登可恢复, + # 因此永久禁用 + 人工 revive),RESTRICTED 是上游按账号/IP 拉黑(本地无解, + # 到期自动半开,逐个号去撞只会扩大标记面)。 + RESTRICTED = "restricted" OTHER = "other" # 其他 4xx/5xx → 累计,连续 3 次 → 10m INVALID = "invalid" # 请求无效(如模型不存在)→ 不冷却凭证,直接 400 回客户端 # 模型级限流(6004):只冷却触发的那个模型,切其他模型立即可用 @@ -48,10 +54,33 @@ class ErrKind(StrEnum): CONCURRENCY = "concurrency" # 该后端无此模型(11102):(账号, 模型) 负缓存,重试无意义 BLOCKED = "blocked" - # 请求级错误(请求体坏 11101 / 上下文超限 11115 / 渠道风控 11128 / - # 图片无效 11135 / CodeArts 非多模态模型收到图片 001001020):不是账号 - # 的问题,不冷却、不累计;仍会换号重试 + # 请求级错误(请求体坏 11101 / 渠道风控 11128 / 图片无效 11135):不是 + # 账号的问题,不冷却、不累计;仍会换号重试。上下文超限(11115)走 + # CONTEXT、模型能力拒绝(CodeArts 001001020)走 CAPABILITY,三者处置 + # 相同但统计口径分开(见各自定义)。 REQUEST = "request" + # 上下文超限(CodeBuddy 11115 `prompt is too long: N > M maximum`):与 + # REQUEST 同一处置(不冷却、不累计、换号重试),但统计要单独成类—— + # 归进 `invalid_request` 时归因面板显示「请求无效」,读起来像客户端参数 + # 写错了,实际动作是「精简上下文或换长窗口模型」(实测 2026-11-02 23:32 + # `1056943 tokens > 1048576 maximum`)。 + CONTEXT = "context" + # 模型能力不匹配(CodeArts `InferHub.001001020.406 The request model is not + # multimodal`):与 INVALID 同一处置(跳过整个上游、全部拒绝才 400), + # 但统计单独成类——「请求无效」会让运维去查参数,实际是「这个模型不支持 + # 这次请求形态(带图),换支持多模态的模型/渠道」。 + CAPABILITY = "capability" + # 排队中(上游明确回 `isQueued` / `RESOURCE_LIMIT` + `retryAfterSeconds`): + # 既不是账号故障也不是请求错误,是「这个号现在满了,等 N 秒再打它」。 + # 不冷却、不累计、不占轮换预算,等在原号上重开(见 executor 的排队重开)。 + # 与 SOFT 的关键差别:SOFT 冷却 60s 且换号,排队按上游点名的秒数等在原号。 + QUEUE = "queue" + # 本机网络/DNS 故障(`httpx.ConnectError` / `socket.gaierror`):失败发生在 + # 连上游之前,与具体凭证无关。归 SOFT 会冷却整池健康号(实测 2026-11-05 + # 睡眠唤醒后 DNS 未就绪,六渠道同时 ENOTFOUND,短冷却把 codebuddy 全部号 + # 打成「无可用凭证」)。与 REQUEST/CONTEXT 同为**零动作**,但统计口径单独 + # 成类,避免归因面板把本机故障读成「上游超时」。 + NETWORK = "network" # 冷却作用域是「(凭证, 模型)」而非整个凭证的错误类别。 @@ -59,6 +88,16 @@ class ErrKind(StrEnum): # 否则上一次模型级限流的豁免会泄漏到本次账号级冷却上(换模型错误绕过)。 MODEL_SCOPED_KINDS = frozenset({ErrKind.MODEL, ErrKind.BLOCKED, ErrKind.CONCURRENCY}) +# 零动作分类:不是账号/凭证的错,不冷却、不累计,只换号重试。 +# REQUEST = 请求体坏 / 渠道风控;CONTEXT = 上下文超限(11115); +# NETWORK = 本机 DNS/连接失败(连上游之前就失败,惩罚凭证只会放大网络抖动)。 +# 与 INVALID 的区别:INVALID 跳过**整个上游**(换号也回同一条),这些只换号。 +NO_PENALTY_KINDS = frozenset({ErrKind.REQUEST, ErrKind.CONTEXT, ErrKind.NETWORK}) + +# 「这次请求形态上游不接受」→ 换号无用,跳过整个上游。EXECUTOR 对这两类都 +# `_skip_provider` + 以 400 收尾;统计口径分开(invalid_request / model_capability)。 +REJECT_KINDS = frozenset({ErrKind.INVALID, ErrKind.CAPABILITY}) + def body_hint(body: bytes, limit: int = 2048) -> str: """上游错误响应体的单行摘要(进日志便于定位拒绝原因)。 @@ -153,7 +192,7 @@ def _retry_after_delta(raw: str, now: int) -> float | None: def parse_retry_after(headers: Any, now: int) -> int | None: """上游 `Retry-After` → 「何时恢复」的 epoch 秒;缺头/非法返回 None。 - 这是唯一解析入口:八渠道在构造 `UpstreamHTTPError` 时统一调用 + 这是唯一解析入口:九渠道在构造 `UpstreamHTTPError` 时统一调用 `retry_hints`(本函数的薄包装),再交给调度器按错误分类的作用域钳制。 """ delta = _retry_after_delta(_header_value(headers, "retry-after"), now) @@ -170,14 +209,62 @@ def parse_retry_after_seconds(headers: Any, now: int) -> float | None: return delta -def retry_hints(headers: Any, now: int | None = None) -> dict[str, Any]: +# 响应体里点名的重置时刻:CodeBuddy 国际站 `429 + 6004` 不回 `Retry-After` +# 头,恢复时刻只写在正文里——`{"code":6004,"msg":"usage exceeds frequency +# limit, but don't worry, your usage will reset at 2026-10-25 22:34:25 UTC+8, +# alternatively, you can switch to the other models..."}`。 +# 只解析这一种带显式 UTC 偏移的形态:没有偏移的本地时刻无从判断时区, +# 猜错会把冷却写到过去(立刻解冻)或几天后(长期移出池子),一律放弃。 +_RESET_AT_TEXT = re.compile( + r"reset\s+at\s+(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})" + r"\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?", + re.IGNORECASE) + + +def parse_reset_at(body: bytes | str, now: int) -> int | None: + """响应体里的「usage will reset at … UTC+8」→ 恢复时刻 epoch;无则 None。 + + 与 `parse_retry_after` 同一语义(「何时恢复」的绝对时刻),供 + `retry_hints` 在**没有 `Retry-After` 头**时兜底。过去时刻 / 无法解析 + 返回 None:宁可回落本地常量冷却,也不要让一个坏正文把冷却写到过去。 + """ + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body + if not text or "reset" not in text.lower(): + return None + match = _RESET_AT_TEXT.search(text) + if match is None: + return None + year, month, day, hour, minute, second, sign, hours, minutes = match.groups() + try: + offset = timedelta(hours=int(hours), minutes=int(minutes or 0)) + moment = datetime(int(year), int(month), int(day), int(hour), int(minute), + int(second), + tzinfo=timezone(-offset if sign == "-" else offset)) + except ValueError: # 2 月 30 日 / 偏移越界这类脏值 + return None + return int(moment.timestamp()) if int(moment.timestamp()) > now else None + + +def retry_hints(headers: Any, now: int | None = None, + body: bytes | str | None = None) -> dict[str, Any]: """`UpstreamHTTPError` 的 retry 关键字参数(无头时两值均为 None)。 让各渠道的 raise 点保持一行:`**retry_hints(response.headers)`。 + + `body` 是**头缺失时的兜底来源**(见 `parse_reset_at`):部分上游(CodeBuddy + 国际站 429+6004)只把恢复时刻写在正文里,不给 `Retry-After` 头,不解析正文 + 就只能回落到固定 600s 基数、到期重试再撞一次 429。头优先于正文——头是协议 + 标准位,正文只是文案。 """ moment = int(time.time()) if now is None else now - return {"retry_at": parse_retry_after(headers, moment), - "retry_after_seconds": parse_retry_after_seconds(headers, moment)} + retry_at = parse_retry_after(headers, moment) + retry_after_seconds = parse_retry_after_seconds(headers, moment) + if retry_at is None and body is not None: + reset_at = parse_reset_at(body, moment) + if reset_at is not None: + retry_at = reset_at + retry_after_seconds = float(reset_at - moment) + return {"retry_at": retry_at, "retry_after_seconds": retry_after_seconds} async def read_body_bounded(response: Any, limit: int = BOUNDED_BODY_LIMIT) -> bytes: @@ -194,7 +281,138 @@ async def read_body_bounded(response: Any, limit: int = BOUNDED_BODY_LIMIT) -> b return bytes(collected[:limit]) -_BUSINESS_CODE_RE = re.compile(r'"code"\s*:\s*(-?\d+)') +# 上游「排队中」信号:`isQueued` 为真,或子码点名 RESOURCE_LIMIT。 +# 这是跨渠道的**协议形状**而非某家私有码,故放中立层。 +QUEUE_FLAG_KEYS = ("isQueued", "is_queued", "queued") +QUEUE_WAIT_KEYS = ("retryAfterSeconds", "retry_after_seconds", "retryAfter", "retry_after") +QUEUE_SUB_CODES = ("RESOURCE_LIMIT", "RESOURCE_LIMIT_EXCEEDED") +# 上游只说「排队」不给秒数时的兜底等待(秒)。取 5s:比 SOFT 的 60s 短得多, +# 又足以让排队轮次真正错开,不至于变成忙等。 +QUEUE_DEFAULT_WAIT_SECONDS = 5.0 +# 单轮最长等待(秒):上游可能点名「2 小时后再来」,照等会把客户端连接吊死。 +# 超过上限就按上限等一轮,下一轮仍排队则轮次用尽、退回普通错误处理。 +QUEUE_MAX_WAIT_SECONDS = 60.0 +# 单次请求最多在原号上重开几轮。上限存在的意义:上游若持续排队,无限重开 +# 会把客户端连接吊死;3 轮 + 兜底 5s 最坏多等 ~15s,比直接 503 更友好。 +QUEUE_MAX_ROUNDS = 3 + + +def parse_queue_hint(body: bytes | str) -> float | None: + """上游 body 是否表示「排队中」→ 建议等待秒数;非排队返回 None。 + + 形状来自上游信封:`{"isQueued": true, "retryAfterSeconds": 30, + "subCode": "RESOURCE_LIMIT"}`。三者**任一**成立即认定排队——只认 + `isQueued` 会漏掉只给子码的上游,只认子码又会把「配额真的耗尽」误判成 + 排队(故子码只认 `RESOURCE_LIMIT` 这一族,不含 `QUOTA_EXCEEDED`)。 + + 宁可漏判不可误判:识别不出就返回 None,走原有的错误分类路径(最坏是 + 一次 SOFT 冷却换号),绝不能把普通失败当成排队而无限等下去。 + """ + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body + if not text.strip(): + return None + try: + payload = json.loads(text) + except json.JSONDecodeError: + return None + return _queue_wait_in(payload, depth=0) + + +def _queue_wait_in(node: Any, depth: int) -> float | None: + """在(可能嵌套的)信封里找排队标记;命中返回等待秒数。 + + 只下钻 3 层:上游信封常见 `{"data": {"error": {...}}}`,再深就不是 + 协议结构而是业务正文了,继续递归只会在长正文上白烧 CPU。 + + **字符串也要下钻**:上游常把内层报文**再序列化成字符串**(Qoder 的 + 排队报文就是 `{"code":"403","message":"{\\"code\\":\\"10605\\",…}"}`, + 排队标记在第二层字符串里)。只认 dict/list 会漏判排队 → 403 被当成 + 硬失败 → **好凭证被硬禁用**(实测 2026-10-26:kmodel_latest 排队时 + 回 403 + `isQueued`,凭证被误判死)。 + + 标记节点**没有**秒数时不立即回落兜底值,而是继续往深处找带秒数的 + 那一层(Qoder 的 `retryAfterSeconds:30` 在最内层),实在没有才用兜底。 + """ + if depth > 3: + return None + if isinstance(node, dict): + if _is_queue_flag(node): + wait = _queue_wait_seconds(node) + if wait is not None: + return wait + for value in node.values(): + found = _queue_wait_in(value, depth + 1) + if found is not None: + return found + return QUEUE_DEFAULT_WAIT_SECONDS + for value in node.values(): + found = _queue_wait_in(value, depth + 1) + if found is not None: + return found + elif isinstance(node, list): + for item in node: + found = _queue_wait_in(item, depth + 1) + if found is not None: + return found + elif isinstance(node, str): + found = _queue_wait_in_text(node, depth) + if found is not None: + return found + return None + + +def _queue_wait_in_text(text: str, depth: int) -> float | None: + """字符串里的 JSON 载荷 → 继续找排队标记(上游把内层报文再序列化)。 + + **不额外消耗深度**:串里套串(上游把排队报文序列化两次)是同一个协议 + 层级,计深度会让最内层的 `retryAfterSeconds` 落在预算外(实测 Qoder + 排队报文串了两层,秒数在最内层)。真正的层级由 dict/list 分支计。 + """ + stripped = text.strip() + if not stripped or stripped[0] not in "{[": + return None + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + return None + return _queue_wait_in(parsed, depth) + + +def _is_queue_flag(node: dict[str, Any]) -> bool: + for key in QUEUE_FLAG_KEYS: + if node.get(key) is True: + return True + for key in ("subCode", "sub_code", "errorCode", "code"): + value = node.get(key) + if isinstance(value, str) and value.upper() in QUEUE_SUB_CODES: + return True + return False + + +def _queue_wait_seconds(node: dict[str, Any]) -> float | None: + """节点自带的等待秒数;没有则 None(由调用方决定回落策略)。""" + for key in QUEUE_WAIT_KEYS: + value = node.get(key) + if isinstance(value, bool): + continue + if isinstance(value, (int, float)) and math.isfinite(value) and value >= 0: + return float(value) + return None + + +_BUSINESS_CODE_RE = re.compile(r'"code"\s*:\s*"?(-?\d+(?:-\d+)*)"?') + + +def _code_value(token: str) -> int: + """业务码文本 → 整数。 + + 上游会用连字符分组写同一族码(`11-128` 即 `11128`,2026-11-02 实测 + 出现在 403 的 body 里)。带连字符时按「去掉分隔符」还原;纯负数 + (`-3`)保持原义。 + """ + if "-" in token[1:]: + return int(token.replace("-", "")) + return int(token) def business_codes(text: str) -> frozenset[int]: @@ -207,8 +425,13 @@ def business_codes(text: str) -> frozenset[int]: 不搜裸数字:时间戳、流水号里也会出现 11102 这类片段,直接 substring 匹配会把无关响应判成模型错误(例如 `"code":111020` 含有 `11102`)。 只认 `"code":` 键值形态,且要求整个数字 token 相等。 + + **字符串码与连字符码同样认**(2026-11-02 上游已在 wire 上发 + `{"code":"11-128"}`):漏认的后果不对称——403/401 分支会把「请求级 + 风控」误判成 `ErrKind.DEAD` 并**硬禁用整池好号**,而多认一个码最多是 + 多一次冷却。故数字前后的引号视为可选,连字符按分组符还原。 """ - return frozenset(int(match) for match in _BUSINESS_CODE_RE.findall(text)) + return frozenset(_code_value(match) for match in _BUSINESS_CODE_RE.findall(text)) async def aclose_stream(stream: Any) -> None: @@ -330,6 +553,12 @@ class Event: # 流内 error 事件的业务分类:provider 解析时按自己的错误码语义定好, # executor 不再维护第二份 code→kind 映射(两份必然漂移) error_kind: ErrKind | None = None + # 上游点名「过多久再来」(秒):目前只有排队信号带它。executor 据此决定 + # 在原号上等多久再重开;None 表示上游没给,按 `QUEUE_DEFAULT_WAIT_SECONDS`。 + retry_after_seconds: float | None = None + # 上游响应的模型名(协议里有就填,缺省 None):executor 只用来做 + # 「静默替换模型」观测,不一致时只告警、不改响应、不冷却凭证。 + model: str | None = None def event_error_kind(event: Event) -> ErrKind: @@ -351,12 +580,16 @@ class Quota: total: float | None = None cycle_end: int | None = None # 最早到期(epoch);无到期信息的渠道为 None # 到期阶梯 [(到期 epoch, 该套餐剩余积分)]:选号按"窗口内即将到期积分"排序。 - # 按包独立到期的渠道(CodeBuddy、TRAE 的权益包)有值;企业版等无此信息为 None。 + # 按包独立到期的渠道(CodeBuddy、TRAE 的权益包)有值;上游只给窗口量的渠道 + # (CodeArts 的「7 天内到期量」)把合计挂在该窗口右端;无到期信息为 None。 expiry_ladder: list[tuple[int, float]] | None = None # 额度明细包:[{"name", "total", "used", "end"}],仅用于展示。 # 与 expiry_ladder 分开存:后者的结构装不下名称,且两者口径不同 # (明细额外含「已过期但仍有余额」与「已用完但仍有效」的包)。 packages: list[dict[str, Any]] | None = None + # 网页明细源状态:None = 未配置网页 Cookie 或失败原因未知;"ok" = 网页明细 + # 正常;"expired" = 网页会话失效(凭证行提示重贴,额度仍以汇总口径为准)。 + web_cookie_state: str | None = None probed_at: int | None = None probe_failed: bool = False @@ -399,6 +632,25 @@ class CheckinResult: # 回填成「当前窗口 endAt」(= 下一轮开放的时刻)后,任务在新窗口出现时 # 会自动再跑一轮,签到窗口每天只打一次上游。 seal_until: int | None = None + # 失败后的最早重试时刻(epoch 秒);None = 由任务按指数退避决定。 + # 用于「上游每日积分尚未发放」这类**已知要等到某个时点**的失败,避免 + # 凌晨 00:00 刚过就每 10 分钟空锤(上游发放/账单同步有延迟)。成功时 + # 无意义;任务会把该时刻与自身退避取较晚者。 + retry_at: int | None = None + + +@dataclass(slots=True) +class ActivityResult: + """活跃上报一条的结果。ok=False 时 message 必为一句人话。 + + 放中立层(而不是某个渠道模块)是因为两个渠道都要用:CodeBuddy 补发 + 对话事件、小浣熊发设备心跳——语义相同(「让上游看到这个账号今天活跃过」), + 形状也相同,各写一份只会漂移。 + """ + + ok: bool + message: str = "" + user_id: str = "" @dataclass(slots=True) diff --git a/src/provider/codearts/__init__.py b/src/provider/codearts/__init__.py index 6beee50b..c2d9bf43 100644 --- a/src/provider/codearts/__init__.py +++ b/src/provider/codearts/__init__.py @@ -9,10 +9,11 @@ `tasks.refresh.RefreshTask`(唯一持有者,**先落库再同步**)承担,额度探测 等旁路**不得**顺手刷新——否则一个一次性 refresh_token 被两个地方各消费一次, 后到的必然报 `the refresh token has been used`,DB 里的 token 也被烧掉。 -3. **每日签到领 1000 积分**(`/v1/ops/delivery` → `claim` → `confirm`)。 +3. **每日签到**(`/v1/ops/delivery` → `claim` → `confirm`)。 `CheckinTask` 与管理台靠 `getattr` 自动发现,故这里实现 `checkin` / `checkin_status` / `checkin_scope`;`claim` 后**必须** `confirm`,否则积分 - 可能不到账(上游 `CLAIMED` 是「已领未确认」而非终态)。 + 可能不到账(上游 `CLAIMED` 是「已领未确认」而非终态)。活动按 + `type=USER_LOGIN` 挑、`campaignId` 取当轮响应(上游换过 ID 与面额)。 4. **SSE 不是标准分隔**(逐行 `data:`、无空行),且 `text` 是**累计全文**, 解析层必须做替换语义的差量,见 `events.TextSnapshot`。 @@ -28,6 +29,8 @@ from typing import Any from ...provider.base import CheckinResult, ErrKind, Event, Model, Quota +from ...tasks.pacer import paced +from ..login_identity import login_identity_for from . import dpop from . import events as codearts_events from .client import CodeArtsClient @@ -88,20 +91,25 @@ async def probe_quota(self, credential_data: dict) -> Quota: credential = CodeArtsCredential.from_dict(credential_data) return await self.client.probe_quota(credential) + def set_web_cookie(self, credential_data: dict, cookie: str) -> dict: + """写入/清除网页控制台 Cookie(逐笔积分到期的数据源)。 + + 只改这一个字段,不重走 `from_dict` 归一(与 Qoder 同纪律):避免把老 + 凭证里未识别的字段丢掉,导出/再导入的可逆性靠这条保证。空串 = 清除, + 额度探测回到 statistics 的汇总口径。 + """ + data = dict(credential_data) + data["web_cookie"] = cookie + return data + async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: """引擎调用入口:dict 凭证 → 上游流 → 中立事件。""" credential = CodeArtsCredential.from_dict(credential_data) key = credential_key(credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) async def refresh(self, credential_data: dict) -> dict: """预刷新:回写新 refresh_token 与临时凭证(一次性 token 必须轮转)。""" @@ -111,6 +119,14 @@ async def refresh(self, credential_data: dict) -> dict: # ---------------------------------------------------------------- 签到 + def login_identity(self, credential_data: dict) -> str: + """重新登录合并键:上游账号 `uid`(caller-identity 的 principal_id)。 + + 判据统一在 `provider/login_identity.py`(登录合并与历史重复清理共用 + 同一套),门面这里只做转发。 + """ + return login_identity_for(self.id, credential_data) + async def checkin(self, credential_data: dict) -> CheckinResult: return await self.client.checkin( CodeArtsCredential.from_dict(credential_data)) diff --git a/src/provider/codearts/auth.py b/src/provider/codearts/auth.py index bdd01176..46238bb8 100644 --- a/src/provider/codearts/auth.py +++ b/src/provider/codearts/auth.py @@ -146,8 +146,12 @@ def _parse_json(response: httpx.Response) -> dict[str, Any]: try: data = response.json() except ValueError as error: + # 异常文本带上游端点 URL 只进日志;对外文案用受控标识(PROPOSAL §6 + # 「错误响应不回流上游正文」)。同族调用点都传 public=(见 + # codearts/client.py:500、trae/client.py:516)。 raise UpstreamProtocolViolation( - f"non-JSON token response from {response.request.url}") from error + f"non-JSON token response from {response.request.url}", + public="upstream returned a non-JSON response") from error if not isinstance(data, dict): raise UpstreamProtocolViolation("token response is not an object") return data diff --git a/src/provider/codearts/client.py b/src/provider/codearts/client.py index 115719aa..d13cf348 100644 --- a/src/provider/codearts/client.py +++ b/src/provider/codearts/client.py @@ -6,8 +6,9 @@ `/v1/chat/chat` 走 §4 的 `task=chat` 形状,作为兼容路径保留。 * **模型发现** `GET {snap}/v1/model/builtin`(`Agent-Type: PromptCenter`)。 * **额度** `GET {snap}/snap-manager/v1/statistics/plugin`(`Agent-Type: - PromptCenter`),单位本身就是积分(内置模型计费);积分包带独立到期日, - 登记成 `expiry_ladder` 参与调度「快过期的先用」。**每日签到** + PromptCenter`),单位本身就是积分(内置模型计费);上游只给「7 天内到期量」 + (`package_credit_expiring_amount`),按该口径登记成 `expiry_ladder` 参与调度 + 「快过期的先用」。**每日签到** 走另一套接口(`GET {snap}/v1/ops/delivery` → `claim` → `confirm`,见下), 临时凭证的续期由 `tasks.refresh.RefreshTask` 独占(先落库再同步); 额度探测只读余额,绝不在此刷新一次性 refresh_token,见 `probe_quota`。 @@ -19,6 +20,7 @@ from __future__ import annotations import copy +import datetime import hashlib import json import logging @@ -26,6 +28,7 @@ import secrets import time from collections.abc import AsyncIterator +from dataclasses import replace from typing import Any from urllib.parse import urlsplit @@ -33,25 +36,30 @@ from ...provider import base from ...provider.base import Event, Model, Quota, UpstreamReloginRequired -from ...provider.proxy import build_client +from ..lazy_clients import LazyClients from . import auth as codearts_auth from . import events as codearts_events from . import signer from .credential import CodeArtsCredential, merge_refreshed from .events import ( AGENT_TYPE_PROMPT_CENTER, - CAMPAIGN_DAILY_CREDIT, EP_CHAT, EP_CHAT_V2, + EP_CREDIT_CAMPAIGN, + EP_CREDIT_OVERVIEW, EP_CURRENT_USER, EP_MODEL_BUILTIN, EP_OPS_CLAIM, EP_OPS_CONFIRM, EP_OPS_DELIVERY, EP_STATISTICS_PLUGIN, + MAX_WEB_CREDIT_PAGES, OPS_CHANNEL, + PORTAL_HOST, SNAP_ENGINE_HOST, STS_HOST, + WEB_CREDIT_PAGE_SIZE, + WEB_PORTAL_UA, UpstreamProtocolViolation, ) @@ -61,13 +69,16 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) -def sdk_date(now: float | None = None) -> str: - """`X-Sdk-Date` 格式(UTC,`YYYYMMDDTHHMMSSZ`)。""" - return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(now if now is not None else time.time())) + +# 签名与请求必须同值,实现归 signer(officeace 共用同一份) +sdk_date = signer.sdk_date + +#: claim 成功但 confirm 未完成时 `claim_daily_credit` 回的业务码。上游业务码 +#: 恒为非负(`-1` 已用于「上游没给 code」),故取 -2 作本地标记;非 0 即可让 +#: `checkin()` 归失败。见 `claim_daily_credit` / `_confirm_daily_credit`。 +CONFIRM_FAILED_CODE = -2 def new_chat_id() -> str: @@ -103,6 +114,14 @@ def request_path(url: str) -> tuple[str, str]: return split.path or "/", split.query +# 输出上限硬上限:目录里 11 个内置模型的 `max_tokens` **全部**是 65536, +# 上游把它当上限而非默认值(客户端给 384000 会被拒收)。下游只校验全局 +# `MAX_OUTPUT_TOKENS = 4_000_000`,故必须在这里按渠道夹取——直接拒收会让 +# 客户端以为整条渠道不可用(判「参数无效」跳过)。取不到目录时也用这个值: +# 它同时是全量内置模型的最小与最大值。 +MAX_OUTPUT_TOKENS = 65536 + + def prepare_body(payload: dict[str, Any], model: str, *, chat_id: str, user_id: str = "", legacy: bool = False) -> dict[str, Any]: @@ -136,10 +155,16 @@ def prepare_body(payload: dict[str, Any], model: str, *, body["user_id"] = user_id return body - body = {key: value for key, value in payload.items() - if key in ("temperature", "top_p", "max_tokens", "reasoning_effort", - "tools", "tool_choice", "stop", "presence_penalty", - "frequency_penalty", "response_format")} + # 只挡「必须由本渠道改写/注入」的键,其余原样透传。 + # + # 这里曾经是 9 个键的白名单:上游是原生 OpenAI 形状,白名单会把 + # `top_k` / `seed` / `parallel_tool_calls` / `max_completion_tokens` / + # `stream_options` 等**上游其实支持**的字段静默丢弃——客户端不报错,只是 + # 行为悄悄变了(采样参数失效、用量拿不到)。黑名单把「透传」设为默认, + # 新字段不需要再回来改这一行。 + _OVERRIDDEN = ("model", "stream", "messages", "chat_id", + "prompt_cache_key", "tool_stream") + body = {key: value for key, value in payload.items() if key not in _OVERRIDDEN} body.update({ "model": model, "stream": True, @@ -148,6 +173,12 @@ def prepare_body(payload: dict[str, Any], model: str, *, "prompt_cache_key": chat_id, "tool_stream": True, }) + # 输出上限必须夹取:上游把 65536 当**上限**(目录 11 个模型全是这个值), + # 超出的 `max_tokens` / `max_completion_tokens` 一律收到上限,不拒单。 + for key in ("max_tokens", "max_completion_tokens"): + value = body.get(key) + if isinstance(value, int) and not isinstance(value, bool): + body[key] = min(value, MAX_OUTPUT_TOKENS) return body @@ -212,7 +243,17 @@ class UpstreamHTTPError(base.UpstreamHTTPError): classify_status = staticmethod(codearts_events.classify_status) -class CodeArtsClient: +class _WebCookieExpired(Exception): + """portal 会话失效(未登录)。 + + portal 未登录不是 401:实测带失效 Cookie + XHR 头回 **200 空 body**, + 页面导航请求回 SPA HTML,登录跳转则回 3xx 到 `auth.huaweicloud.com`。 + 这些形态都归「需要重贴 Cookie」,与 5xx/网络/形状变化(只是暂时拿不到 + 明细)分开,前者才在管理台打「已失效」标记。 + """ + + +class CodeArtsClient(LazyClients): """上游 HTTP 客户端。主机与登录配置可覆盖,便于测试与灰度。""" def __init__( @@ -236,21 +277,6 @@ def __init__( self._short_client = short_client self._proxy = proxy - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - def chat_url(self) -> str: """本次聊天要用的完整地址(legacy 走 `/v1/chat/chat`)。""" path = EP_CHAT if self._legacy else EP_CHAT_V2 @@ -277,7 +303,7 @@ async def stream_chat(self, credential: CodeArtsCredential, payload: dict[str, A headers=chat_headers(credential, trace_id=secrets.token_hex(16)), payload=raw, x_sdk_date=sdk_date()) snapshot = codearts_events.TextSnapshot() - async with self._stream().stream("POST", url, content=raw, headers=headers) as response: + async with self._stream.stream("POST", url, content=raw, headers=headers) as response: if response.status_code >= 400: body_bytes = await base.read_body_bounded(response) raise UpstreamHTTPError(response.status_code, body_bytes, @@ -310,10 +336,16 @@ async def _fetch_builtin_models(self, credential: CodeArtsCredential) -> list[Mo # ------------------------------------------------------------ 额度 async def probe_quota(self, credential: CodeArtsCredential) -> Quota: - """积分余额(只读,不刷新)。 + """积分余额(只读,不刷新);有网页 Cookie 时叠加逐笔到期明细。 - 走 `{snap}/snap-manager/v1/statistics/plugin`:内置模型按积分计费, - 积分包带独立到期日,登记成 `expiry_ladder` 参与「快过期先用」的选号排序。 + `statistics/plugin` 是**汇总口径的唯一来源**(remaining / total / + 套餐到期日),网页明细只负责「哪一笔积分什么时候到期」: + + * 无 `web_cookie` → 原样返回 statistics 结果,`expiry_ladder` 仍是 + 「7 天内到期量挂在探测时刻 + 7 天」的近似桶; + * 有 `web_cookie` → 拉 portal 的 activities/benefits,用真实 `expireAt` + 构造逐包明细与到期阶梯;Cookie 失效/形状变化**静默回退**,不把 + 额度探测判失败(那会让账号看起来耗尽)。 临时凭证的续期**不在**这里做:refresh_token 是一次性的,必须由 `tasks.refresh.RefreshTask`(先落库再同步)独占轮转。若此处顺手刷新, @@ -321,7 +353,25 @@ async def probe_quota(self, credential: CodeArtsCredential) -> Quota: `the refresh token has been used`,且此处刷新结果不落库、DB 里的 token 被烧成废票——实测由此把整条渠道打成 APIG.0602 硬失效。 """ - return await self._fetch_credits(credential) + quota = await self._fetch_credits(credential) + if not credential.web_cookie: + return quota + try: + detail = await self._probe_web_credits(credential) + except _WebCookieExpired as error: + # 会话过期只能重贴(portal 只认浏览器登录态,STS/refresh_token + # 都换不出新会话,实测)。这里只打标记供管理台提示,额度继续用汇总。 + logger.warning("CodeArts 网页 Cookie 已失效(%s),额度详情节回退汇总口径;" + "请在管理台重新粘贴", error) + return replace(quota, web_cookie_state="expired") + except (httpx.HTTPError, ValueError, UpstreamProtocolViolation) as error: + logger.info("CodeArts 网页额度接口不可用(%s: %s),回退汇总口径", + type(error).__name__, error) + return quota + if detail is None: + return replace(quota, web_cookie_state="ok") + packages, ladder = detail + return _merge_web_credits(quota, packages, ladder, "ok") async def _fetch_credits(self, credential: CodeArtsCredential) -> Quota: """`/snap-manager/v1/statistics/plugin` → 额度(单位已是积分)。 @@ -334,11 +384,98 @@ async def _fetch_credits(self, credential: CodeArtsCredential) -> Quota: url, credential, short_headers(AGENT_TYPE_PROMPT_CENTER)) return parse_credits(data) + async def _probe_web_credits( + self, credential: CodeArtsCredential, + ) -> tuple[list[dict[str, Any]], list[tuple[int, float]] | None] | None: + """网页控制台逐笔积分;无活动/无有效条目返回 None。 + + `_WebCookieExpired` 单独上抛(由 `probe_quota` 标记状态);其余失败 + (5xx、网络、形状变化)也上抛,由调用方回退汇总口径,不判探测失败。 + """ + overview = await self._portal_json(credential, EP_CREDIT_OVERVIEW) + rows: list[tuple[Any, Any]] = [] + for campaign_id in _campaign_ids(overview): + rows.extend(await self._web_campaign_rows(credential, campaign_id)) + return parse_web_credits(rows) + + async def _web_campaign_rows( + self, credential: CodeArtsCredential, campaign_id: int, + ) -> list[tuple[Any, Any]]: + """单个活动的逐笔积分(按 `total` 翻页,页大小 100)。""" + rows: list[tuple[Any, Any]] = [] + page = 1 + while page <= MAX_WEB_CREDIT_PAGES: + data = await self._portal_json( + credential, f"{EP_CREDIT_CAMPAIGN}/{campaign_id}", + params={"pageNum": page, "pageSize": WEB_CREDIT_PAGE_SIZE}) + benefits = data.get("benefits") + if not isinstance(benefits, list): + raise UpstreamProtocolViolation( + "credit campaign response missing benefits") + title = data.get("campaignTitle") + rows.extend((title, item) for item in benefits) + if not benefits: + break + total = data.get("total") + if isinstance(total, int) and not isinstance(total, bool): + if len(rows) >= total: + break + elif len(benefits) < WEB_CREDIT_PAGE_SIZE: + break + page += 1 + return rows + + async def _portal_json(self, credential: CodeArtsCredential, path: str, *, + params: dict[str, Any] | None = None) -> dict[str, Any]: + """portal(控制台会话)GET → `data`;非 0 业务码按协议违规上抛。 + + 必须带 `X-Requested-With`:缺了它 portal 把请求当页面导航,回 SPA + 首页 HTML。AK/SK 签名对这些端点无效,**不要**在这里加签。 + """ + url = f"{PORTAL_HOST}{path}" + origin = _origin(PORTAL_HOST) + headers = { + "Accept": "application/json, text/plain, */*", + "X-Requested-With": "XMLHttpRequest", + "Cookie": credential.web_cookie, + "User-Agent": WEB_PORTAL_UA, + "Origin": origin, + "Referer": f"{origin}/", + } + response = await self._short.get(url, headers=headers, params=params) + # 3xx 是跳登录/门户首页;401/403 是显式拒绝。都说明会话已失效。 + if response.status_code in (301, 302, 303, 307, 308, 401, 403): + raise _WebCookieExpired(f"HTTP {response.status_code} from {path}") + response.raise_for_status() + text = response.text + # 未登录时 portal 回 SPA 首页(HTML)或 200 空 body(XHR + 失效 Cookie)。 + if not text.strip() or text.lstrip().startswith("<"): + raise _WebCookieExpired(f"login/HTML response from {path}") + try: + payload = response.json() + except ValueError as error: + raise UpstreamProtocolViolation( + f"non-JSON response from {url}") from error + if not isinstance(payload, dict): + raise UpstreamProtocolViolation( + f"unexpected response from {url}") + code = payload.get("code") + if code in (401, 403): + raise _WebCookieExpired(f"business code {code} from {path}") + if code != 0: + raise UpstreamProtocolViolation( + f"unexpected response from {url}") + data = payload.get("data") + if not isinstance(data, dict): + raise UpstreamProtocolViolation( + f"missing data from {url}") + return data + # ------------------------------------------------------------ 每日签到 async def fetch_checkin_status(self, credential: CodeArtsCredential, ) -> dict[str, Any] | None: - """查「每日签到领1000 积分」活动(只读);上游没有/改版返回 None。 + """查每日签到活动(只读);上游没有/改版返回 None。 走 `snap` 根的 `/v1/ops/delivery`——**不带 `snap-manager` 前缀**(带了 回 APIG.0101)。 @@ -350,9 +487,14 @@ async def fetch_checkin_status(self, credential: CodeArtsCredential, return codearts_events.daily_credit_campaign(data) async def claim_daily_credit(self, credential: CodeArtsCredential, + campaign_id: int, ) -> tuple[int, str]: """领取当日签到积分,返回 `(业务码, 提示文案)`。 + `campaign_id` 必须来自当轮 delivery 响应:上游会换 ID(2026-11 从 1 换成 + 34),写死会让 claim 打到不存在的活动上。调用方(`checkin`)已拿到活动 + 条目,这里不再二次查询。 + **claim 之后必须 confirm**:`CLAIMED`(已领未确认)是上游真实存在的 状态(实测三账号之一正卡在这一档),漏掉 confirm 积分可能不到账。 所以本方法把两步捆在一起,调用方无需知道这个两步协议。 @@ -360,11 +502,14 @@ async def claim_daily_credit(self, credential: CodeArtsCredential, 业务码取上游 `code`(`0` 为成功)。非 0 是**业务**失败而非签名失败—— 实测有 `40001尚未到达权益刷新时间` 这类码,走 HTTP 200,所以不能只看 HTTP 状态码。 + + confirm 失败**回 `CONFIRM_FAILED_CODE`**(见该常量的注释):早先吞掉它的 + 返回值,会让 `checkin()` 报「签到成功」而积分实际未确认到账(实测用户反馈: + 界面显示成功、官网不加积分)。 """ url = f"{self.endpoint}{EP_OPS_CLAIM}" - key = (f"claim_{CAMPAIGN_DAILY_CREDIT}" - f"_{int(time.time() * 1000)}") - body = {"campaignId": CAMPAIGN_DAILY_CREDIT, + key = f"claim_{campaign_id}_{int(time.time() * 1000)}" + body = {"campaignId": campaign_id, "idempotentKey": key, "channel": OPS_CHANNEL} data = await self._request_json( "POST", url, credential, @@ -373,22 +518,28 @@ async def claim_daily_credit(self, credential: CodeArtsCredential, message = str(data.get("message") or "") if code != 0: return (-1 if code is None else code), (message or "领取失败") - await self._confirm_daily_credit(credential) + # confirm 是领取的第二步,它的结果**必须上抛**:回非 0 让 checkin() 归 + # 失败、CheckinTask 当轮不封账;下轮上游 status 已是 CLAIMED,走「只补 + # confirm」分支把状态收尾。吞掉失败则当日封账、再也不补确认。 + if not await self._confirm_daily_credit(credential, campaign_id): + return CONFIRM_FAILED_CODE, "签到确认失败,稍后重试" return 0, message - async def _confirm_daily_credit(self, credential: CodeArtsCredential) -> bool: + async def _confirm_daily_credit(self, credential: CodeArtsCredential, + campaign_id: int) -> bool: """确认已领积分(claim 的第二步);返回是否成功。 - 积分在 **claim 时就已到账**(实测:claim 后总额 +1000),confirm 只把 + 积分在 **claim 时就已到账**(实测:claim 后总额增加当轮面额),confirm 只把 状态从 `CLAIMED` 推到 `CONFIRMED`。所以 confirm 失败不丢积分,但它决定 - 本方法返回值——`checkin()` 的 `CLAIMED` 分支要靠它决定是否封账,吞掉失败 - 会让 `CheckinTask` 当天封账后再也不补确认。 + 两个调用方的返回值——`checkin()` 的 `CLAIMED` 分支与 `claim_daily_credit` + 的 claim 分支都靠它决定是否封账,吞掉失败会让 `CheckinTask` 当天封账后 + 再也不补确认。 """ try: await self._request_json( "POST", f"{self.endpoint}{EP_OPS_CONFIRM}", credential, short_headers(AGENT_TYPE_PROMPT_CENTER), - {"campaignId": CAMPAIGN_DAILY_CREDIT}) + {"campaignId": campaign_id}) except (UpstreamHTTPError, UpstreamProtocolViolation) as error: logger.warning("CodeArts 签到确认失败: %s", error) return False @@ -409,11 +560,22 @@ async def checkin(self, credential: CodeArtsCredential, 活动不存在 / 已过期一律 `ok=True`(不是错误),否则后台任务会每 10 分钟 刷失败日志;`ELIGIBLE` 但不可领、或未知 status 归 `ok=False` 让任务重试 - —— 漏签一天 1000 积分的代价高于多刷几行日志。 + —— 漏签一天 100 积分的代价高于多刷几行日志。 """ campaign = await self.fetch_checkin_status(credential) if campaign is None: + # 活动列表里没有签到活动,多半是上游换了 campaignId/类型的标识; + # 归成功封账是为了不刷失败日志,但会掩盖「任务在、积分不涨」。 + # 因此这条只在**确实没有签到活动**时出现,`campaign_type` 兜底见 + # `daily_credit_campaign`(按 type 而不是 ID 挑)。 return base.CheckinResult(ok=True, message="官方签到活动未开放") + campaign_id = codearts_events.campaign_id(campaign) + if not campaign_id: + # `campaignId` 缺失 = 上游形状变了,这一轮必然失败。**不能**带着 0 + # 去打 claim:那是一个真实写请求(`{"campaignId": 0}` + 幂等键), + # 白打一次上游、还可能命中别的活动;`ok=False` 让任务重试并留痕。 + return base.CheckinResult( + ok=False, message="上游未返回签到活动 campaignId,稍后重试") stamp = int(time.time()) if now is None else now end = codearts_events.campaign_end_epoch(campaign) if end is not None and end <= stamp: @@ -425,13 +587,14 @@ async def checkin(self, credential: CodeArtsCredential, if status == codearts_events.CHECKIN_CLAIMED: # 已领未确认:补齐第二步。confirm 失败要 ok=False,否则 CheckinTask # 当日封账、再也不会补确认(积分已在 claim 时到账,这里只是状态收尾)。 - confirmed = await self._confirm_daily_credit(credential) + confirmed = await self._confirm_daily_credit(credential, campaign_id) if not confirmed: return base.CheckinResult(ok=False, message="签到确认失败,稍后重试") - return base.CheckinResult(ok=True, code=0, message="已补确认签到") + return base.CheckinResult(ok=True, code=0, message="已补确认签到", + credit=codearts_events.campaign_credit(campaign)) if (status == codearts_events.CHECKIN_ELIGIBLE and codearts_events.campaign_claimable(campaign)): - code, message = await self.claim_daily_credit(credential) + code, message = await self.claim_daily_credit(credential, campaign_id) if code != 0: return base.CheckinResult(ok=False, code=code, message=message) return base.CheckinResult( @@ -480,7 +643,7 @@ async def refresh_token(self, credential: CodeArtsCredential) -> CodeArtsCredent """ try: tokens = await codearts_auth.refresh_tokens( - self._short(), self.login, refresh_token=credential.refresh_token, + self._short, self.login, refresh_token=credential.refresh_token, dpop_private_jwk=credential.dpop_private_jwk, code_verifier=credential.code_verifier) except codearts_auth.TokenEndpointError as error: @@ -504,7 +667,7 @@ async def _request_json(self, method: str, url: str, credential: CodeArtsCredent signed = signer.sign_headers( _sign_credential(credential), method=method, path=path, raw_query=query, headers=headers, payload=raw, x_sdk_date=sdk_date()) - response = await self._short().request(method, url, content=raw if body is not None + response = await self._short.request(method, url, content=raw if body is not None else None, headers=signed) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, @@ -551,17 +714,31 @@ def _models_from_items(items: Any) -> list[Model]: return models +# 常规档标记:缺省(字段缺失)/ 空串 / `"default"`。其余(`off_peak` 等)是 +# 折扣或分时档,其 `ratio_display` 不是模型的常规价,一律不采信。 +_REGULAR_TIME_SLOTS: tuple[Any, ...] = (None, "", "default") + + def _credit_rate(credit: Any) -> float | None: """内置条目的 `credit[]` → 消耗倍率(`ratio_display` 形如 `"0.7x"`)。 多档(按上下文长度分段计费)时取首条:同一模型的各档倍率一致,取哪档都一样。 上游换成纯数字或去掉了 `x` 后缀时按同义处理;取不到返回 None(不猜)。 + + **必须跳过时段档**(`time_slot_id` 非 default):2026-11-04 真号实测 + `deepseek-v4.1-flash` 等模型的 `credit[]` 里,`off_peak` 行是**折扣价** + (0.18x → 0.09x,原价写在 `before_display_ratio`)。取到它会让 + `credit_rate` 比常规价低一半;若上游出现「错峰 x0」,`scheduler` 的 + `credit_rate == 0 → 免费渠道整体优先` 会把它当**永久免费**,整池流量 + 打到该渠道。当前上游恰好把 default 排在前面,但那是顺序巧合,不是契约。 """ if not isinstance(credit, list): return None for tier in credit: if not isinstance(tier, dict): continue + if tier.get("time_slot_id") not in _REGULAR_TIME_SLOTS: + continue rate = _parse_ratio(tier.get("ratio_display")) if rate is not None: return rate @@ -617,6 +794,9 @@ def _opt_int(value: Any) -> int | None: "usageOnDemandPackageCredit": "按需积分", "usageBonusPackageCredit": "福利积分", } +#: `package_credit_expiring_amount` 的口径:上游固定统计「7 天内到期量」。 +#: 阶梯只能按这个窗口登记(上游不给逐批到期日),见 `parse_credits`。 +CREDIT_EXPIRING_WINDOW_SECONDS = 7 * 86400 def parse_credits(data: dict[str, Any], *, now: int | None = None) -> Quota: @@ -628,9 +808,20 @@ def parse_credits(data: dict[str, Any], *, now: int | None = None) -> Quota: 形状(实测 2026-10-15): `metrics[]` 每项含 `package_credit_amount` / `_used` / `_remain`,其中 `usageTotalPackageCredit` 是合计,其余(Basic/OnDemand/Bonus)是分项包。 - 故 `remaining/total` 取合计项,分项写进 `packages`(展示用),并给积分包 - 登记到期阶梯——顶层 `end_date` 是整份套餐的到期日(实测每账号不同,如 - `2026-11-02`),积分用不完同样作废,应参与「快过期先用」。 + 故 `remaining/total` 取合计项,分项写进 `packages`(展示用);分项自带的 + `package_credit_expiring_amount`(7 天内到期量)原样透传到 `packages[].expiring` + 供悬浮明细展示。 + + **到期阶梯只认上游的「7 天内到期量」,不认顶层 `end_date`。** 实测同一响应 + 里两者自相矛盾:某账号 `end_date` 距探测只有 2.5 天,而 `expiring` 只有 + 4,868 / 24,391;若 `end_date` 真会把整份余额清零,7 天内到期量必然等于全部 + 余额。结论:`end_date` 是套餐/账期到期日,**不是**逐笔积分的作废时刻(福利 + 积分按批次到账、各自到期),拿它给整份剩余记阶梯会虚构「36 小时内全部作废」 + 的告警,并把选号排序带偏。 + + 上游没有逐批到期日,只给一个窗口量,故把合计挂到「探测时刻 + 7 天」这一个 + 桶上:36h 窗口不会误报,7 天窗口拿到真实数字,随着探测推进该桶会自然逼近 + 真实到期时刻(epoch 不变、now 前进)。 取不到合计项、或合计项无有效数值时返回 `probe_failed=True`(**未知**), 绝不返回 `remaining=0` 被判「已耗尽」——积分账缺失不代表内置模型不能用。 @@ -651,6 +842,9 @@ def parse_credits(data: dict[str, Any], *, now: int | None = None) -> Quota: return Quota(probe_failed=True, probed_at=stamp) end = _package_end_date(data.get("end_date")) packages: list[dict[str, Any]] = [] + # 上游只给「7 天内到期量」且不给逐批日期:合计后挂在探测时刻 + 7 天这一个桶上 + # (见 docstring)。已过期/零量不登记,避免 0 值把阶梯变成「有到期信息」。 + expiring_total = 0.0 for item in metrics: if not isinstance(item, dict): continue @@ -659,6 +853,9 @@ def parse_credits(data: dict[str, Any], *, now: int | None = None) -> Quota: continue amount = _opt_number(item, "package_credit_amount") used = _opt_number(item, "package_credit_used") + expiring = _opt_number(item, "package_credit_expiring_amount") + if expiring is not None: + expiring_total += max(0.0, expiring) if amount is None and used is None: continue packages.append({ @@ -666,8 +863,13 @@ def parse_credits(data: dict[str, Any], *, now: int | None = None) -> Quota: "total": amount or 0.0, "used": used or 0.0, "end": end, + # 7 天内到期的积分。福利积分按批次到账、各自到期,顶层 end_date 只是 + # 整份套餐的到期日;这个字段才是「最近要作废多少」的唯一上游口径。 + "expiring": expiring, }) - ladder = [(end, remaining)] if end and remaining and remaining > 0 else None + ladder = ( + [(stamp + CREDIT_EXPIRING_WINDOW_SECONDS, expiring_total)] + if expiring_total > 0 and remaining and remaining > 0 else None) return Quota(remaining=remaining, total=total, cycle_end=end, expiry_ladder=ladder, packages=packages or None, probed_at=stamp) @@ -680,6 +882,120 @@ def _opt_number(obj: dict[str, Any], key: str) -> float | None: return float(value) +# 网页明细替换掉的分项包:portal 的 campaign benefits 就是福利积分, +# 用它逐笔展开替代 statistics 里那一行汇总(基础/按需仍保留汇总口径)。 +_CREDIT_BONUS_PACKAGE = "福利积分" + + +def parse_web_credits( + rows: list[tuple[Any, Any]], *, now: int | None = None, +) -> tuple[list[dict[str, Any]], list[tuple[int, float]] | None] | None: + """portal campaign benefits → `(packages, expiry_ladder)`;无有效条目返回 None。 + + 每条 benefit 是一次发放:`claimedAmount` 面额、`consumedAmount` 已用、 + `expiredAmount` 已作废、`expireAt` 真实到期时刻。**逐笔到期只能在这里拿到** + (statistics 只给「7 天内到期量」总量)。 + + 口径(实测 2026-11-11): + + * `expiredAmount` 不可信:已过 `expireAt` 的条目仍可能是 0,`status` 也 + 停在 `CLAIMED`/`CONFIRMED`。判死只看 `expireAt <= now`,不依赖这两个字段。 + * 阶梯只收「还在有效期内且有剩余」的条目;**已过期的条目整体丢弃**(不展示), + 剩余 = 面额 − 已用 − 已作废, + 夹紧到 [0, 面额],上游数值偶有超出(消耗统计滞后)。 + * `expiring`(7 天内到期)由同一份明细按窗口算,替代 statistics 的 + 汇总窗口量,展示与阶梯因此同源。 + """ + stamp = int(time.time()) if now is None else now + packages: list[dict[str, Any]] = [] + ladder: list[tuple[int, float]] = [] + for title, item in rows: + if not isinstance(item, dict): + continue + claimed = _opt_number(item, "claimedAmount") + if claimed is None or claimed <= 0: + continue + consumed = _opt_number(item, "consumedAmount") or 0.0 + expired = _opt_number(item, "expiredAmount") or 0.0 + used = min(claimed, max(0.0, consumed + expired)) + remaining = claimed - used + end = _iso_epoch(item.get("expireAt")) + # 已过期的逐笔积分不展示:`expiredAmount`/`status` 会滞后(过期后仍可能 + # 是 0 / CLAIMED),只能按 `expireAt` 判死;留着只会把「早已作废、 + # 上游还没回填」的余额画成可用额度。 + if end is not None and end <= stamp: + continue + expiring: float | None = None + if end is not None and remaining > 0: + ladder.append((end, remaining)) + if end - stamp <= CREDIT_EXPIRING_WINDOW_SECONDS: + expiring = remaining + name = item.get("benefitText") + packages.append({ + "name": name if isinstance(name, str) and name.strip() + else (title if isinstance(title, str) else ""), + "total": claimed, + "used": used, + "end": end, + "expiring": expiring, + }) + if not packages: + return None + ladder.sort() + return packages, (ladder or None) + + +def _merge_web_credits( + quota: Quota, packages: list[dict[str, Any]], + ladder: list[tuple[int, float]] | None, + state: str, +) -> Quota: + """web 逐笔明细替换汇总里的福利包;余额/总量仍以 statistics 为准。""" + kept = [item for item in (quota.packages or []) + if item.get("name") != _CREDIT_BONUS_PACKAGE] + return Quota(remaining=quota.remaining, total=quota.total, + cycle_end=quota.cycle_end, expiry_ladder=ladder, + packages=(kept + packages) or None, probed_at=quota.probed_at, + probe_failed=quota.probe_failed, web_cookie_state=state) + + +def _campaign_ids(overview: dict[str, Any]) -> list[int]: + """overview `items[]` → 去重后的 campaignId 列表(畸形条目跳过)。""" + items = overview.get("items") + if not isinstance(items, list): + return [] + ids: list[int] = [] + for item in items: + if not isinstance(item, dict): + continue + campaign_id = _opt_int(item.get("campaignId")) + if campaign_id is not None and campaign_id > 0 and campaign_id not in ids: + ids.append(campaign_id) + return ids + + +def _iso_epoch(value: Any) -> int | None: + """ISO8601(`2026-11-11T16:10:13.866Z`)→ epoch 秒;不可解析返回 None。""" + if not isinstance(value, str) or not value.strip(): + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.UTC) + return int(parsed.timestamp()) + + +def _origin(url: str) -> str: + """`https://host/portal` → `https://host`(Origin / Referer 用)。""" + parts = urlsplit(url) + return f"{parts.scheme}://{parts.netloc}" + + def _package_end_date(value: Any) -> int | None: """套餐到期日(`"2026-11-02"`,本地 0 点)→ epoch;解析不出返回 None。 diff --git a/src/provider/codearts/credential.py b/src/provider/codearts/credential.py index ccee3cd6..b96dda78 100644 --- a/src/provider/codearts/credential.py +++ b/src/provider/codearts/credential.py @@ -13,12 +13,11 @@ from __future__ import annotations -import json import time from dataclasses import dataclass, field from typing import Any, ClassVar -from ...provider.token_expiry import normalize_epoch +from .. import credential_fields from .events import CLIENT_ID, UpstreamProtocolViolation @@ -42,6 +41,11 @@ class CodeArtsCredential: domain: str = "" domain_id: str = "" nickname: str = "" + # 网页控制台 Cookie(可选):`/portal/promptcenter/v1/ops/credit/*` 只认 + # 华为云控制台会话,AK/SK 签名打不通。有它才能拿到**逐笔积分到期** + # (campaign benefits 的 expireAt),缺失时额度探测保持 statistics 汇总口径 + # (7 天内到期量挂在单个窗口桶上)。 + web_cookie: str = "" def token_expires_at(self) -> int: """STS 临时凭证到期 epoch(秒);未知为 0。 @@ -96,6 +100,7 @@ def to_dict(self) -> dict[str, Any]: "dpop_private_jwk": dict(self.dpop_private_jwk), "domain": self.domain, "domain_id": self.domain_id, "nickname": self.nickname, + "web_cookie": self.web_cookie, } @classmethod @@ -117,40 +122,14 @@ def from_dict(cls, raw: dict[str, Any]) -> CodeArtsCredential: domain=str(raw.get("domain") or ""), domain_id=str(raw.get("domain_id") or raw.get("domainId") or ""), nickname=str(raw.get("nickname") or ""), + web_cookie=str(raw.get("web_cookie") or raw.get("webCookie") + or raw.get("cookie") or ""), ) -def _expiration_from_raw(raw: Any) -> int: - """到期时间归一:数字(epoch,可为毫秒)/ ISO8601 / 数字字符串 → 秒。""" - if isinstance(raw, bool): - return 0 - if isinstance(raw, (int, float)): - return normalize_epoch(int(raw)) - if isinstance(raw, str) and raw.strip(): - text = raw.strip() - try: - return normalize_epoch(int(text)) - except ValueError: - pass - try: - from datetime import datetime - - return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()) - except ValueError: - return 0 - return 0 - - -def _jwk_from_raw(raw: Any) -> dict[str, str]: - """DPoP 私钥 JWK:只收字符串字段,畸形输入返回空(由刷新时报错兜底)。""" - if isinstance(raw, str): - try: - raw = json.loads(raw) - except json.JSONDecodeError: - return {} - if not isinstance(raw, dict): - return {} - return {str(key): value for key, value in raw.items() if isinstance(value, str)} +# 两家华为云系渠道共用同一份解析(测试与旧调用方沿用原名) +_expiration_from_raw = credential_fields.expiration_from_raw +_jwk_from_raw = credential_fields.jwk_from_raw def parse_credentials(raw: dict[str, Any]) -> CodeArtsCredential: @@ -210,4 +189,6 @@ def merge_refreshed( domain=refreshed.domain or credential.domain, domain_id=refreshed.domain_id or credential.domain_id, nickname=credential.nickname, + # 网页 Cookie 与令牌轮转无关:刷新不能把用户粘贴的会话丢掉。 + web_cookie=credential.web_cookie, ) diff --git a/src/provider/codearts/events.py b/src/provider/codearts/events.py index 8e3fcdb8..b65041f4 100644 --- a/src/provider/codearts/events.py +++ b/src/provider/codearts/events.py @@ -70,9 +70,32 @@ EP_OPS_CONFIRM = "/v1/ops/confirm" # delivery 查询的渠道参数(上游按渠道返回可领活动,实测固定 `IDE`)。 OPS_CHANNEL = "IDE" -# 「每日签到领1000 积分」的活动 ID(实测:delivery 同时返回推荐码 3 / -# 新用户 4 / 学生认证 2 / 每日签到 1,只有这个每天可领)。 -CAMPAIGN_DAILY_CREDIT = 1 + +# 网页控制台(portal)积分明细:`/portal/promptcenter/v1/ops/credit/*`。 +# **只认控制台会话 Cookie**(`devclouddevuibjJ_SESSION_ID` 等),AK/SK 签名 +# 打不通(实测:带签名 + XHR 头回 200 空 body)。还必须带 +# `X-Requested-With: XMLHttpRequest`,否则 portal 回 SPA 首页 HTML。 +# overview 动态列出账号参与过的活动,campaign 按 ID 分页返回逐笔积分。 +EP_CREDIT_OVERVIEW = "/promptcenter/v1/ops/credit/overview" +EP_CREDIT_CAMPAIGN = "/promptcenter/v1/ops/credit/campaign" +WEB_CREDIT_PAGE_SIZE = 100 +# 翻页上限:100 条/页 × 20 页 = 2000 笔,签到账号数年也用不满;防上游 +# `total` 异常导致死循环。 +MAX_WEB_CREDIT_PAGES = 20 +WEB_PORTAL_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/130.0.0.0 Safari/537.36") +# 每日签到活动的**历史** ID。早期活动 ID 是 1(「每日签到领1000 积分」);2026-11 +# 上游把它换成 ID 34(「每日签到领100积分」),旧常量匹配不到任何条目 → +# `daily_credit_campaign` 恒返回 None → checkin() 回「官方签到活动未开放」并按 +# 成功封账,于是**签到任务看起来永远成功、积分却不增加**(web 端任务仍在)。 +# +# 所以不能再按 ID 挑:上游会换 ID、也会改积分面额(1000 → 100),只有 +# `type` = `USER_LOGIN`(`extra.triggerEvent` = `user.login`)是签到活动的稳定标识。 +# ID 1 只作为极老响应的兜底保留。 +CAMPAIGN_DAILY_CREDIT_LEGACY = 1 +# 签到活动的类型标识(上游字段 `type`,与 `extra.triggerEvent` 同源)。 +CAMPAIGN_TYPE_DAILY_LOGIN = "USER_LOGIN" # 内置模型接口要求(逆向记录 §7)。 AGENT_TYPE_PROMPT_CENTER = "PromptCenter" @@ -91,11 +114,31 @@ _CONCURRENCY_MARKERS = ("00001041", "并发") # 模型级限流:上游点名当前模型(TPM、429 等),配额类限流恢复慢,走 MODEL。 _MODEL_THROTTLE_MARKERS = ("tpm", "429", "rate limit", "throttl") -# 请求本身不合法(换账号 / 重试都没用)→ REQUEST,不冷却、不累计。 +# 请求与**模型能力**不匹配(换账号没用、重试也没用)→ INVALID,跳过该上游。 # `InferHub.001001020.406 The request model is not multimodal` 是实测原文: -# 请求带了图片而该模型不支持多模态。判成其它类型会让正常用户请求把整条渠道 -# 拉进冷却(3 次 OTHER → 10min 冷却),故按请求侧错误单独分类。 -_REQUEST_MARKERS = ("001001020", "not multimodal") +# 请求带了图片而该模型不支持多模态。这是「模型 + 请求形态」的确定性拒绝, +# 同渠道换任何一个号都会回同一条错误,所以必须跳过**整个上游**去试真正 +# 支持图片的渠道。 +# +# 曾归 `REQUEST`(换号重试)——那是按「请求体坏 / 渠道风控」的经验套的,对 +# 多模态错误是错的:2026-11-01 22:18–22:20 四个带图请求,每条都在 codearts +# 的 3 条凭证上各失败一次,`MAX_ROTATE = 3` 被同一渠道吃满,请求根本没机会 +# 落到 `supports_images=True` 的 codebuddy,最终 400 收尾(4 条未救回失败)。 +# 与 `classify_status` 一致:同样的错误走 HTTP 400 时本来就走 INVALID。 +_MODEL_CAPABILITY_MARKERS = ("001001020", "not multimodal") +# 请求参数本身不合法:`InferHub.001001005.400 The request param is invalid, +# Please check it`(实测原文,2026-11-03 14:41 三条 codearts 凭证相继命中)。 +# +# 必须排在下面的 `"1005" in text` 之前:`001001005` 是上游错误码前缀,子串里 +# 含 `1005`,会命中「权益耗尽」那条**子串**判据 → PLAN → 12h 账号级冷却。 +# 实测后果是三条凭证依次被判「套餐耗尽」,整条渠道(约 2.2 万剩余积分)空转 +# 12h,全部负载压到 codebuddy。语义上这条是请求侧问题:换号无用、换渠道/改 +# 请求才有用,判 INVALID(REJECT_KINDS:跳过整个上游并以 400 收尾)。 +# +# 与 `001001020`(能力不匹配)同一手法:都用错误码前缀做前置 marker,不靠 +# 业务码数字匹配——`CODE.001001005.400` 这种「点分前缀 + 子码」形态里,任何 +# 裸子串判据都会误伤。 +_PARAM_INVALID_MARKERS = ("001001005", "param is invalid") # HTTP 状态码侧的限流/并发标记。与流内业务码不同,这里判的是**响应体文本**: # 上游把并发超限放在 HTTP 400(不是 429),命中即应算可重试的限流。 # 并发与否的区分见 _CONCURRENCY_MARKERS(先判并发,再判模型级限流)。 @@ -196,6 +239,16 @@ def parse_line(line: str, snapshot: TextSnapshot) -> list[Event]: def _events_from_payload(payload: dict[str, Any], snapshot: TextSnapshot) -> list[Event]: + events = _events_from_payload_raw(payload, snapshot) + model = payload.get("model") + if isinstance(model, str) and model: + for event in events: + event.model = model + return events + + +def _events_from_payload_raw(payload: dict[str, Any], + snapshot: TextSnapshot) -> list[Event]: error_code = payload.get("error_code") if isinstance(error_code, str) and error_code not in ("", "0"): # 业务错误内嵌在 HTTP 200 的流里:分类在解析时定死(单一来源)。 @@ -317,8 +370,17 @@ def classify_error_code(code: str | None) -> ErrKind: return ErrKind.CONCURRENCY if any(marker in text for marker in _MODEL_THROTTLE_MARKERS): return ErrKind.MODEL - if any(marker in text for marker in _REQUEST_MARKERS): - return ErrKind.REQUEST + if any(marker in text for marker in _MODEL_CAPABILITY_MARKERS): + # 模型能力不匹配(带图请求打到非多模态模型):换同渠道任何一个号 + # 都会回同一条错误,必须跳过整个上游。判 REQUEST 会逐个号去撞, + # 3 条凭证正好吃满 max_rotate(见 _MODEL_CAPABILITY_MARKERS 注释)。 + # 统计口径独立成 `model_capability`:处置同 INVALID(跳过上游 + 400), + # 但归因面板要说「这个模型不支持这次请求形态」而不是「请求写错了」。 + return ErrKind.CAPABILITY + if any(marker in text for marker in _PARAM_INVALID_MARKERS): + # 请求参数不合法(001001005):见 _PARAM_INVALID_MARKERS 注释。必须排在 + # `"1005" in text` 之前,否则子串 `1005` 会把「参数无效」判成「权益耗尽」。 + return ErrKind.INVALID if "1005" in text or "quota" in text or "insufficient" in text: return ErrKind.PLAN if "apig.0301" in text or "decrypt token" in text: @@ -355,6 +417,10 @@ def classify_status(status: int, body: bytes = b"") -> ErrKind: return ErrKind.CREDIT if 1005 in codes: return ErrKind.PLAN + if any(m in lowered for m in _MODEL_CAPABILITY_MARKERS): + # 与流内 `classify_error_code` 同判据:模型能力不匹配(带图请求打到 + # 非多模态模型)换号无用,跳过整个上游;统计记 `model_capability`。 + return ErrKind.CAPABILITY if status in (400, 404) and ( 11102 in codes or any(m in lowered for m in _MODEL_ABSENT_MARKERS)): return ErrKind.BLOCKED @@ -399,13 +465,57 @@ def classify_status(status: int, body: bytes = b"") -> ErrKind: CHECKIN_DONE = ("CONFIRMED", "CONSUMED") +def campaign_type(campaign: dict[str, Any] | None) -> str: + """活动类型标识 → 归一后的字符串;缺失/形状不对返回空串。 + + 优先 `type`;退化时读 `extra.triggerEvent`(`user.login` → `USER_LOGIN`)。 + 两者是上游同一件事的两个字段,实测同时出现;只认 `type` 时,一次只剩 + `triggerEvent` 的响应会落到 `campaignId == 1` 兼容分支,报「活动未开放」 + 并按成功封账——签到从此静默失效。 + """ + if not isinstance(campaign, dict): + return "" + value = campaign.get("type") + if isinstance(value, str) and value.strip(): + return value.strip() + extra = campaign.get("extra") + if not isinstance(extra, dict): + return "" + trigger = extra.get("triggerEvent") + if not isinstance(trigger, str) or not trigger.strip(): + return "" + # `user.login` → `USER_LOGIN`:上游两种写法指同一个活动类型。 + return trigger.strip().replace(".", "_").upper() + + +def campaign_id(campaign: dict[str, Any] | None) -> int: + """活动 `campaignId` → int;缺失/非 int 返回 0。 + + claim / confirm 的 body 必须带当轮响应里的真实 ID(上游会换:2026-11 从 + 1 换成 34)。0 表示「上游没给」,该轮必然失败而不是打着旧 ID 去打错活动。 + """ + if not isinstance(campaign, dict): + return 0 + return _as_int(campaign.get("campaignId")) or 0 + + def daily_credit_campaign(data: dict[str, Any]) -> dict[str, Any] | None: - """delivery 响应 → 「每日签到领1000 积分」活动条目;找不到返回 None。 + """delivery 响应 → 每日签到活动条目;找不到返回 None。 上游把活动列表包在 `data.items`(不是顶层),实测同时返回推荐码 / 新用户 / - 学生认证 / 每日签到四条,按 `campaignId` 精确挑,不靠标题匹配——标题会改。 + 学生认证 / 每日签到四条。**不按 `campaignId` 挑**:上游会换 ID(2026-11 从 + 1 换成 34)也会改面额(1000 → 100 积分),按 ID 匹配会让签到静默失效—— + 表现为任务每天「成功」封账、官网任务仍在、积分不增加。稳定标识只有 + `type == USER_LOGIN`(等价于 `extra.triggerEvent == user.login`)。 + + 同类型命中多条时优先 `claimable=true`(今天还没领的那条),否则取第一条; + `campaignId` 1 只作为极老响应的兼容兜底(那时上游还没有 `type` 字段)。 字段缺失或形状不对一律返回 None(上游改版不该让签到任务抛异常,调用方把 None 归一为「活动未开放」)。 + + **`type` 与 `extra.triggerEvent` 等价**(均已由 `campaign_type` 归一): + `type` 缺失只有 `triggerEvent` 时同样命中,否则一次字段调整就会让签到 + 静默失效(见 `campaign_type` 的注释)。 """ if not isinstance(data, dict): return None @@ -415,11 +525,22 @@ def daily_credit_campaign(data: dict[str, Any]) -> dict[str, Any] | None: items = envelope.get("items") if not isinstance(items, list): return None + candidates: list[dict[str, Any]] = [] for item in items: - if (isinstance(item, dict) - and _as_int(item.get("campaignId")) == CAMPAIGN_DAILY_CREDIT): - return item - return None + if not isinstance(item, dict): + continue + kind = campaign_type(item) + if (kind == CAMPAIGN_TYPE_DAILY_LOGIN + or (not kind + and _as_int(item.get("campaignId")) + == CAMPAIGN_DAILY_CREDIT_LEGACY)): + candidates.append(item) + if not candidates: + return None + for candidate in candidates: + if campaign_claimable(candidate): + return candidate + return candidates[0] def campaign_status(campaign: dict[str, Any] | None) -> str: @@ -453,7 +574,7 @@ def campaign_end_epoch(campaign: dict[str, Any] | None) -> int | None: """活动 `extra.endTime`(ISO8601 UTC)→ epoch;缺失/畸形返回 None。 用于「活动已结束就别再去 claim」。返回 None 时调用方**不**按过期处理—— - 宁可多调一次上游(幂等),也不因为解析失败把每天 1000 积分白白丢掉。 + 宁可多调一次上游(幂等),也不因为解析失败把当天积分白白丢掉。 """ if not isinstance(campaign, dict): return None diff --git a/src/provider/codearts/oauth.py b/src/provider/codearts/oauth.py index 6d0f4871..3e7d378c 100644 --- a/src/provider/codearts/oauth.py +++ b/src/provider/codearts/oauth.py @@ -13,14 +13,13 @@ from __future__ import annotations import logging -import secrets -import time from dataclasses import dataclass from typing import TYPE_CHECKING from urllib.parse import parse_qs, urlsplit import httpx +from ...provider.auth_state import AuthStateStore as AuthStateStoreBase from ...provider.base import AuthFlow, AuthResult, AuthSession from ...provider.proxy import build_client from . import auth as codearts_auth @@ -90,63 +89,28 @@ def parse_first_stage_callback(raw: str) -> tuple[str, str]: @dataclass class _Reservation: - username: str - created_at: int + """本渠道载荷:登录会话 + 门户首次回调下发的 secret。""" + session: LoginSession # 门户首次回调下发的 secret(ticket 轮询必须用这份——本地生成的那份上游 # 不认,实测恒回「无效 ticketId」);""=尚未收到首次回调。 portal_secret: str = "" -class AuthStateStore: +class AuthStateStore(AuthStateStoreBase[_Reservation]): """state 的归属校验、消费与过期清理(对齐 CodeBuddy 的同名实现语义)。""" def __init__(self, ttl_seconds: int = AUTH_STATE_TTL_SECONDS) -> None: - self._ttl = ttl_seconds - self._entries: dict[str, _Reservation] = {} + super().__init__(ttl_seconds) def begin(self, username: str, session: LoginSession, now: int | None = None) -> str: - self.cleanup(now) - reservation = secrets.token_urlsafe(18) - self._entries[reservation] = _Reservation( - username=username, - created_at=int(now if now is not None else time.time()), - session=session) - return reservation - - def owner(self, auth_state: str, username: str, now: int | None = None) -> bool: - """归属校验,**含 TTL**(同 CodeBuddy):过期 state 不得再轮询/重放。""" - entry = self._entries.get(auth_state) - if entry is None or entry.username != username: - return False - current = int(now if now is not None else time.time()) - if current - entry.created_at >= self._ttl: - del self._entries[auth_state] - return False - return True + return super().begin(username, _Reservation(session=session), now) def session_entry(self, auth_state: str, username: str, now: int | None = None) -> _Reservation | None: - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state] - - def consume(self, auth_state: str, username: str, now: int | None = None) -> bool: - """消费:成功后该 state 不可再次轮询或重放。""" - if not self.owner(auth_state, username, now): - return False - del self._entries[auth_state] - return True - - def cancel(self, auth_state: str, username: str, now: int | None = None) -> bool: - return self.consume(auth_state, username, now) - - def cleanup(self, now: int | None = None) -> None: - current = int(now if now is not None else time.time()) - for key in [k for k, v in self._entries.items() - if current - v.created_at >= self._ttl]: - del self._entries[key] + entry = self.reservation(auth_state, username, now) + return None if entry is None else entry.payload class CodeArtsOAuth: @@ -157,7 +121,7 @@ def __init__(self, config: LoginConfig, *, port: int = DEFAULT_CALLBACK_PORT, proxy: str | None = None) -> None: self.config = config self.port = port - self.store = store or AuthStateStore() + self.store = store if store is not None else AuthStateStore() self._client = client # 登录后补账号身份(uid/user_name/nickname)的签名客户端:token 响应 # 通常不带用户名,不补则凭证昵称为空、统计明细的凭证列空白。生产装配 diff --git a/src/provider/codearts/signer.py b/src/provider/codearts/signer.py index 8f9170a7..20d05cd1 100644 --- a/src/provider/codearts/signer.py +++ b/src/provider/codearts/signer.py @@ -18,6 +18,7 @@ import hashlib import hmac +import time from collections.abc import Mapping from dataclasses import dataclass from urllib.parse import quote @@ -41,6 +42,12 @@ def sha256_hex(payload: bytes) -> str: return hashlib.sha256(payload).hexdigest() +def sdk_date(now: float | None = None) -> str: + """`X-Sdk-Date`:UTC `YYYYMMDDTHHMMSSZ`(签名与请求必须同值)。""" + return time.strftime("%Y%m%dT%H%M%SZ", + time.gmtime(now if now is not None else time.time())) + + def hmac_sha256_hex(key: str, message: str) -> str: return hmac.new(key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() diff --git a/src/provider/codebuddy/activity.py b/src/provider/codebuddy/activity.py index 84540648..03be76e0 100644 --- a/src/provider/codebuddy/activity.py +++ b/src/provider/codebuddy/activity.py @@ -30,13 +30,18 @@ import logging import time import uuid -from dataclasses import dataclass from typing import Any import httpx +from ..base import ActivityResult +from ..proxy import build_client from .client import build_headers from .credential import CodeBuddyCredential + +# 中立层定义、本模块再导出:既有调用点(provider.client 的 `from .activity +# import ActivityResult`)与测试都不必改。 +__all__ = ["ActivityResult", "resolve_user_id", "user_id_from_token"] from .events import UpstreamProtocolViolation logger = logging.getLogger(__name__) @@ -57,15 +62,6 @@ def __init__(self, status: int, message: str = "") -> None: self.message = message -@dataclass(slots=True) -class ActivityResult: - """一条上报的结果。ok=False 时 message 必为一句人话。""" - - ok: bool - message: str = "" - user_id: str = "" - - def user_id_from_token(token: str) -> str: """从 bearer JWT 取稳定账号标识(`sub`)。 @@ -166,8 +162,7 @@ def __init__(self, endpoint: str, *, client: httpx.AsyncClient | None = None, @property def _http(self) -> httpx.AsyncClient: if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=False, - proxy=self._proxy) + self._client = build_client(timeout=httpx.Timeout(30.0), proxy=self._proxy) return self._client async def aclose(self) -> None: diff --git a/src/provider/codebuddy/checkin.py b/src/provider/codebuddy/checkin.py index aea06992..e5ae7f56 100644 --- a/src/provider/codebuddy/checkin.py +++ b/src/provider/codebuddy/checkin.py @@ -15,6 +15,7 @@ import httpx from ...provider.base import CheckinResult +from ..proxy import build_client from .client import build_headers from .credential import CodeBuddyCredential from .events import UpstreamProtocolViolation @@ -153,8 +154,7 @@ async def fetch_status(self, credential: CodeBuddyCredential) -> CheckinStatus: @property def _http(self) -> httpx.AsyncClient: if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=False, - proxy=self._proxy) + self._client = build_client(timeout=httpx.Timeout(30.0), proxy=self._proxy) return self._client async def aclose(self) -> None: diff --git a/src/provider/codebuddy/client.py b/src/provider/codebuddy/client.py index 43d5cfa9..038f1f12 100644 --- a/src/provider/codebuddy/client.py +++ b/src/provider/codebuddy/client.py @@ -8,7 +8,9 @@ import asyncio import copy +import gzip import hashlib +import json import logging import time import weakref @@ -22,13 +24,16 @@ import httpx +from ...engine.affinity import resolve_explicit from ...engine.sse import iter_frames +from ...error_text import error_text from ...provider import base from ...provider.base import ErrKind, Event, GrowthResult, Model, Quota -from ...provider.proxy import build_client +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients from . import events as cb_events from .credential import CodeBuddyCredential, parse_credential -from .events import UpstreamProtocolViolation +from .events import MODEL_BLOCKED_CODES, UpstreamProtocolViolation from .headers import ( CN_ENDPOINT, EP_CHAT, @@ -38,12 +43,12 @@ generate_headers, host_of, ) +from .model_access_store import ModelAccessStore # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + DEFAULT_MODELS: tuple[str, ...] = ("glm-5.2", "deepseek-v4-pro") EP_CONFIG = "/v3/config" @@ -65,7 +70,9 @@ MODEL_ACCESS_CACHE_MAX_ENTRIES = 256 MODEL_ACCESS_PROBE_TIMEOUT = 5.0 MODEL_ACCESS_PROBE_CONCURRENCY = 4 -MODEL_ACCESS_DENIED_CODES = frozenset({11102, 11158}) +# 判活与错误分类共用同一个业务码集合(`events.MODEL_BLOCKED_CODES`):两边 +# 各写一份曾让 11158 在探活里算「不可用」、在请求里算「请求无效」。 +MODEL_ACCESS_DENIED_CODES = MODEL_BLOCKED_CODES _MODEL_ACCESS_CACHE: dict[tuple[str, str], tuple[float, dict[str, bool | None]]] = {} _PROBE_PROMPT = "hi" @@ -168,11 +175,16 @@ def sanitize_channel_markers(body: dict[str, Any]) -> int: 见 TECHNICAL.md §3.2)。content 为文本块列表({"type": "text", ...}) 时逐块处理——块形态指纹同样触发(2026-09-21 直证)。只改写出站副本, 客户端会话历史保持原样。返回替换处数(用于日志与测试)。 + + 除 message 正文,`tools[].function.description` 也是同一类指纹的高发区 + (客户端把「你是 Claude Code…」写进工具说明):不洗的话,只要客户端带 + 自定义工具就会被上游整单拒。**只洗 description**,不碰 `parameters` + schema——那是模型调用契约,逐串替换会把枚举/示例值改坏。 """ + replaced_total = _sanitize_tool_descriptions(body.get("tools")) messages = body.get("messages") if not isinstance(messages, list): - return 0 - replaced_total = 0 + return replaced_total for message in messages: if not isinstance(message, dict): continue @@ -196,18 +208,78 @@ def sanitize_channel_markers(body: dict[str, Any]) -> int: return replaced_total +def _sanitize_tool_descriptions(tools: Any) -> int: + """洗工具说明里的厂商指纹;返回命中处数。 + + 只处理 OpenAI 形状(`{"type":"function","function":{...,"description":…}}`), + 不碰 `parameters`:JSON Schema 里的逐串替换会改坏枚举与示例值。 + """ + if not isinstance(tools, list): + return 0 + hits_total = 0 + for tool in tools: + if not isinstance(tool, dict): + continue + function = tool.get("function") + if not isinstance(function, dict): + continue + description = function.get("description") + if not isinstance(description, str): + continue + replaced, hits = _neutralize_text(description) + if hits: + hits_total += hits + function["description"] = replaced + return hits_total + + def build_headers(credential: CodeBuddyCredential, endpoint: str, *, - quota_only: bool = False) -> dict[str, str]: - """头构造统一入口:X-Domain 与 Host 由同一 endpoint 派生。""" + quota_only: bool = False, intl: bool = False, + conversation_seed: str | None = None, + model: str | None = None) -> dict[str, str]: + """头构造统一入口:X-Domain 与 Host 由同一 endpoint 派生。 + + intl=True 走国际站指纹(版本常量、稳定会话 id、官方会话/模型头、JWT uid + 回退),国内站默认参数下逐字节不变。 + """ return generate_headers( endpoint=endpoint, bearer_token=credential.bearer_token, user_id=credential.user_id or None, account_uid=credential.account_uid or None, domain=credential.domain or None, enterprise_id=credential.enterprise_id or None, - department_full_name=credential.department_full_name or None, quota_only=quota_only, + department_full_name=credential.department_full_name or None, + quota_only=quota_only, intl=intl, + conversation_seed=conversation_seed, model=model, ) -class CodeBuddyClient: +def conversation_seed(payload: dict[str, Any]) -> str | None: + """请求体 → 会话种子(用于国际站稳定的 `X-Conversation-ID`)。 + + 优先级:显式会话键 / 会话头(`engine.affinity.resolve_explicit`)→ + 首条非 system 消息的哈希。多轮对话每轮都会重发完整历史,首条用户消息 + 不变,故两者都能让同一对话在重试、换号、重启后仍得到同一个 id;都没有 + (空 messages)时返回 None,调用方回落随机 id。 + + 与 `retry`/换号无关:conversation id 属于**对话**,不属于某次请求或某个 + 凭证,换号重试必须沿用同一个。 + """ + explicit = resolve_explicit(payload) + if explicit is not None: + return explicit + messages = payload.get("messages") + if not isinstance(messages, list): + return None + for message in messages: + if not isinstance(message, dict): + continue + if message.get("role") in ("system", "developer"): + continue + canonical = json.dumps(message, ensure_ascii=False, sort_keys=True) + return "first:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32] + return None + + +class CodeBuddyClient(LazyClients): def __init__( self, *, @@ -217,6 +289,9 @@ def __init__( sanitize_markers: bool = True, require_leading_system: bool = False, proxy: str | None = None, + intl: bool = False, + gzip_body: bool = False, + access_cache_path: str | None = None, ) -> None: self.endpoint = endpoint self._stream_client = stream_client @@ -224,23 +299,22 @@ def __init__( self.sanitize_markers = sanitize_markers self.require_leading_system = require_leading_system self._proxy = proxy - - @property - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - @property - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() + # 国际站指纹开关:版本常量、稳定会话 id、官方会话/模型头、uid 回退 + self.intl = intl + # 官方 CLI 用 Content-Encoding: gzip 发请求;只在显式开启时压缩 + self.gzip_body = gzip_body + # 探活结果落盘缓存(None = 纯进程内,行为与历史一致) + self._access_store = ( + ModelAccessStore(access_cache_path, MODEL_ACCESS_CACHE_TTL_SECONDS) + if access_cache_path else None) + + def _headers(self, credential: CodeBuddyCredential, *, + conversation_seed: str | None = None, + model: str | None = None) -> dict[str, str]: + """按渠道指纹构造请求头;intl 通道补稳定会话 id 与当前模型。""" + return build_headers( + credential, self.endpoint, intl=self.intl, + conversation_seed=conversation_seed, model=model) # ------------------------------------------------------------- 聊天流 @@ -291,13 +365,28 @@ async def stream_chat(self, credential: CodeBuddyCredential, payload: dict[str, "include_usage": True, } url = f"{self.endpoint}{EP_CHAT}" - async with self._stream.stream( - "POST", url, json=body, headers=build_headers(credential, self.endpoint), - ) as response: + headers = self._headers(credential, + conversation_seed=conversation_seed(payload), + model=model) + if self.gzip_body: + # 官方 CLI 发 gzip 体;只在 intl 通道开启(见 main.py 装配) + headers["Content-Encoding"] = "gzip" + content = gzip.compress( + json.dumps(body, ensure_ascii=False).encode("utf-8")) + request = self._stream.stream( + "POST", url, content=content, headers=headers) + else: + request = self._stream.stream( + "POST", url, json=body, headers=headers) + async with request as response: if response.status_code >= 400: raw = await base.read_body_bounded(response) + # body 兜底:国际站 429+6004 只把重置时刻写在正文(`reset at + # … UTC+8`),不给 Retry-After 头;不传 body 就只能回落 600s + # 基数,到期重试再撞一次 429(实测同一号一天被爆 42 次)。 raise UpstreamHTTPError(response.status_code, raw, - **base.retry_hints(response.headers)) + **base.retry_hints(response.headers, + body=raw)) async for frame in iter_frames(response.aiter_bytes()): for event in cb_events.parse_all_events(frame): yield event @@ -342,8 +431,9 @@ async def _fetch_personal_quota(self, credential: CodeBuddyCredential) -> Quota: remaining += package_remaining end = _cycle_end_epoch(account.get("CycleEndTime")) # 展示明细:比调度阶梯(下面的 ladder)宽——已用完但仍有效的包 - # 也要显示。但「已过期且已用完」的包既不携带积分也不可能再被 - # 消耗,只是上游的历史残留,不堆进弹层。 + # 也会下发(前端按「剩余 0」再过滤掉,只保留还有余额的)。但 + # 「已过期且已用完」的包既不携带积分也不可能再被消耗,只是上游 + # 的历史残留,连数据都不留。 if package_remaining > 0 or (end is not None and end > now_epoch): packages.append({ "name": str(account.get("PackageName") or ""), @@ -388,7 +478,7 @@ async def fetch_models(self, credential: CodeBuddyCredential) -> list[Model]: if cached and now - cached[0] < MODEL_CACHE_TTL_SECONDS: return cached[1] - headers = build_headers(credential, self.endpoint) + headers = self._headers(credential) host = host_of(self.endpoint) headers.update({ "Host": host, @@ -466,12 +556,21 @@ async def filter_available_models( cache_key = self._model_access_cache_key(credential) now = time.time() cached = _MODEL_ACCESS_CACHE.get(cache_key) + if cached is None and self._access_store is not None: + # 重启后从落盘缓存恢复:进程内缓存清空即重打 20 连发探活,而那一刻 + # 的「同 IP + 秒级窗口 + 并发 4」正是上游风控的自动化特征 + stored = self._access_store.get(cache_key) + if stored is not None: + cached = stored + _MODEL_ACCESS_CACHE[cache_key] = stored if cached and now - cached[0] < MODEL_ACCESS_CACHE_TTL_SECONDS: access = cached[1] else: access = await self._probe_model_accesses( credential, [model.id for model in models]) self._remember_model_access(cache_key, now, access) + if self._access_store is not None: + self._access_store.put(cache_key, now, access) kept = [model for model in models if access.get(model.id) is not False] if not kept: # 全被拒绝多半是探活整体异常或账号刚降级:保留全量,避免渠道 @@ -529,7 +628,7 @@ async def _probe_model_access( async with asyncio.timeout(MODEL_ACCESS_PROBE_TIMEOUT): async with self._short.stream( "POST", f"{self.endpoint}{EP_CHAT}", json=body, - headers=build_headers(credential, self.endpoint)) as response: + headers=self._headers(credential, model=model_id)) as response: if response.status_code < 400: return True raw = await base.read_body_bounded(response) @@ -554,7 +653,8 @@ def _remember_model_access(cache_key: tuple[str, str], now: float, async def _post_json(self, url: str, payload: dict[str, Any], credential: CodeBuddyCredential, *, quota_only: bool = False) -> dict[str, Any]: - headers = build_headers(credential, self.endpoint, quota_only=quota_only) + headers = build_headers(credential, self.endpoint, quota_only=quota_only, + intl=self.intl) response = await self._short.post(url, json=payload, headers=headers) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, @@ -778,6 +878,10 @@ def import_credential(self, raw: dict) -> dict: def classify(self, status: int, body: bytes) -> ErrKind: return cb_events.classify_status(status, body) + def disabled_reason(self, body: bytes) -> str: + """401/403 的账号级可操作原因(管理台 `disabled_reason` 用)。""" + return cb_events.disabled_reason(body) + async def probe_quota(self, credential_data: dict) -> Quota: return await self.client.fetch_quota(CodeBuddyCredential.from_dict(credential_data)) @@ -789,22 +893,17 @@ async def list_models(self, credential_data: dict) -> list[Model]: except Exception as error: # noqa: BLE001 - 兜底不是静默:错误会带上抛路径 import logging - logging.getLogger(__name__).warning("动态模型拉取失败,回退静态表: %s", error) + logging.getLogger(__name__).warning("动态模型拉取失败,回退静态表: %s", + error_text(error)) return [Model(id=mid) for mid in DEFAULT_MODELS] async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: credential = CodeBuddyCredential.from_dict(credential_data) key = _pacer_key(self.id, credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) def host(self) -> str: return host_of(self.client.endpoint) diff --git a/src/provider/codebuddy/events.py b/src/provider/codebuddy/events.py index f85e52a4..47d03fb1 100644 --- a/src/provider/codebuddy/events.py +++ b/src/provider/codebuddy/events.py @@ -7,120 +7,33 @@ from __future__ import annotations -import json from typing import Any from ...engine.sse import SSEFrame from ...provider.base import ( ErrKind, Event, - EventKind, - UpstreamProtocolViolation, + UpstreamProtocolViolation, # noqa: F401 - credential/client 经本模块导入 Usage, body_hint, business_codes, ) -from ..openai_chunk import first_choice, is_blank_tool_call +from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: """单帧 → 中立事件;[DONE] 与无 delta 的帧返回 None。""" - if not frame.data: - return None - if frame.data.strip() == "[DONE]": - return None - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable CodeBuddy SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("CodeBuddy SSE data is not an object") - - choice = first_choice(payload) - delta = choice.get("delta") if choice else None - delta = delta if isinstance(delta, dict) else {} - finish_reason = choice.get("finish_reason") if choice else None - - tool_calls = delta.get("tool_calls") - if isinstance(tool_calls, list): - # 上游偶发噪声调用:无 name 且 arguments 为空/{}(客户端聚合后 - # 显示 "Tool not found")。正常分片块无 name 但带实际 arguments, - # 必须保留。空名噪声整条丢弃。 - kept = [tc for tc in tool_calls if isinstance(tc, dict) and not is_blank_tool_call(tc)] - if kept: - return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) - - content = delta.get("content") - if isinstance(content, str) and content: - return Event(kind=EventKind.CONTENT, content=content) - - reasoning = delta.get("reasoning_content") - if isinstance(reasoning, str) and reasoning: - return Event(kind=EventKind.REASONING, content=reasoning) - - usage = payload.get("usage") - if isinstance(usage, dict): - return Event(kind=EventKind.USAGE, usage=_usage(usage)) - - if isinstance(finish_reason, str) and finish_reason: - return Event(kind=EventKind.FINISH, finish_reason=finish_reason) - return None + payload = payload_of(frame, "CodeBuddy") + return None if payload is None else openai_event(payload, usage=_usage) def parse_all_events(frame: SSEFrame) -> list[Event]: """一帧拆成多个事件:带 usage 的收尾帧同时给出 finish_reason 与 usage。""" - if not frame.data: - return [] - if frame.data.strip() == "[DONE]": - return [] - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable CodeBuddy SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("CodeBuddy SSE data is not an object") - - events: list[Event] = [e for e in (parse_frame(frame),) if e is not None] - has_usage = any(e.kind is EventKind.USAGE for e in events) - has_finish = any(e.kind is EventKind.FINISH for e in events) - usage = payload.get("usage") - if isinstance(usage, dict) and not has_usage: - events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) - choice = first_choice(payload) - finish_reason = choice.get("finish_reason") if choice else None - if isinstance(finish_reason, str) and finish_reason and not has_finish: - events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) - return events + return openai_events(frame, "CodeBuddy", usage=_usage) def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - def details_int(details_key: str, key: str) -> int | None: - """OpenAI 惯例把这两个字段放在 details 子对象里,顶层同名字段兜底。 - - CB 实测顶层 `reasoning_tokens` 恒缺(早先据此误判为「CB 不回思考 - token」),真值在 `completion_tokens_details`;`cached_tokens` 同构。 - details 值非法(非数值 / bool)时回落顶层,仍缺则 None。 - """ - details = raw.get(details_key) - value = details.get(key) if isinstance(details, dict) else None - if isinstance(value, int) and not isinstance(value, bool): - return value - return as_int(key) - - credit = raw.get("credit") - - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=details_int("completion_tokens_details", "reasoning_tokens"), - cached_tokens=details_int("prompt_tokens_details", "cached_tokens"), - credit=float(credit) if isinstance(credit, (int, float)) and not isinstance(credit, bool) - else None, - ) + return usage_from_openai(raw, credit=True) _MODEL_RATE_LIMIT_CODE = 6004 # 该模型用量超限 → 模型级软冷却 @@ -130,7 +43,16 @@ def details_int(details_key: str, key: str) -> int | None: # saturated」。上游明确要求「switch models」,说明同凭证其他模型可用, # 落账号级 SOFT 会让整张凭证 60s 内全部模型一起不可用。 _MODEL_SATURATED_CODE = 14003 -_MODEL_BLOCKED_CODE = 11102 # 该后端无此模型 → (账号, 模型) 负缓存 +# 「这个号用不了这个模型」→ (账号, 模型) 负缓存。两个业务码同义: +# 11102 该后端无此模型(上游根本没部署) +# 11158 套餐不允许:`model [gpt-6-luna] is not allowed for user type +# [personal_free_user]`(国际站免费号实测 2026-10-29 10:36 / 10:37) +# 两者对**当前凭证**的结论完全一样——重试多少次都失败,换号才有意义,且与 +# 账号健康无关(不能落账号级冷却)。此前只认 11102,11158 掉进 `400 其他` +# 被归成 `invalid_request`,归因面板显示「请求无效」,读起来像客户端参数 +# 写错了(与 2026-10-26 officeAce 81009 同一类误判)。 +# 列表探活(client.filter_available_models)共用本集合,口径不会再漂。 +MODEL_BLOCKED_CODES = frozenset({11102, 11158}) _CREDITS_EXHAUSTED_CODE = 14018 # 积分耗尽 → 冷却到次日签到 _PLAN_EXHAUSTED_CODE = 1005 # 权益/套餐耗尽 # 请求级错误(不是账号的问题):请求体坏 / 上下文超限 / 图片无效 / 渠道风控。 @@ -138,8 +60,33 @@ def details_int(details_key: str, key: str) -> int | None: # 拦截:同凭证同时刻换模型即可成功、同 (凭证, 模型) 秒级交替成功/失败、窗口 # 内自愈,绝不是凭证或模型的问题。归请求级 → 零动作、仅换号重试。 # (若归 INVALID,executor 会跳过该上游全部凭证、零重试直接 400) -_REQUEST_LEVEL_CODES = frozenset({11101, 11115, 11128, 11135}) +_REQUEST_LEVEL_CODES = frozenset({11101, 11128, 11135}) + +# 403 上也是**请求级**的业务码:渠道/内容风控的另一种 HTTP 形态。 +# 11128 在上游同时存在 400 与 403 两种包装(2026-11-02 抓到的正是 +# `403 {"code":"11-128","msg":"request illegal"}`),而 403 在 CodeBuddy 里 +# 一律判 DEAD——一次命中就把整池好号硬禁用。这里把它拦在 DEAD 之前。 +_FORBIDDEN_REQUEST_CODES = frozenset({11128}) +# 上下文超限(`prompt is too long: 1056943 tokens > 1048576 maximum`): +# 处置与请求级错误一致(不罚号、仍换号),但**统计单独成类**——归进 +# `invalid_request` 时归因面板显示「请求无效」,运维会去查参数写法,实际 +# 动作是「精简上下文 / 换长窗口模型」(实测 2026-11-02 23:32 国际站)。 +_CONTEXT_OVERFLOW_CODE = 11115 _BAD_PARAMS_PHRASE = "unmarshal chat params failed" +# 账号级风控限制(403 + `"code":11140`「request illegal」):displayMsg 是 +# `Your account has been restricted due to unusual activity` / +# 「账号存在异常行为,已被限制使用」,displayTips 引导 SUBMIT_FEEDBACK。 +# 与 session 过期不同:重新登录无效,只能申诉或换号。 +# +# 业务码与文案分开存:`"11140"` **不做整段正文子串匹配**——request id、时间戳、 +# 用户回显内容里出现这五个数字就会把普通错误判成 RESTRICTED(2h 冷却、两次即 +# 升级整渠道 24h 暂停)。正文只认三语文案(上游点名人类可读文案时给的兜底)。 +_ACCOUNT_RESTRICTED_CODE = 11140 +_ACCOUNT_RESTRICTED_PHRASES: tuple[str, ...] = ( + "account has been restricted", + "账号存在异常行为", + "帳號存在異常行為", +) def classify_status(status: int, body: bytes = b"") -> ErrKind: @@ -150,7 +97,8 @@ def classify_status(status: int, body: bytes = b"") -> ErrKind: * 402 / 14018 → 余额不足(冷却到次日 04:00,等签到恢复) * 1005 → 权益耗尽(12h 长冷却) - * 11102「该后端无此模型」→ (账号, 模型) 负缓存(400/404 形态) + * 11102/11158「该号用不了这个模型」→ (账号, 模型) 负缓存(400/404 形态) + * 11140「账号被风控限制」→ 账号级长冷却,**当前请求不再换号** * 401/403 → session 失效(硬禁用) * 404 → 软冷却,不累计错误数 * 429 + 6004/14003 → 模型级限流(用量超限 / 模型饱和);429 其他 → 账号级软限流 @@ -162,8 +110,19 @@ def classify_status(status: int, body: bytes = b"") -> ErrKind: return ErrKind.CREDIT if _PLAN_EXHAUSTED_CODE in codes: return ErrKind.PLAN - if status in (400, 404) and _MODEL_BLOCKED_CODE in codes: + if status in (400, 404) and codes & MODEL_BLOCKED_CODES: return ErrKind.BLOCKED + if status >= 400 and _is_account_restricted(body): + # 必须拦在 401/403 → DEAD 之前:11140 是上游按账号/IP 拉黑,不是凭据 + # 失效——判 DEAD 会永久硬禁用并让 executor 逐个号换着撞(实测一次 + # 请求连打 3 个号全 403,新号 3 分钟后也被封)。 + return ErrKind.RESTRICTED + if status == 403 and codes & _FORBIDDEN_REQUEST_CODES: + # 11128 是**渠道/内容风控**("Illegal API invocation from an unapproved + # channel"),同池换号同样被拒,但不是凭据失效。当 DEAD 会硬禁用整池 + # 好号;2026-11-02 上游把该码改成字符串形态 `{"code":"11-128"}`,漏认 + # 就是整池阵亡——故这一条与正则放宽配套。 + return ErrKind.REQUEST if status in (401, 403): return ErrKind.DEAD if status == 404: @@ -173,6 +132,10 @@ def classify_status(status: int, body: bytes = b"") -> ErrKind: if _MODEL_RATE_LIMIT_CODE in codes or _MODEL_SATURATED_CODE in codes else ErrKind.SOFT) if status == 400: + if _CONTEXT_OVERFLOW_CODE in codes: + # 上下文超限:处置同请求级错误(不罚号、仍换号),统计单独成类 + # (见 `_CONTEXT_OVERFLOW_CODE`)。 + return ErrKind.CONTEXT if (codes & _REQUEST_LEVEL_CODES or _BAD_PARAMS_PHRASE in body_hint(body).lower()): # 发给上游的 body 有问题:换号也大概率一样,但不罚号(仍轮转试一次) @@ -180,3 +143,31 @@ def classify_status(status: int, body: bytes = b"") -> ErrKind: # 请求无效(如模型不存在)是客户端错误,冷却凭证只会误伤健康凭证 return ErrKind.INVALID return ErrKind.OTHER + + +def _is_account_restricted(body: bytes) -> bool: + """响应体是不是 CodeBuddy 的「账号被风控限制」(11140 / 三语文案)。 + + 业务码走 `business_codes`(只认 `"code": N` 键值形态)而不是整段正文子串: + 正文里任何位置出现 `11140` 都算的话,一次普通 400/5xx(body 回显用户内容 + 或带 request id)就会误判 RESTRICTED → 该凭证 2h 冷却、两次即整渠道 24h + 暂停。文案匹配是上游只给人类可读文案时的兜底,同样不认裸数字。 + """ + text = body.decode("utf-8", errors="replace") + if _ACCOUNT_RESTRICTED_CODE in business_codes(text): + return True + lowered = text.lower() + return any(phrase in lowered for phrase in _ACCOUNT_RESTRICTED_PHRASES) + + +def disabled_reason(body: bytes = b"") -> str: + """401/403 响应体 → 硬禁用时展示的可操作原因;识别不到返回空串。 + + 为什么需要它:`disabled_reason` 原先一律写 "session dead",而 11140 的 + 真实含义是「账号被上游风控限制」——管理台按 "session dead" 去重新登录 + 只会白折腾,实际动作是提交申诉或换号。这里只回**受控文案**,不拼接上游 + 正文(与 `relogin_required` 同口径)。 + """ + if _is_account_restricted(body): + return "账号被上游限制(账号异常行为),需提交申诉;重新登录无效" + return "" diff --git a/src/provider/codebuddy/growth.py b/src/provider/codebuddy/growth.py index d644ae6c..de30969b 100644 --- a/src/provider/codebuddy/growth.py +++ b/src/provider/codebuddy/growth.py @@ -24,6 +24,7 @@ import httpx +from ..proxy import build_client from .client import build_headers from .credential import CodeBuddyCredential from .events import UpstreamProtocolViolation @@ -317,8 +318,7 @@ def __init__(self, endpoint: str, *, client: httpx.AsyncClient | None = None, @property def _http(self) -> httpx.AsyncClient: if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=False, - proxy=self._proxy) + self._client = build_client(timeout=httpx.Timeout(30.0), proxy=self._proxy) return self._client async def aclose(self) -> None: diff --git a/src/provider/codebuddy/headers.py b/src/provider/codebuddy/headers.py index f65ab675..86606c45 100644 --- a/src/provider/codebuddy/headers.py +++ b/src/provider/codebuddy/headers.py @@ -8,8 +8,12 @@ from __future__ import annotations +import base64 +import hashlib +import json import platform import secrets +import time import uuid from urllib.parse import quote, urlsplit @@ -19,6 +23,16 @@ CLI_VERSION = "2.107.0" OPENAI_JS_PACKAGE_VERSION = "6.25.0" NODE_RUNTIME_VERSION = "v24.11.1" + +# 国际站指纹:国内站这套常量(2.107.0)已稳定工作两年,不动;国际站自 +# 2026-10-15 加渠道校验后,上游会拿「宣称的 CLI 版本 / SDK / Node」与随包 +# 发布的版本比对——长期钉死一个旧版本本身就是自动化特征(第三方实现 +# PanRouter 的 intl executor 原文:a handful of static headers, one IDE/version +# string forever, huge repeated payloads)。这里取官方 2.17x 实测值,只作用于 +# 国际站,出问题可单独回退。 +INTL_CLI_VERSION = "2.178.0" +INTL_OPENAI_JS_PACKAGE_VERSION = "7.39.0" +INTL_NODE_RUNTIME_VERSION = "v26.7.0" EP_CHAT = "/v2/chat/completions" EP_AUTH_STATE = "/v2/plugin/auth/state" EP_AUTH_TOKEN = "/v2/plugin/auth/token" @@ -46,6 +60,60 @@ def encode_department(name: str) -> str: return quote(name, safe="") +# UUID v1 的 node 段:官方 CLI 用同一个固定值(PanRouter 原文 “constant node +# id, as the CLI uses”)。v1 的价值是「带创建时间 + 机器节点」;v4 两者都没有, +# 一眼可判为伪造。 +_UUID_V1_NODE = "426614174000" + +# 会话 → 首次出现时刻。官方 CLI 的 conversation id 在对话创建时固定、之后不再 +# 变化;逐请求随机等于「每问一句就开一个新对话」。进程内记忆即可:重启换一批 +# id 只影响一次,不影响正确性。 +_CONVERSATION_CREATED_AT: dict[str, float] = {} +_CONVERSATION_CACHE_MAX = 1024 + + +def conversation_uuid(seed: str, *, now: float | None = None) -> str: + """会话种子 → 稳定 UUID v1:同一 seed 在进程内每次得到同一个 id。""" + created = _CONVERSATION_CREATED_AT.get(seed) + if created is None: + created = time.time() if now is None else now + if len(_CONVERSATION_CREATED_AT) >= _CONVERSATION_CACHE_MAX: + # 无界增长会长期占内存;会话 id 只是指纹,清空只让仍在进行的 + # 对话换一次 id(不影响正确性,宁可清空也不做 LRU 记账)。 + _CONVERSATION_CREATED_AT.clear() + _CONVERSATION_CREATED_AT[seed] = created + ticks = int(created * 10_000_000) + 0x01B21DD213814000 + digest = hashlib.sha256(seed.encode("utf-8")).digest() + clock_seq = (int.from_bytes(digest[:2], "big") & 0x3FFF) | 0x8000 + return ( + f"{ticks & 0xFFFFFFFF:08x}-" + f"{(ticks >> 32) & 0xFFFF:04x}-" + f"{((ticks >> 48) & 0x0FFF) | 0x1000:04x}-" + f"{clock_seq:04x}-{_UUID_V1_NODE}") + + +def uid_from_token(token: str) -> str: + """JWT `sub` → 账号 uid;不是 JWT / 解不出 / 无 sub 返回空串。 + + 只解 payload 不验签:验签是上游的事,这里只把 token 自带的账号标识带给 + 上游。官方 CLI 每个请求都发 `X-User-Id`(第三方逆向一致结论),缺它时 + 请求无法被关联到账号主体,是国际站风控的一个自动化信号。 + """ + parts = token.split(".") + if len(parts) != 3: + return "" + payload = parts[1] + try: + raw = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)) + claims = json.loads(raw) + except (ValueError, TypeError): + return "" + if not isinstance(claims, dict): + return "" + sub = claims.get("sub") + return sub if isinstance(sub, str) else "" + + def _stainless_arch() -> str: machine = platform.machine().lower() if machine in ("arm64", "aarch64"): @@ -76,13 +144,28 @@ def generate_headers( enterprise_id: str | None = None, department_full_name: str | None = None, quota_only: bool = False, + intl: bool = False, + conversation_seed: str | None = None, + model: str | None = None, ) -> dict[str, str]: """构造上游请求头。 quota_only=True 用于手动凭证的「仅供额度探测」场景:只切换额度接口, 绝不构造或发送企业上下文头(AGENTS.md 约束)。 + + intl=True 切到国际站指纹:版本常量换成 INTL_*、会话 id 按 + `conversation_seed` 稳定化(UUID v1)、补官方 CLI 会发的会话/模型头。 + 国内站传 False,逐字节保持历史形态。 """ host = domain or host_of(endpoint) + cli_version = INTL_CLI_VERSION if intl else CLI_VERSION + sdk_version = (INTL_OPENAI_JS_PACKAGE_VERSION if intl + else OPENAI_JS_PACKAGE_VERSION) + node_version = INTL_NODE_RUNTIME_VERSION if intl else NODE_RUNTIME_VERSION + request_id = uuid.uuid4().hex + # 国际站:同一会话稳定(官方 CLI 用 v1),国内站保持逐请求随机 + conversation_id = (conversation_uuid(conversation_seed) if intl and conversation_seed + else str(uuid.uuid4())) headers = { "Authorization": f"Bearer {bearer_token}", "Content-Type": "application/json", @@ -92,31 +175,45 @@ def generate_headers( "x-stainless-arch": _stainless_arch(), "x-stainless-lang": "js", "x-stainless-os": _stainless_os(), - "x-stainless-package-version": OPENAI_JS_PACKAGE_VERSION, + "x-stainless-package-version": sdk_version, "x-stainless-retry-count": "0", "x-stainless-runtime": "node", - "x-stainless-runtime-version": NODE_RUNTIME_VERSION, - # 会话链路 ID:原实现每次请求随机生成,缺失会触发 11128 渠道风控 - "X-Conversation-ID": str(uuid.uuid4()), + "x-stainless-runtime-version": node_version, + # 会话链路 ID:缺失会触发 11128 渠道风控 + "X-Conversation-ID": conversation_id, "X-Conversation-Request-ID": secrets.token_hex(16), "X-Conversation-Message-ID": uuid.uuid4().hex, - "X-Request-ID": uuid.uuid4().hex, + "X-Request-ID": request_id, "X-Agent-Intent": "craft", "X-Agent-Purpose": "conversation", "X-IDE-Type": "CLI", "X-IDE-Name": "CLI", - "X-IDE-Version": CLI_VERSION, - "User-Agent": f"CLI/{CLI_VERSION} CodeBuddy/{CLI_VERSION}", + "X-IDE-Version": cli_version, + "User-Agent": f"CLI/{cli_version} CodeBuddy/{cli_version}", "X-Private-Data": "false", "X-CodeBuddy-Request": "1", "X-Product": "SaaS", "X-Domain": host, "Host": host, } + if intl: + # 官方 CLI 还会带的头(ccLoad 从官方安装包抽出的协议常量表)。 + # 只补语义确定的:会话 id / 链路 id / 产品版本 / 当前模型。值不明的 + # (X-Agent-Type / X-Data-Tag / X-Root-Request-ID)宁可不发——发一个 + # 官方从不发的头本身就是信号。 + headers["X-Product-Version"] = cli_version + headers["X-Trace-ID"] = request_id + headers["X-Session-ID"] = conversation_id + if model: + headers["X-Model-ID"] = model if quota_only: return headers - # OAuth 凭证优先用 account_uid,否则回退 user_id + # OAuth 凭证优先用 account_uid,否则回退 user_id;国际站再回退到 JWT `sub` + # (本地 6 个 intl 凭证的 user_id / account_uid 都是空串,官方 CLI 却每个 + # 请求都发 X-User-Id)。 effective_user = account_uid or user_id + if not effective_user and intl: + effective_user = uid_from_token(bearer_token) if effective_user: headers["X-User-Id"] = effective_user if enterprise_id: diff --git a/src/provider/codebuddy/model_access_store.py b/src/provider/codebuddy/model_access_store.py new file mode 100644 index 00000000..ec928211 --- /dev/null +++ b/src/provider/codebuddy/model_access_store.py @@ -0,0 +1,132 @@ +"""CodeBuddy 模型可用性探活结果的落盘缓存。 + +为什么需要:国际站的 `filter_available_models` 会为每个账号并发打 20 次 +`max_tokens=1` 的探活请求,结果原本只存在**进程内**字典(TTL 6h)。进程一 +重启缓存即空,于是每次重启都对每个账号重放一轮 20 连发——同 IP、秒级窗口、 +并发 4,正是上游风控眼里的自动化特征(2026-10-27 当天 11 次重启 ≈ 1320 次)。 + +落盘后重启不再重探;文件损坏 / 版本不符一律当作空缓存(探活是可选优化, +绝不能因为它把模型列表带崩)。 + +只存「账号标识 + endpoint → 模型可用性」,不存 token 明文:key 是昵称或 +token 的 SHA-256(与进程内缓存同一套 identity,见 +`CodeBuddyClient._model_access_cache_key`)。 +""" + +from __future__ import annotations + +import json +import logging +import os +import time + +logger = logging.getLogger(__name__) + +VERSION = 1 +# 条目上限:账号数 × 渠道数的量级,超出按最旧淘汰(文件不能无界增长) +MAX_ENTRIES = 256 + + +def cache_path(data_dir: str) -> str: + """落盘位置:与模型目录快照同一个 data 目录。""" + return os.path.join(data_dir, "codebuddy-model-access.json") + + +class ModelAccessStore: + """`{(identity, endpoint): (checked_at, {model: bool|None})}` 的原子落盘缓存。""" + + def __init__(self, path: str, ttl_seconds: float, + *, clock=time.time) -> None: + self.path = path + self.ttl_seconds = ttl_seconds + self._clock = clock + self._entries: dict[tuple[str, str], tuple[float, dict[str, bool | None]]] = {} + self._loaded = False + + # ------------------------------------------------------------ 读 + + def _load(self) -> None: + if self._loaded: + return + self._loaded = True + try: + with open(self.path, encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, ValueError): + # 文件缺失 / 损坏 / 无权限:空缓存即可,探活照常发生 + return + if not isinstance(payload, dict) or payload.get("version") != VERSION: + return + entries = payload.get("entries") + if not isinstance(entries, dict): + return + cutoff = self._clock() - self.ttl_seconds + for raw_key, raw_value in entries.items(): + parsed = _parse_entry(raw_key, raw_value, cutoff) + if parsed is not None: + self._entries[parsed[0]] = parsed[1] + + def get(self, key: tuple[str, str]) -> tuple[float, dict[str, bool | None]] | None: + """命中未过期条目返回 `(checked_at, access)`;否则 None。""" + self._load() + entry = self._entries.get(key) + if entry is None: + return None + if self._clock() - entry[0] >= self.ttl_seconds: + del self._entries[key] + return None + return entry + + # ------------------------------------------------------------ 写 + + def put(self, key: tuple[str, str], checked_at: float, + access: dict[str, bool | None]) -> None: + self._load() + self._entries[key] = (checked_at, dict(access)) + if len(self._entries) > MAX_ENTRIES: + oldest = sorted(self._entries, key=lambda item: self._entries[item][0]) + for stale in oldest[:len(self._entries) - MAX_ENTRIES]: + del self._entries[stale] + self._save() + + def _save(self) -> None: + """原子写盘;失败只记日志(缓存不是正确性依赖)。""" + payload = { + "version": VERSION, + "entries": { + _dump_key(key): {"checked_at": entry[0], "access": entry[1]} + for key, entry in self._entries.items() + }, + } + tmp = f"{self.path}.tmp" + try: + directory = os.path.dirname(self.path) + if directory: + os.makedirs(directory, exist_ok=True) + with open(tmp, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False) + os.replace(tmp, self.path) + except OSError: + logger.warning("模型可用性缓存写盘失败:%s", self.path, exc_info=True) + + +def _dump_key(key: tuple[str, str]) -> str: + return f"{key[0]}\x1f{key[1]}" + + +def _parse_entry(raw_key: object, raw_value: object, cutoff: float): + """落盘条目 → (key, (checked_at, access));形状不对/过期返回 None。""" + if not isinstance(raw_key, str) or "\x1f" not in raw_key: + return None + if not isinstance(raw_value, dict): + return None + checked_at = raw_value.get("checked_at") + access = raw_value.get("access") + if not isinstance(checked_at, (int, float)) or not isinstance(access, dict): + return None + if float(checked_at) < cutoff: + return None + clean = {model: value for model, value in access.items() + if isinstance(model, str) and (value is None or isinstance(value, bool))} + identity, _, endpoint = raw_key.partition("\x1f") + return (identity, endpoint), (float(checked_at), clean) diff --git a/src/provider/codebuddy/oauth.py b/src/provider/codebuddy/oauth.py index d65e1624..3de0425b 100644 --- a/src/provider/codebuddy/oauth.py +++ b/src/provider/codebuddy/oauth.py @@ -12,13 +12,12 @@ from __future__ import annotations -import secrets -import time from dataclasses import dataclass, field from typing import Any import httpx +from ...provider.auth_state import AuthStateStore as AuthStateStoreBase from ...provider.base import AuthFlow, AuthResult, AuthSession from ...provider.proxy import build_client from .credential import CodeBuddyCredential, validate_domain @@ -43,74 +42,36 @@ class AuthProgress: @dataclass -class _Reservation: - username: str - created_at: int +class _Payload: + """本渠道载荷:上游 state(轮询必用)+ 分阶段进度(可变)。""" + upstream_state: str progress: AuthProgress = field(default_factory=AuthProgress) -class AuthStateStore: +class AuthStateStore(AuthStateStoreBase[_Payload]): """state 的归属校验、消费与过期清理。""" def __init__(self, ttl_seconds: int = AUTH_STATE_TTL_SECONDS) -> None: - self._ttl = ttl_seconds - self._entries: dict[str, _Reservation] = {} + super().__init__(ttl_seconds) def begin(self, username: str, upstream_state: str, now: int | None = None) -> str: - self.cleanup(now) - reservation = secrets.token_urlsafe(18) - self._entries[reservation] = _Reservation( - username=username, created_at=int(now if now is not None else time.time()), - upstream_state=upstream_state) - return reservation + return super().begin(username, _Payload(upstream_state=upstream_state), now) def upstream_state(self, auth_state: str, username: str, now: int | None = None) -> str | None: """轮询上游时必须用上游发的 state,而不是本地的 reservation。""" - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state].upstream_state - - def owner(self, auth_state: str, username: str, now: int | None = None) -> bool: - """归属校验,**含 TTL**:过期 reservation 不得再轮询/重放。 - - `cleanup()` 只在 `begin()` 时触发;若 owner 不判过期,一个过期的 - state 在下次 begin 之前会一直可被轮询(每次轮询都打上游)。 - """ - entry = self._entries.get(auth_state) - if entry is None or entry.username != username: - return False - current = int(now if now is not None else time.time()) - if current - entry.created_at >= self._ttl: - del self._entries[auth_state] # 惰性清理,避免字典堆积 - return False - return True + payload = self.payload(auth_state, username, now) + return None if payload is None else payload.upstream_state def progress(self, auth_state: str, username: str, now: int | None = None) -> AuthProgress | None: - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state].progress + payload = self.payload(auth_state, username, now) + return None if payload is None else payload.progress def set_progress(self, auth_state: str, progress: AuthProgress) -> None: if auth_state in self._entries: - self._entries[auth_state].progress = progress - - def consume(self, auth_state: str, username: str, now: int | None = None) -> bool: - """消费:成功后该 state 不可再次轮询或重放。""" - if not self.owner(auth_state, username, now): - return False - del self._entries[auth_state] - return True - - def cancel(self, auth_state: str, username: str, now: int | None = None) -> bool: - return self.consume(auth_state, username, now) - - def cleanup(self, now: int | None = None) -> None: - current = int(now if now is not None else time.time()) - for key in [k for k, v in self._entries.items() if current - v.created_at >= self._ttl]: - del self._entries[key] + self._entries[auth_state].payload.progress = progress class CodeBuddyOAuth: @@ -118,7 +79,7 @@ def __init__(self, endpoint: str, *, client: httpx.AsyncClient | None = None, store: AuthStateStore | None = None, proxy: str | None = None) -> None: self.endpoint = endpoint - self.store = store or AuthStateStore() + self.store = store if store is not None else AuthStateStore() self._client = client self._proxy = proxy diff --git a/src/provider/codebuddy/refresh.py b/src/provider/codebuddy/refresh.py index 0bf8a53e..608306db 100644 --- a/src/provider/codebuddy/refresh.py +++ b/src/provider/codebuddy/refresh.py @@ -14,6 +14,7 @@ import httpx from ..base import UpstreamReloginRequired +from ..proxy import build_client from .credential import CodeBuddyCredential, validate_domain from .events import UpstreamProtocolViolation from .headers import EP_ACCOUNTS, EP_SWITCH_ENTERPRISE, EP_TOKEN_REFRESH, host_of @@ -49,8 +50,7 @@ def __init__(self, endpoint: str, *, client: httpx.AsyncClient | None = None, @property def _http(self) -> httpx.AsyncClient: if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0), trust_env=False, - proxy=self._proxy) + self._client = build_client(timeout=httpx.Timeout(30.0), proxy=self._proxy) return self._client async def aclose(self) -> None: diff --git a/src/provider/credential_fields.py b/src/provider/credential_fields.py new file mode 100644 index 00000000..703d09e1 --- /dev/null +++ b/src/provider/credential_fields.py @@ -0,0 +1,79 @@ +"""华为云系渠道(CodeArts / OfficeAce)共用的凭证字段解析。 + +两家凭证落盘形状一致(STS 临时 AK/SK + DPoP 私钥 + ISO8601 到期时间), +解析规则曾各写一份且字节级相同,统一到这里。协议私有部分(字段名清单、 +client_id 默认值、刷新绑定字段)仍留在各渠道,本模块只做「取值/归一」。 +""" + +from __future__ import annotations + +import json +from typing import Any + +from .token_expiry import normalize_epoch + + +def text_field(raw: dict[str, Any], *keys: str) -> str: + """按优先级取第一个非空字符串字段。""" + for key in keys: + value = raw.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def epoch_from_iso(value: Any) -> int: + """ISO8601(带 `Z` 或偏移)→ epoch 秒;坏值 / 缺失返回 0。""" + if not isinstance(value, str) or not value.strip(): + return 0 + from datetime import datetime + + text = value.strip().replace("Z", "+00:00") + try: + return int(datetime.fromisoformat(text).timestamp()) + except ValueError: + return 0 + + +def expiration_from_raw(raw: Any) -> int: + """到期时间归一:数字(epoch,可为毫秒)/ ISO8601 / 数字字符串 → 秒。""" + if isinstance(raw, bool): + return 0 + if isinstance(raw, (int, float)): + return normalize_epoch(int(raw)) + if isinstance(raw, str) and raw.strip(): + text = raw.strip() + try: + return normalize_epoch(int(text)) + except ValueError: + pass + return epoch_from_iso(text) + return 0 + + +def jwk_from_raw(raw: Any) -> dict[str, str]: + """DPoP 私钥 JWK:只收字符串字段,畸形输入返回空(由刷新时报错兜底)。""" + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return {} + if not isinstance(raw, dict): + return {} + return {str(key): value for key, value in raw.items() if isinstance(value, str)} + + +def relogin_required(body: bytes, markers: tuple[str, ...]) -> str: + """令牌端点响应体 → 该凭证是否已**不可自愈**,需要重新登录。 + + 返回可直接展示的原因(不含上游正文,符合不回显上游响应的约束); + 可重试的失败(网络、5xx、限流、绑定项齐全的普通 4xx)返回空串。 + + 为什么必须在这里判终态而不是让 RefreshTask 重试:refresh_token 一次性, + 被消费/失配之后每次重试都是同一张废票。判错的代价是「一次偶发故障永久 + 废掉一条好凭证」,所以只认各渠道 `_RELOGIN_MARKERS` 里上游点名的那几类。 + """ + text = body.decode("utf-8", errors="replace").lower() + if any(marker in text for marker in markers): + return "刷新令牌已失效,需重新登录" + return "" diff --git a/src/provider/kilo/client.py b/src/provider/kilo/client.py index 7546cfec..c68896a7 100644 --- a/src/provider/kilo/client.py +++ b/src/provider/kilo/client.py @@ -36,7 +36,8 @@ Model, Quota, ) -from ...provider.proxy import build_client +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients from . import events as kilo_events # 上游端点(公开事实,非用户可配置输入) @@ -48,8 +49,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + def prepare_body(payload: dict[str, Any], model: str) -> dict[str, Any]: @@ -86,7 +86,7 @@ class UpstreamHTTPError(base.UpstreamHTTPError): classify_status = staticmethod(kilo_events.classify_status) -class KiloClient: +class KiloClient(LazyClients): """上游 HTTP 客户端。host 可覆盖,便于测试与灰度。""" def __init__( @@ -102,25 +102,10 @@ def __init__( self._short_client = short_client self._proxy = proxy - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - async def stream_chat(self, payload: dict[str, Any], model: str) -> AsyncIterator[Event]: """POST chat/completions 并逐事件产出中立 Event。非 2xx 抛 UpstreamHTTPError。""" body = prepare_body(payload, model) - async with self._stream().stream( + async with self._stream.stream( "POST", f"{self.host}{EP_CHAT}", json=body, headers=request_headers(), ) as response: if response.status_code >= 400: @@ -140,7 +125,7 @@ async def fetch_models(self) -> list[Model]: 无静态白名单:上游增删免费模型、改 `isFree` 自动跟随。候选全灭时 抛出让上层用上次成功的缓存兜底,避免 Kilo 从模型列表里消失。 """ - response = await self._short().get( + response = await self._short.get( f"{self.host}{EP_MODELS}", headers=request_headers()) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, @@ -232,14 +217,9 @@ async def list_models(self, credential_data: dict) -> list[Model]: async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: # 与 zen 同样单桶(同渠道共用一条匿名额度),并发模式下必须配对 release。 - if self.pacer is not None: - await self.pacer.wait_turn("kilo") - try: + async with paced(self.pacer, "kilo"): async for event in self.client.stream_chat(payload, model): yield event - finally: - if self.pacer is not None: - self.pacer.release("kilo") async def aclose(self) -> None: await self.client.aclose() diff --git a/src/provider/kilo/events.py b/src/provider/kilo/events.py index 301c6000..e6312ef7 100644 --- a/src/provider/kilo/events.py +++ b/src/provider/kilo/events.py @@ -20,7 +20,6 @@ from __future__ import annotations -import json from typing import Any from ...engine.sse import SSEFrame @@ -28,97 +27,24 @@ ErrKind, Event, EventKind, - UpstreamProtocolViolation, + UpstreamProtocolViolation, # noqa: F401 - 测试经本模块访问 Usage, ) -from ..openai_chunk import first_choice, is_blank_tool_call +from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: """单帧 → 单个代表性事件;`[DONE]` 与无内容帧返回 None。""" - payload = _payload(frame) - if payload is None: - return None - return _event_from_payload(payload) - - -def _event_from_payload(payload: dict[str, Any]) -> Event | None: - """已解析 JSON → 代表性事件。 - - 优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。 - """ - choice = first_choice(payload) - delta = choice.get("delta") if choice else None - delta = delta if isinstance(delta, dict) else {} - finish_reason = choice.get("finish_reason") if choice else None - - tool_calls = delta.get("tool_calls") - if isinstance(tool_calls, list): - # 空名且空参的噪声调用(客户端聚合后显示 "Tool not found"): - # 正常分片块无 name 但带实际 arguments,必须保留。 - kept = [tc for tc in tool_calls - if isinstance(tc, dict) and not is_blank_tool_call(tc)] - if kept: - return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) - - content = delta.get("content") - if isinstance(content, str) and content: - return Event(kind=EventKind.CONTENT, content=content) - - # Kilo 用 delta.reasoning(而非 reasoning_content) - reasoning = delta.get("reasoning") - if isinstance(reasoning, str) and reasoning: - return Event(kind=EventKind.REASONING, content=reasoning) - - error = payload.get("error") - if isinstance(error, dict): - return _error_event(error) - - usage = payload.get("usage") - if isinstance(usage, dict): - return Event(kind=EventKind.USAGE, usage=_usage(usage)) - - if isinstance(finish_reason, str) and finish_reason: - return Event(kind=EventKind.FINISH, finish_reason=finish_reason) - return None + payload = payload_of(frame, "Kilo") + return None if payload is None else openai_event( + payload, reasoning_keys=("reasoning",), error_event=_error_event, + usage=_usage) def parse_all_events(frame: SSEFrame) -> list[Event]: - """一帧拆成多个事件:同一 chunk 可同时携带正文/思考与 finish_reason。 - - 必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」 - 这种合并帧会丢掉 finish(客户端永远收不到结束信号)。 - """ - payload = _payload(frame) - if payload is None: - return [] - events: list[Event] = [e for e in (_event_from_payload(payload),) if e is not None] - has_usage = any(e.kind is EventKind.USAGE for e in events) - has_finish = any(e.kind is EventKind.FINISH for e in events) - - usage = payload.get("usage") - if isinstance(usage, dict) and not has_usage: - events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) - choice = first_choice(payload) - finish_reason = choice.get("finish_reason") if choice else None - if isinstance(finish_reason, str) and finish_reason and not has_finish: - events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) - return events - - -def _payload(frame: SSEFrame) -> dict[str, Any] | None: - """帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。""" - if not frame.data: - return None - if frame.data.strip() == "[DONE]": - return None - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable Kilo SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("Kilo SSE data is not an object") - return payload + """一帧拆成多个事件:同一 chunk 可同时携带正文/思考与 finish_reason。""" + return openai_events(frame, "Kilo", reasoning_keys=("reasoning",), + error_event=_error_event, usage=_usage) def _error_event(error: dict[str, Any]) -> Event: @@ -140,22 +66,7 @@ def _error_event(error: dict[str, Any]) -> Event: def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - # 缓存命中:OpenAI 惯例在 prompt_tokens_details.cached_tokens,顶层兜底 - details = raw.get("prompt_tokens_details") - cached = (details or {}).get("cached_tokens") if isinstance(details, dict) else None - if not isinstance(cached, int) or isinstance(cached, bool): - cached = as_int("cached_tokens") - - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=as_int("reasoning_tokens"), - cached_tokens=cached, - ) + return usage_from_openai(raw) def classify_status(status: int, body: bytes = b"") -> ErrKind: diff --git a/src/provider/lazy_clients.py b/src/provider/lazy_clients.py new file mode 100644 index 00000000..11368b62 --- /dev/null +++ b/src/provider/lazy_clients.py @@ -0,0 +1,76 @@ +"""渠道客户端的懒建 HTTP 连接池。 + +7 个渠道各写一份「两个懒建 client + aclose」,差异只有 `@property` 与方法 +两种写法。统一成 property:调用点一律 `self._stream` / `self._short`。 + +为什么懒建:绝大多数请求只用其中一条池(聊天走 stream、探活走 short), +两个都预建会白占一份连接池;而未建的那条在 `aclose()` 里跳过即可。 + +**按凭证分池**(出口 IP 粘性):`CREDENTIAL_PROXY_POOL` 非空时,同一凭证的 +请求固定走同一个出口代理。`httpx` 的代理是连接池级参数,所以每条出口各建 +一份池(`_pools[(流/短, 代理)]`)——不能在同一 client 上按请求换代理。 +上下文里的凭证 id 由 engine 层设置(`request_context.get_credential_id`)。 +未配置池时行为与之前逐字节一致(始终只有一条池)。 +""" + +from __future__ import annotations + +import httpx + +from ..request_context import get_credential_id +from .proxy import SHORT_TIMEOUT, STREAM_TIMEOUT, build_client, credential_proxy + + +class LazyClients: + """`_stream` / `_short` 两条按需建立的连接池 + 统一释放。 + + 子类在 `__init__` 里设置 `_stream_client` / `_short_client` / `_proxy` + (前三项为 None 表示「按需自建」)。显式注入的 client(测试替身)永远优先, + 且不参与按凭证分池——替身本来就不出网。 + + `_pools` 由本类懒初始化(子类普遍不调 `super().__init__`,不能依赖构造)。 + """ + + _stream_client: httpx.AsyncClient | None + _short_client: httpx.AsyncClient | None + _proxy: str | None + + def _effective_proxy(self) -> str | None: + """当前请求的出口:凭证池分配优先,其次渠道级 `PROVIDER_PROXIES`。""" + return credential_proxy(get_credential_id()) or self._proxy + + def _pooled(self, timeout: httpx.Timeout, kind: str) -> httpx.AsyncClient: + proxy = self._effective_proxy() + pools: dict[tuple[str, str | None], httpx.AsyncClient] | None = getattr( + self, "_pools", None) + if pools is None: + pools = {} + self._pools = pools + key = (kind, proxy) + client = pools.get(key) + if client is None: + client = build_client(timeout=timeout, proxy=proxy) + pools[key] = client + return client + + @property + def _stream(self) -> httpx.AsyncClient: + """流式请求池:读无上限(SSE 长连接),写 60s(大 payload 也要发完)。""" + if self._stream_client is not None: + return self._stream_client + return self._pooled(STREAM_TIMEOUT, "stream") + + @property + def _short(self) -> httpx.AsyncClient: + """短请求池(探活 / 额度 / 模型列表):整体 30s。""" + if self._short_client is not None: + return self._short_client + return self._pooled(SHORT_TIMEOUT, "short") + + async def aclose(self) -> None: + """释放全部池(默认池 + 各出口池);从未建立的跳过。""" + clients = [self._stream_client, self._short_client] + clients.extend(getattr(self, "_pools", {}).values()) + for client in clients: + if client is not None: + await client.aclose() diff --git a/src/provider/lingxi/__init__.py b/src/provider/lingxi/__init__.py new file mode 100644 index 00000000..3793cd8f --- /dev/null +++ b/src/provider/lingxi/__init__.py @@ -0,0 +1,132 @@ +"""WPS 灵犀(Lingxi / 灵犀 Claw)渠道。 + +与其余渠道的差异(决定了本包为什么这样切): + +1. **三条请求才是一次对话**:建 cowork 会话 → 领 credits run → 打 + `agent-runtime/chat/completions`。上游**按 run 计费**(一个 run 内多次调用 + 只扣一次智点),故 run 按「账号 × 模型」缓存 1 小时、并发单飞(见 + `client.ensure_run`)。 +2. **鉴权只有 cookie**:`wps_sid`,没有 refresh 接口。过期只能重新粘贴, + 真正的判据是上游 401(不按时长猜,见 `credential.py`)。 +3. **私有 SSE**:`choices[].text` 而非 `delta.content`,没有 `[DONE]`, + `usage.total_tokens` 是上下文大小(自算)、`cached_tokens` / + `reasoning_tokens` 是扁平字段(见 `events.py`)。 +4. **工具 schema 必须先压平**:`anyOf` / `$ref` / 数组型 `type` 会让上游 + 整轮回「AI服务异常」,见 `client.sanitize_schema`。 + +登录轨道不实现:凭据是 WPS 账号 cookie,只能由用户从浏览器(网页版 +`lingxi.kdocs.cn` 同样可用)复制后经「JSON 导入」录入 —— 与 Zen/Kilo 的 +「方法缺失自然跳过」同一分工。 +""" + +from __future__ import annotations + +import time +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any + +from ...provider.base import ( + CheckinResult, + ErrKind, + Event, + Model, + Quota, + UpstreamProtocolViolation, +) +from ...tasks.pacer import paced +from ..login_identity import login_identity_for +from . import events as lingxi_events +from .client import LingxiClient, RunExpired, UpstreamHTTPError +from .credential import LingxiCredential + + +@dataclass(slots=True) +class LingxiProvider: + """Provider 协议实现(细接口)。只依赖注入的 client,不 import config。""" + + client: LingxiClient = field(default_factory=LingxiClient) + pacer: Any | None = None + + id: str = "lingxi" + + # ------------------------------------------------------ 凭证生命周期 + + def import_credential(self, raw: dict) -> dict: + """手工导入:整段 Cookie / 裸 `wps_sid`;缺 sid 直接拒绝。""" + return LingxiCredential.from_import(raw).to_dict() + + def classify(self, status: int, body: bytes) -> ErrKind: + return lingxi_events.classify_status(status, body) + + def login_identity(self, credential_data: dict) -> str: + """重新登录合并键:上游 `uid`(缺省用 sid 哈希,见 credential.identity)。""" + return login_identity_for(self.id, credential_data) + + # ------------------------------------------------------------ 额度 + + async def probe_quota(self, credential_data: dict) -> Quota: + credential = LingxiCredential.from_dict(credential_data) + if not credential.wps_sid: + return Quota(probe_failed=True, probed_at=int(time.time())) + return await self.client.probe_quota(credential) + + # ------------------------------------------------------------ 签到 + + async def checkin(self, credential_data: dict) -> CheckinResult: + credential = LingxiCredential.from_dict(credential_data) + if not credential.wps_sid: + return CheckinResult(ok=False, message="凭证缺少 wps_sid") + try: + return await self.client.checkin(credential) + except UpstreamHTTPError as error: + if error.status == 401: + return CheckinResult(ok=False, message="登录态已失效,请重新粘贴 Cookie") + return CheckinResult(ok=False, message=f"签到失败(HTTP {error.status})") + except (UpstreamProtocolViolation, OSError): + return CheckinResult(ok=False, message="签到响应格式异常") + + async def checkin_status(self, credential_data: dict) -> dict: + credential = LingxiCredential.from_dict(credential_data) + if not credential.wps_sid: + return {"active": False, "today_checked_in": False} + return await self.client.checkin_status(credential) + + def checkin_scope(self, credential_data: dict) -> str: + """签到隔离键:uid / sid 哈希;身份未知返回空串(调用方回落凭证 ID)。""" + identity = LingxiCredential.from_dict(credential_data).identity() + return f"lingxi|{identity}" if identity else "" + + # ------------------------------------------------------------ 模型 + + async def list_models(self, credential_data: dict) -> list[Model]: + credential = LingxiCredential.from_dict(credential_data) + if not credential.wps_sid: + raise UpstreamProtocolViolation("lingxi credential missing wps_sid") + return await self.client.fetch_models(credential) + + async def stream_chat(self, credential_data: dict, payload: dict, + model: str) -> AsyncIterator[Event]: + """聊天:run 失效时丢弃缓存重建一次(上游 `SQLNoRows`)。""" + credential = LingxiCredential.from_dict(credential_data) + if not credential.wps_sid: + raise UpstreamProtocolViolation("lingxi credential missing wps_sid") + async with paced(self.pacer, credential.identity()): + # 两轮要么 return 要么 raise,循环不会自然耗尽(按构造不可达分支) + for attempt in (0, 1): # pragma: no branch + try: + async for event in self.client.stream_chat( + credential, payload, model): + yield event + return + except RunExpired: + self.client.forget_run(credential, model) + if attempt: + raise UpstreamProtocolViolation( + "灵犀会话反复失效(重建后仍 SQLNoRows)") from None + + async def aclose(self) -> None: + await self.client.aclose() + + +__all__ = ["LingxiProvider"] \ No newline at end of file diff --git a/src/provider/lingxi/client.py b/src/provider/lingxi/client.py new file mode 100644 index 00000000..0e2d23cd --- /dev/null +++ b/src/provider/lingxi/client.py @@ -0,0 +1,872 @@ +"""WPS 灵犀上游客户端(桌面端 agent-runtime 通路)。 + +三条请求做成一次对话(取证:magpie-wps-lingxi 1.3.31 / MiraiHub): + +1. `POST /api/aioffice/v1/cowork/sessions` → `session_id` +2. `POST /api/public/v1/credits/runs` → `run_id`(**按 run 计费**:一个 run + 内多次调用只扣一次智点,故 run 按「账号 × 模型」缓存 1 小时、不滑动续期) +3. `POST /api/public/v1/agent-runtime/chat/completions`(自定义 SSE) + +账号面(profile / plans / balance / tasks)走同一 base;`lingxi.wps.cn` 不通 +时回落 `lingxi.kdocs.cn`(kdocs 账号的集群)。 + +鉴权只有 `Cookie: wps_sid=…`,再追加三段桌面客户端标记(`x-kso-*`)。 +没有 refresh 接口 —— cookie 过期只能重新粘贴,真正的判据是上游 401。 +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import re +import time +import uuid +from collections.abc import AsyncIterator +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx + +from ...provider import base +from ...provider.base import ( + CheckinResult, + Event, + EventKind, + Model, + Quota, + UpstreamProtocolViolation, +) +from ..lazy_clients import LazyClients +from . import events as lingxi_events +from .credential import APP_NAME, APP_VERSION, DEVICE_ID, LingxiCredential + +#: 账号面 / 对话面主域与回落域(顺序即尝试顺序) +BASE = "https://lingxi.wps.cn" +FALLBACK_BASE = "https://lingxi.kdocs.cn" + +EP_PROFILE = "/api/public/v1/account/profile" +EP_PLANS = "/api/aioffice/v1/sessions/plans" +EP_BALANCE = "/api/public/v1/credits/balance" +EP_TASKS = "/api/public/v1/tasks" +EP_CLAIM = "/api/public/v1/tasks/%s/claim" +EP_SESSIONS = "/api/aioffice/v1/cowork/sessions" +EP_RUNS = "/api/public/v1/credits/runs" +EP_CHAT = "/api/public/v1/agent-runtime/chat/completions" + +#: run 缓存时长:上游按 run 计费,一个 run 内多次调用只扣一次;不滑动续期。 +RUN_TTL_SECONDS = 3600 +#: 对话总时长上限:推理模型可能很久(与参考实现同口径) +CHAT_TIMEOUT = 600.0 +#: 账号面短请求超时 +API_TIMEOUT = 30.0 +#: 上游客户端类型:桌面端(原生 tools / tool_calls 只在这条通路上有) +CLIENT_TYPE = "lingxi_claw" +CLIENT_LANG = "zh-CN" +#: 临时积分窗口(天):余额里「快到期」的那部分 +TEMP_CREDIT_DAYS = 15 + +#: 模型目录不可达时的内置快照(与客户端 1.3.31 的 14 个下拉项一致) +DEFAULT_MODELS: tuple[dict[str, Any], ...] = ( + {"id": "auto", "name": "自动推荐 (Auto)", "rate": 1.0, + "context": 1048576, "vision": False, "reasoning": True}, + {"id": "deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", "rate": 0.12, + "context": 1048576, "vision": True, "reasoning": True}, + {"id": "deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash", "rate": 0.09, + "context": 1048576, "vision": False, "reasoning": True}, + {"id": "deepseek-v4-pro-0813", "name": "DeepSeek V4 Pro", "rate": 0.40, + "context": 1048576, "vision": False, "reasoning": True}, + {"id": "kimi-k3", "name": "Kimi-K3", "rate": 1.60, + "context": 1048576, "vision": True, "reasoning": True}, + {"id": "GLM-5.3", "name": "GLM-5.3", "rate": 0.75, + "context": 1048576, "vision": False, "reasoning": True}, + {"id": "GLM-5.3-Flash", "name": "GLM-5.3-Flash", "rate": 0.11, + "context": 1048576, "vision": True, "reasoning": True}, + {"id": "qwen3.8_max", "name": "Qwen3.8-Max", "rate": 0.80, + "context": 1048576, "vision": True, "reasoning": True}, + {"id": "qwen3.8_flash", "name": "Qwen3.8-Flash", "rate": 0.08, + "context": 1048576, "vision": True, "reasoning": True}, + {"id": "minimax_m3", "name": "MiniMax-M3", "rate": 0.20, + "context": 1048576, "vision": True, "reasoning": False}, + {"id": "doubao_seed_2_1_pro_260628", "name": "Doubao-Seed-2.1 Pro", + "rate": 0.50, "context": 262144, "vision": True, "reasoning": True}, + {"id": "mimo-v2.6-pro", "name": "Xiaomi MiMo-V2.6 Pro", "rate": 0.10, + "context": 1048576, "vision": True, "reasoning": False}, + {"id": "plan_pro", "name": "Pro 模式 (深度推理)", "rate": 1.0, + "context": 1048576, "vision": False, "reasoning": True}, + {"id": "plan_max", "name": "Max 模式 (复杂任务)", "rate": 1.0, + "context": 1048576, "vision": False, "reasoning": True}, +) + +#: 工具 schema 压平的最大深度(防止递归 $ref 打爆栈) +_MAX_SCHEMA_DEPTH = 24 + + +class UpstreamHTTPError(base.UpstreamHTTPError): + """上游非 2xx;`kind()` 走本渠道的状态码规则。""" + + classify_status = staticmethod(lingxi_events.classify_status) + + +class RunExpired(RuntimeError): + """run / 会话在上游侧失效(`SQLNoRows` /「会话不存在」):重建一次即可。 + + 只在**首个有效帧之前**抛出:流已经开始就无法回滚已下发的内容。 + """ + + +class LingxiClient(LazyClients): + """上游 HTTP 客户端;host 可覆盖(测试与灰度)。""" + + def __init__( + self, + *, + host: str = BASE, + fallback_host: str = FALLBACK_BASE, + stream_client: httpx.AsyncClient | None = None, + short_client: httpx.AsyncClient | None = None, + proxy: str | None = None, + ) -> None: + self.host = host.rstrip("/") + self.fallback_host = fallback_host.rstrip("/") + self._stream_client = stream_client + self._short_client = short_client + self._proxy = proxy + # run 缓存与单飞锁:同一 (账号, 模型) 并发时只建一个 run(多建多扣智点) + self._runs: dict[str, dict[str, Any]] = {} + self._run_locks: dict[str, asyncio.Lock] = {} + self._catalog: dict[str, Any] | None = None + + # ------------------------------------------------------------ 基础设施 + + def bases(self) -> tuple[str, ...]: + """账号面 / 对话面的域名回落顺序(去重,主域在前)。""" + if self.fallback_host == self.host: + return (self.host,) + return (self.host, self.fallback_host) + + def headers(self, credential: LingxiCredential, *, + accept: str = "application/json") -> dict[str, str]: + """账号面请求头(Cookie + 桌面客户端标记)。""" + return { + "Cookie": credential.cookie_header(), + "Content-Type": "application/json", + "Accept": accept, + "X-Client-Type": CLIENT_TYPE, + "X-Client-Lang": CLIENT_LANG, + "X-Cc-Agent-Type": CLIENT_TYPE, + "X-Request-Id": uuid.uuid4().hex, + "x-kso-app-name": APP_NAME, + "x-kso-app-version": APP_VERSION, + "x-kso-device-id": DEVICE_ID, + } + + async def _json(self, method: str, url: str, *, + headers: dict[str, str], + body: dict[str, Any] | None = None, + timeout: float = API_TIMEOUT) -> dict[str, Any]: + """短请求 → JSON(非 2xx 抛 `UpstreamHTTPError`,传输错误单独归类)。""" + try: + response = await self._short.request( + method, url, headers=headers, json=body, timeout=timeout) + except httpx.TransportError as error: + raise base.UpstreamTransportError(str(error)) from error + if response.status_code >= 400: + raise UpstreamHTTPError(response.status_code, + await base.read_body_bounded(response), + **base.retry_hints(response.headers)) + try: + data = response.json() + except ValueError as error: + raise UpstreamProtocolViolation("non-JSON response") from error + if not isinstance(data, dict): + raise UpstreamProtocolViolation("response is not an object") + return data + + async def _account_json(self, credential: LingxiCredential, path: str, + *, body: dict[str, Any] | None = None, + method: str = "GET") -> dict[str, Any]: + """账号面 GET/POST,主域失败回落一次。 + + **401 也回落**:kdocs 账号的 cookie 只在 `lingxi.kdocs.cn` 集群有效, + 主域回 401 不代表凭据失效(参考实现同口径:除审核拦截外都换域重试)。 + 两个域都失败才上抛最后一个错误。 + """ + last: Exception | None = None + for base_host in self.bases(): + try: + return await self._json(method, base_host + path, + headers=self.headers(credential), + body=body) + except (UpstreamHTTPError, base.UpstreamTransportError, + UpstreamProtocolViolation) as error: + last = error + if last is not None: + raise last + raise UpstreamProtocolViolation("灵犀没有可用的账号域") # pragma: no cover + + # ---------------------------------------------------------- 账号面 + + async def fetch_profile(self, credential: LingxiCredential) -> dict[str, Any]: + """GET 账号资料:`data.id` / `data.name`(身份补全与昵称)。""" + data = await self._account_json(credential, EP_PROFILE) + payload = data.get("data") + return payload if isinstance(payload, dict) else {} + + async def fetch_balance(self, credential: LingxiCredential) -> dict[str, Any]: + """GET 智点余额(`total_balance` 为主,`bonus_credits[]` 带到期时刻)。""" + data = await self._account_json(credential, EP_BALANCE) + payload = data.get("data") + return payload if isinstance(payload, dict) else {} + + async def probe_quota(self, credential: LingxiCredential) -> Quota: + """余额 → 中立额度。积分就是主账,不做 token 换算。 + + `total` 取「余额 与 赠送总额 的较大者」:上游只给剩余,没给已消耗, + 因此健康度是**近似**比例(参考实现同口径,界面只展示剩余与批次)。 + """ + payload = await self.fetch_balance(credential) + remaining = _num(payload.get("total_balance")) + if remaining is None: + remaining = _num(payload.get("credits_balance")) + if remaining is None: + return Quota(probe_failed=True, probed_at=int(time.time())) + batches = payload.get("bonus_credits") + items = batches if isinstance(batches, list) else [] + summary = payload.get("bonus_summary") + summary = summary if isinstance(summary, dict) else {} + bonus_total = _num(summary.get("total_value")) or 0.0 + ladder: list[tuple[int, float]] = [] + packages: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + expire = _parse_expire(item.get("expire_time")) + balance = _num(item.get("balance")) or 0.0 + if expire <= 0 or balance <= 0: + continue + packages.append({"name": "赠送智点", "total": balance, + "used": 0, "end": expire}) + if expire - time.time() <= TEMP_CREDIT_DAYS * 86400: + ladder.append((expire, balance)) + ladder.sort() + now = int(time.time()) + return Quota( + remaining=remaining, + total=max(remaining, bonus_total), + expiry_ladder=ladder or None, + packages=packages or None, + probed_at=now, + ) + + async def fetch_models(self, credential: LingxiCredential) -> list[Model]: + """GET 模型目录(`plans` + `models`);失败回落内置快照。 + + 目录失败**不是**凭证故障(返回空列表由调用方回落),但 401 必须抛出: + 那是登录态失效,不能拿快照假装还活着。 + """ + try: + data = await self._account_json(credential, EP_PLANS) + except UpstreamHTTPError as error: + if error.status in (401, 402): + raise + return _snapshot_models() + except (base.UpstreamTransportError, UpstreamProtocolViolation): + return _snapshot_models() + payload = data.get("data") + payload = payload if isinstance(payload, dict) else {} + models: list[Model] = [] + seen: set[str] = set() + for group, multiplier in (("plans", None), ("models", True)): + items = payload.get(group) + if not isinstance(items, list): + continue + for item in items: + model = _model_of(item, multiplier) + if model is None or model.id in seen: + continue + seen.add(model.id) + models.append(model) + return models or _snapshot_models() + + async def fetch_tasks(self, credential: LingxiCredential) -> list[dict[str, Any]]: + """GET 任务中心(签到 / 一次性任务)。""" + data = await self._account_json(credential, EP_TASKS) + payload = data.get("data") + tasks = payload.get("tasks") if isinstance(payload, dict) else None + return [item for item in tasks if isinstance(item, dict)] \ + if isinstance(tasks, list) else [] + + async def claim_task(self, credential: LingxiCredential, key: str, + date: str = "") -> dict[str, Any]: + """POST 领取任务;`date` 是签到窗口日期(上游要求 `YYYY-MM-DD`)。""" + path = EP_CLAIM % key + if date: + path += "?date=" + date + return await self._account_json(credential, path, body={}, method="POST") + + async def checkin(self, credential: LingxiCredential) -> CheckinResult: + """每日签到:奖励按**领前/领后余额差**算。 + + 上游对重复领取回 200 假成功(`result=ok` 但余额不动),因此不能只看 + HTTP/result;余额差为 0 时读任务状态判定「今日已签」。 + """ + before = None + try: + payload = await self.fetch_balance(credential) + before = _num(payload.get("total_balance")) + except UpstreamHTTPError as error: + if error.status == 401: + return CheckinResult(ok=False, message="登录态已失效,请重新粘贴 Cookie") + tasks = await self.fetch_tasks(credential) + daily = next((item for item in tasks + if str(item.get("task_key") or "") == "daily_check_in"), None) + if daily is None: + return CheckinResult(ok=False, message="上游任务中心里没有每日签到") + status = str(daily.get("status") or "") + if status == "claimed": + return CheckinResult(ok=True, already_checked_in=True, + message="今日已签到", status=daily) + claim = await self.claim_task(credential, "daily_check_in", + _claim_date(daily)) + result = str(claim.get("result") or "") + if result and result.lower() != "ok": + return CheckinResult(ok=False, message=str(claim.get("hint") + or lingxi_events.text_of(claim))) + credit: float | None = None + try: + after = _num((await self.fetch_balance(credential)).get("total_balance")) + except UpstreamHTTPError: + after = None + if before is not None and after is not None: + credit = max(0.0, after - before) + if credit is None or credit <= 0: + credit = _reward_of(claim, daily) + if credit and credit > 0: + return CheckinResult(ok=True, credit=credit, message="签到成功", + status=daily) + return CheckinResult(ok=True, already_checked_in=True, + message="今日已签到(上游幂等,余额未变化)", + status=daily) + + async def checkin_status(self, credential: LingxiCredential) -> dict[str, Any]: + """只读签到状态(管理台展示)。""" + tasks = await self.fetch_tasks(credential) + daily = next((item for item in tasks + if str(item.get("task_key") or "") == "daily_check_in"), None) + if daily is None: + return {"active": False, "today_checked_in": False} + status = str(daily.get("status") or "") + extra = _dict(daily.get("extra")) + return { + "active": True, + "today_checked_in": status == "claimed", + "streak_days": _num(extra.get("consecutive_days")), + "today_credit": _num(extra.get("today_reward")), + "activity_name": "每日签到(智点)", + } + + # ------------------------------------------------------------ 对话通路 + + def _run_key(self, credential: LingxiCredential, model: str) -> str: + return f"{credential.identity()}|{model}" + + def forget_run(self, credential: LingxiCredential, model: str) -> None: + """丢弃缓存的 run(上游说会话不存在时调用)。""" + self._runs.pop(self._run_key(credential, model), None) + + def _cached_run(self, key: str) -> dict[str, Any] | None: + cached = self._runs.get(key) + if cached is None: + return None + if time.time() - float(cached.get("at") or 0) >= RUN_TTL_SECONDS: + self._runs.pop(key, None) + return None + return cached + + async def ensure_run(self, credential: LingxiCredential, model: str, + messages: list[Any] | None = None) -> dict[str, Any]: + """拿一个可用 run:命中缓存(1 小时内)就用,否则建 会话 + 领 run。 + + 并发单飞:同一 (账号, 模型) 的并发请求共享同一把锁 —— 各建一个 run + 会各扣一次智点。锁内重读缓存,避免等锁期间别人已经建好。 + """ + key = self._run_key(credential, model) + cached = self._cached_run(key) + if cached is not None: + return cached + lock = self._run_locks.setdefault(key, asyncio.Lock()) + async with lock: + cached = self._cached_run(key) + if cached is not None: + return cached + run = await self._create_run(credential, model, messages or []) + self._runs[key] = run + return run + + async def _create_run(self, credential: LingxiCredential, model: str, + messages: list[Any]) -> dict[str, Any]: + """建会话 + 领 run,主域失败回落一次。""" + last: Exception | None = None + for base_host in self.bases(): + try: + session_id = await self._create_session(credential, base_host) + run = await self._create_credit_run(credential, base_host, + session_id, model, messages) + except (UpstreamHTTPError, base.UpstreamTransportError, + UpstreamProtocolViolation) as error: + # 401/402 也换域:kdocs 账号只在自己的集群上认这份 cookie + last = error + continue + run.update({"base": base_host, "at": time.time()}) + return run + if last is not None: + raise last + # 两轮回落要么成功要么都抛异常,循环结束必然有 last(按构造不可达) + raise UpstreamProtocolViolation( # pragma: no cover + "灵犀没有可用的对话域") + + async def _create_session(self, credential: LingxiCredential, + base_host: str) -> str: + body = {"session_type": "cowork_client", "agent_version": 2, + "env": {"device_id": DEVICE_ID, + "device_name": "coding2api", + "work_space_dir": "coding2api"}} + data = await self._json("POST", base_host + EP_SESSIONS, + headers=self.headers(credential, accept="application/json"), + body=body) + session_id = str(_dict(data.get("data")).get("session_id") or "").strip() + if not session_id: + raise UpstreamProtocolViolation("灵犀未返回 session_id") + return session_id + + async def _create_credit_run(self, credential: LingxiCredential, base_host: str, + session_id: str, model: str, + messages: list[Any]) -> dict[str, Any]: + body = {"client_request_id": str(int(time.time() * 1000)), + "session_id": session_id, + "model_provider": "lingxi", + "model_id": model, + "agent_input": {"text": last_user_text(messages)[:60], + "files": [], "file_ids": [], + "expect_model": model, "refs": [], + "permission_mode": "default", + "current_mode": "agent", + "browser_plugin_enabled": False}} + data = await self._json("POST", base_host + EP_RUNS, + headers=self.headers(credential, accept="application/json"), + body=body) + payload = _dict(data.get("data")) + run_id = str(payload.get("run_id") or "").strip() + if not run_id: + raise UpstreamProtocolViolation("灵犀未返回 run_id") + group_id = str(payload.get("group_id") or "").strip() or run_id + return {"session_id": session_id, "run_id": run_id, "group_id": group_id} + + async def stream_chat(self, credential: LingxiCredential, payload: dict[str, Any], + model: str) -> AsyncIterator[Event]: + """跑完三步并逐帧产出中立事件;非 2xx 抛 `UpstreamHTTPError`。 + + `RunExpired` 只在首帧之前抛出(流已开始就无法回滚);调用方据此丢弃 + 缓存的重建一次。 + """ + run = await self.ensure_run(credential, model, payload.get("messages")) + body = build_body(payload, model) + headers = self.headers(credential, accept="text/event-stream") + headers["x-cc-session-id"] = run["session_id"] + headers["x-cc-run-id"] = run["run_id"] + url = f"{run['base']}{EP_CHAT}" + started = False + try: + async with self._stream.stream("POST", url, json=body, + headers=headers, + timeout=CHAT_TIMEOUT) as response: + if response.status_code >= 400: + raw = await base.read_body_bounded(response) + raise UpstreamHTTPError(response.status_code, raw, + **base.retry_hints(response.headers)) + async for line in response.aiter_lines(): + frame = _frame_of(line) + if frame is None: + continue + if frame == {}: + return + error = lingxi_events.frame_error(frame) + if error is not None: + code, message = error + if not started and lingxi_events.is_session_expired(message): + raise RunExpired(message) + yield Event(kind=EventKind.ERROR, error_message=message, + error_kind=lingxi_events.classify_code(code, message)) + return + events = lingxi_events.parse_frame(frame) + if events: + started = True + for event in events: + yield event + if any(event.kind == EventKind.FINISH for event in events): + return + except httpx.TransportError as error: + raise base.UpstreamTransportError(str(error)) from error + # 上游在 finish_reason 之前断流:如实报错,不伪造成功收尾 + raise UpstreamProtocolViolation( + "灵犀上游流在完成事件之前结束(响应被截断)") + + async def aclose(self) -> None: + await super().aclose() + + +# ---------------------------------------------------------------- 请求体 + + +def last_user_text(messages: list[Any]) -> str: + """最后一条 user 文本(上游用它做 run 标题)。""" + for item in reversed(messages or []): + if not isinstance(item, dict) or str(item.get("role")) != "user": + continue + content = item.get("content") + text = "" + if isinstance(content, str): + text = content + elif isinstance(content, list): + text = "".join(str(block.get("text") or "") + for block in content if isinstance(block, dict)) + text = text.strip() + if text: + return text[:60] + return "灵犀会话" + + +def normalize_messages(messages: Any) -> list[dict[str, Any]]: + """入站消息归一:丢弃**孤立 tool 消息**(没有紧邻声明其 id 的 assistant)。 + + 上游对这类历史一律 400;与 Qoder 同一口径 —— 只丢弃、不重排、不改 + content。`assistant.tool_calls` 需与紧随其后的 `tool` 结果配对。 + """ + if not isinstance(messages, list): + return [] + out: list[dict[str, Any]] = [] + declared: set[str] = set() + for item in messages: + if not isinstance(item, dict): + continue + message = dict(item) + role = str(message.get("role") or "") + if role == "assistant": + calls = message.get("tool_calls") + declared = set() + if isinstance(calls, list): + for call in calls: + if isinstance(call, dict) and isinstance(call.get("id"), str): + declared.add(call["id"]) + elif role == "tool": + call_id = message.get("tool_call_id") + if not isinstance(call_id, str) or call_id not in declared: + continue + declared.discard(call_id) + else: + declared = set() + out.append(message) + return out + + +def build_body(payload: dict[str, Any], model: str) -> dict[str, Any]: + """OpenAI 入站 → 上游 agent-runtime 请求体。 + + `max_tokens` 必须包进 `base_llm_arguments`(顶层上游不理); + `reasoning_effort` 顶层与 `llm_arguments` 各写一份;工具 schema 必须先 + 压平(`anyOf` / `$ref` / 数组型 `type` 会让整轮失败)。 + """ + body: dict[str, Any] = {"stream": True, "model": model, + "messages": normalize_messages( + copy.deepcopy(payload.get("messages")))} + tools = payload.get("tools") + if isinstance(tools, list) and tools: + usable = [tool for tool in (_tool_of(item) for item in tools + if isinstance(item, dict)) if tool is not None] + if usable: + body["tools"] = usable + choice = _tool_choice_of(payload.get("tool_choice")) + if choice is not None: + body["tool_choice"] = choice + temperature = payload.get("temperature") + if isinstance(temperature, (int, float)) and not isinstance(temperature, bool): + body["temperature"] = temperature + max_tokens = payload.get("max_tokens") + if isinstance(max_tokens, int) and not isinstance(max_tokens, bool) \ + and max_tokens > 0: + body["base_llm_arguments"] = {"max_tokens": max_tokens} + effort = payload.get("reasoning_effort") + if isinstance(effort, str) and effort.strip(): + body["reasoning_effort"] = effort.strip() + body["llm_arguments"] = {"reasoning_effort": effort.strip()} + return body + + +def _tool_of(tool: dict[str, Any]) -> dict[str, Any] | None: + """单个工具定义:只保留 `type=function` 且带 function 的条目。 + + 上游只支持函数工具;其它类型(`web_search` 之类)原样发过去会让整轮 + 回一句「AI服务异常」,故直接丢弃 —— 少一个内置工具,比整轮失败好。 + """ + if str(tool.get("type") or "") != "function": + return None + function = tool.get("function") + if not isinstance(function, dict): + return None + out = dict(tool) + piece = dict(function) + piece["parameters"] = sanitize_schema(piece.get("parameters")) + out["function"] = piece + return out + + +def _tool_choice_of(choice: Any) -> str | None: + """`tool_choice` → 上游可接受的值。 + + 实测(2026-11-09):字符串 `auto` / `none` / `required` 全部接受, + **对象形态一律 400 `InvalidArgument`**(上游不支持点名具体函数)。 + 故对象按语义降级成 `required`(仍保证「必须调用工具」),非法值丢弃。 + """ + if isinstance(choice, str): + return choice.strip() or None + if isinstance(choice, dict): + return "required" + return None + + +def sanitize_schema(schema: Any, defs: dict[str, Any] | None = None, + seen: frozenset[str] = frozenset(), + depth: int = 0) -> dict[str, Any]: + """把任意 JSON Schema 压成上游能吃的朴素子集。 + + 上游只认最朴素的形状:出现 `anyOf` / `oneOf` / `$ref` 就整轮回 + 「AI服务异常」,`type` 用数组则 400。故: + + * `$ref` 展开本地指针(循环引用降级成 string); + * `anyOf` / `oneOf` 选最「具体」的一支(object > array > 标量); + * `type` 数组取第一个非 null; + * `properties` / `items` 递归压平,其余元数据(description/enum/required) + 保留。 + """ + if depth > _MAX_SCHEMA_DEPTH: + return {"type": "string"} + if not isinstance(schema, dict): + return {"type": _type_of(schema)} + defs = defs if isinstance(defs, dict) else {} + local = schema.get("$defs") + if isinstance(local, dict): + defs = {**defs, **local} + node = {key: value for key, value in schema.items() if key != "$defs"} + definition = node.pop("definitions", None) + if isinstance(definition, dict): + defs = {**defs, **definition} + + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/"): + if ref in seen: + return {"type": "string"} + target: Any = defs + for token in ref[2:].replace("~1", "/").replace("~0", "~").split("/"): + if isinstance(target, dict) and token in target: + target = target[token] + if target is None: # pragma: no cover - 指针遍历不会产出 None + return {"type": "string"} + return sanitize_schema(target, defs, seen | {ref}, depth + 1) + + for key in ("anyOf", "oneOf"): + branches = node.pop(key, None) + if isinstance(branches, list) and branches: + candidates = [item for item in branches if isinstance(item, dict)] + if not candidates: + return {"type": "string"} + chosen = max(candidates, key=_branch_score) + merged = sanitize_schema(chosen, defs, seen, depth + 1) + for extra in ("description", "title", "default"): + if extra in node and extra not in merged: + merged[extra] = node[extra] + return merged + + kind = node.get("type") + if isinstance(kind, list): + kinds = [item for item in kind if isinstance(item, str) and item != "null"] + kind = kinds[0] if kinds else "string" + node["type"] = kind if isinstance(kind, str) else "string" + + properties = node.get("properties") + if isinstance(properties, dict): + node["properties"] = { + name: sanitize_schema(value, defs, seen, depth + 1) + for name, value in properties.items()} + items = node.get("items") + if isinstance(items, dict): + node["items"] = sanitize_schema(items, defs, seen, depth + 1) + for key in ("allOf", "not", "if", "then", "else", "$id", "$schema"): + node.pop(key, None) + return node + + +def _type_of(value: Any) -> str: + if isinstance(value, bool): + return "boolean" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, list): + return "array" + return "string" + + +def _branch_score(node: dict[str, Any]) -> tuple[int, int]: + rank = {"object": 3, "array": 2}.get(str(node.get("type") or ""), 1) + return (rank, len(node)) + + +def _frame_of(line: Any) -> dict[str, Any] | None: + """一行 → 帧 dict;`None` 表示空行/注释/坏行,`{}` 表示 `[DONE]`。 + + 上游**每帧是单行** `data:{…}`(有时连着多行 `data:`),不能用「空行分帧」 + 的通用 SSE 解析器 —— 那会把多帧吞成一帧。 + """ + if line is None: + return None + text = str(line).strip() + if not text or text.startswith(":") or text.startswith("event:") \ + or text.startswith("id:"): + return None + if text.startswith("data:"): + text = text[5:].strip() + if not text: + return None + if text == "[DONE]": + return {} + try: + frame = json.loads(text) + except ValueError: + return None + return frame if isinstance(frame, dict) else None + + +def _snapshot_models() -> list[Model]: + return [Model(id=item["id"], name=item["name"], + credit_rate=float(item["rate"]), + max_input_tokens=item["context"], + max_output_tokens=65536, + supports_images=bool(item["vision"]), + supports_tool_call=True, + supports_reasoning=bool(item["reasoning"])) + for item in DEFAULT_MODELS] + + +def _model_of(item: Any, multiplier: bool | None) -> Model | None: + """目录条目 → 中立 Model;缺 key 的条目跳过。""" + if not isinstance(item, dict): + return None + model_id = item.get("key") + if not isinstance(model_id, str) or not model_id.strip(): + return None + efforts = item.get("reasoning_efforts") + return Model( + id=model_id.strip(), + name=str(item.get("name") or model_id).strip(), + credit_rate=(_multiplier_of(item) if multiplier else None), + max_input_tokens=_int(item.get("max_context_tokens")), + max_output_tokens=_int(item.get("max_new_tokens")), + supports_images=bool(item.get("support_multimodal")), + supports_tool_call=True, + supports_reasoning=(bool(efforts) if isinstance(efforts, list) + else (False if multiplier else None)), + ) + + +def _multiplier_of(item: dict[str, Any]) -> float | None: + """目录条目 → 倍率(兼容 `multiplier` / `credit_rate` / 嵌套 `credit`)。 + + 上游 1.3.31 的 `models[]` 用 `multiplier`,值是 `"~0.12x"` 这类带标记 + 字符串(实测 2026-11-09:全角波浪线 + 数值 + `x`,`plans[]` 同字段为空串); + 不同版本/灰度也见过数值与 `credit.multiplier` 的嵌套形态,依次尝试, + 取不到返回 None(不显示倍率,不猜)。 + """ + for key in ("multiplier", "credit_rate", "rate"): + rate = _rate(item.get(key)) + if rate is not None: + return rate + credit = item.get("credit") + return _rate(credit.get("multiplier")) if isinstance(credit, dict) else None + + +def _reward_of(claim: dict[str, Any], daily: dict[str, Any]) -> float | None: + payload = _dict(claim.get("data")) + extra = _dict(payload.get("extra")) + for value in (extra.get("today_reward"), payload.get("reward_amount"), + daily.get("reward_amount")): + number = _num(value) + if number is not None: + return number + return None + + +def _claim_date(daily: dict[str, Any]) -> str: + """签到窗口日期:任务里给了就用,否则用本地今天(上游按 UTC+8 换日)。""" + extra = _dict(daily.get("extra")) + for key in ("claim_date", "date", "today"): + value = extra.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d") + + +def _parse_expire(value: Any) -> int: + """`YYYY-MM-DD HH:mm[:ss]`(UTC+8)→ epoch 秒;解析不了返回 0。""" + text = str(value or "").strip() + if not text: + return 0 + if text.endswith("Z"): + text = text[:-1] + candidate = text.replace("T", " ") + if candidate.endswith("+08:00"): + candidate = candidate[:-6] + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + parsed = datetime.strptime(candidate, fmt) + except ValueError: + continue + return int(parsed.replace(tzinfo=timezone(timedelta(hours=8))).timestamp()) + return 0 + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +#: 倍率文本:上游给的是带标记的字符串(`0.12x`、`0.75倍`、`×1.6`), +#: 也可能直接是数。只认「一个数字 + 可选倍率标记」,其余一律 None —— 宁可 +#: 不显示倍率,也不把 `模式` 这类文案错认成数字。 +_RATE_PATTERN = re.compile( + r"^[~~〜≈约×xX]*\s*([0-9]+(?:\.[0-9]+)?)\s*(?:x|×|倍|倍率)?\s*$", re.IGNORECASE) + + +def _rate(value: Any) -> float | None: + """倍率 → float;`"0.12x"` / `"0.75倍"` / `"×1.6"` / `0.5` 都能取。""" + if isinstance(value, str): + match = _RATE_PATTERN.match(value.strip()) + return float(match.group(1)) if match else None + return _num(value) + + +def _num(value: Any) -> float | None: + """宽松取数(上游积分可能是 int / float / 字符串)。bool 不算。""" + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError: + return None + return None + + +def _int(value: Any) -> int | None: + number = _num(value) + return int(number) if number is not None else None diff --git a/src/provider/lingxi/credential.py b/src/provider/lingxi/credential.py new file mode 100644 index 00000000..fff252ab --- /dev/null +++ b/src/provider/lingxi/credential.py @@ -0,0 +1,151 @@ +"""WPS 灵犀凭证:只有一份 `wps_sid` Cookie,**没有刷新接口**。 + +上游登录走 WPS 账号 SSO(网页版 / 桌面客户端同一套 cookie),拿不到 +设备码 / 扫码换票的公开协议,程序侧换不出新票。因此: + +* 凭证只保留 `wps_sid` 与可选的伴生 `uid` / `copilot_server_branch`; +* 不写 `expires`:插件实测 cookie 约一年有效(无官方承诺),真正的判据是 + 上游 `401`。按时长写死过期会让还能用的凭证被提前摘掉、还会让预刷新任务 + 每小时白跑一次; +* 过期只能重新粘贴 —— `refresh()` 的语义因此降级为「重新校验 + 刷余额」 + (见 `provider.refresh`)。 + +输入形态宽松(用户会从 DevTools 整段复制):裸 sid、`wps_sid=…`、 +`Cookie: wps_sid=…; uid=…`、带引号的粘贴、以及 `{"cookie": "…"}` 都归一成 +同一个 `LingxiCredential`。 +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any + +from ..base import UpstreamProtocolViolation + +#: Cookie 名(上游字段名与 Cookie 名一致,见 UPSTREAM-API 取证) +SID_KEY = "wps_sid" +#: 可选的伴生 cookie:账号 uid 与集群分支(带上更接近真实客户端) +UID_KEY = "uid" +BRANCH_KEY = "copilot_server_branch" +#: 归一后保留的键(其余 cookie 一律丢弃:它们与业务请求无关,留着只会外泄) +KEPT_KEYS = (SID_KEY, UID_KEY, BRANCH_KEY) + + +def _strip_prefix(text: str) -> str: + """去掉首尾引号与 `Cookie:` 前缀(用户从 DevTools 复制时常带)。 + + 顺序必须「先去引号、再去前缀、再去引号」:粘贴常见形态是 + `"Cookie: wps_sid=…"`(整段被引号包住),先判断前缀会漏掉它。 + """ + value = text.strip().strip("\"'").strip() + if value.lower().startswith("cookie:"): + value = value[len("cookie:"):].strip() + return value.strip().strip("\"'") + + +def parse_cookie_input(raw: str) -> dict[str, str]: + """整段 Cookie / 裸 sid → `{键: 值}`(只保留需要的三个键)。 + + 裸 sid(不含 `=`)直接当作 `wps_sid`;含 `=` 的串按 `;` 分段解析。 + 空段、缺 `=` 的段、空值一律跳过 —— 那不是可用凭证。 + """ + text = _strip_prefix(raw) + if not text: + return {} + if "=" not in text: + return {SID_KEY: text} + values: dict[str, str] = {} + for segment in text.split(";"): + key, sep, value = segment.partition("=") + if not sep: + continue + name = key.strip() + item = value.strip() + if name in KEPT_KEYS and item: + values[name] = item + return values + + +def _pick(raw: dict[str, Any], *names: str) -> str: + """按顺序取第一个非空字符串值。""" + for name in names: + value = raw.get(name) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +@dataclass(slots=True) +class LingxiCredential: + """归一后的灵犀凭证。""" + + wps_sid: str + uid: str = "" + branch: str = "" + + @classmethod + def from_dict(cls, raw: Any) -> LingxiCredential: + """落库 JSON → 凭证;缺 sid 返回空凭证(调用方据此报错 / 探活失败)。""" + if not isinstance(raw, dict): + return cls(wps_sid="") + # `wps_sid` / `cookie` 都可能装整段 Cookie;`raw` 兼容旧导入字段 + source = _pick(raw, SID_KEY, "cookie", "raw") + pairs = parse_cookie_input(source) + sid = pairs.get(SID_KEY, "") + uid = _pick(raw, UID_KEY) or pairs.get(UID_KEY, "") + branch = _pick(raw, BRANCH_KEY) or pairs.get(BRANCH_KEY, "") + return cls(wps_sid=sid, uid=uid, branch=branch) + + @classmethod + def from_import(cls, raw: Any) -> LingxiCredential: + """导入入口:形态非法(不是对象 / 没有 sid)直接拒绝,不落空凭证。""" + if not isinstance(raw, dict): + raise UpstreamProtocolViolation( + "credential is not an object: paste {\"cookie\": \"wps_sid=…\"} " + "or the whole Cookie header") + credential = cls.from_dict(raw) + if not credential.wps_sid: + raise UpstreamProtocolViolation( + "missing wps_sid: paste the Cookie header from " + "lingxi.kdocs.cn (contains wps_sid=…) or {\"wps_sid\": \"…\"}") + return credential + + def to_dict(self) -> dict[str, str]: + """落库形态:只留非空字段(空串回写会让界面显示一个空 uid)。""" + out = {SID_KEY: self.wps_sid} + if self.uid: + out[UID_KEY] = self.uid + if self.branch: + out[BRANCH_KEY] = self.branch + return out + + def cookie_header(self) -> str: + """`Cookie:` 头的值(顺序固定,便于测试与日志比对)。""" + parts = [f"{SID_KEY}={self.wps_sid}"] + if self.uid: + parts.append(f"{UID_KEY}={self.uid}") + if self.branch: + parts.append(f"{BRANCH_KEY}={self.branch}") + return "; ".join(parts) + + def identity(self) -> str: + """稳定身份:`uid` 优先,缺省用 sid 的 sha256 前 16 位。 + + **不落 sid 原文**:这个值会进运行缓存键与登录合并键,日志里出现 + 就等于泄露登录态。sid 本身对同一账号在重新登录后会变,故 uid 缺失时 + 只用于「本次进程内」的隔离,不承诺跨会话合并。 + """ + if self.uid: + return self.uid + if not self.wps_sid: + return "" + digest = hashlib.sha256(self.wps_sid.encode("utf-8")).hexdigest() + return f"sid-{digest[:16]}" + + +#: 桌面客户端伪装用的稳定设备号(固定值,随机值会被当成新设备)。 +DEVICE_ID = hashlib.sha256(b"coding2api-lingxi-device").hexdigest() +#: 桌面客户端版本(取证来源:安装包 1.3.31;上游按客户端类型放行) +APP_NAME = "win-lingxi-desktop" +APP_VERSION = "1.3.31" \ No newline at end of file diff --git a/src/provider/lingxi/events.py b/src/provider/lingxi/events.py new file mode 100644 index 00000000..73fc4e18 --- /dev/null +++ b/src/provider/lingxi/events.py @@ -0,0 +1,245 @@ +"""WPS 灵犀上游 SSE 帧 → 中立事件。 + +上游帧不是 OpenAI chunk,形状是(取证见 `docs/` 与 magpie / MiraiHub): + +``` +{"code":"Success","usage":{…},"choices":[{"text":"…","reasoning_content":"…", + "tool_calls":[…],"finish_reason":"stop"}]} +``` + +三个必须照抄、改了就坏的点: + +1. **没有 `[DONE]`**:收尾靠 `finish_reason` 非空,由客户端补 FINISH; +2. **`usage.total_tokens` 是「当前上下文大小」**(含缓存命中),直接透传会 + 让用量虚高几十倍 —— 必须自算 `prompt + completion`; +3. `cached_tokens` / `reasoning_tokens` 是**扁平字段**,方向与 OpenAI 相反 + (OpenAI 放在 `prompt_tokens_details` / `completion_tokens_details` 里)。 + +错误两种:HTTP 非 2xx(走 `classify_status`),以及 **HTTP 200 帧内** +`code != Success` / `result != ok`(走 `classify_code`)。后者最坑的是 +`SQLNoRows` /「会话不存在」——run 过期,重建一次即可,不是账号故障。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from ...provider.base import ErrKind, Event, EventKind, Usage + +#: 审核拦截:上游把「需求被拦」也塞进 200 帧,且文案多变。 +AUDIT_PATTERN = re.compile( + r"\bAuditFailed\b|\baudit[_-]?blocked\b|暂时无法回答此问题|内容审核", re.I) +#: run / 会话失效(重建一次即可,不罚号) +_SESSION_EXPIRED = re.compile( + r"SQLNoRows|会话不存在|session not found|数据不存在", re.I) +#: 余额/积分不足(走 CREDIT:冷却到次日,而不是换号重试) +_CREDIT_EXHAUSTED = re.compile( + r"credit[s]?\s*(?:is\s*)?(?:not enough|insufficient|exhausted)|" + r"insufficient[_ ]credits|积分不足|余额不足|智点不足", re.I) +#: 模型不存在 / 套餐不允许(负缓存,重试无意义) +_MODEL_MISSING = re.compile( + r"model[^.]{0,40}(?:not found|not exist|not allowed|unsupported)|" + r"模型不存在|无此模型|模型不可用", re.I) +#: 鉴权失效(cookie 过期 / 被踢下线) +_AUTH_FAILED = re.compile( + r"unauthor|unauthenticated|login required|not logged in|" + r"wps_sid (?:is )?(?:required|invalid|expired)|" + r"登录(?:态)?(?:已)?失效", re.I) + + +def text_of(payload: Any) -> str: + """从上游错误体里挖一条人类可读的话(顺序照抄参考实现)。""" + if not isinstance(payload, dict): + return str(payload or "")[:300] + for key in ("hint", "msg", "message", "detail", "error_message"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip()[:300] + data = payload.get("data") + if isinstance(data, dict): + inner = text_of(data) + if inner: + return inner + return "" + + +def frame_error(frame: Any) -> tuple[str, str] | None: + """帧内业务错误 → `(code, message)`;正常帧返回 None。""" + if not isinstance(frame, dict): + return None + code = frame.get("code") + if isinstance(code, str) and code.strip() and code.strip().lower() != "success": + return code.strip(), text_of(frame) or f"灵犀上游返回 {code.strip()}" + result = frame.get("result") + if isinstance(result, str) and result.strip() and result.strip().lower() != "ok": + return result.strip(), text_of(frame) or f"result={result.strip()}" + return None + + +def is_session_expired(message: str) -> bool: + """run / 会话失效(调用方据此丢弃缓存、重建一次)。""" + return bool(_SESSION_EXPIRED.search(message or "")) + + +def is_audit_blocked(message: str) -> bool: + """审核拦截:同一个请求重发还是被拦,故按请求级 400 处理。""" + return bool(AUDIT_PATTERN.search(message or "")) + + +def classify_code(code: str | None, message: str = "") -> ErrKind: + """帧内业务码/文案 → 错误分类(只按已取证语义,不猜)。 + + * 审核拦截 → INVALID(换号无用,直接 400); + * 鉴权失效 → DEAD(需重新粘贴 Cookie); + * 额度不足 → CREDIT(冷却到次日签到恢复); + * 模型不存在 / 套餐不允许 → BLOCKED((账号, 模型) 负缓存); + * 其余 → OTHER(4xx/5xx 里最保守的一档,累计 3 次冷却)。 + """ + text = f"{code or ''} {message or ''}" + if _SESSION_EXPIRED.search(text): + # run 过期:重建一次即可,不是账号故障(调用方在首帧前会重建) + return ErrKind.SOFT + if is_audit_blocked(text): + return ErrKind.INVALID + if _AUTH_FAILED.search(text): + return ErrKind.DEAD + if _CREDIT_EXHAUSTED.search(text): + return ErrKind.CREDIT + if _MODEL_MISSING.search(text): + return ErrKind.BLOCKED + return ErrKind.OTHER + + +def classify_status(status: int, body: bytes = b"") -> ErrKind: + """HTTP 状态码分类。 + + 灵犀的拒绝几乎都来自**凭证或请求**,不是渠道级故障: + + * 401 → DEAD(登录态失效,只能重新粘贴 Cookie); + * 402 → CREDIT(余额/积分不足); + * 403 → SOFT:可能是风控抖动,也可能是接口权限;按可换号重试处理, + 不承认它是「账号被拉黑」(没有取证支持 RESTRICTED); + * 404 / 400 / 422 → 模型或参数问题(上游把「模型不可用」回 404); + 400 归 REQUEST(不冷却、换号重试)——上游 400 多是请求形态问题; + * 429 → MODEL(限流点名模型;同号其他模型仍可用,与 CB 的 6004 同型); + * 其余 → OTHER。 + """ + if status == 401: + return ErrKind.DEAD + if status == 402: + return ErrKind.CREDIT + if status == 403: + return ErrKind.SOFT + if status == 404: + return ErrKind.INVALID + if status in (400, 422): + return ErrKind.REQUEST + if status == 429: + return ErrKind.MODEL + return ErrKind.OTHER + + +def _usage(raw: Any) -> Usage | None: + """上游 usage → 中立 Usage(total 自算,明细字段搬进 OpenAI 嵌套形状)。""" + if not isinstance(raw, dict) or not raw: + return None + prompt = _int(raw.get("prompt_tokens")) + completion = _int(raw.get("completion_tokens")) + cached = _int(raw.get("cached_tokens")) + if cached is None: + cached = _int(_nested(raw, "prompt_tokens_details", "cached_tokens")) + reasoning = _int(raw.get("reasoning_tokens")) + if reasoning is None: + reasoning = _int(_nested(raw, "completion_tokens_details", + "reasoning_tokens")) + return Usage( + input_tokens=prompt if prompt is not None else 0, + output_tokens=completion if completion is not None else 0, + cached_tokens=cached, + reasoning_tokens=reasoning, + ) + + +def _nested(node: dict[str, Any], outer: str, inner: str) -> Any: + value = node.get(outer) + return value.get(inner) if isinstance(value, dict) else None + + +def _int(value: Any) -> int | None: + """宽松取整数:bool 不算(`True` 会被 int() 变成 1,是脏数据)。""" + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + +def _tool_calls(items: Any) -> list[dict] | None: + """上游 tool_calls 列表 → 中立形状(保留 index/id/function)。""" + if not isinstance(items, list): + return None + out: list[dict] = [] + for item in items: + if not isinstance(item, dict): + continue + call: dict[str, Any] = {} + index = _int(item.get("index")) + if index is not None: + call["index"] = index + if isinstance(item.get("id"), str) and item["id"]: + call["id"] = item["id"] + call["type"] = "function" + function = item.get("function") + if isinstance(function, dict): + piece: dict[str, Any] = {} + if isinstance(function.get("name"), str) and function["name"]: + piece["name"] = function["name"] + if isinstance(function.get("arguments"), str): + piece["arguments"] = function["arguments"] + call["function"] = piece + if call: + out.append(call) + return out or None + + +def parse_frame(frame: Any) -> list[Event]: + """一帧 → 若干中立事件(正文/思考/工具/用量/收尾可同帧)。 + + 结构非法(非对象)不抛错也不产出 —— 上游偶尔夹带心跳/空帧; + 但**业务错误绝不静默**:`code != Success` 一律产出 ERROR 事件。 + """ + if not isinstance(frame, dict): + return [] + error = frame_error(frame) + if error is not None: + code, message = error + return [Event(kind=EventKind.ERROR, + error_code=None, + error_message=message, + error_kind=classify_code(code, message))] + events: list[Event] = [] + choices = frame.get("choices") + choice = choices[0] if isinstance(choices, list) and choices else None + if isinstance(choice, dict): + text = choice.get("text") + if isinstance(text, str) and text: + events.append(Event(kind=EventKind.CONTENT, content=text)) + reasoning = choice.get("reasoning_content") + if isinstance(reasoning, str) and reasoning: + events.append(Event(kind=EventKind.REASONING, content=reasoning)) + calls = _tool_calls(choice.get("tool_calls")) + if calls: + events.append(Event(kind=EventKind.TOOL_CALLS, tool_calls=calls)) + finish = choice.get("finish_reason") + if isinstance(finish, str) and finish.strip(): + events.append(Event(kind=EventKind.FINISH, + finish_reason=finish.strip())) + usage = _usage(frame.get("usage")) + if usage is not None: + events.append(Event(kind=EventKind.USAGE, usage=usage)) + return events \ No newline at end of file diff --git a/src/provider/login_identity.py b/src/provider/login_identity.py new file mode 100644 index 00000000..9343c211 --- /dev/null +++ b/src/provider/login_identity.py @@ -0,0 +1,148 @@ +"""重新登录合并键:渠道 → 稳定上游身份。 + +登录落库(`api/credential_login.py`)与运维侧的历史清理共用这一套判据。 + +这里刻意**不 import 任何渠道子包**(`codearts` / `officeace` 的门面要反向 +import 本模块,否则成环),只按落盘 JSON 的字段名取值——字段名是落盘契约, +与 `credential.py` 的 `to_dict()` 一致,改动时三处(`to_dict` / `from_dict` / +本模块)必须同步。 + +为什么不能比对整份凭证 JSON:重新登录后 access token / refresh_token / +DPoP 私钥必然全变,整份比对永远不相等,结果是每次登录都新增一条。 + +**为什么是身份集合而不是单个键**:CodeArts 的 `uid` 优先、`domain_id|user_name` +兜底。若只认首选键,一次身份补全接口失败(`oauth._fill_identity` 尽力而为, +网络错误只记日志)的登录拿不到 `uid`,落库键退化成 `domain_id|user_name`, +与老行的 `uid` 键**永远不相等** → 又新增一条重复凭证,正是本功能要消除的 +回归。故两侧都给出**全部可推导别名**,按交集判定同一账号。 +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Iterable +from typing import Any + +#: 同一账号的别名集合;空集 = 无法安全合并。 +IdentitySet = frozenset[str] + + +def _text(raw: dict[str, Any], *keys: str) -> str: + for key in keys: + value = raw.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _domain_name(raw: dict[str, Any]) -> str: + domain_id = _text(raw, "domain_id", "domainId") + user_name = _text(raw, "user_name", "userName") + return f"{domain_id}|{user_name}" if domain_id and user_name else "" + + +def codearts_login_identity(credential_data: dict[str, Any]) -> str: + """CodeArts 首选身份:`uid`(caller-identity 的 principal_id);缺则 `domain_id|user_name`。""" + uid = _text(credential_data, "uid", "user_id", "userId") + return uid or _domain_name(credential_data) + + +def codearts_login_identities(credential_data: dict[str, Any]) -> IdentitySet: + """CodeArts 全部别名:`uid` 与 `domain_id|user_name` 都给出(有才给)。 + + 两者不互斥:老行可能只有 `uid`,新登录可能只有 `domain|user`,交集非空 + 即同一账号。 + """ + aliases = { + _text(credential_data, "uid", "user_id", "userId"), + _domain_name(credential_data), + } + return frozenset(alias for alias in aliases if alias) + + +def officeace_login_identity(credential_data: dict[str, Any]) -> str: + """OfficeAce 首选身份:`account_id`;缺则 `principal_id`。""" + return _text(credential_data, "account_id", "accountId", + "principal_id", "principalId") + + +def officeace_login_identities(credential_data: dict[str, Any]) -> IdentitySet: + """OfficeAce 全部别名:`account_id` 与 `principal_id` 都给出(有才给)。""" + return frozenset( + alias for alias in ( + _text(credential_data, "account_id", "accountId"), + _text(credential_data, "principal_id", "principalId"), + ) if alias) + + +def _lingxi_sid_identity(raw: dict[str, Any]) -> str: + """sid 哈希身份:`sid-`(与 `lingxi/credential.identity` 同口径)。 + + 刻意不 import `lingxi` 子包(门面反向 import 本模块,会成环),故在这里 + 复制同一段纯函数;两处改口径必须同步(有测试钉住)。 + """ + sid = _text(raw, "wps_sid") + if not sid: + return "" + return "sid-" + hashlib.sha256(sid.encode("utf-8")).hexdigest()[:16] + + +def lingxi_login_identity(credential_data: dict[str, Any]) -> str: + """灵犀首选身份:`uid`;缺则 sid 哈希(重新登录后 sid 会变,仅兜底)。""" + return _text(credential_data, "uid") or _lingxi_sid_identity(credential_data) + + +def lingxi_login_identities(credential_data: dict[str, Any]) -> IdentitySet: + """灵犀全部别名:`uid` 与 sid 哈希(老行只有 sid 时靠它合并)。""" + aliases = {_text(credential_data, "uid"), + _lingxi_sid_identity(credential_data)} + return frozenset(alias for alias in aliases if alias) + + +#: 首选身份(展示 / 门面转发用);只有声明了身份的渠道才参与重新登录合并。 +LOGIN_IDENTITIES: dict[str, Callable[[dict[str, Any]], str]] = { + "codearts": codearts_login_identity, + "officeace": officeace_login_identity, + "lingxi": lingxi_login_identity, +} + +#: 别名集合(合并匹配用):与 `LOGIN_IDENTITIES` 必须覆盖同一批渠道。 +LOGIN_IDENTITY_SETS: dict[str, Callable[[dict[str, Any]], IdentitySet]] = { + "codearts": codearts_login_identities, + "officeace": officeace_login_identities, + "lingxi": lingxi_login_identities, +} + + +def _safe_call(getter: Callable[[dict[str, Any]], Any], + credential_data: dict[str, Any]) -> Any: + """畸形凭证不抛:登录路径不能因为一条脏数据整体失败。""" + try: + return getter(credential_data) + except Exception: # noqa: BLE001 - 畸形凭证不影响登录本身 + return None + + +def login_identity_for(provider_id: str, credential_data: dict[str, Any]) -> str: + """取该渠道的首选身份;渠道未声明 / 解析失败 / 空值一律返回空串。 + + 返回空串 = 无法安全合并,调用方退回「新增」或「跳过」,绝不猜。 + """ + getter = LOGIN_IDENTITIES.get(provider_id) + if getter is None: + return "" + value = _safe_call(getter, credential_data) + return value if isinstance(value, str) else "" + + +def login_identities_for(provider_id: str, + credential_data: dict[str, Any]) -> IdentitySet: + """取该渠道的全部身份别名;渠道未声明 / 解析失败 / 全部为空 → 空集。""" + getter = LOGIN_IDENTITY_SETS.get(provider_id) + if getter is None: + return frozenset() + value = _safe_call(getter, credential_data) + if not isinstance(value, Iterable) or isinstance(value, (str, bytes)): + return frozenset() + return frozenset(alias for alias in value + if isinstance(alias, str) and alias) diff --git a/src/provider/naming.py b/src/provider/naming.py index 035bb8c1..95a7afec 100644 --- a/src/provider/naming.py +++ b/src/provider/naming.py @@ -63,6 +63,20 @@ # `MiMo-V2.6-Pro`:`Vendor: Model` 的冒号式前缀削不到它,键会多出 # `xiaomi-`,同一模型被拆成两条(对外多出 `xiaomi-mimo-v2.6-pro`)。 "xiaomi-mimo-v2.6-pro": ("mimo-v2.6-pro", "MiMo V2.6 Pro"), + # 小浣熊(商汤)把托管模型统一加 `sn-` 前缀,且版本号用连字符(`5-3`) + # 而非点号(`5.3`):不登记就是「同一模型两条对外条目」,用户得挑渠道才 + # 路由得通。右侧取其余渠道已在用的写法(CodeArts 的 `glm-5.3` 等)。 + "sn-glm-5-3": ("glm-5.3", "GLM 5.3"), + "sn-glm-5-3-flash": ("glm-5.3-flash", "GLM 5.3 Flash"), + "sn-kimi-k3": ("kimi-k3", "Kimi K3"), + # `1017` 是快照日期、不是模型身份:小浣熊的这个档位就是 DeepSeek V4.1 Flash + # 本身(与 CodeArts 的 `deepseek-v4.1-flash` 同倍率 0.18x)。不收敛会让同一 + # 模型多出一条 `deepseek-v4.1-flash-1017` 的对外条目。 + "sn-deepseek-v4-1-flash-1017": ("deepseek-v4.1-flash", + "DeepSeek V4.1 Flash"), + "sn-sensenova-6-8-flash": ("sensenova-6.8-flash", "Sensenova 6.8 Flash"), + "sn-sensenova-6-8-flash-lite": ("sensenova-6.8-flash-lite", + "Sensenova 6.8 Flash Lite"), } _SYNONYM_DISPLAY: dict[str, str] = { key: display for key, (_, display) in MODEL_SYNONYMS.items()} diff --git a/src/provider/officeace/__init__.py b/src/provider/officeace/__init__.py index 870354a4..f87b8296 100644 --- a/src/provider/officeace/__init__.py +++ b/src/provider/officeace/__init__.py @@ -48,10 +48,12 @@ Quota, UpstreamProtocolViolation, ) +from ...tasks.pacer import paced from ..codearts import dpop +from ..login_identity import login_identity_for from . import events as officeace_events from .client import OfficeAceClient -from .credential import OfficeAceCredential +from .credential import OfficeAceCredential, validate_gateway_url from .oauth import AuthStateStore @@ -91,6 +93,9 @@ def import_credential(self, raw: dict) -> dict: if not credential.model_base_url: raise UpstreamProtocolViolation( "credential missing model_base_url (officeAce 网关地址)") + # 网关地址是**长期**凭据的收件方(Basic app_key:app_secret 不过期), + # 手工导入时必须是 https 且不指向内网——见 `validate_gateway_url`。 + validate_gateway_url(credential.model_base_url) # DPoP 私钥有效性在导入时校验:坏 JWK 只有到刷新才暴露,而那时用户 # 已经以为登录成功了(`InvalidDPoPHeader` 只在 STS 端出现)。 if credential.dpop_private_jwk: @@ -127,15 +132,9 @@ async def stream_chat(self, credential_data: dict, payload: dict, """引擎调用入口:dict 凭证 → 上游流 → 中立事件。""" credential = OfficeAceCredential.from_dict(credential_data) key = credential_key(credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) # -------------------------------------------------------- 额度探测 @@ -156,6 +155,14 @@ async def refresh(self, credential_data: dict) -> dict: # ------------------------------------------------------------ 签到 + def login_identity(self, credential_data: dict) -> str: + """重新登录合并键:上游账号 `account_id`(`principal_id` 兜底)。 + + 判据统一在 `provider/login_identity.py`(登录合并与历史重复清理共用 + 同一套),门面这里只做转发。 + """ + return login_identity_for(self.id, credential_data) + async def checkin(self, credential_data: dict) -> CheckinResult: return await self.client.claim_bonus( OfficeAceCredential.from_dict(credential_data)) diff --git a/src/provider/officeace/client.py b/src/provider/officeace/client.py index 4f9d9573..fbb165f4 100644 --- a/src/provider/officeace/client.py +++ b/src/provider/officeace/client.py @@ -39,10 +39,11 @@ UpstreamProtocolViolation, UpstreamReloginRequired, ) -from ...provider.proxy import build_client from .. import naming +from ..codearts.signer import sdk_date +from ..lazy_clients import LazyClients from . import events as officeace_events -from .credential import OfficeAceCredential, merge_refreshed +from .credential import OfficeAceCredential, merge_refreshed, validate_gateway_url from .events import ( EP_BONUS_CLAIM, EP_CHAT, @@ -58,8 +59,7 @@ # 流式无总超时防长流截断;短请求 30s 防悬挂(与其余渠道同策略)。 # 写超时覆盖上传请求体阶段,不覆盖首字等待(大上下文请求体可达数 MB)。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + #: 签到活动展示名(上游没给活动名时的兜底)。 DEFAULT_CHECKIN_NAME = "每日签到有礼" @@ -110,7 +110,7 @@ class UpstreamHTTPError(base.UpstreamHTTPError): classify_status = staticmethod(officeace_events.classify_status) -class OfficeAceClient: +class OfficeAceClient(LazyClients): """上游 HTTP 客户端。主机可覆盖,便于测试与灰度。""" def __init__( @@ -136,22 +136,8 @@ def __init__( # 模型费率负缓存:{小写模型名: cost_coefficient};失败不清空(宁可旧值) self._rates: dict[str, float] = {} - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, - proxy=self._proxy) - return self._stream_client - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, - proxy=self._proxy) - return self._short_client - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() # ---------------------------------------------------------------- 聊天 @@ -173,7 +159,7 @@ async def stream_chat(self, credential: OfficeAceCredential, logger.info("officeAce 调整 max_tokens:客户端 %s → 上游 %s(model=%s)", payload.get("max_tokens", payload.get("max_completion_tokens")), bumped, model) - async with self._stream().stream( + async with self._stream.stream( "POST", url, json=body, headers=request_headers(credential), ) as response: if response.status_code >= 400: @@ -199,7 +185,7 @@ async def fetch_models(self, credential: OfficeAceCredential) -> list[Model]: "credential missing model_base_url", public="该凭证缺少 officeAce 网关地址,请重新登录该账号") url = f"{self._gateway_of(credential)}{EP_MODELS}" - response = await self._short().get( + response = await self._short.get( url, headers={"Authorization": credential.authorization, "Accept": "application/json"}) if response.status_code >= 400: @@ -368,7 +354,7 @@ async def refresh_token(self, credential: OfficeAceCredential, """ from .oauth import OfficeAceOAuth - oauth = OfficeAceOAuth(self.login, client=self._short()) + oauth = OfficeAceOAuth(self.login, client=self._short) try: tokens = await oauth.refresh_tokens( refresh_token=credential.refresh_token, @@ -397,11 +383,14 @@ def _gateway_of(self, credential: OfficeAceCredential) -> str: validate` 下发的是裸主机(`https://modelgw-xxx...`),而桌面端的 `models.json` 里 `api_base` 自带 `/v2`。不归一就会拼出 `/v2/v2/chat/completions`(实测 404)。 + + 出口再校验一次(`validate_gateway_url`):导入期的校验只覆盖新写入的 + 凭证,**升级前落库的旧行**没经过它,而这里每次请求都会走到。 """ base = (credential.model_base_url or self.gateway).rstrip("/") if base.endswith("/v2"): base = base[:-3] - return base + return validate_gateway_url(base) async def _v11_json(self, method: str, path: str, credential: OfficeAceCredential, @@ -416,7 +405,7 @@ async def _v11_json(self, method: str, path: str, self._credential_sign(credential), method=method, url=url, headers=headers or {}, payload=body or b"", x_sdk_date=_sdk_date(self._now()), region=self.region) - response = await self._short().request( + response = await self._short.request( method, url, headers=signed, content=body if body is not None else None) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, @@ -481,10 +470,8 @@ def as_bool(*keys: str) -> bool | None: ) -def _sdk_date(moment: int | None = None) -> str: - """`X-Sdk-Date`:UTC `YYYYMMDDTHHMMSSZ`。""" - return time.strftime("%Y%m%dT%H%M%SZ", - time.gmtime(moment if moment is not None else time.time())) +# 签名与请求必须同值,实现归 codearts.signer(本渠道共用同一份) +_sdk_date = sdk_date def _next_beijing_midnight(moment: int) -> int: diff --git a/src/provider/officeace/credential.py b/src/provider/officeace/credential.py index 47f75fee..83061734 100644 --- a/src/provider/officeace/credential.py +++ b/src/provider/officeace/credential.py @@ -21,16 +21,62 @@ from __future__ import annotations import base64 +import ipaddress import json import time from dataclasses import dataclass, field from typing import Any, ClassVar +from urllib.parse import urlsplit -from ...provider.token_expiry import normalize_epoch +from .. import credential_fields from ..base import UpstreamProtocolViolation from .events import CLIENT_ID +def validate_gateway_url(url: str) -> str: + """校验 `model_base_url`(模型网关地址)并归一化。 + + 这个值有两处来源:上游 `client-permission-validate` 下发,以及**手工导入** + (`POST /api/credentials`,operator 即可写)。聊天把它拼成 + `{gateway}/v2/chat/completions` 并带上 `Authorization: Basic + base64(app_key:app_secret)`,而 `model_app_secret` **不过期**。所以它是一张 + 能发到任意主机的长期凭据:不校验就等于给 operator 一个 SSRF 跳板 + (`http://169.254.169.254` 探云元数据)外加把上游密钥递给任意主机。 + + 口径(不做主机白名单:网关按账号/区域下发,写死域名会在上游变更时打断功能): + + - 必须 `https`——`http` 等于明文传长期密钥(本机调试也走 https 隧道); + - 必须带主机名,且拒绝内网 / 回环 / 链路本地 / 保留地址(与告警 webhook + 的 SSRF 口径一致,见 `runtime_settings._is_blocked_webhook_host`); + - 域名不在此处解析(解析要 IO,且 DNS 可变):字面量 IP 直接判,域名交给 + 运行期的出口校验。 + + 只做校验,不改写存储形状——`/v2` 尾缀的归一仍由 + `OfficeAceClient._gateway_of` 负责(两条导入路径形状不同,见那里)。 + """ + text = (url or "").strip().rstrip("/") + if not text: + raise UpstreamProtocolViolation("credential missing model_base_url (officeAce 网关地址)") + parts = urlsplit(text) + if parts.scheme != "https": + raise UpstreamProtocolViolation( + "model_base_url must use https:// (refusing to send credentials in clear text)") + host = (parts.hostname or "").strip().lower() + if not host: + raise UpstreamProtocolViolation("model_base_url has no host") + try: + address = ipaddress.ip_address(host) + except ValueError: + pass + else: + if (address.is_private or address.is_loopback or address.is_link_local + or address.is_reserved or address.is_multicast + or address.is_unspecified or not address.is_global): + raise UpstreamProtocolViolation( + "model_base_url must not point at a private/loopback/reserved address") + return text + + @dataclass(slots=True) class OfficeAceCredential: """归一化后的 officeAce 凭证(扁平形落盘)。""" @@ -147,43 +193,10 @@ def has_chat(self) -> bool: return bool(self.model_app_key and self.model_app_secret) -def _text(raw: dict[str, Any], *keys: str) -> str: - """按优先级取第一个非空字符串字段。""" - for key in keys: - value = raw.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def _expiration_from_raw(raw: Any) -> int: - """到期时间归一:数字(epoch,可为毫秒)/ ISO8601 / 数字字符串 → 秒。""" - if isinstance(raw, bool): - return 0 - if isinstance(raw, (int, float)): - return normalize_epoch(int(raw)) - if isinstance(raw, str) and raw.strip(): - text = raw.strip() - try: - return normalize_epoch(int(text)) - except ValueError: - pass - from .events import epoch_from_iso - - return epoch_from_iso(text) - return 0 - - -def _jwk_from_raw(raw: Any) -> dict[str, str]: - """DPoP 私钥 JWK:只收字符串字段,畸形输入返回空(由刷新时报错兜底)。""" - if isinstance(raw, str): - try: - raw = json.loads(raw) - except json.JSONDecodeError: - return {} - if not isinstance(raw, dict): - return {} - return {str(key): value for key, value in raw.items() if isinstance(value, str)} +# 两家华为云系渠道共用同一份解析(测试与旧调用方沿用原名) +_text = credential_fields.text_field +_expiration_from_raw = credential_fields.expiration_from_raw +_jwk_from_raw = credential_fields.jwk_from_raw def parse_tokens(raw: dict[str, Any]) -> OfficeAceCredential: diff --git a/src/provider/officeace/events.py b/src/provider/officeace/events.py index ea635dec..6c498ca8 100644 --- a/src/provider/officeace/events.py +++ b/src/provider/officeace/events.py @@ -18,12 +18,22 @@ | `ModelArts.81009` | 模型名上游不认 | **模型** | | `ModelArts.81111/81114` | 该模型 TPM 限流 | 模型(短冷却) | | `ModelArts.81113` | 请求体过大 | 请求 | -| `APIG.1009` / `AppKey or AppSecret is invalid` | 凭据真坏了 | **账号** | +| `APIG.1009` / `AppKey or AppSecret is invalid` | 凭据真坏了,**或积分耗尽** | **账号**(见下) | | `APIG.0303` | app 不存在(把 STS 当 Basic 用) | **账号**(配置错) | `81004`/`81009` 走 HTTP 403/404,若按状态码直接判 DEAD 会把整条凭据打 死——而它们其实只说明「这个号没这个模型」。故业务码判定必须**先于**状态码。 +**`APIG.1009` 有两种成因,分类层只认 DEAD**:积分耗尽时上游回的是同一条 +`401 AppKey or AppSecret is invalid`(实测 2026-11-02:三条凭证 +`quota_remaining = 0 / quota_total = 4100`,同一条错误也出现在模型列表接口 +上)。分类层没有额度上下文,判 `CREDIT` 会让「AppKey 真的坏了」静默成 +「等签到」;而判 `DEAD` 会导致硬禁用 + 运维重登(积分是 0,重登无解)。 +故这里保持 DEAD(凭据级),由引擎按**新鲜额度证据**收口:本地 +`health == -1` 或 `quota_remaining <= 0` 直接降级为 `CREDIT`(冷却到次日 +04:00,不硬禁用);证据陈旧(正数 / 未探测)时先现场复探一次再判(见 +`src/engine/executor.py::_credit_if_exhausted` / `_probe_quota_now`)。 + **限流走伪 200**:华为网关限流时会 HTTP 200 + `data:{"error":...}`, 首帧正常、第二帧才是错误也出现过。解析层因此不能假设「2xx 就是好流」, 错误帧必须照常产出 `EventKind.ERROR` 交给引擎分类。 @@ -31,7 +41,6 @@ from __future__ import annotations -import json import time from typing import Any @@ -44,7 +53,7 @@ Usage, business_codes, ) -from ..openai_chunk import first_choice, is_blank_tool_call +from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai # ---------------------------------------------------------------- 域与端点 @@ -92,6 +101,9 @@ #: 模型级限流(TPM)。只冷却「凭据 × 模型」,其它模型不受影响。 _MODEL_THROTTLE_MARKERS: tuple[str, ...] = ("81111", "81112", "81114") #: 凭据级故障:app key 坏了 / 网关分片换了。 +#: `APIG.1009` 也是「积分耗尽」的报错形态(同一条 401 文案,见模块 docstring): +#: 这里照判 DEAD(没有额度上下文),由 executor 按额度证据降级为 CREDIT +#: (证据陈旧时先现场复探,见 executor._credit_if_exhausted)。 _AUTH_DEAD_MARKERS: tuple[str, ...] = ( "apig.1009", "appkey or appsecret is invalid", @@ -198,91 +210,15 @@ def classify_error_code(code: int | None, message: str = "") -> ErrKind: def parse_frame(frame: SSEFrame) -> Event | None: """单帧 → 代表性事件;`[DONE]` 与无内容帧返回 None。""" - payload = _payload(frame) - if payload is None: - return None - return _event_from_payload(payload) + payload = payload_of(frame, "officeAce") + return None if payload is None else openai_event( + payload, error_event=_error_event, error_as_string=True) def parse_all_events(frame: SSEFrame) -> list[Event]: - """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。 - - 必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」 - 这种合并帧会丢掉 finish(客户端永远收不到结束信号)。 - """ - payload = _payload(frame) - if payload is None: - return [] - events: list[Event] = [e for e in (_event_from_payload(payload),) if e is not None] - has_usage = any(e.kind is EventKind.USAGE for e in events) - has_finish = any(e.kind is EventKind.FINISH for e in events) - - usage = payload.get("usage") - if isinstance(usage, dict) and not has_usage: - events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) - choice = first_choice(payload) - finish_reason = choice.get("finish_reason") if choice else None - if isinstance(finish_reason, str) and finish_reason and not has_finish: - events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) - return events - - -def _event_from_payload(payload: dict[str, Any]) -> Event | None: - """已解析 JSON → 代表性事件。 - - 优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。 - 错误排在正文之后是有意的:伪 200 的第二帧常常「无正文 + error」, - 而带正文的帧不该被错误覆盖。 - """ - choice = first_choice(payload) - delta = choice.get("delta") if choice else None - delta = delta if isinstance(delta, dict) else {} - finish_reason = choice.get("finish_reason") if choice else None - - tool_calls = delta.get("tool_calls") - if isinstance(tool_calls, list): - kept = [tc for tc in tool_calls - if isinstance(tc, dict) and not is_blank_tool_call(tc)] - if kept: - return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) - - content = delta.get("content") - if isinstance(content, str) and content: - return Event(kind=EventKind.CONTENT, content=content) - - # 上游推理模型给 `reasoning_content`(OpenAI 兼容惯例) - reasoning = delta.get("reasoning_content") - if isinstance(reasoning, str) and reasoning: - return Event(kind=EventKind.REASONING, content=reasoning) - - error = payload.get("error") - if isinstance(error, dict): - return _error_event(error) - if isinstance(error, str) and error: - return _error_event({"message": error}) - - usage = payload.get("usage") - if isinstance(usage, dict): - return Event(kind=EventKind.USAGE, usage=_usage(usage)) - - if isinstance(finish_reason, str) and finish_reason: - return Event(kind=EventKind.FINISH, finish_reason=finish_reason) - return None - - -def _payload(frame: SSEFrame) -> dict[str, Any] | None: - """帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。""" - if not frame.data: - return None - if frame.data.strip() == "[DONE]": - return None - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable officeAce SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("officeAce SSE data is not an object") - return payload + """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。""" + return openai_events(frame, "officeAce", error_event=_error_event, + error_as_string=True) def _error_event(error: dict[str, Any]) -> Event: @@ -310,25 +246,7 @@ def _error_event(error: dict[str, Any]) -> Event: def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - details = raw.get("prompt_tokens_details") - cached = (details or {}).get("cached_tokens") if isinstance(details, dict) else None - if not isinstance(cached, int) or isinstance(cached, bool): - cached = as_int("cached_tokens") - completion_details = raw.get("completion_tokens_details") - reasoning = (completion_details or {}).get("reasoning_tokens") \ - if isinstance(completion_details, dict) else None - if not isinstance(reasoning, int) or isinstance(reasoning, bool): - reasoning = as_int("reasoning_tokens") - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=reasoning, - cached_tokens=cached, - ) + return usage_from_openai(raw) # ------------------------------------------------------------ 订阅 / 积分 diff --git a/src/provider/officeace/oauth.py b/src/provider/officeace/oauth.py index b0f5df21..28174e46 100644 --- a/src/provider/officeace/oauth.py +++ b/src/provider/officeace/oauth.py @@ -27,10 +27,7 @@ from __future__ import annotations -import base64 -import hashlib import logging -import secrets import time from collections.abc import Callable from dataclasses import dataclass, field @@ -38,6 +35,8 @@ import httpx +from ..auth_state import AuthStateStore as AuthStateStoreBase +from ..auth_state import pkce_pair from ..base import ( AuthFlow, AuthResult, @@ -46,6 +45,7 @@ read_body_bounded, ) from ..codearts import dpop +from ..codearts.signer import sdk_date from ..proxy import build_client from . import events as officeace_events from .credential import OfficeAceCredential, parse_tokens @@ -116,28 +116,18 @@ class LoginSession: polls: int = 0 -@dataclass(slots=True) +@dataclass class _Reservation: - """一条 pending 登录:归属人 + 创建时刻 + 会话。 + """本渠道载荷:登录会话(轮询计数就地 +1)。 归属按用户名校验是安全约束而非风格:单槽全局态会让两个管理员的并发登录 - 互相覆盖,把 A 的凭证记到 B 名下。 + 互相覆盖,把 A 的凭证记到 B 名下(归属校验在基类 `AuthStateStore.owner`)。 """ - username: str - created_at: int session: LoginSession -def pkce_pair(verifier: str | None = None) -> tuple[str, str]: - """PKCE `(code_verifier, code_challenge)`;challenge = BASE64URL(SHA256(v))。""" - value = verifier or secrets.token_urlsafe(32) - digest = hashlib.sha256(value.encode("ascii")).digest() - challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") - return value, challenge - - -class AuthStateStore: +class AuthStateStore(AuthStateStoreBase[_Reservation]): """pending 登录会话的登记、归属校验、消费与过期清理。 对外 `state` 是**本地生成的 reservation**,不是云端签发的 state: @@ -147,76 +137,29 @@ class AuthStateStore: def __init__(self, ttl_seconds: int = LOGIN_TTL_SECONDS, max_pending: int = MAX_PENDING_SESSIONS) -> None: - self._ttl = ttl_seconds - self._max_pending = max_pending - self._entries: dict[str, _Reservation] = {} + super().__init__(ttl_seconds, max_pending=max_pending) def begin(self, username: str, session: LoginSession, now: int | None = None) -> str: - self.cleanup(now) - # 攒了太多没登完的就顶掉最老的:拒绝会让人以为功能坏了,而每条 - # pending 会话都在轮询云端,留一堆也只是浪费。 - while len(self._entries) >= self._max_pending: - oldest = min(self._entries, key=lambda key: self._entries[key].created_at) - del self._entries[oldest] - reservation = secrets.token_urlsafe(18) - self._entries[reservation] = _Reservation( - username=username, - created_at=int(now if now is not None else time.time()), - session=session) - return reservation - - def owner(self, auth_state: str, username: str, - now: int | None = None) -> bool: - """归属校验,**含 TTL**:过期 state 不得再轮询或重放。""" - entry = self._entries.get(auth_state) - if entry is None or entry.username != username: - return False - current = int(now if now is not None else time.time()) - if current - entry.created_at >= self._ttl: - del self._entries[auth_state] - return False - return True + return super().begin(username, _Reservation(session=session), now) def session_entry(self, auth_state: str, username: str, now: int | None = None) -> _Reservation | None: - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state] + entry = self.reservation(auth_state, username, now) + return None if entry is None else entry.payload def touch(self, auth_state: str, now: int | None = None) -> None: """记一次轮询(排障用;会话可能已被并发消费,故容忍缺失)。""" entry = self._entries.get(auth_state) if entry is None: return - session = entry.session - entry.session = LoginSession( + session = entry.payload.session + entry.payload.session = LoginSession( state=session.state, code_verifier=session.code_verifier, code_challenge=session.code_challenge, auth_url=session.auth_url, dpop_private_jwk=session.dpop_private_jwk, created_at=session.created_at, polls=session.polls + 1) - def consume(self, auth_state: str, username: str, - now: int | None = None) -> bool: - """消费:成功后该 state 不可再次轮询或重放。""" - if not self.owner(auth_state, username, now): - return False - del self._entries[auth_state] - return True - - def cancel(self, auth_state: str, username: str, - now: int | None = None) -> bool: - return self.consume(auth_state, username, now) - - def cleanup(self, now: int | None = None) -> None: - current = int(now if now is not None else time.time()) - for key in [k for k, v in self._entries.items() - if current - v.created_at >= self._ttl]: - del self._entries[key] - - def __len__(self) -> int: - return len(self._entries) - class OfficeAceOAuth: """登录流程实现(**poll 轨道**:云端承接回调,服务端轮询取码)。""" @@ -227,7 +170,7 @@ def __init__(self, config: LoginConfig | None = None, *, proxy: str | None = None, now: Callable[[], int] | None = None) -> None: self.config = config or LoginConfig() - self.store = store or AuthStateStore() + self.store = store if store is not None else AuthStateStore() self._client = client self._proxy = proxy self._now = now or (lambda: int(time.time())) @@ -468,7 +411,5 @@ async def fetch_model_info(self, credential: OfficeAceCredential) -> dict[str, s return officeace_events.parse_model_info(payload) -def _sdk_date(moment: int | None = None) -> str: - """`X-Sdk-Date`:UTC `YYYYMMDDTHHMMSSZ`(签名与请求必须同值)。""" - return time.strftime("%Y%m%dT%H%M%SZ", - time.gmtime(moment if moment is not None else time.time())) +# 签名与请求必须同值,实现归 codearts.signer(本渠道共用同一份) +_sdk_date = sdk_date diff --git a/src/provider/openai_chunk.py b/src/provider/openai_chunk.py index 2610ea1e..8ff77dd5 100644 --- a/src/provider/openai_chunk.py +++ b/src/provider/openai_chunk.py @@ -8,9 +8,12 @@ from __future__ import annotations +import json +from collections.abc import Callable from typing import Any -from .base import UpstreamProtocolViolation +from ..engine.sse import SSEFrame +from .base import Event, EventKind, UpstreamProtocolViolation, Usage # 空 JSON 对象串(`arguments` 有时是未解析的原始字符串);用拼接构造以避开 # 编辑期对 `` 字面量的意外改写。 @@ -49,3 +52,190 @@ def is_blank_tool_call(tc: dict[str, Any]) -> bool: return False arguments = function.get("arguments") return arguments in (None, "", _EMPTY_ARGUMENTS_JSON) or arguments == {} + + +def _opt_int(value: Any) -> int | None: + """整数取值:非 int / bool 一律 None(绝不把「未上报」量化成 0)。""" + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def details_int(raw: dict[str, Any], details_key: str, key: str) -> int | None: + """details 子对象优先,顶层同名字段兜底。 + + OpenAI 惯例把 `cached_tokens` / `reasoning_tokens` 放在 details 里(未命中时 + 报 0,是有效值而非缺失),顶层只是兼容形状。details 值非法(非数值 / + bool)时回落顶层,仍缺则 None。 + """ + details = raw.get(details_key) + value = details.get(key) if isinstance(details, dict) else None + if isinstance(value, int) and not isinstance(value, bool): + return value + return _opt_int(raw.get(key)) + + +def usage_from_openai( + raw: dict[str, Any], + *, + credit: bool = False, + cached_fallbacks: tuple[str, ...] = (), +) -> Usage: + """标准 OpenAI `usage` 对象 → 中立 `Usage`。 + + 各渠道差异只有两处,参数化即可,不猜键: + * `credit`:上游是否真给 credit 字段(Zen / CodeBuddy 有); + * `cached_fallbacks`:details 与顶层都缺时的额外候选键(TRAE 的 + `cache_read_input_tokens`)。 + + `reasoning_tokens` / `cached_tokens` 一律 details 优先、顶层兜底:这是 + 7 份原实现的**并集**——details 缺失时回落顶层,绝不丢顶层值。 + """ + cached = details_int(raw, "prompt_tokens_details", "cached_tokens") + for key in cached_fallbacks: + if cached is None: + cached = _opt_int(raw.get(key)) + credit_value = raw.get("credit") if credit else None + return Usage( + input_tokens=_opt_int(raw.get("prompt_tokens")), + output_tokens=_opt_int(raw.get("completion_tokens")), + reasoning_tokens=details_int(raw, "completion_tokens_details", "reasoning_tokens"), + cached_tokens=cached, + credit=(float(credit_value) + if isinstance(credit_value, (int, float)) + and not isinstance(credit_value, bool) else None), + ) + + +def payload_of(frame: SSEFrame, label: str) -> dict[str, Any] | None: + """SSE 帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。 + + `label` 只进报错文案(便于定位是哪条渠道的帧坏了)。 + """ + if not frame.data: + return None + if frame.data.strip() == "[DONE]": + return None + try: + payload = json.loads(frame.data) + except json.JSONDecodeError as error: + raise UpstreamProtocolViolation(f"unparsable {label} SSE data") from error + if not isinstance(payload, dict): + raise UpstreamProtocolViolation(f"{label} SSE data is not an object") + return payload + + +def _parse_openai_event( + payload: dict[str, Any], + *, + reasoning_keys: tuple[str, ...] = ("reasoning_content",), + error_event: Callable[[dict[str, Any]], Event] | None = None, + error_as_string: bool = False, + usage: Callable[[dict[str, Any]], Usage] = usage_from_openai, +) -> Event | None: + """已解析 OpenAI chunk → 代表性事件。 + + 优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。 + + 差异参数化,不猜键: + * `reasoning_keys`:Zen / CodeBuddy / OfficeAce 用 `reasoning_content`, + Kilo 用 `reasoning`。按顺序取第一个非空串。 + * `error_event`:错误信封的分类是各渠道私有码表,只注入回调。 + * `error_as_string`:OfficeAce 实测有 `{"error": "文本"}` 形态。 + """ + choice = first_choice(payload) + delta = choice.get("delta") if choice else None + delta = delta if isinstance(delta, dict) else {} + finish_reason = choice.get("finish_reason") if choice else None + + tool_calls = delta.get("tool_calls") + if isinstance(tool_calls, list): + # 无 name 且 arguments 为空的噪声调用(客户端聚合后显示 + # "Tool not found"):正常分片块无 name 但带实际 arguments,必须保留。 + kept = [tc for tc in tool_calls + if isinstance(tc, dict) and not is_blank_tool_call(tc)] + if kept: + return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) + + content = delta.get("content") + if isinstance(content, str) and content: + return Event(kind=EventKind.CONTENT, content=content) + + for key in reasoning_keys: + reasoning = delta.get(key) + if isinstance(reasoning, str) and reasoning: + return Event(kind=EventKind.REASONING, content=reasoning) + + if error_event is not None: + error = payload.get("error") + if isinstance(error, dict): + return error_event(error) + if error_as_string and isinstance(error, str) and error: + return error_event({"message": error}) + + usage_raw = payload.get("usage") + if isinstance(usage_raw, dict): + return Event(kind=EventKind.USAGE, usage=usage(usage_raw)) + + if isinstance(finish_reason, str) and finish_reason: + return Event(kind=EventKind.FINISH, finish_reason=finish_reason) + return None + + +def openai_events( + frame: SSEFrame, + label: str, + *, + reasoning_keys: tuple[str, ...] = ("reasoning_content",), + error_event: Callable[[dict[str, Any]], Event] | None = None, + error_as_string: bool = False, + usage: Callable[[dict[str, Any]], Usage] = usage_from_openai, +) -> list[Event]: + """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。 + + 必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」 + 这种合并帧会丢掉 finish(客户端永远收不到结束信号)。 + """ + payload = payload_of(frame, label) + if payload is None: + return [] + representative = _parse_openai_event( + payload, reasoning_keys=reasoning_keys, error_event=error_event, + error_as_string=error_as_string, usage=usage) + events: list[Event] = [e for e in (representative,) if e is not None] + has_usage = any(e.kind is EventKind.USAGE for e in events) + has_finish = any(e.kind is EventKind.FINISH for e in events) + + usage_raw = payload.get("usage") + if isinstance(usage_raw, dict) and not has_usage: + events.append(Event(kind=EventKind.USAGE, usage=usage(usage_raw))) + choice = first_choice(payload) + finish_reason = choice.get("finish_reason") if choice else None + if isinstance(finish_reason, str) and finish_reason and not has_finish: + events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) + model = _payload_model(payload) + if model is not None: + for event in events: + event.model = model + return events + + +def _payload_model(payload: dict[str, Any]) -> str | None: + """上游 chunk 里自报的模型名(非空字符串才算;各渠道字段都叫 `model`)。""" + value = payload.get("model") + return value if isinstance(value, str) and value else None + + +def openai_event( + payload: dict[str, Any], + *, + reasoning_keys: tuple[str, ...] = ("reasoning_content",), + error_event: Callable[[dict[str, Any]], Event] | None = None, + error_as_string: bool = False, + usage: Callable[[dict[str, Any]], Usage] = usage_from_openai, +) -> Event | None: + """`_parse_openai_event` + 上游自报模型名(观测用,不参与任何改写)。""" + event = _parse_openai_event( + payload, reasoning_keys=reasoning_keys, error_event=error_event, + error_as_string=error_as_string, usage=usage) + if event is not None: + event.model = _payload_model(payload) + return event diff --git a/src/provider/probe_failure.py b/src/provider/probe_failure.py new file mode 100644 index 00000000..54da1328 --- /dev/null +++ b/src/provider/probe_failure.py @@ -0,0 +1,36 @@ +"""额度探测失败 → 「需重新登录」的可操作原因。 + +额度探测只是一次只读 GET,但它和聊天请求命中同一份凭证:上游回 DEAD +(灵犀 401、CodeBuddy 且账号被限制等)时,这张凭证已经不可能再被聊天 +路径救活。历史实现只写 `health = NULL`(未知),凭证仍是 `enabled=1`, +调度器下一轮照样挑它、再撞一次 401,管理台也要等到某次聊天失败才看到 +「session dead」。 + +这里只把**分类为 DEAD** 的失败升级成硬禁用;其余失败(网络抖动、探测 +接口单独 4xx)保持原口径「未知」,绝不因为一时探不到额度就把号停掉。 +""" + +from __future__ import annotations + +from .base import ErrKind, UpstreamHTTPError + +#: 不同渠道的 DEAD 文案:管理台直接显示,必须指向下一步动作。 +_LINGXI_REASON = ( + "WPS 登录态已失效(同一浏览器登录新账号会顶掉旧会话)," + "请重新粘贴 Cookie;多账号请用独立浏览器配置分别登录" +) +_GENERIC_REASON = "登录态失效(额度探测返回 DEAD),请重新登录或重新粘贴凭证" + + +def relogin_reason(provider_id: str, error: Exception) -> str: + """DEAD 失败 → 可操作原因;其他失败返回空串(调用方保持「未知」)。""" + if not isinstance(error, UpstreamHTTPError): + return "" + try: + kind = error.kind() + except (AttributeError, TypeError): + # 基类异常没绑定 classify_status(只有测试替身会这么用):不升级处置 + return "" + if kind is not ErrKind.DEAD: + return "" + return _LINGXI_REASON if provider_id == "lingxi" else _GENERIC_REASON \ No newline at end of file diff --git a/src/provider/proxy.py b/src/provider/proxy.py index d1abc386..dbf83588 100644 --- a/src/provider/proxy.py +++ b/src/provider/proxy.py @@ -11,6 +11,7 @@ from __future__ import annotations +import hashlib from collections.abc import Iterable import httpx @@ -50,10 +51,86 @@ def parse_provider_proxies(raw: str, known: Iterable[str]) -> dict[str, str]: return result +def parse_proxy_pool(raw: str) -> tuple[str, ...]: + """`CREDENTIAL_PROXY_POOL` 文本 → 代理 URL 元组(启动期配置)。 + + 格式与 `PROVIDER_PROXIES` 一致(分号分隔、严格协议校验、忽略空段), + 但没有 `渠道=` 前缀:这是一个**全局出口池**,按凭证 id 稳定哈希取桶。 + 重复项按首次出现去重(池子写重了不会让某出口被抽中两次)。 + """ + urls: list[str] = [] + for segment in raw.split(";"): + segment = segment.strip() + if not segment: + continue + scheme = segment.split("://", 1)[0].lower() if "://" in segment else "" + if scheme not in PROXY_SCHEMES: + raise ValueError( + f"CREDENTIAL_PROXY_POOL 代理协议不支持: {segment!r}" + f"(可选: {', '.join(PROXY_SCHEMES)})") + if segment not in urls: + urls.append(segment) + return tuple(urls) + + +class CredentialProxyPool: + """按凭证 id 稳定分配出口(「一号一出口」)。 + + 分配用 `sha256(credential_id)` 取模而不是哈希表轮转:同一凭证在**重启后** + 仍落在同一出口——出口稳定性正是上游风控在看的信号,进程内轮转会让每次 + 重启都换 IP,等于没做。池子增删成员会让部分凭证换桶,这是可接受的运维 + 代价(改池子本身就是要换出口)。 + + 空池 / 无凭证 id(虚拟凭证、后台无上下文)→ None,调用方回落渠道级代理。 + """ + + def __init__(self, urls: Iterable[str] = ()) -> None: + self._urls = tuple(urls) + + def __bool__(self) -> bool: + return bool(self._urls) + + def __len__(self) -> int: + return len(self._urls) + + def for_credential(self, credential_id: str | None) -> str | None: + if not self._urls or not credential_id: + return None + digest = hashlib.sha256(credential_id.encode("utf-8")).digest() + index = int.from_bytes(digest[:8], "big") % len(self._urls) + return self._urls[index] + + +# 启动期全局池:provider 构造时不逐个传参(8 个渠道 × 构造点太多), +# 与 `PROVIDER_PROXIES` 同生命周期——改值必须重启。 +_POOL = CredentialProxyPool() + + +def set_proxy_pool(pool: CredentialProxyPool) -> None: + """装配全局出口池(`build_app` 启动时调用一次;测试可直接替换)。""" + global _POOL + _POOL = pool + + +def get_proxy_pool() -> CredentialProxyPool: + return _POOL + + +def credential_proxy(credential_id: str | None) -> str | None: + """凭证 → 出口代理 URL;未配置池或虚拟凭证返回 None。""" + return _POOL.for_credential(credential_id) + + def build_client(*, timeout: httpx.Timeout, proxy: str | None = None) -> httpx.AsyncClient: """构造上游 HTTP 客户端:显式按渠道代理优先,永远忽略环境代理。 `trust_env=False` 是既有约定——不吃 `HTTP_PROXY` 等环境变量,避免部署环境 的全局代理意外劫持带 Token 的上游请求;按渠道代理只经 `proxy=` 显式生效。 """ - return httpx.AsyncClient(timeout=timeout, trust_env=False, proxy=proxy) \ No newline at end of file + return httpx.AsyncClient(timeout=timeout, trust_env=False, proxy=proxy) + + +# 流式请求:读无上限(SSE 长连接),写 60s(大 payload 也要发完),连接/取池各 10s。 +STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) +# 短请求(探活 / 额度 / 模型列表):整体 30s。 +SHORT_TIMEOUT = httpx.Timeout(30.0) \ No newline at end of file diff --git a/src/provider/qoder/__init__.py b/src/provider/qoder/__init__.py index 93be91bc..4ffe36a2 100644 --- a/src/provider/qoder/__init__.py +++ b/src/provider/qoder/__init__.py @@ -32,6 +32,7 @@ Model, Quota, ) +from ...tasks.pacer import paced from . import events as qoder_events from .client import QoderClient, realm_for from .credential import QoderCredential, parse_credential @@ -100,6 +101,16 @@ def classify(self, status: int, body: bytes) -> ErrKind: async def probe_quota(self, credential_data: dict) -> Quota: return await self.client.probe_quota(QoderCredential.from_dict(credential_data)) + def set_web_cookie(self, credential_data: dict, cookie: str) -> dict: + """写入/清除网页 Cookie(逐包到期的数据源,见 `client.probe_quota`)。 + + 只改这一个字段:不重走 `from_dict` 归一,避免把老凭证里未识别的字段 + 丢掉(导出/再导入的可逆性靠这条保证)。空串 = 清除,回到汇总口径。 + """ + data = dict(credential_data) + data["web_cookie"] = cookie + return data + async def list_models(self, credential_data: dict) -> list[Model]: return await self.client.fetch_models( QoderCredential.from_dict(credential_data)) @@ -108,15 +119,9 @@ async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: credential = QoderCredential.from_dict(credential_data) key = credential_key(credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) # ---------------------------------------------------------------- 签到 diff --git a/src/provider/qoder/auth.py b/src/provider/qoder/auth.py index d0fd2f6d..60ff2e51 100644 --- a/src/provider/qoder/auth.py +++ b/src/provider/qoder/auth.py @@ -20,7 +20,6 @@ import hashlib import logging import os -import secrets import time import uuid from dataclasses import dataclass @@ -29,6 +28,7 @@ import httpx +from ...provider.auth_state import AuthStateStore as AuthStateStoreBase from ...provider.base import AuthFlow, AuthResult, AuthSession from ...provider.proxy import build_client from .credential import QoderCredential, credential_from_device @@ -118,62 +118,29 @@ def build_auth_url(realm: str, *, challenge: str, nonce: str, @dataclass class _Reservation: - username: str - created_at: int + """本渠道载荷:PKCE verifier + 设备码 nonce + 组织/机器标识。""" + verifier: str nonce: str realm: str machine_id: str -class AuthStateStore: +class AuthStateStore(AuthStateStoreBase[_Reservation]): """state 的归属校验、消费与过期清理(与 CodeBuddy 同语义,不同存储载荷)。""" def __init__(self, ttl_seconds: int = AUTH_STATE_TTL_SECONDS) -> None: - self._ttl = ttl_seconds - self._entries: dict[str, _Reservation] = {} + super().__init__(ttl_seconds) def begin(self, username: str, *, verifier: str, nonce: str, realm: str, machine_id: str, now: int | None = None) -> str: - self.cleanup(now) - reservation = secrets.token_urlsafe(18) - self._entries[reservation] = _Reservation( - username=username, created_at=int(now if now is not None else time.time()), - verifier=verifier, nonce=nonce, realm=realm, machine_id=machine_id) - return reservation - - def owner(self, auth_state: str, username: str, now: int | None = None) -> bool: - """归属校验,**含 TTL**(同 CodeBuddy):过期 state 不得再轮询/重放。""" - entry = self._entries.get(auth_state) - if entry is None or entry.username != username: - return False - current = int(now if now is not None else time.time()) - if current - entry.created_at >= self._ttl: - del self._entries[auth_state] - return False - return True + return super().begin(username, _Reservation( + verifier=verifier, nonce=nonce, realm=realm, + machine_id=machine_id), now) def reservation(self, auth_state: str, username: str, now: int | None = None) -> _Reservation | None: - if not self.owner(auth_state, username, now): - return None - return self._entries[auth_state] - - def consume(self, auth_state: str, username: str, now: int | None = None) -> bool: - """消费:成功后该 state 不可再次轮询或重放。""" - if not self.owner(auth_state, username, now): - return False - del self._entries[auth_state] - return True - - def cancel(self, auth_state: str, username: str, now: int | None = None) -> bool: - return self.consume(auth_state, username, now) - - def cleanup(self, now: int | None = None) -> None: - current = int(now if now is not None else time.time()) - for key in [k for k, v in self._entries.items() - if current - v.created_at >= self._ttl]: - del self._entries[key] + return self.payload(auth_state, username, now) class QoderOAuth: @@ -201,7 +168,8 @@ def __init__( config = get_realm_config(realm) self.openapi = (openapi or config.openapi).rstrip("/") self.website = (website or config.website).rstrip("/") - self.store = store or AuthStateStore(ttl_seconds=ttl_seconds) + self.store = (store if store is not None + else AuthStateStore(ttl_seconds=ttl_seconds)) self._machine_id = machine_id self._client = client self._now = now or (lambda: int(time.time())) diff --git a/src/provider/qoder/client.py b/src/provider/qoder/client.py index c8592027..07636b25 100644 --- a/src/provider/qoder/client.py +++ b/src/provider/qoder/client.py @@ -39,13 +39,14 @@ from ...provider.base import ( CheckinResult, CheckinStatus, + ErrKind, Event, EventKind, Model, Quota, UpstreamTransportError, ) -from ...provider.proxy import build_client +from ..lazy_clients import LazyClients from . import events as qoder_events from .cosy import CosySession, CosySessionCache, qoder_encode from .credential import QoderCredential, merge_refreshed @@ -69,8 +70,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + # 本区域无签到接口的 HTTP 状态(国际版实测 404;405/410 同义)。活动制与旧接口 # 共用:两者都不可用才算「本区域无签到」。 @@ -128,6 +128,31 @@ def _openapi_request_id(uid: str) -> str: return derive_request_id(uid) +def _normalize_null_content(messages: list[dict[str, Any]]) -> int: + """把 `content: null` 归一为空串(返回改写条数)。 + + OpenAI 允许「带 tool_calls 的 assistant 把 content 置为 null」,但 Qoder + 上游不接受:它把这条消息当成「没有内容」而跳过,紧随其后的 `role:"tool"` + 就成了孤儿,于是以 400 `Messages with role 'tool' must be a response to a + preceding message with 'tool_calls'` 拒绝——报文指向 tool 配对,真实原因 + 却是 assistant 的 null content(2026-10-23 实测:同一请求体只把 null 换成 + `""` 即从 400 变 200,且与 reasoning_content / 并行调用 / 轮数无关)。 + + `""` 与 `null` 语义等价(都表示「这条 assistant 没有正文」),对上游是 + 合法形状,所以这里就地归一,不改其他字段。 + """ + rewritten = 0 + for message in messages: + if not isinstance(message, dict): + continue + if message.get("role") not in ("assistant", "tool"): + continue + if message.get("content", "") is None: + message["content"] = "" + rewritten += 1 + return rewritten + + def _drop_orphan_tool_messages( messages: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]: """丢弃没有对应 `assistant.tool_calls` 的 `role:"tool"` 消息。 @@ -186,8 +211,19 @@ def build_chat_body(payload: dict[str, Any], model: str) -> dict[str, Any]: messages = json.loads(json.dumps(raw_messages)) if raw_messages is not None else [] # 非 list 形状(畸形请求)交给上游裁决,不做结构假设 dropped = 0 + rewritten = 0 if isinstance(messages, list): + # 必须先归一 null content:上游把 content:null 的 assistant 当空消息跳过, + # 其后的 tool 结果就成了孤儿并被报成 tool 配对错误(见函数 docstring) + rewritten = _normalize_null_content(messages) messages, dropped = _drop_orphan_tool_messages(messages) + if rewritten: + # DEBUG 而非 INFO:这是每请求都可能命中一次的「已自动修复」提示,不是 + # 故障。实测 Qoder 长会话下每请求都打(7 天 10309 行),占 err.log 的 + # 19%,把真正的 WARNING 挤出视野。归一是**成功路径**,排障时按需 + # 打开 DEBUG 即可。 + logger.debug("Qoder 请求把 %d 条消息的 null content 归一为空串" + "(上游会把它当空消息跳过、再报 tool 配对 400)", rewritten) if dropped: # 只记条数:正文里可能有用户数据,日志不落消息内容 logger.warning("Qoder 请求含 %d 条孤立 tool 消息,已丢弃(上游会以 400 拒绝)", @@ -218,7 +254,7 @@ def build_chat_body(payload: dict[str, Any], model: str) -> dict[str, Any]: return body -class QoderClient: +class QoderClient(LazyClients): """上游 HTTP 客户端。`host` = openapi(业务),`gateway` = 推理网关。 主机与登录地址可覆盖(测试 / 灰度);HTTP 客户端可注入 MockTransport。 @@ -247,23 +283,6 @@ def __init__( self._model_cache: dict[str, tuple[float, list[Model]]] = {} self._proxy = proxy - @property - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - @property - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - # --------------------------------------------------------------- 推理流 async def stream_chat(self, credential: QoderCredential, payload: dict[str, Any], @@ -324,7 +343,20 @@ def _decode_frame(self, frame: Any) -> list[Event]: if envelope is None: return [] if envelope.status != 200: - kind = qoder_events.classify_error_code(envelope.status, envelope.body) + # 排队信号必须在分类**之前**拦下:上游把「排队中」包成 403,而 + # 403 在这里会被 classify_error_code 判成 DEAD 直接硬禁用凭证—— + # 排队恰恰说明号是好的,只是忙。同时把上游点名的等待秒数带给 + # executor,它据此在原号上重开(见 executor 的排队重开)。 + queue_wait = base.parse_queue_hint(envelope.body) + if queue_wait is not None: + return [Event(kind=EventKind.ERROR, error_code=envelope.status, + error_message=envelope.body[:2000], + error_kind=ErrKind.QUEUE, + retry_after_seconds=queue_wait)] + # 信封错误与 HTTP 错误必须同一判据:早先这里直接调 + # `classify_error_code`,403 不看会话失效标记就判 DEAD——「每日计费 + # 次数用尽」这类次日自愈的号被硬禁用(实测 2026-10-27 15:39)。 + kind = qoder_events.classify_status(envelope.status, envelope.body.encode()) # 200 字符只够看到外层 `"details":"{\"error\":{\"message\":...`——上游把 # 真实原因嵌在转义的 details 里,截断后根因(如缺哪个 tool_call_id) # 永久丢失,排障时等于没打。放宽到 2000。 @@ -332,6 +364,11 @@ def _decode_frame(self, frame: Any) -> list[Event]: error_message=envelope.body[:2000], error_kind=kind)] if envelope.body == "[DONE]": return [] + # 保活帧:上游发 `body:"null"`(外层 200、内层 JSON null)保活, + # 不含内容。丢掉即可,绝不能当协议违规——那会把正常输出打断成 + # 「流式响应失败」(2026-10-26 实测连续 13 次)。 + if qoder_events.is_keepalive_body(envelope.body): + return [] return qoder_events.parse_inner_chunk(envelope.body) # ------------------------------------------------------------ 模型发现 @@ -393,10 +430,44 @@ async def fetch_models(self, credential: QoderCredential) -> list[Model]: # ---------------------------------------------------------------- 额度 async def probe_quota(self, credential: QoderCredential) -> Quota: - """`GET {openapi}/api/v2/quota/usage`(`userQuota` + `addOnQuota`)。""" + """额度探测:优先网页接口(逐包到期),回退 device token 的汇总接口。 + + 网页账号页的 `/api/v2/me/usages/big_model_credits` 才给 per-package + `expires_at`;device token 的 `/api/v2/quota/usage` 只有两桶汇总,顶层 + `expiresAt` 是周期重置值(实测 9999-12-31),不能当包级到期用。前者 + 需要网页登录 Cookie,且会过期——打不通就静默回退,不影响出号。 + """ + if credential.web_cookie: + web = await self._probe_web_quota(credential) + if web is not None: + return web data = await self._get_json(f"{self.host}{EP_QUOTA}", credential) return qoder_events.parse_quota(data, now=self._now()) + async def _probe_web_quota(self, credential: QoderCredential) -> Quota | None: + """网页额度接口;Cookie 失效/形状变化一律返回 None(由调用方回退)。""" + config = credential.realm_config() + url = f"{config.website}{qoder_events.EP_CREDITS_WEB}" + headers = { + "Accept": "application/json, text/plain, */*", + "Cookie": credential.web_cookie, + "User-Agent": qoder_events.WEB_ACCOUNT_UA, + "Origin": config.website, + "Referer": config.website + "/account/usage", + } + try: + response = await self._short.get(url, headers=headers) + data = await _parse_json_response(response, url) + except (base.UpstreamHTTPError, base.UpstreamTransportError, + qoder_events.UpstreamProtocolViolation, httpx.HTTPError) as error: + # Cookie 会过期(网页会话寿命,不是 token 寿命),上游也可能删接口。 + # 这里只是「少一份明细」,不是凭证问题:不回写失败、不冷却、不告警, + # 回退到汇总接口继续出号。 + logger.info("qoder 网页额度接口不可用(%s: %s),回退 /quota/usage", + type(error).__name__, error) + return None + return qoder_events.parse_web_quota(data, now=self._now()) + # ---------------------------------------------------------------- 签到 async def checkin(self, credential: QoderCredential) -> CheckinResult: diff --git a/src/provider/qoder/credential.py b/src/provider/qoder/credential.py index 498774f2..657f56e4 100644 --- a/src/provider/qoder/credential.py +++ b/src/provider/qoder/credential.py @@ -69,6 +69,10 @@ class QoderCredential: organization_id: str = "" organization_name: str = "" domain: str = "" + # 网页账号页 Cookie(可选):/api/v2/me/usages/big_model_credits 只认网页 + # 登录会话,device token 打不通。有它才能拿到**逐包到期**(见 + # events.parse_web_quota);缺失时额度探测照旧走 /quota/usage。 + web_cookie: str = "" @property def is_oauth(self) -> bool: @@ -115,6 +119,7 @@ def to_dict(self) -> dict[str, Any]: "organization_id": self.organization_id, "organization_name": self.organization_name, "domain": self.domain, + "web_cookie": self.web_cookie, } @classmethod @@ -141,6 +146,7 @@ def from_dict(cls, raw: dict[str, Any]) -> QoderCredential: organization_id=_opt_str(raw, "organization_id", "organizationId"), organization_name=_opt_str(raw, "organization_name", "organizationName"), domain=domain, + web_cookie=_opt_str(raw, "web_cookie", "webCookie", "cookie"), ) @@ -232,6 +238,7 @@ def merge_refreshed( organization_id=credential.organization_id, organization_name=credential.organization_name, domain=credential.domain or get_realm_config(credential.realm).domain, + web_cookie=credential.web_cookie, ) diff --git a/src/provider/qoder/events.py b/src/provider/qoder/events.py index 5afb5c00..eff11a9f 100644 --- a/src/provider/qoder/events.py +++ b/src/provider/qoder/events.py @@ -15,9 +15,12 @@ 错误分类沿用本项目中立层(`provider.base.ErrKind`)语义;Qoder 没有观测到 CB/TRAE 那套 `1005/6004/11102` 业务码,信封里的 `statusCodeValue` 本身就是 -HTTP 状态语义,故按状态码分类。唯一例外:上游会把**自身节点执行故障**也包成 -400(`[FAIL]node:… msg:Execution failed`),这类按 `NODE_FAILURE_MARKERS` 识别后 -归模型级瞬时故障(`ErrKind.MODEL`),见 `classify_error_code`。 +HTTP 状态语义,故按状态码分类——**HTTP 非 2xx 与流内信封共用 `classify_status` +一套判据**(排队 / 每日计费耗尽 / 会话失效 / 其余 SOFT 的优先级见该函数)。 +两个例外:上游会把**自身节点执行故障**包成 400(`[FAIL]node:… msg:Execution failed`), +这类按 `NODE_FAILURE_MARKERS` 识别后归模型级瞬时故障(`ErrKind.MODEL`); +「每日计费次数用尽」(403 `{"code":"110","message":"Billing daily count exceeded"}`) +按 `DAILY_QUOTA_MARKERS` 归 `ErrKind.CREDIT`,等次日恢复而不是硬禁用。 """ from __future__ import annotations @@ -37,8 +40,9 @@ Quota, UpstreamProtocolViolation, Usage, + parse_queue_hint, ) -from ..openai_chunk import first_choice +from ..openai_chunk import first_choice, usage_from_openai from ..token_expiry import normalize_epoch # --------------------------------------------------------------------------- @@ -103,6 +107,10 @@ class RealmConfig: EP_DEVICE_REFRESH = "/api/v1/deviceToken/refresh" EP_USERINFO = "/api/v1/userinfo" EP_QUOTA = "/api/v2/quota/usage" +# 网页账号页的额度接口(需网页登录 Cookie,device token 打不通):只有它给 +# **逐包到期**(`quota_detail[].expires_at`),device token 的 /quota/usage 只给 +# 两桶汇总。见 parse_web_quota。 +EP_CREDITS_WEB = "/api/v2/me/usages/big_model_credits" EP_PLAN = "/api/v2/user/plan" EP_CHECKIN_STATUS = "/sash/api/v1/me/daily-check-in/status" EP_CHECKIN_CLAIM = "/sash/api/v1/me/daily-check-in/claim" @@ -137,6 +145,11 @@ class RealmConfig: MODELS_SIGN_PLAIN = b"" CLIENT_UA = "Go-http-client/2.0" +# 网页账号页的 UA:额度接口按网页会话鉴权,用 CLI 的 Go-http-client UA 会被 +# 风控挡(实测同 Cookie 下浏览器 UA 200)。只用于 `EP_CREDITS_WEB`。 +WEB_ACCOUNT_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36") DEFAULT_USER_TYPE = "personal_professional_trial" # 上游主动吊销离线会话的标记:命中则刷新无意义,需要重新登录。 @@ -149,6 +162,12 @@ class RealmConfig: # 完全不同:换凭证不解决问题但会自愈,必须归为模型级瞬时冷却而非 INVALID。 NODE_FAILURE_MARKERS = ("[FAIL]node:", "Execution failed") +# 上游「每日计费次数用尽」(实测 2026-10-27 15:39,流内信封 403 +# `{"code":"110","message":"Billing daily count exceeded"}`):号本身没坏, +# 额度按上游日边界恢复——重登无效,硬禁用会把一个明天就能用的号踢出池子。 +# 归 CREDIT(冷却到次日 04:00),不进 DEAD。 +DAILY_QUOTA_MARKERS = ("Billing daily count exceeded",) + # 信封内层 delta 的空占位键(只在取值为空/假时才剔除,非空值一律保留)。 NOISE_KEYS = ("extra_fields", "refusal", "reasoning_content") @@ -184,15 +203,23 @@ def is_session_dead(text: str) -> bool: return any(marker in text for marker in SESSION_DEAD_MARKERS) +def is_daily_quota_exceeded(text: str) -> bool: + """响应体是否为「每日计费次数用尽」(次日自动恢复,与凭证状态无关)。""" + return any(marker in text for marker in DAILY_QUOTA_MARKERS) + + def is_node_failure(text: str) -> bool: """响应体是否为「上游节点故障」包装的 400(见 `NODE_FAILURE_MARKERS`)。""" return any(marker in text for marker in NODE_FAILURE_MARKERS) def classify_error_code(code: int | None, body: bytes | str = b"") -> ErrKind: - """状态/信封码 → ErrKind(401/403 的会话失效细节由 `classify_status` 补全)。 + """状态/信封码 → ErrKind(HTTP 与流内信封共用,判据必须一视同仁)。 * 402 → 余额不足(等签到恢复) + * 报文命中 `DAILY_QUOTA_MARKERS` → CREDIT:「每日计费次数用尽」 + (403 `{"code":"110","message":"Billing daily count exceeded"}`), + 号没坏、次日自动恢复,重登无效也不能硬禁用 * 429 → 模型级限流(上游频控点名具体模型,换模型立即可用) * 418/500/502/503/504 → SOFT:上游把自身故障包装成 418/5xx,属瞬时类, 短冷却换号即可,不该累计成「连续 3 次 → 10m」 @@ -200,30 +227,48 @@ def classify_error_code(code: int | None, body: bytes | str = b"") -> ErrKind: * 400 且报文命中 `NODE_FAILURE_MARKERS` → MODEL:上游节点执行失败(如 qfmodel 的 `[FAIL]node:… Execution failed`),属**模型级瞬时**故障, 只冷却该模型、换模型立即可用,不能当成「模型不存在」直接 400 + * 报文带排队标记 → QUEUE:上游把「排队中」包成 403,而 403 按状态码看是 + 「会话失效 → 硬禁用凭证」。排队恰恰说明号是好的、只是忙——判成 DEAD + 会把可自愈的凭证打进冷宫(实测 2026-10-26:kmodel_latest 排队时回 + 403 + `isQueued`,凭证被误判死)。排队判据放在这里(而不是只在调用 + 点)才能保证**任何**入口都不会漏判,`classify_status` 因此只做转发。 + * 401 → DEAD(硬禁用,需重新登录):401 是凭据/会话失效的确定信号, + 实测没有「瞬时抖动」形态(`TOKEN_EXPIRE` 等,见 + `tests/provider/qoder/test_client.py`)。放宽它只会让已被吊销的凭据 + 在 60s 轮换里反复撞上游(fail-open)。 + * 403 且带会话失效标记 → DEAD;其余 403 → SOFT:实测 Qoder 的 403 多数 + 是上游瞬时/风控抖动,直接硬禁用会把可自愈的凭证打进冷宫。**流内信封 + 也走这里**(`client._decode_frame` 调 `classify_status`),两条路径必须 + 同一判据。 """ + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body if code == 402: return ErrKind.CREDIT - if code in (401, 403): + if is_daily_quota_exceeded(text): + return ErrKind.CREDIT + if parse_queue_hint(text) is not None: + return ErrKind.QUEUE + if code == 401: return ErrKind.DEAD + if code == 403: + return ErrKind.DEAD if is_session_dead(text) else ErrKind.SOFT if code == 429: return ErrKind.MODEL if code in (418, 500, 502, 503, 504): return ErrKind.SOFT if code in (400, 404, 422): - text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body return ErrKind.MODEL if is_node_failure(text) else ErrKind.INVALID return ErrKind.OTHER def classify_status(status: int, body: bytes = b"") -> ErrKind: - """HTTP 状态码分类。 + """HTTP 状态码分类(流内信封错误同样走这里)。 - 401/403 只有**明确带会话失效标记**时才归 DEAD(硬禁用);否则按 SOFT - 短冷却轮换——实测 Qoder 的 401/403 多数是上游瞬时/风控抖动,直接硬禁用 - 会把可自愈的凭证打进冷宫。 + 只是 `classify_error_code` 的转发:判据只写一处,HTTP 与流内信封两条 + 路径不可能漂移(历史教训:判据散在两处时,流内 403 被判 DEAD 而 HTTP + 路径已放宽,同一个号一半请求被硬禁用)。排队 / 计费次数用尽的优先级 + 见 `classify_error_code`。 """ - if status in (401, 403) and not is_session_dead(body.decode("utf-8", errors="replace")): - return ErrKind.SOFT return classify_error_code(status, body) @@ -292,6 +337,33 @@ def _load_body(body: str) -> dict[str, Any]: return payload +def is_keepalive_body(body: str) -> bool: + """内层 body 是否是上游的空载荷(保活帧),而非内容帧。 + + 实测(2026-10-26):上游在流中会发 + `{"headers":…,"body":"null","statusCodeValue":200,"statusCode":"OK"}` + 这样的**保活帧**——外层状态 200、内层是 JSON `null`。它不含任何内容, + 只是让连接保持活跃。 + + 早先把它当成「内层不是对象」→ 抛 `UpstreamProtocolViolation` → 整条流 + 被判失败(2026-10-26 16:29-16:53 连续 13 次「流式响应失败: Qoder + inner chunk is not an object」)。模型其实已经在正常输出,只是被保活帧 + 打断——这正是「静默出错」的典型形态。 + + 判据用**结构**而不是字符串前缀:`json.loads` 成功且结果是 `None` / + 空字典 / 空列表,都属于空载荷;非空对象不是。 + """ + try: + payload = json.loads(body) + except json.JSONDecodeError: + return False + if payload is None: + return True + if isinstance(payload, (dict, list)): + return not payload + return False + + def clean_delta(delta: dict[str, Any]) -> dict[str, Any]: """剔除 delta 里的空噪声字段(只删假值,非空值一律保留)。 @@ -365,24 +437,15 @@ def parse_inner_chunk(body: str) -> list[Event]: events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) if finish_reason is not None: events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) + model = payload.get("model") + if isinstance(model, str) and model: + for event in events: + event.model = model return events def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - details = raw.get("prompt_tokens_details") - cached = (details or {}).get("cached_tokens") if isinstance(details, dict) else None - if not isinstance(cached, int) or isinstance(cached, bool): - cached = as_int("cached_tokens") - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=as_int("reasoning_tokens"), - cached_tokens=cached, - ) + return usage_from_openai(raw) # --------------------------------------------------------------------------- @@ -459,12 +522,16 @@ def parse_quota(payload: dict[str, Any], *, now: int) -> Quota: 明细包只作展示(各自独立到期,汇总数字看不出是哪些包),`cycle_end` 取 `expiresAt`(毫秒自动归一)。 + + **这里没有逐包到期**:实测该接口只给两桶汇总,`expiresAt` 是周期重置 + (实测值 9999-12-31),不承载任何包级到期。要逐包到期必须走网页账号页 + 接口,见 `parse_web_quota`。 """ base = _obj(payload.get("userQuota")) addon = _obj(payload.get("addOnQuota")) remaining = _num(base, "remaining") + _num(addon, "remaining") total = _num(base, "total") + _num(addon, "total") - end = _opt_epoch(payload.get("expiresAt")) + end = _real_epoch(payload.get("expiresAt")) packages = [ {"name": "基础额度", "total": _num(base, "total"), "used": _num(base, "used"), "end": end}, @@ -475,6 +542,119 @@ def parse_quota(payload: dict[str, Any], *, now: int) -> Quota: packages=packages, probed_at=now) +# 网页额度接口的容器:顺序即展示顺序。国内版实测三个容器都返回(额度为 0 的 +# 也返回),缺键时当作空容器。 +_WEB_QUOTA_CONTAINERS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("套餐内积分", ("plan_quota", "planQuota")), + ("资源包", ("resource_package_quota", "resourcePackageQuota")), + ("SOTA 专属积分", + ("dedicated_resource_package_quota", "dedicatedResourcePackageQuota")), +) + +# `quota_detail[].source` 的中文名:同一个容器里的包来源可能不同(套餐内也有 +# 赠送来源),包名直接写来源更便于判断「哪一笔先过期」。未知来源退回容器名。 +_WEB_QUOTA_SOURCES: dict[str, str] = { + "PLAN": "套餐内积分", + "RESOURCE_PACKAGE_SOURCE_BONUS": "赠送积分", + "RESOURCE_PACKAGE_SOURCE_PURCHASE": "购买积分", +} + + +def _first_num(source: dict[str, Any], *keys: str) -> float: + """取第一个数值字段(兼容 snake_case / camelCase 两套上游拼法)。""" + for key in keys: + value = source.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + return 0.0 + + +def _first_epoch(source: dict[str, Any], *keys: str) -> int | None: + for key in keys: + epoch = _opt_epoch(source.get(key)) + if epoch is not None: + return epoch + return None + + +# 上游「无到期」哨兵:253402214400000 毫秒 = 9999-12-31。真实重置不会在 70 年 +# 之后,任何 ≥ 2100-01-01 的到期值一律按「无到期信息」处理,否则界面会显示 +# 「9999/12/31 重置」这种等于没说的日期。 +EXPIRY_SENTINEL_MIN = 4_102_444_800 + + +def _real_epoch(value: Any) -> int | None: + epoch = _opt_epoch(value) + if epoch is None or epoch >= EXPIRY_SENTINEL_MIN: + return None + return epoch + + +def parse_web_quota(payload: dict[str, Any], *, now: int) -> Quota | None: + """网页账号页额度接口 → Quota(**逐包到期** + 到期阶梯)。 + + `GET {website}/api/v2/me/usages/big_model_credits`(需网页登录 Cookie, + device token 打不通;见 `client.QoderClient._probe_web_quota`)。响应按容器 + 分组(套餐内 / 资源包 / SOTA),每容器 `quota_detail[]` 给逐包的 + `limit_value` / `used_value` / `remaining_value` / `expires_at`(毫秒)/ + `is_active`。只有这个接口能回答「哪笔积分什么时候作废」,`/quota/usage` + 的顶层 `expiresAt` 做不到。 + + 口径: + - `remaining` / `total` 用各容器 summary 求和(上游已按容器汇总;不再叠加 + 顶层 `total_quota`,那是三者的合计,会翻倍); + - 明细包按 `quota_detail` 逐条展开,`end` 取该包自己的 `expires_at`; + `is_active=False` 的包不计入阶梯(上游已判失效,不能再被消耗),但仍 + 保留在 `packages` 里展示——「过期了还剩多少」是排查时最想知道的一条; + - `expiry_ladder` 只收 `is_active=True`、剩余 > 0、有到期时间的包, + 供选号「快过期先用」; + - `cycle_end` 取阶梯里最早到期时刻(Quota 的定义)。 + """ + packages: list[dict[str, Any]] = [] + ladder: list[tuple[int, float]] = [] + total = used = remaining = 0.0 + for label, keys in _WEB_QUOTA_CONTAINERS: + container = _obj(next((payload[key] for key in keys + if isinstance(payload.get(key), dict)), None)) + summary = _obj(container.get("quota_summary") + or container.get("quotaSummary")) + total += _first_num(summary, "limit_value", "limitValue") + used += _first_num(summary, "used_value", "usedValue") + remaining += _first_num(summary, "remaining_value", "remainingValue") + detail = container.get("quota_detail") or container.get("quotaDetail") + if not isinstance(detail, list): + continue + for item in detail: + if not isinstance(item, dict): + continue + limit = _first_num(item, "limit_value", "limitValue") + used_value = _first_num(item, "used_value", "usedValue") + remain = _first_num(item, "remaining_value", "remainingValue") + end = _first_epoch(item, "expires_at", "expiresAt") + if end is not None and end >= EXPIRY_SENTINEL_MIN: + end = None + source = str(item.get("source") or "") + active = item.get("is_active", item.get("isActive")) + packages.append({ + "name": _WEB_QUOTA_SOURCES.get(source, label), + "total": limit, + "used": used_value, + "end": end, + }) + if active is False or remain <= 0 or end is None: + continue + ladder.append((end, remain)) + if not packages: + # 空响应(容器全缺)不是额度 0:返回 None 让调用方回退 `/quota/usage`, + # 避免把「上游换了形状」显示成「余额耗尽」。 + return None + cycle_end = min((stamp for stamp, _ in ladder), default=None) + return Quota(remaining=max(0.0, remaining), total=total, + cycle_end=cycle_end, + expiry_ladder=sorted(ladder) if ladder else None, + packages=packages, probed_at=now) + + def _claimed_today(value: Any, now: int) -> bool: """lastClaimedAt(epoch,毫秒自动归一)是否落在服务器本地「今天」。""" if isinstance(value, bool) or not isinstance(value, (int, float)): diff --git a/src/provider/raccoon/__init__.py b/src/provider/raccoon/__init__.py new file mode 100644 index 00000000..21064ead --- /dev/null +++ b/src/provider/raccoon/__init__.py @@ -0,0 +1,23 @@ +"""小浣熊(商汤 Raccoon)渠道。 + +上游是**标准 OpenAI 兼容协议**,没有私有信封、没有 COSY 签名、没有设备指纹: + +* 推理 `POST https://xiaohuanxiong.com/api/web/llm/v2/chat/completions` +* 模型目录 `GET /api/web/llm/v2/model_catalog` +* 积分余额 `GET /api/web/points/v1/balance` + +凭证是**网页会话 JWT**(cookie `xhx_token`,`iss=xiaohuanxiong.com/iam`, +有效期约 30 天),直接当 `Authorization: Bearer` 使用。本包只承担四件渠道 +私有的事: + +1. 凭证归一:从 JWT / `{"access_token": …}` / 整段 cookie 串里取出 token, + 并从 claims 里解出 `uid` / 过期时间(不验签,只当元数据); +2. 免费模型识别:目录条目带 `billing_effective_multiplier`,为 0 即当免费 + (`credit_rate=0`,调度器据此优先选它),非 0 透传成倍率; +3. **未知模型防护**:上游对不认识的模型名会**静默回落到默认模型并返回 200** + (2026-11-04 实测 `definitely-not-a-model` → 200 + `model=raccoon-090dd3`)。 + 本包在流首帧比对上游回显的 `model`,不一致立即报协议违规,绝不让客户端 + 拿到「问 A 答 B」的结果; +4. 额度口径:积分按**请求**扣(每笔 `points=-1`,与 token 量无关),故 + `probe_quota` 只报余额,不做 token 换算。 +""" \ No newline at end of file diff --git a/src/provider/raccoon/activity.py b/src/provider/raccoon/activity.py new file mode 100644 index 00000000..664a1a2b --- /dev/null +++ b/src/provider/raccoon/activity.py @@ -0,0 +1,392 @@ +"""小浣熊的「签到」与「活跃上报」:都是 GET/POST 幂等探测,没有领取动作。 + +## 签到(`checkin`) + +上游**没有**「点击领取」接口(官方前端 points 客户端只有 `getBalance` / +`getBills`,整包搜不到「签到」)。每日 300 积分由服务端在 00:00 自动发放, +**当天登录过**才会把有效期延长到 7 天;没登录则当日 24:00 清零(官方规则 +与实测一致)。 + +关键:**账单同步有几分钟延迟**。实测 11-06~11-08 的 `daily_grant` 直到 +11-09 01:11 才出现在 `bills` 里,凌晨 00:02 核对必然误报。故 `checkin` +不再要求「当天必须出现新 `daily_grant`」: + +* 今天有发放 → 按 `created_at` 是否在本次窗口内区分「本次触发」/「早些已发」; +* 今天没有新发放,但存在**未过期**的 `daily_grant` → 视为已签到(积分可用, + 只是今天这笔还没落账/还没延期); +* 两者都没有 → 失败,但把最早重试时刻推到 `GRANT_GRACE`(01:30), + 不让任务从 00:02 起每 10 分钟空锤。 + +「只认流水、不看 HTTP 200」的纪律保持不变——余额接口 200 不等于有积分。 + +流水用**上游的服务端过滤** `type=income`(只留 `daily_grant` / +`reward_grant`)。用 `type=all` 时消费流水会挤占条数窗口:实测 2026-11-10 +055920 的 11-09 那笔排在第 62 条,60 条窗口里一条 grant 都看不到,于是 +误报「今日尚未发放」(账户里其实还有 521 分可用)。`income` 里每笔 +`daily_grant` 自带 `expire_at`,到期判定只需看这个字段。 + +另外一个实测事实:**换新 token(登录)会立刻补发当天这笔**——`created_at` +是登录后几秒,`grant_at` 回填 00:00。只拿旧 token 访问 `user_info` / +`home` / 心跳不会触发(实测 75s 内余额不动)。故「没发」的号要人工重新 +登录拿 token,本服务不代登录。 + +## 活跃上报(`activity`) + +`POST /api/web/auth/v1/devices_current_heartbeat` 是网页端真实的设备心跳 +(前端每轮页面加载都发)。只缺 `x-client-*` 头族,补上即 200、幂等。 + +但**心跳只刷新设备 `last_seen_at`,不算「当天登录」**:实测连续几天只发 +心跳,`daily_grant` 仍然在当日 24:00 清零。真正能延期的是网页会话级接口 +(`GET /api/web/auth/v1/user_info` 等),所以活跃上报现在先做一次页面访问、 +再发心跳。`checkin` 也会先访问一次(best-effort),保证即使活跃任务关闭 +也有一次「登录」。 + +## 积分套餐明细(`quota_packages`) + +`/api/web/points/v1/balance` 把额度分成四个池(每日 / 月度 / 奖励 / 充值), +每个池都是**一个数字**,没有逐笔到期信息。逐笔只在流水里: +`type=income` 的 `daily_grant` 行带 `expire_at`、`reward_grant` 带发放额。 +故明细**按发放流水逐笔展开**(`quota_packages(balance, items)`),每日积分的 +池内消耗按「到期早的先消耗」分摊到各笔——上游不提供逐笔剩余,这是推断; +奖励积分直接按发放额列(池值等于各笔之和)。流水拿不到时才退回四个池。 +`expiry_ladder` 与明细同源:由明细里每笔的**剩余**(`total - used`)派生, +组头「窗口内到期」与明细、额度合计三者口径一致。 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from ..base import CheckinResult + +#: 上游业务时区(流水时间戳都是 +08:00,与运行机器时区无关) +CST = timezone(timedelta(hours=8)) + +#: 判定「本次摸出来的」时间窗:流水 created_at 落在 [调用开始-该窗, 现在] 内 +CLAIMED_WINDOW_SECONDS = 90 + +#: 读多少条流水。日常一天几十条,60 足够覆盖当天那条 daily_grant。 +#: **必须配合 `type=income` 使用**:`all` 里消费流水会挤占窗口(实测 +#: 2026-11-10 活跃账号的每日发放排到第 62 条,60 条窗口内一条都不剩)。 +#: 只算发放时一条/天,60 条覆盖约两个月。 +BILL_LOOKBACK = 60 + +#: 每日发放的流水类型 +DAILY_GRANT = "daily_grant" +#: 奖励发放(邀请 / 活动),无到期 +REWARD_GRANT = "reward_grant" +#: 到期清零 +DAILY_EXPIRE = "daily_expire" + +#: 凌晨判定宽限:账单同步有几分钟延迟;00:00 刚过就判必然误报。 +#: 实测 11-06~11-08 的 daily_grant 直到 11-09 01:11 才出现在 bills 里。 +GRANT_GRACE_HOUR = 1 +GRANT_GRACE_MINUTE = 30 + + +def now_cst() -> datetime: + """当前时间(+08:00)。单点封装便于测试注入,也让「今天」的判定只在一处。""" + return datetime.now(CST) + + +def parse_iso(value: Any) -> datetime | None: + """ISO8601(带 +08:00 偏移)→ aware datetime;坏值/缺失返回 None。""" + if not isinstance(value, str) or not value.strip(): + return None + text = value.strip().replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=CST) + + +def pick_today_grant(items: list[Any], now: datetime) -> dict[str, Any] | None: + """从流水里挑出「今天发放的每日积分」。 + + 判据:`biz_type=daily_grant` 且 `points>0` 且 `grant_at`(缺失时回落 + `created_at`)落在**今天**(+08:00)。同一天可能有多条(正常发放 + + 补发),取点数最大的一条。纯函数,便于单测。 + """ + today = now.astimezone(CST).date() + best: dict[str, Any] | None = None + for item in items: + if not isinstance(item, dict) or item.get("biz_type") != DAILY_GRANT: + continue + points = item.get("points") + if not isinstance(points, (int, float)) or isinstance(points, bool) or points <= 0: + continue + reference = parse_iso(item.get("grant_at")) or parse_iso(item.get("created_at")) + if reference is None or reference.astimezone(CST).date() != today: + continue + if best is None or points > best.get("points", 0): + best = item + return best + + +def grant_grace_at(now: datetime) -> datetime: + """当日判定宽限时刻(01:30 +08:00)。""" + current = now.astimezone(CST) + return current.replace(hour=GRANT_GRACE_HOUR, minute=GRANT_GRACE_MINUTE, + second=0, microsecond=0) + + +def pick_active_grants(items: list[Any], now: datetime) -> list[dict[str, Any]]: + """所有**未过期、未被清零**的 `daily_grant`,按到期升序。 + + 已过期(`expire_at <= now`)、缺 `expire_at`、点数非正、以及已被同一天 + 同额度的 `daily_expire` 配对的条目都不算。返回列表而非单条:上游允许 + 多笔每日积分并存(页面登录延期后跨日保留),判断「今天有没有可用积分」 + 要看它们的合计。 + """ + current = now.astimezone(CST) + stamp = current.timestamp() + expired = _expired_keys(items) + active: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict) or item.get("biz_type") != DAILY_GRANT: + continue + points = item.get("points") + if not isinstance(points, (int, float)) or isinstance(points, bool) or points <= 0: + continue + expire = parse_iso(item.get("expire_at")) + if expire is None or expire.timestamp() <= stamp: + continue + if _grant_key(item) in expired: + continue # 已被到期清零消费掉 + active.append(item) + active.sort(key=lambda item: parse_iso(item.get("expire_at")).timestamp()) + return active + + +def checkin_result(items: list[Any], started: float, *, + now: datetime | None = None) -> CheckinResult: + """流水 + 调用开始时刻 → 签到结论。 + + 四态必须能分清,否则用户无法判断「这次摸出来了」还是「今天早些时候 + 已经发过」: + + * 今日有发放且落账时间在本次调用窗口内 → `already_checked_in=False` + (本次触发/本次首次看到,`credit` 是这笔所得) + * 今日有发放但落账更早 → `already_checked_in=True` + * 今日没有新发放,但账户里还有**未过期**的每日积分 → `ok=True` + + `already_checked_in=True`(积分可用;只是今天这笔还没落账/还没延期)。 + 凌晨 00:00~01:30 之间把 `seal_until` 设到 01:30,让任务在账单同步后 + 再确认一次今天这笔。 + * 今日没有发放且没有未过期积分 → `ok=False`;凌晨同样把 `retry_at` + 推到 01:30,避免 00:02 起每 10 分钟空锤。 + """ + current = now or now_cst() + row = pick_today_grant(items, current) + if row is not None: + points = float(row.get("points") or 0) + created = parse_iso(row.get("created_at")) + fresh = (created is not None + and created.timestamp() >= started - CLAIMED_WINDOW_SECONDS) + expire = parse_iso(row.get("expire_at")) + suffix = f"({expire.astimezone(CST):%Y-%m-%d} 到期)" if expire else "" + if fresh: + return CheckinResult(ok=True, credit=points, already_checked_in=False, + message=f"每日积分 +{points:g}{suffix}") + return CheckinResult(ok=True, credit=points, already_checked_in=True, + message=f"今日已发放 {points:g}(不是本次触发){suffix}") + + active = pick_active_grants(items, current) + if active: + total = sum(float(item.get("points") or 0) for item in active) + nearest = parse_iso(active[0].get("expire_at")) + suffix = (f"(最早 {nearest.astimezone(CST):%Y-%m-%d} 到期)" + if nearest else "") + grace = grant_grace_at(current) + seal = int(grace.timestamp()) if current < grace else None + return CheckinResult(ok=True, credit=total, already_checked_in=True, + seal_until=seal, + message=f"今日额度可用 {total:g}{suffix}(本次未产生新发放)") + + grace = grant_grace_at(current) + retry_at = int(grace.timestamp()) if current < grace else None + return CheckinResult(ok=False, retry_at=retry_at, + message="上游今日每日积分尚未发放(稍后重试)") + + +def quota_packages(balance: dict[str, Any], items: list[Any] | None = None, + now: datetime | None = None) -> list[dict[str, Any]]: + """`balance.data` + 发放流水 → 明细包。 + + `items`(`type=income` 的流水)非 None 时**按逐笔发放展开**:每条 + `daily_grant` / `reward_grant` 一行,`end` 是该笔真实到期。上游只给池的 + 当前可用量、不给逐笔剩余,故: + + * 每日积分按「到期早的先消耗」把池内消耗分摊到各笔(各笔剩余之和恒等于 + `daily_points`,与顶部额度合计一致)。这是推断而非上游明示——上游的 + 流水只有整笔发放,没有单笔扣减记录。 + * 奖励积分实测是「每日额度用光后才扣」:池值等于未到期各笔之和,直接 + 按发放额列。 + + `items` 为 None(流水取不到)时退化为四个池各一行——老口径,`end` 只能是 + null,明细里看不到各笔到期。 + """ + if items is not None: + packages = _grant_packages(balance, items, now or now_cst()) + if packages: + return packages + # 没有任何发放流水(新号/流水异常)时仍退回池口径,不让明细空白 + pools = ( + ("每日额度", "daily_points", True), + ("奖励积分", "reward_points", True), + ("月度积分", "monthly_points", True), + # 充值池被冻结时「看得见用不了」:照常列出,但标记不可用 + ("充值积分", "topup_points", not balance.get("topup_frozen")), + ) + packages = [] + for name, key, usable in pools: + value = balance.get(key) + if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0: + continue + packages.append({"name": name, "total": float(value), + "used": 0.0, "end": None, "usable": bool(usable)}) + return packages + + +def _points(value: Any) -> float: + """非负数值字段 → float;坏值按 0(与 `quota_packages` 的宽容口径一致)。""" + if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0: + return float(value) + return 0.0 + + +def _grant_name(prefix: str, item: dict[str, Any]) -> str: + """`每日积分 11-09` / `奖励积分 11-04 10:02`:同一天可能多笔,时间能把它们区分开。 + + 每日积分有 `grant_at`(回填到当天 00:00),只取日期;奖励积分没有 + `grant_at`,用 `created_at` 的「日期 + 时分」。 + """ + granted = parse_iso(item.get("grant_at")) + if granted is not None: + return f"{prefix} {granted.astimezone(CST):%m-%d}" + created = parse_iso(item.get("created_at")) + return (f"{prefix} {created.astimezone(CST):%m-%d %H:%M}" + if created else prefix) + + +def _grant_packages(balance: dict[str, Any], items: list[Any], + current: datetime) -> list[dict[str, Any]]: + """逐笔发放 → 明细包;每日积分的消耗按到期先后分摊。""" + grants = pick_active_grants(items, current) + rewards = [item for item in items + if isinstance(item, dict) + and item.get("biz_type") == REWARD_GRANT + and _points(item.get("points")) > 0] + if not grants and not rewards: + # 一条发放流水都没有(新号 / 流水异常):交给调用方退回池口径, + # 否则会渲染出一堆「流水未覆盖」,读起来像上游在欠账。 + return [] + packages: list[dict[str, Any]] = [] + pool_left = _points(balance.get("daily_points")) + granted = sum(_points(grant.get("points")) for grant in grants) + remaining_pool = pool_left if pool_left else granted + consume = max(0.0, granted - remaining_pool) + for grant in grants: # 已按到期升序 + points = _points(grant.get("points")) + used = min(consume, points) + consume -= used + expire = parse_iso(grant.get("expire_at")) + packages.append({ + "name": _grant_name("每日积分", grant), + "total": points, + "used": used, + "end": int(expire.timestamp()) if expire else None, + "usable": True, + }) + leftover = remaining_pool - granted + if leftover > 0: + # 池里的可用量比流水能解释的还多(流水同步延迟):补一行无到期余额, + # 宁可少一条到期信息,也不要让明细合计与额度合计对不上。 + packages.append({"name": "每日积分(流水未覆盖)", "total": leftover, + "used": 0.0, "end": None, "usable": True}) + for item in rewards: + points = _points(item.get("points")) + expire = parse_iso(item.get("expire_at")) + packages.append({ + "name": _grant_name("奖励积分", item), + "total": points, + "used": 0.0, + "end": int(expire.timestamp()) if expire else None, + "usable": True, + }) + leftover = _points(balance.get("reward_points")) - sum( + _points(item.get("points")) for item in rewards) + if leftover > 0: + packages.append({"name": "奖励积分(流水未覆盖)", "total": leftover, + "used": 0.0, "end": None, "usable": True}) + # 月度 / 充值池没有逐笔流水,仍按池列(数量为 0 不出条目) + for name, key, usable in (("月度积分", "monthly_points", True), + ("充值积分", "topup_points", + not balance.get("topup_frozen"))): + value = _points(balance.get(key)) + if value > 0: + packages.append({"name": name, "total": value, "used": 0.0, + "end": None, "usable": bool(usable)}) + return packages + + +def ladder_from_packages(packages: list[dict[str, Any]], + ) -> list[tuple[int, float]] | None: + """明细包 → `[(到期 epoch, 剩余), …]`,按到期升序。 + + **与明细同源**:剩余取 `total - used`(每日池的消耗已在明细里按到期先后 + 分摊过),否则阶梯会比明细和额度合计还大——组头「窗口内到期」正是拿它算的。 + + 无到期信息的包(奖励 / 月度 / 充值)跳过;没有可用的返回 None + (而不是空列表:空列表会被读成「全都过期了」)。 + """ + ladder: list[tuple[int, float]] = [] + for item in packages: + end = item.get("end") + left = float(item.get("total") or 0) - float(item.get("used") or 0) + if not isinstance(end, int) or isinstance(end, bool) or left <= 0: + continue + ladder.append((end, left)) + ladder.sort(key=lambda pair: pair[0]) + return ladder or None + + +def _grant_key(item: dict[str, Any]) -> tuple[str, float]: + """发放条目的配对键:`grant_at` 的日期 + 点数。 + + `daily_expire` 的 `biz_id`(`20261103:free:300`)与 `daily_grant` 的 + `biz_id`(纯数字)不是同一套 id,故只能按「哪天的多少点」配对。 + """ + grant = parse_iso(item.get("grant_at")) or parse_iso(item.get("created_at")) + day = grant.astimezone(CST).date().isoformat() if grant else "" + return (day, float(item.get("points") or 0)) + + +def _expired_keys(items: list[Any]) -> set[tuple[str, float]]: + keys: set[tuple[str, float]] = set() + for item in items: + if not isinstance(item, dict) or item.get("biz_type") != DAILY_EXPIRE: + continue + points = item.get("points") + if not isinstance(points, (int, float)) or isinstance(points, bool): + continue + keys.add(_grant_key({**item, "points": abs(points)})) + return keys + + +def heartbeat_headers(device_id: str) -> dict[str, str]: + """网页端心跳的头族。 + + `x-client-device-id` 必填(缺了回 `100002 params_invalid_error` / + `param client headers required invalid`)。`device_id` 由凭证派生 + (见 `credential.derive_device_id`),保证同一账号永远是同一台「设备」。 + """ + return { + "x-client-device-id": device_id, + "x-client-platform": "web", + "x-client-version": "156.0.0.0", + "x-raccoon-language": "zh", + "Content-Type": "application/json", + "Accept": "application/json", + } \ No newline at end of file diff --git a/src/provider/raccoon/client.py b/src/provider/raccoon/client.py new file mode 100644 index 00000000..9ea6dbe0 --- /dev/null +++ b/src/provider/raccoon/client.py @@ -0,0 +1,481 @@ +"""小浣熊上游客户端 + Provider 实现。 + +标准 OpenAI 协议,只有三处渠道私有:Bearer 是网页会话 JWT、免费档看 +`billing_effective_multiplier`、未知模型会静默回落(用上游回显的 `model` +兜住)。额度按请求扣积分(每笔 1 分,与 token 量无关)。 +""" + +from __future__ import annotations + +import contextlib +import copy +import time +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any + +import httpx + +from ...engine.sse import iter_frames +from ...provider import base +from ...provider.base import ActivityResult, CheckinResult, ErrKind, Event, Model, Quota +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients +from . import activity as raccoon_activity +from . import credential as raccoon_credential +from . import events as raccoon_events +from .credential import RaccoonCredential + +#: 上游主机(网页与 API 同域) +RACCOON_HOST = "https://xiaohuanxiong.com" +EP_CHAT = "/api/web/llm/v2/chat/completions" +EP_MODELS = "/api/web/llm/v2/model_catalog" +EP_BALANCE = "/api/web/points/v1/balance" +EP_BILLS = "/api/web/points/v2/bills" +#: 流水过滤口径:`all` 混消费;`income` 只留发放(签到/到期阶梯用后者) +BILL_TYPE_ALL = "all" +BILL_TYPE_INCOME = "income" +EP_HEARTBEAT = "/api/web/auth/v1/devices_current_heartbeat" +#: 网页会话级「页面访问」:每日积分延期/账单物化的触发点(心跳不算) +EP_USER_INFO = "/api/web/auth/v1/user_info" + +#: 官方 LLM 子进程的 User-Agent(openai SDK 形态);不带也不影响调用, +#: 带上只是为了不把自己标成脚本——本渠道没有其它指纹伪装。 +OFFICIAL_UA = "AsyncOpenAI/Python 2.8.0" + + +def prepare_body(payload: dict[str, Any], model: str) -> dict[str, Any]: + """下游请求体 → 上游请求体:深拷 messages、写死 model、强制 stream。 + + `messages` 必须深拷贝:下游可能原地改写,不能穿透回引擎持有的 + `request.raw`(会话粘性指纹与续写都读它)。非流式由引擎聚合,故 `stream` + 无条件置 true——与其余渠道同口径,少一条分支。 + """ + body = dict(payload) + body["messages"] = copy.deepcopy(payload.get("messages")) + body["model"] = model + body["stream"] = True + return body + + +def request_headers(token: str, *, accept: str = "text/event-stream") -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": accept, + "User-Agent": OFFICIAL_UA, + } + + +class UpstreamHTTPError(base.UpstreamHTTPError): + """上游非 2xx;`kind()` 走本渠道的状态码规则。""" + + classify_status = staticmethod(raccoon_events.classify_status) + + +class UpstreamProtocolViolation(base.UpstreamProtocolViolation): + """上游返回了本渠道约定之外的东西(模型回显不一致等)。""" + + +class RaccoonClient(LazyClients): + """上游 HTTP 客户端。host 可覆盖,便于测试与灰度。""" + + def __init__( + self, + *, + host: str = RACCOON_HOST, + stream_client: httpx.AsyncClient | None = None, + short_client: httpx.AsyncClient | None = None, + proxy: str | None = None, + ) -> None: + self.host = host.rstrip("/") + self._stream_client = stream_client + self._short_client = short_client + self._proxy = proxy + + async def stream_chat(self, payload: dict[str, Any], model: str, + token: str) -> AsyncIterator[Event]: + """POST chat/completions 并逐事件产出;非 2xx 抛 UpstreamHTTPError。 + + **模型回显校验**:上游对未知模型名静默回落默认模型(实测 2026-11-04), + 首帧带 `model` 时立即比对,不一致抛协议违规——宁可这次请求失败,也不让 + 客户端「问 A 答 B」。 + """ + body = prepare_body(payload, model) + expected = model.strip().lower() + async with self._stream.stream( + "POST", f"{self.host}{EP_CHAT}", json=body, + headers=request_headers(token), + ) as response: + if response.status_code >= 400: + raw = await base.read_body_bounded(response) + raise UpstreamHTTPError(response.status_code, raw, + **base.retry_hints(response.headers)) + async for frame in iter_frames(response.aiter_bytes()): + for event in raccoon_events.parse_frame(frame): + echoed = event.model + if isinstance(echoed, str) and echoed.strip() \ + and echoed.strip().lower() != expected: + raise UpstreamProtocolViolation( + f"model echo mismatch: asked {model!r}, " + f"upstream returned {echoed!r}") + yield event + + async def fetch_models(self, token: str) -> list[Model]: + """GET 模型目录:一层的 `categories[].models[]`,按 id 去重。""" + response = await self._short.get( + f"{self.host}{EP_MODELS}", + headers=request_headers(token, accept="application/json")) + if response.status_code >= 400: + raise UpstreamHTTPError(response.status_code, + await base.read_body_bounded(response)) + try: + data = response.json() + except ValueError as error: + raise UpstreamProtocolViolation("non-JSON models response") from error + payload = data.get("data") if isinstance(data, dict) else None + categories = payload.get("categories") if isinstance(payload, dict) else None + if not isinstance(categories, list): + raise UpstreamProtocolViolation("models response missing categories") + models: dict[str, Model] = {} + for category in categories: + if not isinstance(category, dict): + continue + items = category.get("models") + if not isinstance(items, list): + continue + for item in items: + model = _model_from_item(item) + if model is not None: + models.setdefault(model.id.lower(), model) + if not models: + raise UpstreamProtocolViolation("models api returned no models") + return list(models.values()) + + async def fetch_quota(self, token: str) -> Quota: + """GET 积分余额 + 流水 → 额度(含四个池的明细与逐笔到期阶梯)。 + + 上游只给「当前可用」一个总数,没有总量/消费口径;`total=remaining` + (健康度恒 100 直到 0)。**不编造**其它来源:日额度耗尽与余额耗尽是 + 两回事,猜错会让调度把备用渠道误判为已耗尽。 + + 明细(`packages`)来自 balance 的四个池,逐笔到期(`expiry_ladder`) + 来自流水的 `daily_grant`——两者口径不同,不能互相换算,故分开取。 + 流水失败**不影响**余额结论(余额是本接口的主要产物,到期阶梯只是 + 调度排序的加分项):流水拿不到就退化成「无阶梯」。 + """ + response = await self._short.get( + f"{self.host}{EP_BALANCE}", + headers=request_headers(token, accept="application/json")) + if response.status_code >= 400: + raise UpstreamHTTPError(response.status_code, + await base.read_body_bounded(response)) + try: + data = response.json() + except ValueError as error: + raise UpstreamProtocolViolation("non-JSON balance response") from error + payload = data.get("data") if isinstance(data, dict) else None + points = payload.get("available_points") if isinstance(payload, dict) else None + if not isinstance(points, (int, float)) or isinstance(points, bool): + raise UpstreamProtocolViolation("balance response missing available_points") + remaining = float(points) + now = int(time.time()) + # 流水只取一次:明细(逐笔发放 + 到期)与到期阶梯同一份数据,避免 + # 两个请求之间上游又发了一笔导致面板与阶梯对不上。 + items = await self._fetch_income(token) + packages = raccoon_activity.quota_packages(payload, items) + # 阶梯与明细同源:组头「窗口内到期」按明细里每笔的**剩余**算, + # 不再用「发放额」(那样会把已消耗的部分也算成将要过期)。 + ladder = raccoon_activity.ladder_from_packages(packages) + return Quota(remaining=remaining, total=remaining, packages=packages or None, + expiry_ladder=ladder, probed_at=now) + + async def _fetch_income(self, token: str) -> list[Any] | None: + """发放流水(`type=income`);失败返回 None(不阻断额度探测)。""" + try: + return await self.fetch_bills(token, bill_type=BILL_TYPE_INCOME) + except (UpstreamHTTPError, UpstreamProtocolViolation): + return None + + async def fetch_bills(self, token: str, + limit: int = raccoon_activity.BILL_LOOKBACK, + bill_type: str = BILL_TYPE_ALL) -> list[Any]: + """GET 积分流水(最近 `limit` 条)。 + + `type` 是上游的**服务端过滤**:`all` 混着逐次调用的消费流水, + `income` 只留发放(`daily_grant` / `reward_grant`)。签到与到期阶梯 + 必须用 `income`——`all` 里消费占绝大多数,活跃账号的每日发放会被 + 挤出条数窗口(实测 2026-11-10:055920 的 11-09 那笔排在第 62 条, + 60 条窗口里一条 grant 都看不到,于是误报「今日尚未发放」)。 + """ + response = await self._short.get( + f"{self.host}{EP_BILLS}", + params={"paging.limit": limit, "type": bill_type}, + headers=request_headers(token, accept="application/json")) + if response.status_code >= 400: + raise UpstreamHTTPError(response.status_code, + await base.read_body_bounded(response)) + try: + data = response.json() + except ValueError as error: + raise UpstreamProtocolViolation("non-JSON bills response") from error + payload = data.get("data") if isinstance(data, dict) else None + items = payload.get("items") if isinstance(payload, dict) else None + if not isinstance(items, list): + raise UpstreamProtocolViolation("bills response missing items") + return items + + async def visit(self, token: str) -> dict[str, Any]: + """GET 网页会话 `user_info`:模拟一次页面访问(幂等、只读)。 + + 为什么需要它:每日 300 积分在 00:00 自动发放,但只有「当天登录过」 + 才会延期到 7 天;设备心跳只刷新 `last_seen_at`,实测连续几天只发 + 心跳时每笔积分仍在当日 24:00 清零。网页加载时的 `user_info` 请求 + 是会话级访问,能触发延期与账单物化(实测调用后 4 分钟账单才出现)。 + """ + response = await self._short.get( + f"{self.host}{EP_USER_INFO}", + headers=request_headers(token, accept="application/json")) + if response.status_code >= 400: + raise UpstreamHTTPError(response.status_code, + await base.read_body_bounded(response)) + try: + data = response.json() + except ValueError as error: + raise UpstreamProtocolViolation("non-JSON user_info response") from error + payload = data.get("data") if isinstance(data, dict) else None + return payload if isinstance(payload, dict) else {} + + async def heartbeat(self, token: str, device_id: str) -> dict[str, Any]: + """POST 设备心跳(网页端真实接口,幂等)。 + + 缺 `x-client-*` 头族会回 `100002 params_invalid_error` / + `param client headers required invalid`——实测 2026-11-04。 + """ + headers = request_headers(token, accept="application/json") + headers.update(raccoon_activity.heartbeat_headers(device_id)) + response = await self._short.post( + f"{self.host}{EP_HEARTBEAT}", headers=headers) + if response.status_code >= 400: + raise UpstreamHTTPError(response.status_code, + await base.read_body_bounded(response)) + try: + data = response.json() + except ValueError as error: + raise UpstreamProtocolViolation("non-JSON heartbeat response") from error + payload = data.get("data") if isinstance(data, dict) else None + return payload if isinstance(payload, dict) else {} + + +def _model_from_item(item: Any) -> Model | None: + """目录条目 → 中立 Model;缺 id 的条目跳过。 + + 倍率取 `billing_effective_multiplier`(含限时优惠后的**实际**倍率): + 0 就是免费(调度器会优先选),非 0 透传。上下文/输出上限上游不提供, + 留 None——**不猜**(本仓压缩只在有上限时才裁剪)。 + """ + if not isinstance(item, dict): + return None + model_id = item.get("name") or item.get("model_name") + if not isinstance(model_id, str) or not model_id.strip(): + return None + tags = item.get("tags") + tag_set = {str(tag).lower() for tag in tags} if isinstance(tags, list) else set() + rate = item.get("billing_effective_multiplier") + return Model( + id=model_id.strip(), + name=model_id.strip(), + credit_rate=(float(rate) + if isinstance(rate, (int, float)) and not isinstance(rate, bool) + else None), + supports_images=("vision" in tag_set) if tag_set else None, + supports_tool_call=True, + supports_reasoning=("reasoning" in tag_set) if tag_set else None, + ) + + +@dataclass(slots=True) +class RaccoonProvider: + """Provider 协议实现。 + + 登录轨道不实现:凭证来自浏览器会话,只能由用户从 DevTools 复制后经 + 「JSON 导入」录入(见 `credential.py`)。因此这里没有 `start_auth` / + `poll_auth` / `complete_callback`——写了也只是永远不被调用的死代码; + 与 Zen/Kilo 的「按方法缺失自然跳过」同一分工。 + """ + + client: RaccoonClient = field(default_factory=RaccoonClient) + pacer: Any | None = None + + id: str = "raccoon" + # 最近一次目录里的模型(小写);用于发请求前的廉价校验。空集表示还没拉过 + # 目录,放行(真实保护靠上游回显的 `model`)。`init=False` 让它不参与构造。 + known_models: set[str] = field(default_factory=set, init=False) + + def import_credential(self, raw: dict) -> dict: + """导入入参 → 落库形态;token 为空直接报错(不让空凭证进池)。""" + credential = RaccoonCredential.from_import(raw) + if not credential.access_token: + raise UpstreamProtocolViolation( + "missing access token: paste the xhx_token cookie value " + "or {\"access_token\": \"...\"}") + return credential.to_dict() + + def credential_expires_at(self, credential_data: dict) -> int: + """JWT `exp`:供管理台提示「30 天后要重新取」;0 = 未知。""" + return RaccoonCredential.from_dict(credential_data).expires_at + + def classify(self, status: int, body: bytes) -> ErrKind: + return raccoon_events.classify_status(status, body) + + async def probe_quota(self, credential_data: dict) -> Quota: + credential = RaccoonCredential.from_dict(credential_data) + if not credential.access_token: + return Quota(probe_failed=True, probed_at=int(time.time())) + return await self.client.fetch_quota(credential.access_token) + + # ---------------------------------------------------------- 签到 / 活跃 + + async def checkin(self, credential_data: dict) -> CheckinResult: + """「签到」= 页面访问 + 摸一次积分服务 + 核对流水。 + + 上游没有领取接口(见 `activity.py` 模块文档),故本方法是**幂等只读** + 的组合:`user_info`(会话级访问,触发延期/账单物化)→ balance(触发 + 余额刷新)→ bills 核对 `daily_grant`。只认流水,不看 HTTP 200—— + 否则会重演「界面显示签到成功、积分没到」。 + + 页面访问失败不阻断核对:活跃任务每天还会再访问一次;这里只是让 + 签到本身也带一次「登录」,避免活跃任务被关闭时完全没有延期动作。 + """ + credential = RaccoonCredential.from_dict(credential_data) + if not credential.access_token: + return CheckinResult(ok=False, message="凭证缺少 token") + started = time.time() + # ① 页面级访问:当天这笔积分只有被会话访问才延期到 7 天 + with contextlib.suppress(UpstreamHTTPError, UpstreamProtocolViolation): + await self.client.visit(credential.access_token) + # ② 触达积分服务:服务端在此刷新余额/补写当天流水 + await self.client.fetch_quota(credential.access_token) + # ③ 读流水核对:签到是否真到账。必须用 `income` 过滤——`all` 里 + # 消费流水占绝大多数,活跃账号的每日发放会被挤出条数窗口。 + try: + items = await self.client.fetch_bills(credential.access_token, + bill_type=BILL_TYPE_INCOME) + except UpstreamHTTPError as error: + return CheckinResult(ok=False, message=f"流水查询失败(HTTP {error.status})") + except UpstreamProtocolViolation: + return CheckinResult(ok=False, message="流水响应格式异常") + return raccoon_activity.checkin_result(items, started) + + async def checkin_status(self, credential_data: dict) -> dict: + """只读签到状态:「今天有没有可用积分」+ 有多少。 + + 与 `checkin` 的区别:这里不判「是不是本次触发的」——状态查询只关心 + 今日是否已签/有没有可用积分,不需要触发访问或余额探测。今天没有新 + 发放但仍有未过期每日积分时也算已签(积分可用,只是今天还没落账)。 + """ + credential = RaccoonCredential.from_dict(credential_data) + if not credential.access_token: + return {"active": False, "today_checked_in": False} + items = await self.client.fetch_bills(credential.access_token, + bill_type=BILL_TYPE_INCOME) + now = raccoon_activity.now_cst() + row = raccoon_activity.pick_today_grant(items, now) + if row is not None: + credit: float | None = float(row.get("points") or 0) + else: + active = raccoon_activity.pick_active_grants(items, now) + credit = (sum(float(item.get("points") or 0) for item in active) + if active else None) + return { + "active": True, + "today_checked_in": credit is not None, + "today_credit": credit, + "activity_name": "每日积分(服务端发放/登录延期)", + } + + def checkin_scope(self, credential_data: dict) -> str: + """签到隔离键:uid;身份未知返回空串(调用方回落凭证 ID)。 + + 与 CodeArts / Qoder 同取向:返回空串而非 `raccoon|`——后者会让同渠道 + 所有身份未知的凭证算出同一 scope,`CheckinTask` 的 seen 集合只跑第一个。 + """ + uid = RaccoonCredential.from_dict(credential_data).uid + return f"raccoon|{uid}" if uid else "" + + async def activity(self, credential_data: dict) -> ActivityResult: + """活跃上报:页面访问 + 设备心跳(都幂等、只读)。 + + 为什么不是只发心跳:心跳只刷新设备 `last_seen_at`,不算「当天登录」; + 实测连续几天只发心跳时,每日积分仍会在当日 24:00 清零。网页会话级 + 的 `user_info` 访问才触发延期。故先访问、再心跳;访问失败按活跃失败 + 返回,让任务在同一小时窗口内重试(心跳本身仍会照发)。 + """ + credential = RaccoonCredential.from_dict(credential_data) + if not credential.access_token: + return ActivityResult(ok=False, message="凭证缺少 token") + token = credential.access_token + visit_error: str | None = None + try: + await self.client.visit(token) + except UpstreamHTTPError as error: + visit_error = f"页面访问失败(HTTP {error.status})" + except UpstreamProtocolViolation: + visit_error = "页面访问响应格式异常" + try: + payload = await self.client.heartbeat( + token, raccoon_credential.derive_device_id(credential.uid)) + except UpstreamHTTPError as error: + message = f"心跳失败(HTTP {error.status})" + if visit_error: + message += f";{visit_error}" + return ActivityResult(ok=False, message=message) + except UpstreamProtocolViolation: + message = "心跳响应格式异常" + if visit_error: + message += f";{visit_error}" + return ActivityResult(ok=False, message=message) + seen = payload.get("last_seen_at") + trusted = payload.get("trusted_device") + detail = "设备心跳已上报" + if isinstance(seen, str) and seen: + detail += f"(上游 last_seen_at={seen})" + if trusted is False: + detail += ";注意:该设备未被上游信任" + if visit_error: + return ActivityResult(ok=False, user_id=credential.uid, + message=f"{visit_error};{detail}") + return ActivityResult(ok=True, message=f"页面访问已上报;{detail}", + user_id=credential.uid) + + def activity_scope(self, credential_data: dict) -> str: + """活跃上报隔离键:uid(同账号多凭证共享「每天一次」)。""" + uid = RaccoonCredential.from_dict(credential_data).uid + return f"raccoon|{uid}" if uid else "" + + async def list_models(self, credential_data: dict) -> list[Model]: + credential = RaccoonCredential.from_dict(credential_data) + if not credential.access_token: + raise UpstreamProtocolViolation("raccoon credential missing token") + models = await self.client.fetch_models(credential.access_token) + self.known_models = {model.id.lower() for model in models} + return models + + async def stream_chat(self, credential_data: dict, payload: dict, + model: str) -> AsyncIterator[Event]: + credential = RaccoonCredential.from_dict(credential_data) + if not credential.access_token: + raise UpstreamProtocolViolation("raccoon credential missing token") + if self.known_models and model.strip().lower() not in self.known_models: + # 目录里没有的模型:上游会静默回落默认模型并 200。这里按「模型不存在」 + # 处理(400/INVALID),不进上游也不冷却凭证。 + raise UpstreamHTTPError( + 404, b'{"error":{"message":"model not found in raccoon catalog"}}') + key = credential.uid or "raccoon" + async with paced(self.pacer, key): + async for event in self.client.stream_chat( + payload, model, credential.access_token): + yield event + + async def aclose(self) -> None: + await self.client.aclose() \ No newline at end of file diff --git a/src/provider/raccoon/credential.py b/src/provider/raccoon/credential.py new file mode 100644 index 00000000..e6dc314e --- /dev/null +++ b/src/provider/raccoon/credential.py @@ -0,0 +1,141 @@ +"""小浣熊凭证:网页会话 JWT(`xhx_token`)。 + +上游没有「API Key」形态,凭据就是浏览器登录后的 JWT。导入侧要尽量宽容—— +用户可能粘: + +* 纯 JWT(`eyJ…`); +* `{"access_token": "eyJ…"}` / `{"token": …}` / `{"xhx_token": …}`; +* 整段 cookie(`xhx_token=eyJ…; user_id=…`,从浏览器 DevTools 复制)。 + +`uid` / 过期时间从 JWT claims 解出(**不验签**:验签是上游的事,这里只把 +`sub` 当账号标识、`exp` 当「30 天后要重取」的提示)。解不出来就留空, +不影响调用。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import dataclass +from typing import Any + +#: 允许的入参键名(按优先级)。`token` / `jwt` 是用户手写习惯,`xhx_token` +#: 是浏览器 cookie 的原名。 +_TOKEN_KEYS = ("access_token", "token", "jwt", "xhx_token") + + +def _decode_segment(segment: str) -> dict[str, Any]: + text = segment.replace("-", "+").replace("_", "/") + text += "=" * (-len(text) % 4) + try: + data = json.loads(base64.b64decode(text).decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def jwt_claims(token: str) -> dict[str, Any]: + """JWT 中间段 → claims;不是 JWT(段数不对 / base64 坏)返回空 dict。""" + parts = token.split(".") + if len(parts) != 3 or not parts[1]: + return {} + return _decode_segment(parts[1]) + + +def token_from_cookie(raw: str) -> str: + """整段 cookie / 只有 `xhx_token=…` 的串 → token;拿不到返回空串。""" + for chunk in raw.split(";"): + name, _, value = chunk.strip().partition("=") + if name.strip() == "xhx_token" and value.strip(): + return value.strip() + return "" + + +@dataclass(slots=True) +class RaccoonCredential: + """归一后的凭证。字段全为字符串/整数,便于原样落库。""" + + access_token: str = "" + uid: str = "" + nickname: str = "" + #: JWT `exp`(epoch 秒);0 表示未提供/不可解析 + expires_at: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "access_token": self.access_token, + "uid": self.uid, + "nickname": self.nickname, + "expires_at": self.expires_at, + } + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> RaccoonCredential: + """落库形态 → 凭证;字段缺失按空处理(调用方负责校验 token 非空)。""" + expires = raw.get("expires_at") + return cls( + access_token=_text(raw.get("access_token")), + uid=_text(raw.get("uid")), + nickname=_text(raw.get("nickname")), + expires_at=expires if isinstance(expires, int) and not isinstance( + expires, bool) else 0, + ) + + @classmethod + def from_import(cls, raw: Any) -> RaccoonCredential: + """导入入参 → 凭证。空 token 由调用方(`import_credential`)报错。""" + if isinstance(raw, str): + return cls._from_token(raw) + if not isinstance(raw, dict): + return cls() + token = "" + for key in _TOKEN_KEYS: + value = raw.get(key) + if isinstance(value, str) and value.strip(): + token = value.strip() + break + if not token: + cookie = raw.get("cookie") + if isinstance(cookie, str): + token = token_from_cookie(cookie) + credential = cls._from_token(token) + # 显式字段优先于 JWT 推断(用户可能填了昵称) + credential.uid = _text(raw.get("uid")) or _text(raw.get("user_id")) \ + or credential.uid + credential.nickname = _text(raw.get("nickname")) or _text( + raw.get("user_name")) or credential.nickname + return credential + + @classmethod + def _from_token(cls, token: str) -> RaccoonCredential: + token = token.strip() + claims = jwt_claims(token) + uid = _text(claims.get("sub")) or _text(claims.get("uid")) + expires = claims.get("exp") + return cls( + access_token=token, + uid=uid, + nickname=_text(claims.get("name")) or _text(claims.get("nickname")), + expires_at=expires if isinstance(expires, int) and not isinstance( + expires, bool) else 0, + ) + + +def _text(value: Any) -> str: + return value.strip() if isinstance(value, str) and value.strip() else "" + + +def derive_device_id(uid: str) -> str: + """由 uid 稳定派生设备 id(心跳头 `x-client-device-id` 必填)。 + + 与 Qoder 的机器码同一取向:**随机值会触发风控**(同一账号每次换设备)。 + 上游实测的形态是 `dev<32hex>-`,这里按同形态稳定派生。 + 缺 uid 时用 `anonymous` 占位(可用但会被关联,登录后应补齐)。 + """ + seed = f"raccoon-device:{uid or 'anonymous'}" + digest = hashlib.md5(seed.encode("utf-8"), usedforsecurity=False).hexdigest() + # uuid 形态的固定值(由同一 digest 派生,保证跨进程稳定) + tail = (hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32]) + formatted = f"{tail[:8]}-{tail[8:12]}-{tail[12:16]}-{tail[16:20]}-{tail[20:32]}" + return f"dev{digest}-{formatted}" \ No newline at end of file diff --git a/src/provider/raccoon/events.py b/src/provider/raccoon/events.py new file mode 100644 index 00000000..267cee90 --- /dev/null +++ b/src/provider/raccoon/events.py @@ -0,0 +1,109 @@ +"""小浣熊 SSE → 中立事件映射 + 错误分类。 + +协议是标准 OpenAI 流式(`data: {chat.completion.chunk}`、`data: [DONE]`), +共享 `provider/openai_chunk.py` 的解析原语即可。实测补充(2026-11-04): + +* 正文 `delta.content`,思考 `delta.reasoning_content`(`sn-glm-5-3` 实测), + 同一帧可能同时带正文与 `finish_reason` → 用 `openai_events`; +* `usage` 只在带了 `stream_options.include_usage` 的末帧出现(没有就完全没有); +* 每个 chunk 都带 `model` 回显——本渠道用它防「未知模型静默回落默认模型」; +* 错误信封是 `{"error": {"code": "...", "message": "..."}}`,`code` 是**字符串** + (`user_model_rate_limited`),不是数字。 + +分类口径(都有真机证据): + +| 现象 | 分类 | 理由 | +|---|---|---| +| 401 / 403 + 身份校验失败 | `DEAD` | 会话 JWT 过期或被撤销,只能重新登录后重贴 | +| 402 / 余额不足(points、积分、insufficient、balance) | `CREDIT` | 等次日积分发放,不是凭证坏了 | +| 429 + `user_model_rate_limited` | `CONCURRENCY` | 请求频率超限(并发约 5),排空即恢复 | +| 429 其他 | `SOFT` | 账号级短限流 | +| 400 / 404 / 422 | `INVALID` | 请求或模型名不对,换号无用 | +| 5xx | `OTHER` | 上游故障,累计 3 次冷却 | +""" + +from __future__ import annotations + +from typing import Any + +from ...engine.sse import SSEFrame +from ...provider.base import ( + ErrKind, + Event, + EventKind, + UpstreamProtocolViolation, # noqa: F401 - 测试经本模块访问 + Usage, +) +from ..openai_chunk import openai_events, usage_from_openai + +#: 429 的并发限流标记(实测 `{"error":{"code":"user_model_rate_limited"}}`) +_RATE_LIMIT_MARKERS = ("user_model_rate_limited", "请求频率超限", "rate_limit") +#: 身份校验失败标记:会话 JWT 不可用 +_IDENTITY_MARKERS = ("identity could not be verified", "invalid token", + "unauthorized", "token authentication failed") +#: 余额/积分不足标记 +_CREDIT_MARKERS = ("insufficient", "balance", "points", "积分", "余额") + + +def parse_frame(frame: SSEFrame) -> list[Event]: + """一帧 → 若干中立事件(空帧 / `[DONE]` → 空表)。""" + return openai_events(frame, "Raccoon", + reasoning_keys=("reasoning_content", "reasoning"), + error_event=_error_event, usage=_usage) + + +def _error_event(error: dict[str, Any]) -> Event: + code = error.get("code") + message = error.get("message") + return Event( + kind=EventKind.ERROR, + error_message=str(message) if message is not None else "", + error_kind=classify_error(body=str(code or "") + " " + str(message or "")), + ) + + +def _usage(raw: dict[str, Any]) -> Usage: + return usage_from_openai(raw) + + +def _lower(body: bytes | str) -> str: + if isinstance(body, bytes): + return body.decode("utf-8", errors="replace").lower() + return body.lower() + + +def classify_error(body: bytes | str) -> ErrKind: + """流内错误信封 → 分类(按文本判据,`code` 是字符串)。""" + text = _lower(body) + if any(marker in text for marker in _IDENTITY_MARKERS): + return ErrKind.DEAD + if any(marker in text for marker in _RATE_LIMIT_MARKERS): + return ErrKind.CONCURRENCY + if any(marker in text for marker in _CREDIT_MARKERS): + return ErrKind.CREDIT + return ErrKind.OTHER + + +def classify_status(status: int, body: bytes = b"") -> ErrKind: + """HTTP 状态码 + 响应体 → 分类。 + + 先按语义标记判(429 的并发限流、403 的身份失败、余额不足三类都在正常 + 状态码里出现),再按裸状态码兜底。 + """ + if status in (401, 403): + # 401 一定是凭据问题;403 只有点到「身份校验」才算凭据问题(否则可能是 + # 风控/权限,按请求级处理,不硬禁用一条好凭证) + if status == 401 or any(m in _lower(body) for m in _IDENTITY_MARKERS): + return ErrKind.DEAD + return ErrKind.REQUEST + if status == 402: + return ErrKind.CREDIT + if status == 429: + if any(marker in _lower(body) for marker in _RATE_LIMIT_MARKERS): + return ErrKind.CONCURRENCY + return ErrKind.SOFT + if status in (400, 404, 422): + return ErrKind.INVALID + if status in (502, 503, 504): + return ErrKind.OTHER + return ErrKind.OTHER \ No newline at end of file diff --git a/src/provider/tos.py b/src/provider/tos.py new file mode 100644 index 00000000..4f4ffa92 --- /dev/null +++ b/src/provider/tos.py @@ -0,0 +1,103 @@ +"""渠道 ToS 风险登记(运营判断,非上游条款原文)。 + +目的是把「合规灰区」从散落的 `docs/` 搬到**导入凭证的必经路径**上:管理员加号 +时能看到这个渠道的评级与理由,审计流水里也留一条。评级不参与调度、不阻断导入 +(红线渠道根本没接,不需要运行时拦)。 + +**评级口径**(本项目自己的运营判断,依据见 `docs/channel-integration-recommendation-*` +与各渠道条款;不是律师意见,也不引用条款原文): + +- `green`:上游公开提供 API / 免费层,正常调用不依赖逆向客户端协议; +- `gray`:靠复用订阅制客户端的私有协议/会话,条款未明确允许第三方转发或多用户 + 共享——实践中普遍存在,但存在被收紧/封号的可能; +- `red`:条款明文禁止中转或脚本直调。**当前没有接入任何 red 渠道**(Freebuff + 之类在评估阶段即被排除),保留该档位给未来的登记。 + +新增渠道必须同时登记;漏登记会在导入时回落 `gray` 并注明「未登记」,宁可 +保守也不默认标成 green。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ..engine.model_resolver import KNOWN_PROVIDERS + +__all__ = ["CHANNEL_TOS", "TosRisk", "tos_for"] + +TosLevel = Literal["green", "gray", "red"] + + +@dataclass(frozen=True) +class TosRisk: + level: TosLevel + reason: str + # 依据来源(文档路径或「运营判断」),审计/界面用来说明这行不是条款原文 + basis: str = "运营判断" + + def as_dict(self) -> dict[str, str]: + return {"level": self.level, "reason": self.reason, "basis": self.basis} + + +CHANNEL_TOS: dict[str, TosRisk] = { + "zen": TosRisk( + "green", + "官方公开网关 /zen/v1,免费层免鉴权,调用不依赖逆向客户端", + basis="docs/competitor-scan-2026-10-22.md"), + "kilo": TosRisk( + "green", + "标准 OpenAI 兼容网关,官方 /models 带 isFree 标记", + basis="docs/competitor-scan-2026-10-22.md"), + "codebuddy": TosRisk( + "gray", + "复用个人版客户端私有协议(/v2/plugin/*);条款未明确允许多用户共享或第三方转发", + basis="运营判断"), + "codebuddy_intl": TosRisk( + "gray", + "同国内站协议、独立账号体系;条款未明确允许多用户共享或第三方转发", + basis="运营判断"), + "trae": TosRisk( + "gray", + "复用 IDE 客户端协议与登录态;条款未明确允许脚本直调与多用户共享", + basis="运营判断"), + "qoder": TosRisk( + "gray", + "COSY 私有签名协议 + 设备指纹;条款未明确允许第三方转发", + basis="运营判断"), + "codearts": TosRisk( + "gray", + "华为云 CodeArts 订阅的 OAuth/积分接口,按账号并发受限;共享范围条款未明确", + basis="运营判断"), + "officeace": TosRisk( + "gray", + "AgentArts 控制面开通的应用凭据,按账号下发;条款未明确允许中转", + basis="运营判断"), + "raccoon": TosRisk( + "gray", + "复用网页端登录态(xhx_token 会话 JWT)调官方托管模型网关;条款未明确" + "允许第三方转发与多用户共享,且凭据约 30 天到期需重取", + basis="运营判断"), + "lingxi": TosRisk( + "gray", + "复用 WPS 灵犀桌面/网页登录态(wps_sid Cookie)调 agent-runtime 私有" + "通路,并伪装桌面客户端请求头;条款未明确允许第三方转发与多用户共享," + "无刷新接口(cookie 约一年,失效只能重贴)", + basis="运营判断"), +} + +_UNKNOWN = TosRisk("gray", "渠道未登记 ToS 风险(按保守档处理)", basis="未登记") + + +def tos_for(provider_id: str) -> TosRisk: + """渠道 → 风险登记;未知渠道回落 gray(绝不默认 green)。""" + risk = CHANNEL_TOS.get(provider_id) + if risk is None: + return _UNKNOWN + return risk + + +# KNOWN_PROVIDERS 新增渠道时强制同步登记:模块导入即校验,漏登记直接起不来。 +_MISSING = sorted(set(KNOWN_PROVIDERS) - set(CHANNEL_TOS)) +if _MISSING: # pragma: no cover - 配置错误,正常代码路径不会触发 + raise RuntimeError(f"渠道缺少 ToS 风险登记: {_MISSING}") diff --git a/src/provider/trae/client.py b/src/provider/trae/client.py index 841c6534..d3b86b35 100644 --- a/src/provider/trae/client.py +++ b/src/provider/trae/client.py @@ -16,6 +16,7 @@ import httpx from ...engine.sse import iter_frames +from ...error_text import error_text from ...provider import base from ...provider.base import ( AuthFlow, @@ -27,8 +28,9 @@ Model, Quota, ) -from ...provider.proxy import build_client from ...provider.token_expiry import normalize_epoch +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients from . import events as trae_events from . import pricing from .callback import ( @@ -63,8 +65,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + # SOLO 静态模型表(继承 trae2api-web;动态拉取失败时回退) STATIC_MODELS: tuple[str, ...] = ( @@ -301,7 +302,7 @@ def oauth_headers() -> dict[str, str]: } -class TraeClient: +class TraeClient(LazyClients): """上游 HTTP 客户端。host 可覆盖,便于测试。""" def __init__( @@ -321,21 +322,6 @@ def __init__( self._short_client = short_client self._proxy = proxy - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client - - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client - - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() - # ------------------------------------------------------------- 聊天流 async def stream_chat( @@ -344,15 +330,23 @@ async def stream_chat( """POST llm_utils_chat 并逐事件产出中立 Event。非 2xx 抛 UpstreamHTTPError。""" body = prepare_body(payload, model) url = f"{self.agent_host}{EP_CHAT}" - async with self._stream().stream( + async with self._stream.stream( "POST", url, json=body, headers=solo_headers(credential), ) as response: if response.status_code >= 400: raw = await base.read_body_bounded(response) raise UpstreamHTTPError(response.status_code, raw, **base.retry_hints(response.headers)) + model_hint: str | None = None async for frame in iter_frames(response.aiter_bytes()): + # 元数据帧(extra_info 等)回显的真实模型名:贴到后续事件上, + # 供 executor 做「静默替换」观测(帧本身无下游语义,不产出事件) + hint = trae_events.frame_model(frame) + if hint is not None: + model_hint = hint for event in trae_events.parse_all_events(frame): + if event.model is None and model_hint is not None: + event.model = model_hint _fill_estimated_credit(event, model) yield event @@ -399,10 +393,7 @@ def _to_model(config: dict) -> Model | None: rate = rate_info["data"].get("rate") if isinstance(rate, (int, float)) and not isinstance(rate, bool): credit_rate = float(rate) - context_window = config.get("context_window_tokens") or {} - max_input = context_window.get("dev") if isinstance(context_window, dict) else None - if not isinstance(max_input, int) or isinstance(max_input, bool): - max_input = None + max_input = _context_window(config) return Model( id=str(config["config_name"]), name=str(display.get("display_name") or ""), @@ -522,7 +513,7 @@ async def get_user_info(self, credential: TraeCredential) -> tuple[str, str]: async def _post_json(self, url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]: - response = await self._short().post(url, json=body, headers=headers) + response = await self._short.post(url, json=body, headers=headers) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, await base.read_body_bounded(response)) @@ -556,6 +547,31 @@ def _trae_supports_reasoning(config: dict[str, Any]) -> bool | None: return None +def _context_window(config: dict[str, Any]) -> int | None: + """上下文窗口:取 `max`,缺失或非法时回落 `dev`。 + + 上游 `context_window_tokens` 是 `{dev, max}` 两档:`max` 是模型真实上限 + (如 glm-5.3 的 1M、mimo 的 981504),`dev` 是保守档(200k 量级)。 + 只读 `dev` 会让列表里 1M 上下文的模型报 200k,客户端的上下文压缩提前 + 触发,把本可以装下的对话截断。`max` 不大于 `dev` 时没有信息量,按 `dev`。 + """ + window = config.get("context_window_tokens") + if not isinstance(window, dict): + return None + dev = _positive_int(window.get("dev")) + top = _positive_int(window.get("max")) + if top is not None and (dev is None or top > dev): + return top + return dev + + +def _positive_int(value: Any) -> int | None: + """严格正整数:bool / 非 int / ≤0 一律视为缺失。""" + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + return None + return value + + def _pack_name(pack: dict[str, Any]) -> str: """权益包名称:优先 package_extra 的具体包名,逐级回落到描述。 @@ -681,7 +697,7 @@ async def list_models(self, credential_data: dict) -> list[Model]: self._dynamic_models_blocked_until = time.monotonic() + 300 logging.getLogger(__name__).warning( - "TRAE 动态模型拉取失败,回退静态表 5 分钟: %s", error) + "TRAE 动态模型拉取失败,回退静态表 5 分钟: %s", error_text(error)) # 静态表:重名时 id 用实测大小写,元数据优先取动态结果, # 其次取上次成功的动态拉取(负缓存期倍率不丢) merged: list[Model] = [] @@ -713,15 +729,9 @@ async def stream_chat(self, credential_data: dict, payload: dict, """引擎调用入口:dict 凭证 → 上游流 → 中立事件。""" credential = TraeCredential.from_dict(credential_data) key = _pacer_key(credential) - if self.pacer is not None: - await self.pacer.wait_turn(key) - try: + async with paced(self.pacer, key): async for event in self.client.stream_chat(credential, payload, model): yield event - finally: - # 并发模式必须归还名额,否则该凭证被当成永远在途而失去节流 - if self.pacer is not None: - self.pacer.release(key) async def refresh(self, credential_data: dict) -> dict: refreshed = await self.client.refresh_token(TraeCredential.from_dict(credential_data)) diff --git a/src/provider/trae/events.py b/src/provider/trae/events.py index b03e1b85..50fd12e3 100644 --- a/src/provider/trae/events.py +++ b/src/provider/trae/events.py @@ -66,24 +66,25 @@ def parse_frame(frame: SSEFrame) -> Event | None: raise UpstreamProtocolViolation(f"SSE data for {name!r} is not an object") if name == "output": - return _parse_output(payload) + return _attach_model(_parse_output(payload), payload) if name == "token_usage": - return Event(kind=EventKind.USAGE, usage=_parse_usage(payload)) + return _attach_model(Event(kind=EventKind.USAGE, usage=_parse_usage(payload)), + payload) if name == "done": reason = payload.get("finish_reason") finish = reason if isinstance(reason, str) else None - return Event(kind=EventKind.FINISH, finish_reason=finish) + return _attach_model(Event(kind=EventKind.FINISH, finish_reason=finish), payload) if name == "error": code = payload.get("code") message = payload.get("message") normalized_code = code if isinstance(code, int) and not isinstance(code, bool) else None - return Event( + return _attach_model(Event( kind=EventKind.ERROR, error_code=normalized_code, error_message=message if isinstance(message, str) else None, # 分类在解析时定死(单一来源);executor 直接消费 error_kind=classify_error_code(normalized_code), - ) + ), payload) # name ∈ KNOWN_EVENT_NAMES,上面分支已穷尽;防御未来新增名字漏写分支 raise UpstreamProtocolViolation( # pragma: no cover f"unhandled known event {name!r}") @@ -257,3 +258,34 @@ def classify_status(status: int, body: bytes = b"") -> ErrKind: # 换凭证没用 → 跳过该上游;其余 400 同样按请求无效处理 return ErrKind.INVALID return ErrKind.OTHER + + +def _attach_model(event: Event | None, payload: dict[str, Any]) -> Event | None: + """把上游自报的模型名挂到事件上(观测用;缺省保持 None)。""" + model = payload.get("model") + if event is not None and isinstance(model, str) and model: + event.model = model + return event + + +# 实测 TRAE 只有元数据帧回显真实模型:`metadata.model`(偶发空串)、 +# `extra_info.model`(真实模型名)、`timing_cost.provider_model_name`。 +# output / done / token_usage 帧都不带 → 由 client 把这里取到的名字贴到后续事件。 +_MODEL_HINT_EVENTS = frozenset({"metadata", "timing_cost", "extra_info"}) + + +def frame_model(frame: SSEFrame) -> str | None: + """元数据帧里回显的真实模型名;缺帧 / 空串 / 坏 JSON 一律 None。""" + if frame.event.strip() not in _MODEL_HINT_EVENTS or not frame.data: + return None + try: + payload = json.loads(frame.data) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict): + return None + for key in ("model", "provider_model_name"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + return None diff --git a/src/provider/zen/client.py b/src/provider/zen/client.py index cd9eb043..c8382bfd 100644 --- a/src/provider/zen/client.py +++ b/src/provider/zen/client.py @@ -47,7 +47,8 @@ Model, Quota, ) -from ...provider.proxy import build_client +from ...tasks.pacer import paced +from ..lazy_clients import LazyClients from . import events as zen_events logger = logging.getLogger(__name__) @@ -58,8 +59,15 @@ EP_MODELS = "/zen/v1/models" # 门禁要求的 UA 版本下限;实测 1.18.0 及以上通过,1.17.9 返回 426 MIN_OPENCODE_VERSION = "1.18.0" -# 门禁要求 tools 里必须出现的工具名(大小写敏感) +# 门禁要求 tools 里必须出现的工具名(大小写敏感)。列表可经 +# `ZEN_GATE_TOOLS` 覆盖:第三方实测上游在 10-14 收紧后要求 +# `bash/glob/grep/read` 四件套,本仓实测仍以两件套通过 —— 属移动靶, +# 故做成配置而非硬编码(改完重启即换,无需改代码)。 REQUIRED_TOOLS: tuple[str, ...] = ("bash", "read") +# 上游 API 根。`/zen/v1` 与 `/inference/v1` 当前都返回 200(2026-10-28 实测 +# `GET /models`),第三方实测称前者已死、后者可用 —— 结论冲突且免费额度耗尽 +# 无法端到端复验,故默认保持现状,另经 `ZEN_API_ROOT` 可切,随时回退。 +DEFAULT_API_ROOT = "/zen/v1" # 注入名 → 客户端常见同义工具名(按优先级)。模型调了我们注入的伪工具时, # 改写成客户端真实声明的同义工具再透传,避免「调用被吞 → 空 stop → 客户端 # 提前结束回合」。键是门禁名,值只收录各 agent 客户端的真实工具名。 @@ -88,6 +96,12 @@ # 预热在应用开始服务 /health 之前,不能无限等首字(实测正常 1–12s,偶发 >100s)。 # 超时按不可用处理:宁可少显示一个慢模型,也不拖垮启动与健康检查。 PROBE_TIMEOUT = 20.0 +# 探活判定「该模型由此主机确定不可用」的状态码:付费/需鉴权(401)、已下线 +# (400/404)、区域限制(403)。命中即从免费候选里剔除。 +# 其余非 2xx(429 匿名免费额度耗尽、5xx、连接超时)按「暂不可判定」保留—— +# 上游限流或抖动不该把整条免费渠道清空(实测 2026-11-03 匿名探测全线 +# `429 FreeUsageLimitError`,旧口径要求 2xx 会让 Zen 显示「无模型」)。 +PROBE_DEAD_STATUSES = frozenset({400, 401, 403, 404}) # 探活用最小请求(真发一次流式对话;只看响应头,不读 body)。 PROBE_PROMPT = "hi" # 探活结果缓存时长(秒):探活是整条模型列表链路里最贵的一步——逐个真发一次 @@ -102,8 +116,7 @@ # 写超时覆盖的是上传请求体阶段,不是首字等待:大上下文请求体可达数 MB, # 经代理链路 10s 会稳定超时;首字等待由 read=None + # stream_first_event_timeout_seconds 负责,两者不要混为一谈。 -STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=60.0, pool=10.0) -SHORT_TIMEOUT = httpx.Timeout(30.0) + # 403 门禁抖动重试(实测 2026-10-09/12 共 7 次):上游**偶发**对同一请求回 # FreeTierError 403,数秒后的同请求即 200(门禁条件每次都满足,抖动来自上游)。 @@ -127,6 +140,21 @@ _SESSION_TIME_MASK = (1 << 48) - 1 +def _normalize_api_root(root: str) -> str: + """校验 API 根:必须 `/` 开头、不含 query/fragment/空白,去掉尾部斜杠。 + + 配置错误在启动时就报错,而不是把畸形 URL 发给上游(那只会得到 404, + 看起来像「上游挂了」)。 + """ + value = root.strip().rstrip("/") + if not value: + return DEFAULT_API_ROOT + if (not value.startswith("/") or "?" in value or "#" in value + or any(ch.isspace() for ch in value)): + raise ValueError(f"ZEN_API_ROOT 必须是 / 开头的路径,收到 {root!r}") + return value + + def _session_time_prefix(now_ms: int) -> str: """毫秒时间戳 → 会话 id 的 12 位十六进制前缀(官方编码的复刻)。""" value = (~(now_ms * _SESSION_TIME_SHIFT + 1)) & _SESSION_TIME_MASK @@ -145,12 +173,22 @@ def new_session_id(*, now_ms: int | None = None) -> str: def gate_headers(*, version: str = MIN_OPENCODE_VERSION, - session_id: str | None = None) -> dict[str, str]: - """免费层门禁头。每次请求新生成 session(门禁只校验格式,不校验归属)。""" + session_id: str | None = None, + user_agent: str = "") -> dict[str, str]: + """免费层门禁头。每次请求新生成 session(门禁只校验格式,不校验归属)。 + + `user_agent` 为空 → `opencode/`(实测通过的形态);非空时按模板 + 渲染,模板里出现 `{version}` 就代入版本号(如 + `opencode/latest/{version}/cli`),没有占位符则原样使用。UA 形态同样是 + 移动靶,交给 `ZEN_USER_AGENT` 配置而不是硬编码。 + """ + agent = user_agent or "opencode/{version}" + if "{version}" in agent: + agent = agent.replace("{version}", version) return { "Content-Type": "application/json", "Accept": "text/event-stream", - "User-Agent": f"opencode/{version}", + "User-Agent": agent, "x-opencode-session": session_id or new_session_id(), # 匿名也通;带公开占位符比完全省略更稳(避免网关把无 Authorization # 的请求路由到别的鉴权分支) @@ -223,7 +261,8 @@ def _find_synonym(tools: list[Any], names: tuple[str, ...]) -> dict[str, Any] | return None -def ensure_gate_tools(body: dict[str, Any]) -> frozenset[str]: +def ensure_gate_tools(body: dict[str, Any], + required: tuple[str, ...] = REQUIRED_TOOLS) -> frozenset[str]: """确保 body.tools 同时含 `bash`/`read`,返回**本次注入**的名字集合。 只补缺、不覆盖:用户自带同名工具时视为真实工具,不注入也不过滤。 @@ -234,7 +273,7 @@ def ensure_gate_tools(body: dict[str, Any]) -> frozenset[str]: tools = list(existing) if isinstance(existing, list) else [] present = _tool_names(tools) injected: set[str] = set() - for name in REQUIRED_TOOLS: + for name in required: if name not in present: proto = _find_synonym(tools, TOOL_SYNONYMS.get(name, ())) tools.append(_empty_tool(name, proto)) @@ -260,6 +299,7 @@ def gate_aliases(tools: list[Any] | None, def prepare_body(payload: dict[str, Any], model: str, + required: tuple[str, ...] = REQUIRED_TOOLS, ) -> tuple[dict[str, Any], frozenset[str]]: """上游请求体:深拷 messages、强制 stream、注入门禁工具。 @@ -271,7 +311,7 @@ def prepare_body(payload: dict[str, Any], model: str, body["messages"] = copy.deepcopy(payload.get("messages")) body["model"] = model body["stream"] = True - injected = ensure_gate_tools(body) + injected = ensure_gate_tools(body, required) return body, injected @@ -324,7 +364,7 @@ class UpstreamHTTPError(base.UpstreamHTTPError): classify_status = staticmethod(zen_events.classify_status) -class ZenClient: +class ZenClient(LazyClients): """上游 HTTP 客户端。host / version 可覆盖,便于测试与灰度。""" def __init__( @@ -332,6 +372,9 @@ def __init__( *, host: str = ZEN_HOST, version: str = MIN_OPENCODE_VERSION, + api_root: str = DEFAULT_API_ROOT, + gate_tools: tuple[str, ...] = REQUIRED_TOOLS, + user_agent: str = "", free_suffix: str = FREE_MODEL_SUFFIX, models_cache_ttl: float = MODELS_CACHE_TTL_SECONDS, stream_client: httpx.AsyncClient | None = None, @@ -340,6 +383,9 @@ def __init__( ) -> None: self.host = host.rstrip("/") self.version = version + self.api_root = _normalize_api_root(api_root) + self.gate_tools = tuple(gate_tools) or REQUIRED_TOOLS + self.user_agent = user_agent self.free_suffix = free_suffix self.models_cache_ttl = models_cache_ttl # 上次判活的 (monotonic 时间, 模型表);TTL 内直接复用,不再重探。 @@ -348,24 +394,18 @@ def __init__( self._short_client = short_client self._proxy = proxy - def _stream(self) -> httpx.AsyncClient: - if self._stream_client is None: - self._stream_client = build_client(timeout=STREAM_TIMEOUT, proxy=self._proxy) - return self._stream_client + def _chat_url(self) -> str: + return f"{self.host}{self.api_root}/chat/completions" - def _short(self) -> httpx.AsyncClient: - if self._short_client is None: - self._short_client = build_client(timeout=SHORT_TIMEOUT, proxy=self._proxy) - return self._short_client + def _models_url(self) -> str: + return f"{self.host}{self.api_root}/models" - async def aclose(self) -> None: - for client in (self._stream_client, self._short_client): - if client is not None: - await client.aclose() + def _gate_headers(self) -> dict[str, str]: + return gate_headers(version=self.version, user_agent=self.user_agent) async def stream_chat(self, payload: dict[str, Any], model: str) -> AsyncIterator[Event]: """POST chat/completions 并逐事件产出中立 Event。非 2xx 抛 UpstreamHTTPError。""" - body, injected = prepare_body(payload, model) + body, injected = prepare_body(payload, model, self.gate_tools) tool_filter = _InjectedToolFilter( injected, gate_aliases(body.get("tools"), injected)) # 有实际(保留或改写)tool_call 时才允许 finish_reason=tool_calls; @@ -373,9 +413,9 @@ async def stream_chat(self, payload: dict[str, Any], model: str) -> AsyncIterato kept_tool_calls = False attempt = 0 while True: - async with self._stream().stream( - "POST", f"{self.host}{EP_CHAT}", json=body, - headers=gate_headers(version=self.version), + async with self._stream.stream( + "POST", self._chat_url(), json=body, + headers=self._gate_headers(), ) as response: if response.status_code == 403 and attempt < GATE_403_RETRIES: # 门禁抖动重试:正文**不读**直接退避——httpx 退出 context 时 @@ -405,11 +445,16 @@ async def stream_chat(self, payload: dict[str, Any], model: str) -> AsyncIterato return async def fetch_models(self) -> list[Model]: - """GET /zen/v1/models,只返回**探活可用**的免费模型。 + """GET /zen/v1/models,只返回**免费候选**(探活确认不可用的才剔除)。 上游清单含全部(含付费)模型且无任何免费标记,直接透传会把 70 多个 选不得的付费模型塞进 Playground(选中即 401)。此处按后缀收窄候选, - 再逐个探活,只有真正匿名可用的才对下游可见: + 再逐个探活,只有**确定不可用**(付费 401 / 下线 400 / 区域限制 403)的 + 才从结果里剔除;`429`(匿名免费额度耗尽)/ 5xx / 连接超时一律保留—— + 上游限流或抖动不该把整条免费渠道清空(实测 2026-11-03 匿名探测全线 + `429 FreeUsageLimitError`,旧口径要求 2xx 会让 Zen 显示「无模型」)。 + 与 CodeBuddy 国际「429 / 5xx / 超时保留」同一取向。 + `fetch_models` 的结果同时也是引擎登记模型归属的来源,过滤后扁平名 请求不会再被路由到 zen 的付费模型上(否则 401 会误伤虚拟凭证)。 @@ -420,8 +465,8 @@ async def fetch_models(self) -> list[Model]: cached_at, cached_models = self._models_cache if time.monotonic() - cached_at < self.models_cache_ttl: return list(cached_models) - response = await self._short().get( - f"{self.host}{EP_MODELS}", headers=gate_headers(version=self.version)) + response = await self._short.get( + self._models_url(), headers=self._gate_headers()) if response.status_code >= 400: raise UpstreamHTTPError(response.status_code, await base.read_body_bounded(response)) @@ -449,50 +494,67 @@ async def fetch_models(self) -> list[Model]: credit_rate=0.0)) if not candidates: raise zen_events.UpstreamProtocolViolation("models api returned no free candidates") - alive = await self._probe_alive([model.id for model in candidates]) - models = [model for model in candidates if model.id in alive] + available = await self._probe_available([model.id for model in candidates]) + models = [model for model in candidates if model.id in available] if not models: - # 全部探活失败:多半是上游整体故障而非真的没有免费模型, - # 抛出让上层用上次成功的缓存兜底,避免 zen 从列表里消失。 + # 候选全被**确定**判死(付费 401 / 下线 400 / 区域 403):多半是上游 + # 真实下架或本机网络受限。抛出让上层用上次成功的缓存兜底。 raise zen_events.UpstreamProtocolViolation("no free model passed liveness probe") self._models_cache = (time.monotonic(), list(models)) return models - async def _probe_alive(self, model_ids: list[str]) -> set[str]: - """并发探活:只留下**匿名请求返回 2xx** 的模型。 + async def _probe_available(self, model_ids: list[str]) -> set[str]: + """并发探活:剔除**确定不可用**的模型,保留可用与暂不可判定的。 - 付费模型恒 401、已下线 400、区域限制 403、上游故障 5xx,一律不算 - 可用;连接/超时按不可用处理。用于模型列表展示,不触达业务统计。 + `_probe_one` 判 `False`(付费 401 / 下线 400 / 区域 403)才剔除; + `True`(2xx)与 `None`(429 / 5xx / 连接超时——暂不可判定)都保留。 + 用于模型列表展示,不触达业务统计。 """ semaphore = asyncio.Semaphore(PROBE_CONCURRENCY) async def one(model_id: str) -> str | None: async with semaphore: - if await self._probe_one(model_id): - return model_id - return None + if await self._probe_one(model_id) is False: + return None + return model_id results = await asyncio.gather(*(one(model_id) for model_id in model_ids)) return {model_id for model_id in results if model_id is not None} - async def _probe_one(self, model_id: str) -> bool: - """单模型探活:真发一次最小流式请求,2xx 即算可用。 + async def _probe_one(self, model_id: str) -> bool | None: + """单模型探活:`True` 可用 / `False` 确定不可用 / `None` 暂不可判定。 只看状态行、**不读 body**:进 context 即拿到响应头,随即关闭连接, - 不会等整段生成跑完(付费 401 / 下线 400 都在响应头就定性)。 - 整体套 `asyncio.timeout`:首字过慢按不可用,不拖垮启动与 /health。 + 不会等整段生成跑完(付费 401 / 下线 400 / 区域 403 都在响应头就定性)。 + 整体套 `asyncio.timeout`:首字过慢按 `None`(暂不可判定,保留模型), + 不拖垮启动与 /health。 """ body, _ = prepare_body( - {"messages": [{"role": "user", "content": PROBE_PROMPT}]}, model_id) + {"messages": [{"role": "user", "content": PROBE_PROMPT}]}, model_id, + self.gate_tools) try: async with asyncio.timeout(PROBE_TIMEOUT): - async with self._short().stream( - "POST", f"{self.host}{EP_CHAT}", json=body, - headers=gate_headers(version=self.version), + async with self._short.stream( + "POST", self._chat_url(), json=body, + headers=self._gate_headers(), ) as response: - return response.status_code < 300 + status = response.status_code + if status < 300: + return True + if status in PROBE_DEAD_STATUSES: + return False + # 429 = 匿名免费额度耗尽。它是**已知的稳态**(同一批免费模型 + # 会持续回 429,每轮 8 条),打 WARNING 只会把真问题挤出视野 + # ——实测 2026-11-08 一天 224 条,占全部 WARNING 的 73%。 + # 降级为 DEBUG:还保留「哪个模型被限流」的排障线索。 + if status == 429: + logger.debug("zen 探活 %s 暂不可判定:HTTP 429", model_id) + return None + logger.warning("zen 探活 %s 暂不可判定:HTTP %s", model_id, status) + return None except (httpx.HTTPError, TimeoutError): - return False + logger.warning("zen 探活 %s 暂不可判定:连接失败/超时", model_id) + return None @dataclass(slots=True) @@ -529,14 +591,9 @@ async def list_models(self, credential_data: dict) -> list[Model]: async def stream_chat(self, credential_data: dict, payload: dict, model: str) -> AsyncIterator[Event]: - if self.pacer is not None: - await self.pacer.wait_turn("zen") - try: + async with paced(self.pacer, "zen"): async for event in self.client.stream_chat(payload, model): yield event - finally: - if self.pacer is not None: - self.pacer.release("zen") async def aclose(self) -> None: await self.client.aclose() diff --git a/src/provider/zen/events.py b/src/provider/zen/events.py index 59552735..e0d2d13c 100644 --- a/src/provider/zen/events.py +++ b/src/provider/zen/events.py @@ -16,7 +16,6 @@ from __future__ import annotations -import json from typing import Any from ...engine.sse import SSEFrame @@ -24,96 +23,22 @@ ErrKind, Event, EventKind, - UpstreamProtocolViolation, + UpstreamProtocolViolation, # noqa: F401 - 测试经本模块访问 Usage, ) -from ..openai_chunk import first_choice, is_blank_tool_call +from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai def parse_frame(frame: SSEFrame) -> Event | None: """单帧 → 单个代表性事件;`[DONE]` 与无内容帧返回 None。""" - payload = _payload(frame) - if payload is None: - return None - return _event_from_payload(payload) - - -def _event_from_payload(payload: dict[str, Any]) -> Event | None: - """已解析 JSON → 代表性事件。 - - 优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。 - """ - choice = first_choice(payload) - delta = choice.get("delta") if choice else None - delta = delta if isinstance(delta, dict) else {} - finish_reason = choice.get("finish_reason") if choice else None - - tool_calls = delta.get("tool_calls") - if isinstance(tool_calls, list): - # 无 name 且 arguments 为空的噪声调用(客户端聚合后显示 - # "Tool not found"):正常分片块无 name 但带实际 arguments,必须保留。 - kept = [tc for tc in tool_calls - if isinstance(tc, dict) and not is_blank_tool_call(tc)] - if kept: - return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept) - - content = delta.get("content") - if isinstance(content, str) and content: - return Event(kind=EventKind.CONTENT, content=content) - - reasoning = delta.get("reasoning_content") - if isinstance(reasoning, str) and reasoning: - return Event(kind=EventKind.REASONING, content=reasoning) - - error = payload.get("error") - if isinstance(error, dict): - return _error_event(error) - - usage = payload.get("usage") - if isinstance(usage, dict): - return Event(kind=EventKind.USAGE, usage=_usage(usage)) - - if isinstance(finish_reason, str) and finish_reason: - return Event(kind=EventKind.FINISH, finish_reason=finish_reason) - return None + payload = payload_of(frame, "Zen") + return None if payload is None else openai_event( + payload, error_event=_error_event, usage=_usage) def parse_all_events(frame: SSEFrame) -> list[Event]: - """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。 - - 必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」 - 这种合并帧会丢掉 finish(客户端永远收不到结束信号)。 - """ - payload = _payload(frame) - if payload is None: - return [] - events: list[Event] = [e for e in (_event_from_payload(payload),) if e is not None] - has_usage = any(e.kind is EventKind.USAGE for e in events) - has_finish = any(e.kind is EventKind.FINISH for e in events) - - usage = payload.get("usage") - if isinstance(usage, dict) and not has_usage: - events.append(Event(kind=EventKind.USAGE, usage=_usage(usage))) - choice = first_choice(payload) - finish_reason = choice.get("finish_reason") if choice else None - if isinstance(finish_reason, str) and finish_reason and not has_finish: - events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason)) - return events - - -def _payload(frame: SSEFrame) -> dict[str, Any] | None: - """帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。""" - if not frame.data: - return None - if frame.data.strip() == "[DONE]": - return None - try: - payload = json.loads(frame.data) - except json.JSONDecodeError as error: - raise UpstreamProtocolViolation("unparsable Zen SSE data") from error - if not isinstance(payload, dict): - raise UpstreamProtocolViolation("Zen SSE data is not an object") - return payload + """一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。""" + return openai_events(frame, "Zen", error_event=_error_event, usage=_usage) def _error_event(error: dict[str, Any]) -> Event: @@ -134,25 +59,7 @@ def _error_event(error: dict[str, Any]) -> Event: def _usage(raw: dict[str, Any]) -> Usage: - def as_int(key: str) -> int | None: - value = raw.get(key) - return value if isinstance(value, int) and not isinstance(value, bool) else None - - # 缓存命中:OpenAI 惯例在 prompt_tokens_details.cached_tokens,顶层兜底 - details = raw.get("prompt_tokens_details") - cached = (details or {}).get("cached_tokens") if isinstance(details, dict) else None - if not isinstance(cached, int) or isinstance(cached, bool): - cached = as_int("cached_tokens") - - credit = raw.get("credit") - return Usage( - input_tokens=as_int("prompt_tokens"), - output_tokens=as_int("completion_tokens"), - reasoning_tokens=as_int("reasoning_tokens"), - cached_tokens=cached, - credit=float(credit) if isinstance(credit, (int, float)) and not isinstance(credit, bool) - else None, - ) + return usage_from_openai(raw, credit=True) def classify_status(status: int, body: bytes = b"") -> ErrKind: diff --git a/src/request_context.py b/src/request_context.py index 6d0543ef..3997408f 100644 --- a/src/request_context.py +++ b/src/request_context.py @@ -20,6 +20,8 @@ import re import uuid +from collections.abc import Iterator +from contextlib import contextmanager from contextvars import ContextVar # 采信的入站 X-Request-ID 字符集:可见 ASCII 里去掉空格与引号类,长度 1-128。 @@ -79,6 +81,37 @@ def get_api_key_id() -> str | None: return _api_key_id.get() +# 当前请求使用的凭证 id(credentials.id)。executor 在每次调用上游前设置: +# 出站代理按凭证分配(「一号一出口」,见 `provider/proxy.py`),provider 层 +# 读它选连接池。**只由 engine 层设置**(凭证来自调度器,不是客户端输入)。 +# 后台任务(探活/签到)也会设置——同一个账号的探活与推理走同一出口才一致。 +_credential_id: ContextVar[str | None] = ContextVar("credential_id", default=None) + + +def set_credential_id(value: str | None) -> None: + """在当前上下文设置凭证 id;None = 复原(回落到渠道级代理)。""" + _credential_id.set(value) + + +def get_credential_id() -> str | None: + """读当前上下文的凭证 id;无请求上下文(或虚拟凭证)返回 None。""" + return _credential_id.get() + + +@contextmanager +def credential_scope(credential_id: str | None) -> Iterator[None]: + """临时把当前凭证设为 `credential_id`,退出时**还原**(不写成 None)。 + + 用于后台任务与管理台单条动作:循环里逐条设置,退出后不能把外层(如批量 + 端点自己在用的默认出口)误清成空。`token` 还原是 contextvar 的标准做法。 + """ + token = _credential_id.set(credential_id) + try: + yield + finally: + _credential_id.reset(token) + + # 入站请求头带来的会话标识(E1):客户端(Codex / Claude Code / opencode 等) # 用 `x-session-id` 这类头声明「这是同一段对话」,比消息前缀兜底可靠得多。 # 与 request_id 同一套纪律:**入站头是客户端可写输入**,按白名单字符集与长度 @@ -86,12 +119,20 @@ def get_api_key_id() -> str | None: # 里的显式键),不会影响模型、路由或鉴权。 SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") -# 接受的头名,按优先级排列。前三个是 opencode / Codex 生态实际在用的写法, -# 后两个是 conversation_id 的头形式(部分客户端把它放头里)。 +# 接受的头名,按优先级排列。前三个是 opencode / Codex 生态实际在用的写法; +# `x-claude-code-session-id` 是 Claude Code 2.1.x 起的会话头(body 只有 +# `metadata.user_id` 的 JSON 串,粘性只能靠它,否则退化到「消息前缀」兜底, +# 上下文被压缩/裁剪后就断粘);`thread-id` / `session-id` 是 **Codex 2026 起 +# 的现行写法**(openai/codex#22193:官方只发连字符形式,下划线别名已被移除 +# ——部分代理拒收 `_`);后两个是 conversation_id 的头形式(部分客户端把它 +# 放头里)。带前缀的 `x-session-id` 仍排第一:语义更具体,且历史客户端一直在用。 SESSION_ID_HEADERS: tuple[bytes, ...] = ( b"x-session-id", b"x-opencode-session", b"x-session-affinity", + b"x-claude-code-session-id", + b"thread-id", + b"session-id", b"conversation-id", b"conversation_id", ) diff --git a/src/runtime_settings.py b/src/runtime_settings.py index 5f1be3c7..ec5eccd7 100644 --- a/src/runtime_settings.py +++ b/src/runtime_settings.py @@ -49,6 +49,9 @@ class HotSetting: maximum: float | None = None task: str | None = None # 所属后台任务 key(见 tasks/status.py);None=网关/调度配置 group: str | None = None # task 为 None 时的分组 key(见 GATEWAY_GROUPS) + # 任务卡片内的分节标题(同一任务内聚合,如运维告警的「规则阈值」): + # 19 项平铺一列太长;None = 不分组。只对 task 非空的项有意义。 + section: str | None = None # 生效下限(读时钳制):存量覆盖值可能低于现行下限(旧版 minimum 更宽时 # 写入),读路径直接钳到 floor 自愈——否则 UI 回显写入值、实际跑钳制值, # 两者静默分叉。floor ≥ minimum 恒成立。 @@ -59,13 +62,17 @@ def env_name(self) -> str: return self.key.upper() -# 无任务归属的配置项(`task is None`)在管理台里的分组:key → 界面上的一级 tab -# 名。顺序即前端展示顺序;分组归属与标签由后端定义,前端不硬编码 key 列表, -# 后端加项/改归属不用动前端。留空的分组不渲染。 +# 无任务归属的配置项(`task is None`)在管理台里的分组:key → 「网关配置」下的 +# 二级 tab 名。顺序即前端展示顺序;分组归属与标签由后端定义,前端不硬编码 key +# 列表,后端加项/改归属不用动前端。留空的分组不渲染。 GATEWAY_GROUPS: tuple[tuple[str, str], ...] = ( - ("routing", "模型路由"), + ("model_routing", "模型与回退"), + ("compress", "上下文压缩"), + ("stream_thinking", "流式与思考"), + ("gateway_control", "网关与计费"), ("affinity", "选号与会话"), - ("channels", "渠道节流"), + ("chat_throttle", "聊天节流"), + ("codearts_throttle", "CodeArts 节流"), ("pacer", "后台任务节流"), ("observability", "可观测性"), ) @@ -75,47 +82,70 @@ def env_name(self) -> str: HOT_SETTINGS: tuple[HotSetting, ...] = ( HotSetting("default_model", str, "默认模型", "请求未指定 model(或为 auto/空)时使用的模型名。", - group="routing"), + group="model_routing"), HotSetting("model_blocklist", str, "模型黑名单", "fnmatch glob,逗号分隔;只影响 /v1/models 与 Playground 列表," "直连指定不受影响。留空表示不过滤。", - group="routing"), + group="model_routing"), HotSetting("context_compress_enabled", bool, "上下文压缩", "按模型目录里的输入上限裁剪过长对话,避免撞上游硬限制" "(CodeBuddy 11115 prompt is too long)。目录里查不到上限的模型" - "不受影响。", group="routing"), + "不受影响。", group="compress"), HotSetting("context_compress_reserve_tokens", int, "上下文压缩:预留输出 token", "压缩预算里为模型回复预留的输出 token 数。", minimum=0, - group="routing"), + group="compress"), HotSetting("context_compress_min_keep_messages", int, "上下文压缩:至少保留消息数", "无论多长都保留的最近消息条数(保证当前这轮对话完整)。", - minimum=0, group="routing"), + minimum=0, group="compress"), HotSetting("context_compress_safety_ratio", float, "上下文压缩:安全系数", "按模型上限的百分比计算压缩预算,给 token 估算误差留余量。", - minimum=0.0, maximum=1.0, group="routing"), + minimum=0.0, maximum=1.0, group="compress"), + HotSetting("context_compress_sticky_enabled", bool, "上下文压缩:粘性裁剪", + "开启后一次裁到预算的 60% 并记住保留窗口的起点,跨轮前缀不变," + "上游前缀缓存才能命中(客户端每轮重发完整历史时,不粘性就只有 " + "system 段可复用,命中率会掉到个位数)。关闭 = 每轮按当轮预算" + "重算,只有前缀本来就单调增长时才划算。", + group="compress"), + HotSetting("tool_output_compress_enabled", bool, "tool 输出压缩", + "开启后只压缩 `role=tool` 且超过阈值(下一项)的纯文本结果:" + "去掉 ANSI 转义、折叠连续重复行、保留命中错误关键字的行。" + "确定性压缩(同输入同输出),不破坏上游前缀缓存;" + "默认关,关闭时载荷逐字节不变。", + group="compress"), + HotSetting("tool_output_compress_max_chars", int, "tool 输出压缩:字符阈值", + "单条 tool 结果超过该字符数才压缩(≤0 视为关闭)。" + "默认 8000(约 2K token 纯 ASCII)。", + minimum=0, group="compress"), HotSetting("model_fallback_groups", str, "跨渠道 fallback 兼容组", "请求模型在主渠道全部不可用时,按兼容组依次回退到组内其他模型。" "格式 `组名=成员1,成员2;组名2=成员3`;成员可带 `@渠道` 固定回退渠道。" "留空 = 不启用。回退会用尽该组(×3 轮换预算)后正常 503。", - group="routing"), + group="model_routing"), HotSetting("model_aliases", str, "自定义模型别名", "给客户端一个稳定的对外模型名(上游改 id 时只改这里)。格式 " "`对外名=模型@渠道;对外名2=模型`;列表与请求解析用同一份。" "与真实模型 id 或自动别名撞车视为配置错误,不静默覆盖。" - "留空 = 不启用。", group="routing"), + "留空 = 不启用。", group="model_routing"), HotSetting("stream_first_event_timeout_seconds", float, "流式首事件超时(秒)", "上游接了连接却长时间不吐第一个数据帧时,在**出帧前**换号重试" "(此时切换安全)。注释帧/心跳不重置计时。0 = 关闭;实测慢上下文" "首字可达 40–71s,内置默认必须保持关闭,用时按部署实际填(推荐 60)。", - minimum=0.0, group="routing"), + minimum=0.0, group="stream_thinking"), HotSetting("stream_idle_timeout_seconds", float, "流式空闲超时(秒)", "**已出帧**后上游长时间不再吐数据时断流并以错误帧收尾——半截输出" "不可回滚,换号会把两个账号的内容拼给客户端。0 = 关闭(推荐 300)。", - minimum=0.0, group="routing"), + minimum=0.0, group="stream_thinking"), HotSetting("short_cooldown_wait_seconds", float, "短冷却等待上限(秒)", "全池都在短冷却、没有任何立即可用候选时,最短剩余冷却不超过该值" - "则等待后续上(而非直接 503)。等待期间不占在途租约。0 = 关闭" - "(快速失败,保持旧行为)。", minimum=0.0, group="affinity"), + "则等待后续上(而非直接 503)。本轮轮换试过的号同样参与,等到的" + "号会解禁重取一次。等待期间不占在途租约。默认 60;0 = 关闭" + "(快速失败)。", minimum=0.0, group="affinity"), + HotSetting("nonstream_keepalive_seconds", float, "非流式保活(秒)", + "`stream=false` 的请求在聚合完成前不发任何字节,CDN / 反代的空闲" + "超时会切连接。设为 N>0 时,N 秒内没完成就转 chunked,先吐空白" + "再补 JSON 正文——空白是合法 JSON 前导(RFC 8259),解析器照常" + "工作。0 = 关闭(本部署 nginx 3600s + frp TCP 无此问题);" + "只在 CDN 后面需要,推荐 15。", minimum=0.0, group="stream_thinking"), HotSetting("quota_expiry_window_seconds", int, "到期积分主窗口(秒)", "把「距到期 ≤ 该秒数」的积分加总作为选号第一排序指标;" "≤0 关闭整套到期排序(次窗口一并失效)。", minimum=0, @@ -154,8 +184,10 @@ def env_name(self) -> str: "「模型→渠道」归属表与落盘快照一起变陈旧。下限 5 分钟。", minimum=5, task="model_catalog", floor=5), HotSetting("model_probe_minutes", int, "模型探活周期(分钟)", - "对仍被负缓存(上游「无此模型」)的 (渠道, 模型) 发一次最小对话的" - "间隔:探活成功立即解除避让,额度/限流类失败只记「不确定」不禁用。" + "对仍被负缓存(上游「无此模型」)与已判死的 (渠道, 模型) 发一次" + "最小对话的间隔:探活成功立即恢复展示并解除避让,额度/限流类" + "失败只记「不确定」不禁用。判死即从模型列表摘掉且无到期时间," + "复探是唯一的恢复入口。" "0 = 关闭(默认):探活会真发上游请求、消耗额度,是否开启按余量" "决定;负缓存本身 6 小时起步,开启后建议 ≥60 分钟。", minimum=0, task="model_probe"), @@ -168,44 +200,55 @@ def env_name(self) -> str: HotSetting("usd_cny_rate", float, "美元兑人民币汇率", "成本估算里把 OpenRouter 的美元刊例价折成人民币的汇率" "(1 USD = 该值 CNY);改后只影响之后写入的请求,历史成本不重算。", - minimum=0.000001, group="routing"), + minimum=0.000001, group="gateway_control"), HotSetting("codebuddy_chat_min_interval", float, "CodeBuddy 聊天最小间隔(秒)", "同渠道同凭证顺序连发的最小请求间隔(0 关闭);按凭证分桶、桶内" "允许并发,故同渠道同模型的并发请求不会被串行化。", - minimum=0.0, group="channels"), + minimum=0.0, group="chat_throttle"), HotSetting("codebuddy_intl_chat_min_interval", float, "CodeBuddy 国际聊天最小间隔(秒)", "国际站(www.codebuddy.ai)专用节流(默认 5s,0 关闭)。与国内站" "各自独立、不共享——共用会让两站请求排进同一条队列互相空等。", - minimum=0.0, group="channels"), + minimum=0.0, group="chat_throttle"), HotSetting("zen_chat_min_interval", float, "Zen 聊天最小间隔(秒)", "zen 匿名免费层专用节流(0 关闭,默认)。与 CodeBuddy/TRAE 各自" "独立、不共享——共享会让 zen 请求排在 CB/TRAE 之后空等最小间隔。", - minimum=0.0, group="channels"), + minimum=0.0, group="chat_throttle"), HotSetting("kilo_chat_min_interval", float, "Kilo 聊天最小间隔(秒)", "Kilo 匿名免费层专用节流(0 关闭,默认)。与 zen / CodeBuddy / TRAE" - "各自独立,不共享。", minimum=0.0, group="channels"), + "各自独立,不共享。", minimum=0.0, group="chat_throttle"), + HotSetting("raccoon_chat_min_interval", float, "小浣熊聊天最小间隔(秒)", + "小浣熊真实账号渠道专用节流(0 关闭,默认)。与其余渠道各自独立," + "不共享。", minimum=0.0, group="chat_throttle"), + HotSetting("lingxi_chat_min_interval", float, "灵犀聊天最小间隔(秒)", + "灵犀真实账号渠道专用节流(0 关闭,默认)。上游按 run 计费," + "run 缓存与单飞已避免重复计费;与其余渠道各自独立,不共享。", + minimum=0.0, group="chat_throttle"), + HotSetting("raccoon_max_concurrency", int, "小浣熊每账号并发上限", + "上游按请求频率限流(实测 5 并发放行、第 6 个 429 " + "user_model_rate_limited)。默认 2 留余量;0 关闭上限。", + minimum=0, group="codearts_throttle"), HotSetting("qoder_chat_min_interval", float, "Qoder 聊天最小间隔(秒)", "Qoder 真实账号渠道专用节流(默认 5s,0 关闭)。与其余渠道各自" - "独立,不共享。", minimum=0.0, group="channels"), + "独立,不共享。", minimum=0.0, group="chat_throttle"), HotSetting("codearts_chat_min_interval", float, "CodeArts 聊天最小间隔(秒)", "CodeArts 真实账号渠道专用节流(默认 5s,0 关闭)。与其余渠道各自" - "独立,不共享。", minimum=0.0, group="channels"), + "独立,不共享。", minimum=0.0, group="codearts_throttle"), HotSetting("officeace_chat_min_interval", float, "OfficeAce 聊天最小间隔(秒)", "OfficeAce 真实账号渠道专用节流(默认 0 关闭)。上游没有 CodeArts " "那种硬并发限,被限流时走伪 200 的模型级短冷却;与其余渠道各自" - "独立,不共享。", minimum=0.0, group="channels"), + "独立,不共享。", minimum=0.0, group="chat_throttle"), HotSetting("codearts_max_concurrency", int, "CodeArts 每账号并发上限", "CodeArts 上游硬限每账号并发会话数 3,超限请求直接 400 " "TM.00001041(实测 77% 失败率主因)。默认 3 对齐上游;0 关闭上限" "(回到「有在途即放行」,会再次击穿)。", minimum=0, - group="channels"), + group="codearts_throttle"), HotSetting("codearts_request_window_seconds", float, "CodeArts 账号节流窗口(秒)", "与「每账号并发上限」组合成滑动窗口:最近这么多秒内最多启动 N 次。" "上游口径是「每账号每约 60s 最多 3 个会话」(会话在流结束后仍滞留" "数十秒),纯在途上限挡不住突发,故默认 60s;0 关闭窗口口径。", - minimum=0.0, group="channels"), + minimum=0.0, group="codearts_throttle"), HotSetting("pacer_min_seconds", float, "后台任务节流下限(秒)", "后台任务相邻上游请求的最小间隔;0 关闭节流。", minimum=0.0, group="pacer"), @@ -227,6 +270,10 @@ def env_name(self) -> str: "在时点**之前**提前一个 0..N 分钟的随机偏移执行,避免多部署" "同一分钟齐打上游。按「日期 + 时点」去重,抖动不会让同一时点" "跑两次。0 = 不抖。", minimum=0, maximum=1440, task="checkin"), + HotSetting("checkin_skip_providers", str, "跳过签到的渠道", + "逗号分隔的渠道名(如 `raccoon`)。上游长期停发每日积分时用它" + "止损:只跳过这些渠道的签到,聊天与其它渠道不受影响。留空 = 全签。", + task="checkin"), HotSetting("log_buffer_lines", int, "日志页保留行数", "「任务与配置」→ 日志页的进程内环形缓冲行数(只收本进程日志," "不读文件)。0 = 关闭该页(端点返回 404)。日志行发出前经脱敏" @@ -236,41 +283,88 @@ def env_name(self) -> str: # 这个任务最近报了什么。webhook 留空 = 只留站内记录、不外推。 HotSetting("alert_enabled", bool, "运维告警开关", "是否启用后台告警评估。关闭后不落站内记录也不推送 webhook。", - task="alert"), + task="alert", section="基础与迟滞"), HotSetting("alert_webhook_url", str, "告警 Webhook 地址", "命中告警时 POST JSON 的地址,多个用逗号分隔;留空 = 只在管理台" - "留站内记录、不外推。", task="alert"), + "留站内记录、不外推。", task="alert", section="基础与迟滞"), + HotSetting("alert_feishu_webhook_url", str, "飞书机器人 Webhook", + "飞书群自定义机器人的入站地址;留空 = 不推飞书。消息按飞书" + "文本卡片格式发送(msg_type=text)。", + task="alert", section="基础与迟滞"), + HotSetting("alert_slack_webhook_url", str, "Slack Incoming Webhook", + "Slack 入站 Webhook 地址;留空 = 不推 Slack。消息体为 {text}。", + task="alert", section="基础与迟滞"), HotSetting("alert_interval_minutes", int, "运维告警周期(分钟)", - "后台评估四类风险的一轮间隔;下限 1 分钟。", minimum=1, - task="alert", floor=1), + "后台评估七类规则的一轮间隔;下限 1 分钟。", minimum=1, + task="alert", section="基础与迟滞", floor=1), HotSetting("alert_silence_minutes", int, "告警静默窗(分钟)", "同一条告警(规则 + 对象)在该窗口内只落库/推送一次,避免持续" - "状态每轮刷屏;0 关闭静默(每轮都报)。", minimum=0, task="alert"), + "状态每轮刷屏;0 关闭静默(每轮都报)。", minimum=0, task="alert", + section="基础与迟滞"), HotSetting("alert_for_ticks", int, "告警进入迟滞(连续命中轮数)", "同一条告警连续命中这么多轮才真正告警(滤掉单轮毛刺);" - "1 = 不迟滞、首轮即报。", minimum=1, task="alert", floor=1), + "1 = 不迟滞、首轮即报。", minimum=1, task="alert", section="基础与迟滞", floor=1), HotSetting("alert_clear_ticks", int, "告警恢复迟滞(连续未命中轮数)", "告警中的条目连续这么多轮不再命中才确认恢复(写 resolved_at " "并外推恢复通知),避免单轮抖动误撤。", minimum=1, - task="alert", floor=1), + task="alert", section="基础与迟滞", floor=1), HotSetting("alert_pool_ready_min", int, "池耗尽阈值(可用数)", "凭证池可用数少于该值时告警(服务活着但用不了);0 关闭该规则。", - minimum=0, task="alert"), + minimum=0, task="alert", section="规则阈值"), HotSetting("alert_task_failures", int, "任务连续失败阈值", "某后台任务连续失败达到该次数时告警(成功一轮清零);0 关闭该规则。", - minimum=0, task="alert"), + minimum=0, task="alert", section="规则阈值"), HotSetting("alert_token_expiry_hours", int, "token 到期预警窗口(小时)", "凭证 token 剩余时间少于该小时数时告警;0 关闭该规则。", - minimum=0, task="alert"), + minimum=0, task="alert", section="规则阈值"), HotSetting("alert_error_rate_threshold", float, "上游错误率阈值", "统计窗内失败占比达到该值时告警;0 关闭该规则。", minimum=0.0, - maximum=1.0, task="alert"), + maximum=1.0, task="alert", section="规则阈值"), HotSetting("alert_error_rate_min_requests", int, "错误率最小样本数", "统计窗内请求数少于该值时不判错误率(样本太小无意义)。", - minimum=1, task="alert"), + minimum=1, task="alert", section="规则阈值"), HotSetting("alert_error_rate_window_minutes", int, "错误率统计窗(分钟)", "计算上游错误率的时间窗;0 表示只看当前瞬间(无样本,不触发)。", - minimum=0, task="alert"), + minimum=0, task="alert", section="规则阈值"), + HotSetting("alert_key_burst_tokens", int, "单 Key 用量暴涨阈值(token)", + "统计窗内某把 API Key 的 token 总量达到该值即告警(Key 泄露/" + "多人共用的信号);0 关闭该规则。", minimum=0, task="alert", + section="单 Key 与时段"), + HotSetting("alert_key_burst_window_minutes", int, "单 Key 用量统计窗(分钟)", + "按 Key 聚合 token 的时间窗;0 关闭该规则。", minimum=0, task="alert", + section="单 Key 与时段"), + HotSetting("alert_off_hours_start", int, "非工作时段起点(时)", + "与「终点」一起定义工作时间 `[start, end)`(本地时区,可跨零点," + "如 9 → 19)。起点与终点相等 = 关闭该规则。", minimum=0, + maximum=23, task="alert", section="单 Key 与时段"), + HotSetting("alert_off_hours_end", int, "非工作时段终点(时)", + "工作时间区间的右端(不含)。与起点相等 = 关闭该规则。", + minimum=0, maximum=23, task="alert", section="单 Key 与时段"), + HotSetting("alert_off_hours_min_requests", int, "非工作时段请求数阈值", + "非工作时段内请求数达到该值才告警(避免个别重试误报);0 关闭。", + minimum=0, task="alert", section="单 Key 与时段"), + HotSetting("alert_budget_cny", float, "月度成本预算(元)", + "按本地自然月累计可定价成本,达到分层阈值时告警;0 关闭该规则。" + "只统计匹配到价表与汇率的明细,未定价模型不计入。", minimum=0.0, + task="alert", section="月度预算"), + HotSetting("alert_budget_levels", str, "预算分层(%)", + "逗号分隔的百分比档位,默认 50,80,95,100;只报当前达到的最高档," + "100% 为 critical。留空 = 不分层(规则不生效)。", task="alert", section="月度预算"), + HotSetting("alert_channel_dead_min_requests", int, "渠道零成功最小样本数", + "统计窗内某渠道请求数达到该值且**一次都没成功**才告警;" + "0 = 关闭该规则。用于抓「渠道整体挂了」——积分耗尽整批 401 这类" + "故障会被错误率阈值稀释。", minimum=0, + task="alert", section="渠道与签到"), + HotSetting("alert_channel_dead_window_minutes", int, "渠道零成功统计窗(分钟)", + "判定「渠道死亡」的回看窗口。渠道故障通常是小时级,比错误率窗长。", + minimum=1, task="alert", section="渠道与签到", floor=1), + HotSetting("alert_checkin_fail_min_streak", int, "签到连续失败轮数阈值", + "同一凭证最近连续这么多轮签到都失败才告警(中间成功一次即清零);" + "0 = 关闭该规则。补「任务跑成功但上游业务返回失败」的盲区。", + minimum=0, task="alert", section="渠道与签到"), + HotSetting("alert_checkin_fail_window_hours", int, "签到失败回看窗(小时)", + "只统计该窗口内的签到记录,避免长期失败凭证在保留期边缘反复进出。", + minimum=1, task="alert", section="渠道与签到", floor=1), # SQLite 备份:改周期时人就在看这个任务最近备份了什么。 HotSetting("backup_interval_minutes", int, "备份周期(分钟)", "后台把主库一致性快照(VACUUM INTO)写进 DATA_DIR/backups/ 的" @@ -287,6 +381,28 @@ def env_name(self) -> str: "并发抓取全部状态源的一轮间隔;官方状态页本身是分钟级更新,抓得" "更密只是白打别人的接口。下限 1 分钟。", minimum=1, task="upstream_status", floor=1), + HotSetting("prefix_debug_enabled", bool, "前缀缓存诊断", + "每次请求往日志写一行载荷指纹:消息数、system/tools 摘要、与同凭证" + "同模型上一条请求的最长公共前缀消息序号、分叉处消息摘要(全部是" + "sha256 前 16 位,不含正文)。用来定位命中率异常时前缀在哪条消息" + "分叉、压缩是否每轮都在裁剪。默认关。", + group="observability"), + HotSetting("gateway_paused", bool, "网关暂停(kill switch)", + "开启后所有 API Key 鉴权的 `/v1` 端点(chat / messages / responses /" + " models / usage / balance)立即返 503(`type=service_paused`)," + "管理台与登录照常可用——异常消耗或上游事故时一键止血。暂停原因" + "写进日志与审计,不回显给调用方。默认关。", + group="gateway_control"), + HotSetting("gateway_pause_reason", str, "暂停原因", + "配合「网关暂停」使用:写清为什么停(事故编号 / 上游状态页链接)," + "只进服务端日志与审计。留空按「未填原因」记录。", + group="gateway_control"), + HotSetting("dump_request_bodies", bool, "请求体诊断落盘", + "把每次 `/v1/chat/completions` 的**原始请求体**(含完整对话内容)" + "写到 `DATA_DIR/dumps/`,目录 0700、文件 0600、保留 200 个且不超过 " + "24 小时。只用于排查客户端差异,**排查完立刻关**:这是本服务唯一" + "把用户对话落盘的开关。改动会进审计。默认关。", + group="observability"), HotSetting("metrics_enabled", bool, "Prometheus 指标出口", "是否暴露 `GET /metrics`(Prometheus text 格式:请求/token/凭证池/" "失败计数)。**不鉴权**,与 /healthz 同口径;指标已禁用高基数标签" @@ -304,13 +420,13 @@ def env_name(self) -> str: "开启后出站响应不再包含模型的思考内容(chat 的 reasoning_content / " "Anthropic 的 thinking 块 / Responses 的 reasoning item 一并过滤)。" "**只过滤响应、不改写请求体**:上游仍照常思考,只是不交给客户端。", - group="routing"), + group="stream_thinking"), HotSetting("reasoning_effort_map", str, "档位映射(出站)", "把客户端显式给的 `reasoning_effort` 改写成渠道期望的档位。格式 " "`渠道=from:to,from:to;*=from:to`(渠道取 KNOWN_PROVIDERS,`*` 兜底);" "目标写 `none` 表示删除该键、走渠道默认。留空 = 原样透传。只改写" "客户端显式值,渠道自身补的默认档不受影响。", - group="routing"), + group="stream_thinking"), HotSetting("anthropic_thinking_efforts", str, "Anthropic thinking→档位", "把 Anthropic 入站的 `thinking.budget_tokens` 翻成上游 " "`reasoning_effort`。格式 `预算:档位,预算:档位,disabled:档位`:" @@ -318,7 +434,7 @@ def env_name(self) -> str: "budget_tokens 中最大的那一档」,低于最小阈值则不映射。留空 = " "完全不映射(`thinking` 照旧忽略)。Anthropic 未给档位对应表," "阈值由使用者自行决定。", - group="routing"), + group="stream_thinking"), ) HOT_BY_KEY: dict[str, HotSetting] = {item.key: item for item in HOT_SETTINGS} @@ -375,7 +491,8 @@ def _coerce(spec: HotSetting, raw: Any) -> Any: raise InvalidSetting(f"{spec.key} 不能小于 {spec.minimum}") if spec.maximum is not None and value > spec.maximum: raise InvalidSetting(f"{spec.key} 不能大于 {spec.maximum}") - if spec.key == "alert_webhook_url": + if spec.key in ("alert_webhook_url", "alert_feishu_webhook_url", + "alert_slack_webhook_url"): _validate_webhook_urls(value) return value @@ -418,7 +535,20 @@ def _is_blocked_webhook_host(host: str) -> bool: return False # 域名:写入期解析不了,交给投递前复核 return (address.is_private or address.is_loopback or address.is_link_local or address.is_reserved - or address.is_multicast or address.is_unspecified) + or address.is_multicast or address.is_unspecified + # `is_private` 不覆盖 CGNAT 共享地址段 100.64.0.0/10(RFC 6598): + # `ipaddress` 只对 10/8、172.16/12、192.168/16 等回 True。该段在 + # 部分云厂商内是可达的内网地址(实测 100.100.100.200 能通), + # 不补这条就是 SSRF 绕过的现成口子。 + or address in _CGNAT_SHARED + # 非 global 的地址一律拦:覆盖 198.18.0.0/15(基准测试段)、 + # 203.0.113.0/24(文档段)等「保留但 is_private=False」的段。 + or not address.is_global) + + +#: RFC 6598 运营商级 NAT 共享地址段。单独列出是因为 `ipaddress` 的 +#: `is_private` / `is_global` 对它的判定随 Python 版本有过变化,写死更稳。 +_CGNAT_SHARED = ipaddress.ip_network("100.64.0.0/10") def blocked_webhook_host(url: str) -> bool: @@ -614,8 +744,10 @@ def snapshot(self) -> list[dict[str, Any]]: "overridden": self.is_overridden(spec.key), # 所属后台任务(管理台据此把配置归到任务卡片下);None = 网关/调度项 "task": spec.task, - # task 为 None 时的分组 key(见 GATEWAY_GROUPS);前端据此分一级 tab + # task 为 None 时的分组 key(见 GATEWAY_GROUPS);前端据此分组展示 "group": spec.group, + # 任务卡片内的分节标题;None = 不分组 + "section": spec.section, } for spec in HOT_SETTINGS ] @@ -650,6 +782,10 @@ def stream_idle_timeout_seconds(self) -> float: def short_cooldown_wait_seconds(self) -> float: return float(self.get("short_cooldown_wait_seconds")) + @property + def nonstream_keepalive_seconds(self) -> float: + return float(self.get("nonstream_keepalive_seconds")) + @property def blocklist_patterns(self) -> tuple[str, ...]: """与 `Settings.blocklist_patterns` 同口径,但基于生效值重算。 @@ -671,6 +807,22 @@ def quota_expiry_secondary_window_seconds(self) -> int: def context_compress_enabled(self) -> bool: return bool(self.get("context_compress_enabled")) + @property + def prefix_debug_enabled(self) -> bool: + return bool(self.get("prefix_debug_enabled")) + + @property + def dump_request_bodies(self) -> bool: + return bool(self.get("dump_request_bodies")) + + @property + def gateway_paused(self) -> bool: + return bool(self.get("gateway_paused")) + + @property + def gateway_pause_reason(self) -> str: + return str(self.get("gateway_pause_reason") or "") + @property def context_compress_reserve_tokens(self) -> int: return int(self.get("context_compress_reserve_tokens")) @@ -683,6 +835,18 @@ def context_compress_min_keep_messages(self) -> int: def context_compress_safety_ratio(self) -> float: return float(self.get("context_compress_safety_ratio")) + @property + def context_compress_sticky_enabled(self) -> bool: + return bool(self.get("context_compress_sticky_enabled")) + + @property + def tool_output_compress_enabled(self) -> bool: + return bool(self.get("tool_output_compress_enabled")) + + @property + def tool_output_compress_max_chars(self) -> int: + return int(self.get("tool_output_compress_max_chars")) + @property def conversation_sticky_seconds(self) -> int: return int(self.get("conversation_sticky_seconds")) @@ -739,6 +903,18 @@ def zen_chat_min_interval(self) -> float: def kilo_chat_min_interval(self) -> float: return float(self.get("kilo_chat_min_interval")) + @property + def raccoon_chat_min_interval(self) -> float: + return float(self.get("raccoon_chat_min_interval")) + + @property + def lingxi_chat_min_interval(self) -> float: + return float(self.get("lingxi_chat_min_interval")) + + @property + def raccoon_max_concurrency(self) -> int: + return int(self.get("raccoon_max_concurrency")) + @property def qoder_chat_min_interval(self) -> float: return float(self.get("qoder_chat_min_interval")) @@ -783,6 +959,10 @@ def checkin_times(self) -> str: def checkin_jitter_minutes(self) -> int: return int(self.get("checkin_jitter_minutes")) + @property + def checkin_skip_providers(self) -> str: + return str(self.get("checkin_skip_providers")) + @property def log_buffer_lines(self) -> int: return int(self.get("log_buffer_lines")) @@ -797,6 +977,14 @@ def alert_enabled(self) -> bool: def alert_webhook_url(self) -> str: return str(self.get("alert_webhook_url")) + @property + def alert_feishu_webhook_url(self) -> str: + return str(self.get("alert_feishu_webhook_url")) + + @property + def alert_slack_webhook_url(self) -> str: + return str(self.get("alert_slack_webhook_url")) + @property def alert_interval_minutes(self) -> int: return int(self.get("alert_interval_minutes")) @@ -837,6 +1025,50 @@ def alert_error_rate_min_requests(self) -> int: def alert_error_rate_window_minutes(self) -> int: return int(self.get("alert_error_rate_window_minutes")) + @property + def alert_key_burst_tokens(self) -> int: + return int(self.get("alert_key_burst_tokens")) + + @property + def alert_key_burst_window_minutes(self) -> int: + return int(self.get("alert_key_burst_window_minutes")) + + @property + def alert_off_hours_start(self) -> int: + return int(self.get("alert_off_hours_start")) + + @property + def alert_off_hours_end(self) -> int: + return int(self.get("alert_off_hours_end")) + + @property + def alert_off_hours_min_requests(self) -> int: + return int(self.get("alert_off_hours_min_requests")) + + @property + def alert_budget_cny(self) -> float: + return float(self.get("alert_budget_cny")) + + @property + def alert_budget_levels(self) -> str: + return str(self.get("alert_budget_levels")) + + @property + def alert_channel_dead_min_requests(self) -> int: + return int(self.get("alert_channel_dead_min_requests")) + + @property + def alert_channel_dead_window_minutes(self) -> int: + return int(self.get("alert_channel_dead_window_minutes")) + + @property + def alert_checkin_fail_min_streak(self) -> int: + return int(self.get("alert_checkin_fail_min_streak")) + + @property + def alert_checkin_fail_window_hours(self) -> int: + return int(self.get("alert_checkin_fail_window_hours")) + # ---------------------------------------------------------- SQLite 备份 @property diff --git a/src/stats/collector.py b/src/stats/collector.py index e2160deb..6ebf6315 100644 --- a/src/stats/collector.py +++ b/src/stats/collector.py @@ -26,18 +26,30 @@ # 实际会写入的取值(与 executor / api 层的 error_type 一一对应): # client_disconnect — 客户端中途断开(executor.stream) # credential_unavailable — session 失效(_error_type_for(DEAD)) + # account_restricted — 账号被上游风控限制(_error_type_for(RESTRICTED)) # invalid_request — 请求无效(_record_invalid) + # model_not_found — 上游明确说「没有这个模型」(_error_type_for(BLOCKED)) # no_healthy_credential — 无可用凭证(executor 耗尽轮换) # model_unavailable — 耗尽轮换但候选全部只因**该模型**的模型级 # 冷却不可用(_exhaust_error_type;换个模型即可用) - # rate_limit — 权益耗尽(_error_type_for(PLAN)) + # rate_limit — 权益/额度耗尽(_error_type_for(PLAN/CREDIT/MODEL/ + # CONCURRENCY/QUEUE))或上游限流(SOFT) + # context_overflow — 上下文超限(11115 prompt too long) + # model_capability — 模型不支持该请求形态(CodeArts 001001020 not multimodal) + # upstream_timeout — 链路层瞬态(连接超时 / 读超时 / TLS 中断;只看异常类型) + # network_error — 本机 DNS/连接失败(ConnectError/gaierror,连上游之前) # upstream_error / upstream_protocol — 其它上游失败 # upstream_first_event_timeout — 出帧前上游不吐首个数据事件(A1) # upstream_idle_timeout — 已出帧后上游停止吐数据(A1) + # truncated_stream — 流正常关闭但没走到终态(不冷却凭证,只留痕) "client_disconnect", "credential_unavailable", - "invalid_request", "no_healthy_credential", "model_unavailable", - "rate_limit", "upstream_error", "upstream_protocol", + "account_restricted", + "invalid_request", "model_not_found", "no_healthy_credential", + "model_unavailable", "context_overflow", "model_capability", + "rate_limit", "upstream_error", "upstream_protocol", "upstream_timeout", + "network_error", "upstream_first_event_timeout", "upstream_idle_timeout", + "truncated_stream", }) MAX_MODEL_LENGTH = 64 @@ -84,7 +96,8 @@ class StatsCollector: def __init__(self, db, *, prices: Callable[[], Mapping[str, tuple[float, float, float]]] | None = None, usd_cny_rate: Callable[[], float] | None = None, - on_record: Callable[[dict[str, Any]], None] | None = None) -> None: + on_record: Callable[[dict[str, Any]], None] | None = None, + on_write: Callable[[], None] | None = None) -> None: """`prices` / `usd_cny_rate` 是零参取值器(热更:每写一条现读当前价表 与汇率)。两者都缺省为 None = 不估算成本(既有测试与老装配保持原行为, cost 两列恒为 NULL)。 @@ -93,11 +106,17 @@ def __init__(self, db, *, 供 `GET /api/stats/events/stream` 的 SSE 实时流)。回调拿到的是**已脱敏** 的行字典(与 `/api/stats/events` 同一字段集,另带 rowid),且异常被吞 ——统计写入绝不能因为一个订阅者出问题而失败。 + + `on_write` 是「任何一行落库后」的零参通知(装配时接 + `PaidModelRepository.invalidate`):实测扣费校准的快照 TTL 是 600s, + 不失效的话「刚观测到上游真扣费」要等 10 分钟才影响选号,而这正是 + 目录 x0 数据烧额度的窗口。异常同样吞掉。 """ self._db = db self._prices = prices self._usd_cny_rate = usd_cny_rate self._on_record = on_record + self._on_write = on_write # 进程内「失败请求 × 原因」计数(供 /metrics 的 errors_total)。 # 为什么不落库、不读明细表:① usage_hourly 没有 error_type 维度; # ② usage_events 只留 90 天,用它算 `_total` 会让计数器随裁剪**下降** @@ -203,8 +222,21 @@ def record( # 事务内拿 rowid,提交后再广播:订阅者拿到的是「已落库」的行, # 与随后刷新 `/api/stats/events` 看到的是同一条(含 rowid 游标)。 rowid = cursor.lastrowid + self._invalidate_paid_snapshot() self._notify(rowid, event) + def _invalidate_paid_snapshot(self) -> None: + """通知「实测扣费」快照作废(未装配 / 出问题都不影响写入)。 + + 放在事务提交之后:快照查询要读到这一行才有意义。 + """ + if self._on_write is None: + return + try: + self._on_write() + except Exception as error: # noqa: BLE001 - 快照失效失败不能影响统计写入 + logger.warning("实测扣费快照失效失败: %s", error) + def _notify(self, rowid: int | None, event: UsageEvent) -> None: """把刚写入的明细推给实时流订阅者(未装配 / 出问题都不影响写入)。 @@ -310,6 +342,67 @@ def window_error_rate(self, *, since: int) -> tuple[int, int]: "FROM usage_events WHERE ts >= ?", (since,)).fetchone() return int(row["requests"] or 0), int(row["failed"] or 0) + def window_dead_channels(self, *, since: int, + min_requests: int) -> list[dict[str, Any]]: + """近窗口内「有流量但一次都没成功」的渠道,供渠道死亡告警。 + + 判据是**零成功**而不是错误率:officeace 2026-11-08 积分耗尽后回的是 + 流内 401,请求记录 `ok=0`;同类故障也可能整批走 fallback 而完全不落在 + 本渠道的 `ok=1` 上。按错误率算会被 `failed/requests` 的高阈值稀释, + 而「这个渠道在当前窗口内一次都没成功」是一个不会误判的信号。 + + `min_requests` 过滤掉样本过少的渠道(一次失败就报警等于噪声)。返回 + `[{provider, requests, last_ok}]`,`last_ok` 供文案说明「多久没成功」。 + """ + rows = self._db.connect().execute( + "SELECT provider, COUNT(*) AS requests, " + "COALESCE(SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END), 0) AS ok_count, " + "MAX(CASE WHEN ok = 1 THEN ts END) AS last_ok " + "FROM usage_events WHERE ts >= ? GROUP BY provider", + (since,)).fetchall() + return [ + {"provider": row["provider"], "requests": int(row["requests"] or 0), + "last_ok": int(row["last_ok"]) if row["last_ok"] is not None else None} + for row in rows + if int(row["requests"] or 0) >= max(1, min_requests) + and int(row["ok_count"] or 0) == 0 + ] + + def window_key_usage(self, *, since: int, + limit: int = 50) -> list[dict[str, Any]]: + """近窗口内按 API Key 聚合的用量(token 降序),供「单 Key 暴涨」告警。 + + 只统计带 `api_key_id` 的明细:Cookie 会话(Playground)没有 Key, + 按 Key 告警对它们无意义。`tokens` 是输入+输出之和,缺字段按 0 计 + (上游未上报时不给假数据,0 就是 0)。`limit` 只捞最重的几个 Key—— + 告警不需要全量列表,全量在按 Key 统计页。 + """ + rows = self._db.connect().execute( + "SELECT api_key_id, username, COUNT(*) AS requests, " + "COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0)" + " AS tokens " + "FROM usage_events WHERE ts >= ? AND api_key_id IS NOT NULL " + "GROUP BY api_key_id, username ORDER BY tokens DESC LIMIT ?", + (since, max(1, limit))).fetchall() + return [{"api_key_id": row["api_key_id"], "username": row["username"], + "requests": int(row["requests"] or 0), + "tokens": int(row["tokens"] or 0)} for row in rows] + + def window_cost(self, *, since: int) -> tuple[float, int, int]: + """近窗口成本(人民币):`(已花费, 可定价小时数, 总小时数)`。 + + 读小时汇总(永久保留,与统计页同口径)。后两项给告警文案用:成本只 + 覆盖「匹配到 OpenRouter 刊例价 + 当时汇率」的明细,未定价模型是 0 贡献, + 覆盖率低时预算告警会偏乐观——detail 里写明覆盖情况,不假装准确。 + """ + row = self._db.connect().execute( + "SELECT COALESCE(SUM(cost_cny_sum), 0) AS spent, " + "COALESCE(SUM(CASE WHEN cost_known > 0 THEN 1 ELSE 0 END), 0) AS known, " + "COUNT(*) AS hours " + "FROM usage_hourly WHERE hour_utc >= ?", (since,)).fetchone() + return (float(row["spent"] or 0), int(row["known"] or 0), + int(row["hours"] or 0)) + def error_totals(self) -> list[tuple[str, str, int]]: """进程内「渠道 × 失败原因」累计计数(稳定排序)。 diff --git a/src/stats/query.py b/src/stats/query.py index af52d221..c2097985 100644 --- a/src/stats/query.py +++ b/src/stats/query.py @@ -7,21 +7,47 @@ from ..sorting import parse_sort_order, sql_order -METRIC_COLUMNS: dict[str, str] = { - # metric 参数 → usage_hourly 上的取值表达式 - "requests": "requests", - "tokens": "input_tokens + output_tokens", - "latency": "latency_sum", # 均值由调用方除以 ok_count - "ttfb": "ttfb_sum", # 同上 +# 图表指标 → 取值语义。小时汇总只存 sum / count,因此**派生比率必须在聚合后 +# 相除**:先按小时求比率再平均是另一种口径(会被低量小时等权带偏),不能混用。 +METRIC_KINDS: dict[str, str] = { + "requests": "sum_requests", + "tokens": "sum_tokens", # 成本默认口径是人民币(与本模块展示一致);仅含可定价明细的小时才有值。 - "cost": "cost_cny_sum", + "cost": "sum_cost", + "latency": "mean_latency", # 均值由 ok_count 归一 + "ttfb": "mean_ttfb", # 同上 + # 派生指标(P1):成功率 / 缓存命中率 / 单请求成本。 + "success_rate": "ratio_ok", + "cache_rate": "ratio_cache", + "cost_per_request": "per_request_cost", } -# 分组统计下发的分位档:P50 / P90 / P95 / P99(latency 与 ttfb 各一组列)。 -# **不加进 METRIC_COLUMNS**:图表指标读小时汇总,只存 sum/count,数学上无法 -# 还原分布;分位只能从明细表算(见 `_percentile_join`)。 +# 三个图表查询(timeline / model_timeline)共用的聚合列。 +# +# 为什么一次取全:派生指标要的是**多个 sum 相除**,只选 `SUM(expr)` 一个值就 +# 得为每个 metric 拼不同的 SQL,三处(timeline、model_timeline、将来新增的) +# 迟早会分叉。这里固定取齐 10 列,取值语义留给 `_metric_value` 决定。 +_METRIC_AGGREGATES = """ + SUM(requests) AS requests, + SUM(ok_count) AS ok_count, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(cached_tokens) AS cached_tokens, + SUM(cached_known) AS cached_known, + SUM(cost_cny_sum) AS cost_cny_sum, + SUM(cost_known) AS cost_known, + SUM(latency_sum) AS latency_sum, + SUM(ttfb_sum) AS ttfb_sum""" + +# 分位档:图表时序用的分位(延迟图要「中位数线 + P95 带」)。 +# **不加进 METRIC_KINDS**:分位读明细表(小时汇总只存 sum/count,数学上无法 +# 还原分布),见 `percentile_timeline`。 PERCENTILES: tuple[int, ...] = (50, 90, 95, 99) +# 稠密时间轴上限:`timeline` 按小时补桶,超过这个跨度就不再补(「全部」范围 +# 可能横跨几个月,几万个空点只会拖垮图表)。约 92 天,覆盖最长的 90 天窗口。 +MAX_DENSE_HOURS = 92 * 24 + # 明细聚合子查询(分位 + 输出速度)在主查询里的 JOIN 别名:`_percentile_join` # 与两个分组路径共用,别名固定。 _DETAIL_AGG_ALIAS = "agg" @@ -206,7 +232,8 @@ def row_speed(*, ok: bool | int, output_tokens: int | None, reasoning_tokens: in # 分组键白名单:`_group_hourly` / `_group_events` 把 key 直接拼进 SQL(列名无法 # 参数绑定),调用点全是本模块内的字面量。留一份显式白名单并在入口断言,未来 # 若有人把外部输入当 key 传进来,会当场炸而不是拼出注入语句。 -_GROUP_KEYS = frozenset({"provider", "model", "username", "credential_id"}) +_GROUP_KEYS = frozenset({"provider", "model", "username", "credential_id", + "api_key_id"}) # CSV 导出单次上限:明细只留 90 天,但全量导出(几万行)会一次性拉进内存并 # 撑爆响应。超上限时明确告知「被截断、请收窄条件」,而不是静默给半份文件。 @@ -406,23 +433,46 @@ class StatsQuery: def __init__(self, db) -> None: self._db = db - def _metric_sql(self, metric: str) -> tuple[str, bool]: - """返回 (取值表达式, 是否均值语义)。非法 metric 回退 requests。""" - expr = METRIC_COLUMNS.get(metric, "requests") - return expr, metric in ("latency", "ttfb") + def _metric_value(self, metric: str, row: Any) -> Any: + """把一行聚合结果按 metric 归一成图表值;无有效样本给 `None`。 - def _metric_value(self, metric: str, expr_value, ok_count, cost_known: int = 0) -> Any: - """把 SQL 原始值按 metric 归一:均值类除以 ok_count,无成功则 0。 + 入参是**整行**(`_METRIC_AGGREGATES` 的 10 列),不再只给单个 + `SUM(expr)`:成功率、缓存命中率、单请求成本都是两个 sum 相除,先聚合 + 后相除才是池级口径(先逐小时相除再平均会被低量小时等权带偏)。 - 成本是**部分可定价**的:某小时/模型若一条明细都没匹配到价表 - (`cost_known=0`),聚合值只是 0,直接展示会被读成「免费」;这里回 None - 让前端显示 —。只要有一条可定价,就按可定价部分求和(宁可少算不虚报)。 + 「无样本」一律给 `None` 而不是 0——前端据此断线(不画成 0 值), + 否则一个没上报缓存的渠道会显示成「命中率 0%」。 + + - `requests` / `tokens`:sum,恒有值 + - `latency` / `ttfb`:成功请求均值,无成功 → None(原实现给 0,会画成 + 「0 ms」的假低谷,与「该小时没有成功请求」是两件事) + - `cost`:仅含可定价明细的小时才有值(部分可定价时按已定价部分求和) + - `success_rate`:ok_count / requests,requests 为 0 → None + - `cache_rate`:cached / input,无上报(cached_known=0)或输入为 0 → None + - `cost_per_request`:cost / requests,无定价或请求为 0 → None """ - if metric in ("latency", "ttfb"): - return round(expr_value / ok_count) if ok_count else 0 - if metric == "cost" and not cost_known: - return None - return expr_value + kind = METRIC_KINDS.get(metric, "sum_requests") + if kind == "sum_requests": + return row["requests"] + if kind == "sum_tokens": + return row["input_tokens"] + row["output_tokens"] + if kind == "sum_cost": + return row["cost_cny_sum"] if row["cost_known"] else None + if kind == "mean_latency": + return round(row["latency_sum"] / row["ok_count"]) if row["ok_count"] else None + if kind == "mean_ttfb": + return round(row["ttfb_sum"] / row["ok_count"]) if row["ok_count"] else None + if kind == "ratio_ok": + return round(row["ok_count"] / row["requests"], 4) if row["requests"] else None + if kind == "ratio_cache": + if not row["cached_known"] or not row["input_tokens"]: + return None + return round(row["cached_tokens"] / row["input_tokens"], 4) + if kind == "per_request_cost": + if not row["cost_known"] or not row["requests"]: + return None + return round(row["cost_cny_sum"] / row["requests"], 6) + raise ValueError(f"未知指标语义: {kind!r}") # pragma: no cover - 词表封闭 @staticmethod def _where(*, username: str | None = None, provider: str | None = None, @@ -589,10 +639,14 @@ def metrics_provider_tokens(self) -> list[tuple[str, int, int, int, int]]: def model_failures(self, *, since: int, limit: int = 200) -> list[dict[str, Any]]: """按 (provider, model) 聚合窗口内的模型级失败(可用性视图的数据源)。 - 只认两类错误:`invalid_request`(当前 executor 把上游「无此模型」的 - `ErrKind.BLOCKED` 也归到这一类)与 `upstream_blocked`(协议预留值, - 未来 provider 直接上报时自动纳入)。窗口由调用方给绝对时刻 `since`, - SQL 走 `idx_usage_ts`;`limit` 由调用方收敛,多取一条供判断截断。 + 只认几类错误:`model_not_found`(executor 对「上游明确说没有这个模型」 + `ErrKind.BLOCKED` 的归类,2026-10-26 从 `invalid_request` 里独立出来)、 + `model_capability`(「这个 (渠道, 模型) 服务不了这次请求形态」, + `ErrKind.CAPABILITY`,2026-11-03 从 INVALID 独立)、`invalid_request` + (请求无效:`ErrKind.INVALID` / `ErrKind.REQUEST`,上游 400 拒绝也落 + 这里)与 `upstream_blocked`(协议预留值,未来 provider 直接上报时自动 + 纳入)。窗口由调用方给绝对时刻 `since`,SQL 走 `idx_usage_ts`;`limit` + 由调用方收敛,多取一条供判断截断。 `last_failed_at` 取该组内最大 `ts`;排序按失败次数降序,同次数按 (provider, model) 升序,保证分页 / 快照稳定。 @@ -601,7 +655,9 @@ def model_failures(self, *, since: int, limit: int = 200) -> list[dict[str, Any] """ SELECT provider, model, COUNT(*) AS failures, MAX(ts) AS last_failed_at FROM usage_events - WHERE ts >= ? AND error_type IN ('upstream_blocked', 'invalid_request') + WHERE ts >= ? AND error_type IN + ('upstream_blocked', 'model_not_found', 'model_capability', + 'invalid_request') GROUP BY provider, model ORDER BY failures DESC, provider ASC, model ASC LIMIT ? @@ -779,6 +835,39 @@ def by_user(self, *, username: str | None = None, return self._group_hourly("username", username=username, since=since, until=until, sort=sort, order=order) + def billing(self, *, start: int, end: int) -> dict[str, Any]: + """月度账单:按人 + 按 Key 两套聚合 + 总计(时间窗 `[start, end)`)。 + + - 按人读小时汇总(永久保留,与统计页同源); + - 按 Key 只能读明细(小时汇总主键没有 api_key_id,明细保留 90 天—— + 跨月账单超过 90 天前会变空,这是保留策略的直接后果,不是查询 bug); + 行里补 `key_name`(api_keys.name,空串保持空串)与本 Key 的归属用户, + 便于对账时不需要再查出 Key 是谁的。 + """ + users = self._group_hourly("username", since=start, until=end) + keys = self._group_events("api_key_id", since=start, until=end) + ownership = { + row["id"]: (row["username"], row["name"]) + for row in self._db.connect().execute( + "SELECT id, username, name FROM api_keys").fetchall() + } + for row in keys: + owner, name = ownership.get(row["api_key_id"], (None, None)) + row["username"] = owner + row["key_name"] = name + totals = { + "requests": sum(row["requests"] for row in users), + "input_tokens": sum(row["input_tokens"] for row in users), + "output_tokens": sum(row["output_tokens"] for row in users), + "cost_cny": round(sum(row.get("cost_cny") or 0 for row in users), 6), + "cost_usd": round(sum(row.get("cost_usd") or 0 for row in users), 6), + } + ok_count = sum(row["ok_count"] for row in users) + totals["success_rate"] = ( + round(ok_count / totals["requests"], 4) if totals["requests"] else None) + return {"users": users, "keys": keys, "totals": totals, + "start": start, "end": end} + def by_credential(self, *, username: str | None = None, since: int | None = None, until: int | None = None, sort: str | None = None, @@ -798,39 +887,89 @@ def timeline(self, *, username: str | None = None, metric: str = "requests") -> list[dict[str, Any]]: """按小时的指标时间序列(usage_hourly 聚合,跨 model 汇总)。 - 每个点含当日出现过的各渠道指标值(按 metric 变化:请求数 / 总 token / - 平均耗时 ms / 平均首字延迟 ms),供前端绘制曲线。渠道集合动态取自 - 数据(新增渠道无需改 SQL),返回结构形如 {hour, codebuddy, trae, qoder}; - 某渠道在该小时没有数据时补 0,保证同一批点的键集合一致,前端画线不 - 会因缺键断线。 + 每个点含该小时各渠道的指标值(按 metric 变化,语义见 `METRIC_KINDS`), + 供前端绘制曲线。渠道集合动态取自数据(新增渠道无需改 SQL),返回结构 + 形如 `{hour, codebuddy, trae, qoder}`。 + + **时间轴是稠密的**:`usage_hourly` 只在有请求时才有行,稀疏下发会让前端 + 把「空档 5 小时」和「相邻 1 小时」画成一样宽(分类轴压缩时间)。这里按 + 小时补齐缺失的桶,让每个点等距: + + - 给了 `since` / `until` 就按窗口补齐(含两端); + - 都没给(「全部」)就补在首个到末个观测小时之间; + - 跨度超过 `MAX_DENSE_HOURS` 时不补(几万个空点只会拖垮图表), + 前端仍按时间戳定位,只是空档不插点。 + + 缺失桶的取值按指标语义区分(`_empty_value`):请求数 / token 补 0(没流量 + 就是 0),均值与比率补 `None`(「没有样本」不是 0,前端据此断线)。 + + 每个点还带一个 `__total` 键 = **全渠道合计**(单独按小时聚合一次)。为什么 + 不在前端把各渠道值相加:均值(耗时 / 首字)与比率(成功率 / 命中率)相加 + 没有意义,必须用同一小时的 sum/count 重新相除。前端「总量」视图读这个键。 """ - expr, _as_mean = self._metric_sql(metric) where, params = self._where(username=username, since=since, since_col="hour_utc", until=until, until_col="hour_utc") rows = self._db.connect().execute( f""" - SELECT hour_utc, provider, - SUM({expr}) AS value, SUM(ok_count) AS ok_count, - SUM(cost_known) AS cost_known + SELECT hour_utc, provider,{_METRIC_AGGREGATES} FROM usage_hourly {where} GROUP BY hour_utc, provider ORDER BY hour_utc """, params).fetchall() + # 全渠道合计:均值/比率必须重新相除,不能把各渠道的值相加 + total_rows = self._db.connect().execute( + f""" + SELECT hour_utc,{_METRIC_AGGREGATES} + FROM usage_hourly {where} + GROUP BY hour_utc + ORDER BY hour_utc + """, params).fetchall() raw: dict[int, dict[str, Any]] = {} + totals: dict[int, Any] = {} providers: set[str] = set() for row in rows: hour = row["hour_utc"] provider = row["provider"] providers.add(provider) - raw.setdefault(hour, {})[provider] = self._metric_value( - metric, row["value"], row["ok_count"], row["cost_known"]) + raw.setdefault(hour, {})[provider] = self._metric_value(metric, row) + for row in total_rows: + totals[row["hour_utc"]] = self._metric_value(metric, row) # 渠道按名排序:同一批点的键顺序稳定(与渠道注册顺序无关) ordered = sorted(providers) + empty = self._empty_value(metric) return [ - {"hour": hour, **{p: raw[hour].get(p, 0) for p in ordered}} - for hour in sorted(raw) + {"hour": hour, + "__total": totals.get(hour, empty), + **{p: raw.get(hour, {}).get(p, empty) for p in ordered}} + for hour in self._dense_hours(raw, since=since, until=until) ] + @staticmethod + def _dense_hours(observed: dict[int, Any], *, since: int | None, + until: int | None) -> list[int]: + """补齐小时桶(见 `timeline` 文档)。跨度超限时退回已观测的小时。""" + if not observed: + return [] + hours = sorted(observed) + start = (since // 3600) * 3600 if since is not None else hours[0] + if until is not None: + # 半开区间:until 整点本身不算(与 `_where` 的 `< until` 一致) + end = ((until - 1) // 3600) * 3600 if until > start else start + else: + end = hours[-1] + # 走到这里必有 end ≥ start:观测到的小时都落在 [since, until) 内, + # 首末观测小时天然被两端夹住,无需再判空 + count = (end - start) // 3600 + 1 + if count > MAX_DENSE_HOURS: + return hours + return [start + index * 3600 for index in range(count)] + + @staticmethod + def _empty_value(metric: str) -> Any: + """缺失小时桶的取值:可加量补 0,均值 / 比率 / 成本补 None(断线)。""" + return 0 if METRIC_KINDS.get(metric) in ("sum_requests", "sum_tokens") else None + + def events(self, *, username: str | None = None, since: int | None = None, until: int | None = None, before: int | None = None, limit: int = 50, @@ -968,23 +1107,42 @@ def errors(self, *, username: str | None = None, since: int | None = None, `limit` 按失败数降序取前 N 组:归因卡片是「谁最该修」的排行榜,长尾 组合(同渠道同模型的不同错误类型各算一组)容易把响应撑大。 + **`rescued`(换号救回数)**:同一用户同一 `request_id` 另有成功行的 + 失败**行**数。换号重试是常态——`invalid_request` 在每次上游拒绝时都落 + 一行,即使随后换了另一个渠道成功(实测 2026-10-23:qoder 53 次失败里 + 36 次最终成功)。只看 `failures` 会把「已救回的尝试」读成「用户受损」, + 故单列下发:`failures - rescued` 是**未被救回的失败尝试数**(同一请求多 + 次失败会计多次,不等于受损请求数)。救援判定**不叠加时间窗**:成功行 + 可能落在窗口右界之后(同一请求的重试),按 `request_id` 全表关联,走 + `idx_usage_request`。 + + **必须加 `s.username = e.username`**:`request_id` 取自入站 + `X-Request-ID`,客户端可伪造;只按 ID 关联会让别的用户的成功行把本用户 + 的失败标成「已救回」,也会让传了 `username=` 的查询读到跨租户数据。 + `now` 可注入(测试确定性):只在 `since` 缺省时用于推算默认窗口。 """ if since is None: since = (now if now is not None else int(time.time())) - ERROR_WINDOW_SECONDS where, params = self._where(username=username, since=since, since_col="ts", - until=until, until_col="ts", ok=0) + until=until, until_col="ts", ok=0, alias="e.") rows = self._db.connect().execute( f""" - SELECT provider, model, error_type, COUNT(*) AS failures, - MAX(ts) AS last_ts - FROM usage_events {where} - GROUP BY provider, model, error_type - ORDER BY failures DESC, provider, model, error_type + SELECT e.provider, e.model, e.error_type, COUNT(*) AS failures, + SUM(CASE WHEN EXISTS ( + SELECT 1 FROM usage_events s + WHERE s.request_id = e.request_id + AND s.username = e.username AND s.ok = 1) + THEN 1 ELSE 0 END) AS rescued, + MAX(e.ts) AS last_ts + FROM usage_events e {where} + GROUP BY e.provider, e.model, e.error_type + ORDER BY failures DESC, e.provider, e.model, e.error_type LIMIT ? """, [*params, max(1, limit)]).fetchall() return [{"provider": row["provider"], "model": row["model"], "error_type": row["error_type"], "failures": int(row["failures"]), + "rescued": int(row["rescued"]), "last_ts": int(row["last_ts"])} for row in rows] def model_timeline(self, *, username: str | None = None, @@ -994,35 +1152,93 @@ def model_timeline(self, *, username: str | None = None, """按小时的各模型指标趋势(usage_hourly 聚合)。 Top N 模型按「请求数」排序(排序口径固定,与图表指标解耦); - 曲线值按 metric 变化(请求数 / 总 token / 平均耗时 / 平均首字延迟)。 - 返回结构 {models, points} 不变。 + 曲线值按 metric 变化(语义见 `METRIC_KINDS`)。返回结构 `{models, points}` + 不变。时间轴与 `timeline` 同样按小时补齐(见其文档)。 """ - expr, as_mean = self._metric_sql(metric) where, params = self._where(username=username, since=since, since_col="hour_utc", until=until, until_col="hour_utc") rows = self._db.connect().execute( f""" - SELECT hour_utc, model, SUM(requests) AS requests, - SUM({expr}) AS value, SUM(ok_count) AS ok_count, - SUM(cost_known) AS cost_known + SELECT hour_utc, model,{_METRIC_AGGREGATES} FROM usage_hourly {where} GROUP BY hour_utc, model ORDER BY hour_utc """, params).fetchall() totals: dict[str, int] = {} - series: dict[str, dict[int, int]] = {} - hours: list[int] = [] + series: dict[str, dict[int, Any]] = {} + observed: dict[int, dict[str, Any]] = {} for row in rows: hour, model = row["hour_utc"], row["model"] - if not hours or hours[-1] != hour: - hours.append(hour) totals[model] = totals.get(model, 0) + row["requests"] - series.setdefault(model, {})[hour] = ( - self._metric_value(metric, row["value"], row["ok_count"], row["cost_known"])) + series.setdefault(model, {})[hour] = self._metric_value(metric, row) + observed.setdefault(hour, {})[model] = True # Top N 模型:按总量降序;总量相同按名字稳定排序 top_models = sorted(totals, key=lambda m: (-totals[m], m))[:top] + empty = self._empty_value(metric) points = [ - {"hour": hour, **{m: series.get(m, {}).get(hour, 0) for m in top_models}} - for hour in hours + {"hour": hour, **{m: series.get(m, {}).get(hour, empty) for m in top_models}} + for hour in self._dense_hours(observed, since=since, until=until) ] return {"models": top_models, "points": points} + + def percentile_timeline(self, *, username: str | None = None, + since: int | None = None, until: int | None = None, + metric: str = "latency", + now: int | None = None) -> dict[str, Any]: + """按小时的分位时序(P50 中位数 + P95 上界),读明细表。 + + 延迟图只画均值会盖住长尾:池级均值 10 s 时 P95 可能 40 s,而「少数请求 + 卡住」正是最该看见的故障形态。故一张图给「中位数线 + P95 上界带」。 + + **只能读明细表**(`usage_events`,仅 90 天):小时汇总只存 sum/count, + 数学上无法还原分布。`metric` 只接受 `latency` / `ttfb`(其余回退 + `latency`);口径与分组表分位一致——只统计成功请求、nearest-rank + (`⌈pct·n/100⌉`)、NULL 不参与排名。 + + `since` 缺省时强制「最近 24 小时」(与 `errors` 同口径):这条 SQL 带 + `ROW_NUMBER() OVER (PARTITION BY 小时)`,无窗口时会对 90 天明细全表做 + 窗口函数排序;**任意登录用户(含 viewer)**都能反复触发,是现成的 DB + CPU DoS。`now` 可注入(测试确定性),只在缺省时用于推算窗口。 + + 返回 `{"points": [{hour, p50, p95, samples}], "metric": ..., "window_days": 90}`; + 缺失小时补 None(前端断线,不画成 0)。`samples` 是该小时的样本数, + 前端可据此说明「该点样本很少」。 + """ + if since is None: + since = (now if now is not None else int(time.time())) - ERROR_WINDOW_SECONDS + column = "ttfb_ms" if metric == "ttfb" else "latency_ms" + where, params = self._where(username=username, ok=1, since=since, until=until) + clause = f"AND {where[6:]}" if where else "" + rows = self._db.connect().execute( + f""" + SELECT hour_utc, + MAX(CASE WHEN rn = (50 * n + 99) / 100 THEN val END) AS p50, + MAX(CASE WHEN rn = (95 * n + 99) / 100 THEN val END) AS p95, + MAX(n) AS samples + FROM ( + SELECT (ts / 3600) * 3600 AS hour_utc, {column} AS val, + ROW_NUMBER() OVER ( + PARTITION BY (ts / 3600) * 3600 ORDER BY {column} + ) AS rn, + COUNT({column}) OVER ( + PARTITION BY (ts / 3600) * 3600 + ) AS n + FROM usage_events + WHERE {column} IS NOT NULL {clause} + ) AS ranked + GROUP BY hour_utc + ORDER BY hour_utc + """, params).fetchall() + observed = {row["hour_utc"]: True for row in rows} + by_hour = {row["hour_utc"]: row for row in rows} + points = [] + for hour in self._dense_hours(observed, since=since, until=until): + row = by_hour.get(hour) + points.append({ + "hour": hour, + "p50": row["p50"] if row else None, + "p95": row["p95"] if row else None, + "samples": row["samples"] if row else 0, + }) + return {"points": points, "metric": column.removesuffix("_ms"), + "window_days": 90} diff --git a/src/tasks/activity.py b/src/tasks/activity.py index bf0b6637..43517411 100644 --- a/src/tasks/activity.py +++ b/src/tasks/activity.py @@ -19,7 +19,9 @@ from zoneinfo import ZoneInfo from ..config import live +from ..db.crypto import CredentialDecryptError from ..db.repo import CredentialRepository, GrowthRepository +from ..error_text import error_text from ..provider.base import GrowthResult, GrowthStep, StepStatus from . import TaskReport @@ -65,21 +67,40 @@ def due(self) -> bool: def _day_key(self) -> str: return self._current().strftime("%Y-%m-%d") + def _prune(self, day: str) -> None: + """丢掉非当天的封账记录:键以日期开头,跨天后不可能再命中, + 长驻进程否则每天每账号各攒一条(无上限)。""" + prefix = f"{day}:" + self._done_scopes = {key for key in self._done_scopes + if key.startswith(prefix)} + async def run_once(self, *, trigger: str = "auto") -> TaskReport: report = TaskReport() + day = self._day_key() + self._prune(day) # 跨天后旧封账记录不再有用 seen: set[str] = set() for candidate in self._credentials.candidates(): - if candidate.disabled: + # 与 growth / 签到 / 刷新同口径:`enabled=0`(用户软暂停)不参与 + # 活跃上报。且必须早于 `seen` 记录(见 checkin 的同类注释)。 + if candidate.disabled or not candidate.enabled: report.skipped += 1 continue provider = self._providers.get(candidate.provider) - data = self._credentials.credential_data(candidate.credential_id) activity_scope = getattr(provider, "activity_scope", None) + # 解不开(APP_SECRET 换过 / 密文损坏)→ 跳过这条凭证:一条坏凭证 + # 不该让整轮活跃上报失败(早先直接冒泡 → 整轮任务挂掉)。 + try: + data = self._credentials.credential_data(candidate.credential_id) + except CredentialDecryptError: + logger.error("活跃上报跳过 %s:凭证解密失败(APP_SECRET 是否被更换过?)", + candidate.credential_id) + report.skipped += 1 + continue if provider is None or data is None or activity_scope is None: report.skipped += 1 continue scope = activity_scope(data) or f"credential|{candidate.credential_id}" - if f"{self._day_key()}:{scope}" in self._done_scopes: + if f"{day}:{scope}" in self._done_scopes: report.skipped += 1 # 当日已成功,不再调上游 continue if scope in seen: @@ -90,13 +111,16 @@ async def run_once(self, *, trigger: str = "auto") -> TaskReport: try: result = await provider.activity(data) except Exception as error: # noqa: BLE001 - 单号失败不拖累其余 + # 必须走 error_text:裸 `%s` 打 httpx.ConnectError / TimeoutError + # 时 str 为空,日志只剩「activity report failed for cred_x: 」, + # 分不清是代理抖动还是上游拒绝(同 checkin / quota_probe 口径)。 logger.warning("activity report failed for %s: %s", - candidate.credential_id, error) + candidate.credential_id, error_text(error)) report.failed += 1 continue if result.ok: report.succeeded += 1 - self._done_scopes.add(f"{self._day_key()}:{scope}") + self._done_scopes.add(f"{day}:{scope}") self._record(candidate.credential_id, result, trigger) else: report.failed += 1 diff --git a/src/tasks/alerting.py b/src/tasks/alerting.py index 224731d2..7144c8c0 100644 --- a/src/tasks/alerting.py +++ b/src/tasks/alerting.py @@ -8,6 +8,9 @@ severity=warning。 3. **token 临近到期**:某凭证的 access token 剩余时间落在窗口内。severity=warning。 4. **上游错误率骤升**:统计窗内失败占比超过阈值且样本数足够。severity=warning。 +5. **单 Key 用量暴涨**:统计窗内某把 Key 的 token 总量越过阈值(泄露/共用信号)。 +6. **非工作时段请求**:工作时间区间之外的请求数达到最小样本。 +7. **预算分层**:当月成本达到预算的 50/80/95/100% 档;只报最高档,100% 为 critical。 投递(用户选的「Webhook + 站内」): - **站内**:每条命中落 `alert_events`,管理台「站内告警记录」回看(重启不丢)。 @@ -180,6 +183,45 @@ def _humanize(seconds: int) -> str: return f"{seconds} 秒" +def month_start(now: int) -> int: + """本地时区当月起点(epoch 秒):预算按自然月结算,与账单页同口径。""" + tm = time.localtime(now) + return int(time.mktime((tm.tm_year, tm.tm_mon, 1, 0, 0, 0, 0, 0, -1))) + + +def parse_levels(raw: str) -> tuple[int, ...]: + """`"50,80,95,100"` → `(50, 80, 95, 100)`;非法项丢弃、去重、升序、限 1..100。 + + 预算分层是运营参数,写错不该让告警任务崩:解析失败按空处理(= 该规则 + 不生效),由设置页的校验兜住正常入口。100 以上不设层(超支仍算 100%)。 + """ + values: set[int] = set() + for part in raw.split(","): + part = part.strip() + if not part: + continue + try: + level = int(part) + except ValueError: + continue + if 1 <= level <= 100: + values.add(level) + return tuple(sorted(values)) + + +def in_off_hours(hour: int, start: int, end: int) -> bool: + """`hour` 是否落在「非工作时段」;`start == end` 表示该规则关闭。 + + 跨零点写法(如 22 → 7)合法:`hour >= start or hour < end`。区间是 + **半开** `[start, end)`,与项目里其他时间窗口径一致。 + """ + if start == end: + return False + if start < end: + return not (start <= hour < end) + return not (hour >= start or hour < end) + + def evaluate_alerts( *, pool: dict[str, int], @@ -189,6 +231,16 @@ def evaluate_alerts( error_counts: tuple[int, int], error_rate_threshold: float, error_rate_min_requests: int, + key_usage: list[dict[str, Any]] | None = None, + key_burst_tokens: int = 0, + off_hours: bool = False, + off_hours_min_requests: int = 0, + budget_cny: float = 0.0, + budget_spent: float = 0.0, + budget_levels: tuple[int, ...] = (), + dead_channels: list[dict[str, Any]] | None = None, + checkin_failures: list[dict[str, Any]] | None = None, + checkin_fail_min_streak: int = 0, ) -> list[Alert]: """按四类规则把信号快照判成告警列表(纯函数,不碰 IO)。 @@ -198,6 +250,15 @@ def evaluate_alerts( - `failing_tasks`:`(key, name, streak)`,调用方已按阈值过滤。 - `expiring`:`[{id, nickname, token_expires_at, ...}]`,已按窗口过滤。 - `error_counts`:`(requests, failed)`,窗口口径由调用方给定。 + - `key_usage`:`[{api_key_id, username, requests, tokens}]`,按 Key 聚合。 + - `off_hours`:调用方按本地时钟算好的「当前是否非工作时段」;纯函数不碰 + 时钟,测试可以任意构造。 + - `budget_*`:当月成本预算与已花费(调用方按月起点聚合)。只报**已达成的 + 最高层**——低层在高一层触发时自然不再命中,由迟滞状态机写成「已恢复」, + 避免 50/80/95/100 四条同时挂着。 + - `dead_channels`:窗口内零成功的渠道(调用方已按最小样本过滤)。 + - `checkin_failures`:签到连续失败凭证(调用方已按最小连续轮数过滤, + 纯函数再按 `checkin_fail_min_streak` 兜一次,0 = 关闭)。 """ alerts: list[Alert] = [] @@ -249,6 +310,93 @@ def evaluate_alerts( "rate": round(rate, 4), "threshold": error_rate_threshold}, )) + # 单 Key 用量暴涨:窗口内总 token 越过阈值。**不是**错误,是滥用/泄露信号 + # (Key 被贴到公共仓库、多人共用一把 Key)。阈值 0 关闭。 + if key_burst_tokens > 0: + for row in key_usage or (): + tokens = int(row.get("tokens") or 0) + if tokens < key_burst_tokens: + continue + alerts.append(Alert( + rule="key_burst", severity=SEVERITY_WARNING, + scope=row.get("api_key_id") or "unknown", + message=(f"API Key 在统计窗内消耗 {tokens} token" + f"({row.get('requests', 0)} 次请求,用户 " + f"{row.get('username') or '-'},阈值 {key_burst_tokens})"), + detail={"api_key_id": row.get("api_key_id"), + "username": row.get("username"), + "tokens": tokens, + "requests": int(row.get("requests") or 0), + "threshold": key_burst_tokens}, + )) + + # 非工作时段仍有请求:可能是定时任务/自动化,也可能是 Key 被外部使用。 + # 只报「窗口内请求数达到最小样本」的次数,避免个别重试就报警。 + requests_total, _failed = error_counts + if off_hours and off_hours_min_requests > 0 and requests_total >= off_hours_min_requests: + alerts.append(Alert( + rule="off_hours", severity=SEVERITY_WARNING, scope="pool", + message=(f"非工作时段仍有 {requests_total} 次请求" + f"(阈值 {off_hours_min_requests})"), + detail={"requests": requests_total, + "threshold": off_hours_min_requests}, + )) + + # 预算分层:50/80/95/100%。只报当前达到的最高层;100%(含超支)为 critical。 + if budget_cny > 0 and budget_levels and budget_spent > 0: + percent = budget_spent / budget_cny * 100 + reached = [level for level in budget_levels if percent >= level] + if reached: + level = max(reached) + alerts.append(Alert( + rule="budget", + severity=SEVERITY_CRITICAL if level >= 100 else SEVERITY_WARNING, + scope=f"budget_{level}", + message=(f"当月成本已达预算的 {percent:.0f}%" + f"(¥{budget_spent:.2f} / ¥{budget_cny:.2f}," + f"触发 {level}% 档)"), + detail={"level": level, "percent": round(percent, 2), + "spent_cny": round(budget_spent, 4), + "budget_cny": budget_cny}, + )) + + # 渠道死亡:窗口内该渠道有流量但**一次都没成功**(零成功,不看错误率)。 + # 2026-11-08 officeace 积分耗尽后 8 小时无成功、raccoon 签到 5 天全失败, + # 现有规则一条都没报——error_rate 被 fallback 稀释,task_failed 只看任务 + # 连续失败(签到「跑成功但业务失败」不算)。 + for row in dead_channels or (): + provider = str(row.get("provider") or "") + requests = int(row.get("requests") or 0) + last_ok = row.get("last_ok") + silence = ("从未成功过" if last_ok is None else + "最近一次成功是 " + f"{time.strftime('%m-%d %H:%M', time.localtime(int(last_ok)))}") + alerts.append(Alert( + rule="channel_dead", severity=SEVERITY_CRITICAL, scope=provider, + message=(f"渠道 {provider} 在统计窗内 {requests} 次请求全部失败" + f"({silence})"), + detail={"provider": provider, "requests": requests, + "last_ok": last_ok}, + )) + + # 签到连续失败:同一凭证最近连续 N 轮签到都是失败(中间成功一次即清零)。 + # 与 task_failed 的区别:签到任务本身「跑成功了」,是上游业务返回失败 + # (如 raccoon「今日积分尚未发放」),任务级计数永远看不见。 + for row in checkin_failures or (): + streak = int(row.get("streak") or 0) + if checkin_fail_min_streak <= 0 or streak < checkin_fail_min_streak: + continue + credential_id = str(row.get("credential_id") or "") + alerts.append(Alert( + rule="checkin_failing", severity=SEVERITY_WARNING, + scope=credential_id, + message=(f"凭证 {credential_id} 连续 {streak} 轮签到失败:" + f"{row.get('message') or '未知原因'}"), + detail={"credential_id": credential_id, "streak": streak, + "message": row.get("message") or "", + "threshold": checkin_fail_min_streak}, + )) + return alerts @@ -276,12 +424,26 @@ def __init__( task_status: TaskStatusStore, enabled: Callable[[], bool] = lambda: True, webhook_url: Callable[[], str] = lambda: "", + feishu_url: Callable[[], str] = lambda: "", + slack_url: Callable[[], str] = lambda: "", pool_ready_min: Callable[[], int] = lambda: 1, task_failures: Callable[[], int] = lambda: 3, token_expiry_hours: Callable[[], int] = lambda: 24, error_rate_threshold: Callable[[], float] = lambda: 0.5, error_rate_min_requests: Callable[[], int] = lambda: 20, error_rate_window_minutes: Callable[[], int] = lambda: 15, + key_burst_tokens: Callable[[], int] = lambda: 0, + key_burst_window_minutes: Callable[[], int] = lambda: 15, + off_hours_start: Callable[[], int] = lambda: 0, + off_hours_end: Callable[[], int] = lambda: 0, + off_hours_min_requests: Callable[[], int] = lambda: 0, + budget_cny: Callable[[], float] = lambda: 0.0, + budget_levels: Callable[[], str] = lambda: "50,80,95,100", + channel_dead_min_requests: Callable[[], int] = lambda: 0, + channel_dead_window_minutes: Callable[[], int] = lambda: 60, + checkin_fail_min_streak: Callable[[], int] = lambda: 0, + checkin_fail_window_hours: Callable[[], int] = lambda: 24, + checkins: Any = None, silence_minutes: Callable[[], int] = lambda: 30, for_ticks: Callable[[], int] = lambda: DEFAULT_FOR_TICKS, clear_ticks: Callable[[], int] = lambda: DEFAULT_CLEAR_TICKS, @@ -295,12 +457,27 @@ def __init__( self._task_status = task_status self._enabled = enabled self._webhook_url = webhook_url + self._feishu_url = feishu_url + self._slack_url = slack_url self._pool_ready_min = pool_ready_min self._task_failures = task_failures self._token_expiry_hours = token_expiry_hours self._error_rate_threshold = error_rate_threshold self._error_rate_min_requests = error_rate_min_requests self._error_rate_window_minutes = error_rate_window_minutes + self._key_burst_tokens = key_burst_tokens + self._key_burst_window_minutes = key_burst_window_minutes + self._off_hours_start = off_hours_start + self._off_hours_end = off_hours_end + self._off_hours_min_requests = off_hours_min_requests + self._budget_cny = budget_cny + self._budget_levels = budget_levels + self._channel_dead_min_requests = channel_dead_min_requests + self._channel_dead_window_minutes = channel_dead_window_minutes + self._checkin_fail_min_streak = checkin_fail_min_streak + self._checkin_fail_window_hours = checkin_fail_window_hours + # 签到审计仓储(CheckinEventRepository):只读取连续失败,None 时跳过该规则。 + self._checkins = checkins self._silence_minutes = silence_minutes self._for_ticks = for_ticks self._clear_ticks = clear_ticks @@ -332,6 +509,32 @@ async def run_once(self) -> dict[str, Any] | None: ] window_minutes = max(0, self._error_rate_window_minutes()) expiring_seconds = max(0, self._token_expiry_hours()) * 3600 + # 渠道死亡窗口独立于错误率窗:渠道挂掉通常是小时级,15 分钟太短。 + dead_minutes = max(0, self._channel_dead_window_minutes()) + dead_min_requests = max(0, self._channel_dead_min_requests()) + dead_channels = ( + self._stats.window_dead_channels( + since=now - dead_minutes * 60, min_requests=dead_min_requests) + if dead_minutes > 0 and dead_min_requests > 0 else []) + # 签到连续失败:窗口按小时(一天 24 轮自动重试已足够暴露问题)。 + fail_hours = max(0, self._checkin_fail_window_hours()) + fail_min_streak = max(0, self._checkin_fail_min_streak()) + checkin_failures = ( + self._checkins.consecutive_failures( + min_streak=fail_min_streak, since=now - fail_hours * 3600) + if self._checkins is not None and fail_min_streak > 0 + and fail_hours > 0 else []) + # 单 Key 暴涨用**自己的**窗口(与错误率窗口解耦:错误率看几分钟, + # 用量暴涨看十几分钟到一小时更合理)。 + burst_minutes = max(0, self._key_burst_window_minutes()) + key_usage = (self._stats.window_key_usage(since=now - burst_minutes * 60) + if self._key_burst_tokens() > 0 and burst_minutes > 0 else []) + start, end = self._off_hours_start(), self._off_hours_end() + off_hours = in_off_hours(time.localtime(now).tm_hour, start, end) + # 预算按自然月(本地):月初成本归零 → 上月的预算告警自然恢复 + budget_cny = max(0.0, self._budget_cny()) + budget_spent = (self._stats.window_cost(since=month_start(now))[0] + if budget_cny > 0 else 0.0) alerts = evaluate_alerts( pool=self._credentials.pool_counts(now), pool_ready_min=self._pool_ready_min(), @@ -341,6 +544,16 @@ async def run_once(self) -> dict[str, Any] | None: since=now - window_minutes * 60), error_rate_threshold=self._error_rate_threshold(), error_rate_min_requests=self._error_rate_min_requests(), + key_usage=key_usage, + key_burst_tokens=self._key_burst_tokens(), + off_hours=off_hours, + off_hours_min_requests=self._off_hours_min_requests(), + budget_cny=budget_cny, + budget_spent=budget_spent, + budget_levels=parse_levels(self._budget_levels()), + dead_channels=dead_channels, + checkin_failures=checkin_failures, + checkin_fail_min_streak=fail_min_streak, ) return await self._dispatch(alerts, now) @@ -429,22 +642,16 @@ async def _deliver(self, alert: Alert, now: int, *, 只等单次超时;总预算再兜一层,防止 `_post` 的自建 client 路径 (其 timeout 是每请求的)在地址很多时累积超时。 """ - urls = [u.strip() for u in self._webhook_url().split(",") if u.strip()] - if not urls: + targets = self._targets(alert, status, now) + if not targets: return False, None - resolved = status == "resolved" - payload = { - "service": "coding2api", "rule": alert.rule, "severity": alert.severity, - "scope": alert.scope, "status": status, - "message": (f"已恢复:{alert.public_message()}" if resolved - else alert.public_message()), - "detail": _public_detail(alert.detail), "ts": now, - } - body = json.dumps(payload, ensure_ascii=False).encode("utf-8") - signature = sign_webhook_body(self._app_secret(), body) try: results = await asyncio.wait_for( - asyncio.gather(*(self._deliver_one(url, body, signature) for url in urls)), + asyncio.gather(*( + self._deliver_one(url, body, + sign_webhook_body(self._app_secret(), body)) + for url, body in targets + )), timeout=_WEBHOOK_TOTAL_BUDGET, ) except TimeoutError: @@ -454,6 +661,39 @@ async def _deliver(self, alert: Alert, now: int, *, errors = [error for error in results if error is not None] return (not errors), (errors[0] if errors else None) + def _targets(self, alert: Alert, status: str, now: int) -> list[tuple[str, bytes]]: + """本轮全部投递目标 → [(地址, 预序列化 body)]。 + + 通用 webhook 发完整 JSON(带签名,收端可复算);飞书 / Slack 各自只认 + 自己的格式,故按渠道单独构造。不同渠道 body 不同、签名逐 body 计算, + 签名仍覆盖实际发送的字节。未配置任何渠道时返回空列表(仅站内记录)。 + """ + resolved = status == "resolved" + message = (f"已恢复:{alert.public_message()}" if resolved + else alert.public_message()) + targets: list[tuple[str, bytes]] = [] + for url in (item.strip() for item in self._webhook_url().split(",")): + if not url: + continue + payload = { + "service": "coding2api", "rule": alert.rule, + "severity": alert.severity, "scope": alert.scope, "status": status, + "message": message, + "detail": _public_detail(alert.detail), "ts": now, + } + targets.append((url, json.dumps(payload, ensure_ascii=False).encode("utf-8"))) + feishu = self._feishu_url().strip() + if feishu: + # 飞书自定义机器人文本消息;不带 detail(可能含内部标识) + targets.append((feishu, json.dumps( + {"msg_type": "text", "content": {"text": f"Coding2API · {message}"}}, + ensure_ascii=False).encode("utf-8"))) + slack = self._slack_url().strip() + if slack: + targets.append((slack, json.dumps( + {"text": f"Coding2API · {message}"}, ensure_ascii=False).encode("utf-8"))) + return targets + async def _deliver_one(self, url: str, body: bytes, signature: str) -> str | None: """投递单个地址;失败只记日志并返回脱敏后的错误串,不抛出。 diff --git a/src/tasks/checkin.py b/src/tasks/checkin.py index fcc25567..3e0d047f 100644 --- a/src/tasks/checkin.py +++ b/src/tasks/checkin.py @@ -6,14 +6,60 @@ import random import time from collections.abc import Callable +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo from ..config import live +from ..db.crypto import CredentialDecryptError from ..db.repo import CredentialRepository +from ..error_text import error_text +from ..request_context import credential_scope from . import TaskReport from .pacer import Pacer logger = logging.getLogger(__name__) +# 签到日边界固定北京时间:上游(CodeBuddy / OfficeAce)的「每日一次」按北京时间 +# 切,与本服务进程的 TZ 无关。此前用 `time.localtime()`,容器/宿主 TZ≠CN 时 +# 日界会偏移数小时,出现「上游已重置但本服务认为今天已签」或反之的静默漏签。 +# 与 `engine/limits.day_start`、`tasks/activity` 同一口径。 +_CN_TZ = ZoneInfo("Asia/Shanghai") + +#: 失败重试退避:首轮失败后 10 分钟再试,连续失败翻倍,封顶 1 小时。 +#: 上游对「今日积分尚未发放」会一直回同一句(实测 2026-11-05 两个号从 00:29 +#: 到 17:54 每 10 分钟锤一次 → 145 条 WARNING + 几千次无效上游调用)。 +#: 退避后一天最多 ~7 次/账号,仍能在当天补签;`trigger="manual"` 不受限制。 +_FAIL_BACKOFF_BASE = 10 * 60 +_FAIL_BACKOFF_MAX = 60 * 60 + + +def _cn_now(now: int | None = None) -> time.struct_time: + """当前时刻的北京时间 struct_time(测试可注入 epoch 秒)。 + + 统一入口:`due` 与 `run_once` 都用它,保证「该不该跑」和「算哪天」同源。 + """ + moment = datetime.fromtimestamp(int(now if now is not None else time.time()), + _CN_TZ) + return moment.timetuple() + + +def _cn_epoch(moment: time.struct_time) -> int: + """北京时间 struct_time → epoch 秒。 + + 不能用 `time.mktime`:它按**进程本地时区**解释 struct_time,TZ≠CN 时 + 算出来的封账截止会偏几小时(签到每天可能漏一次或签两次)。 + """ + return int(datetime(*moment[:6], tzinfo=_CN_TZ).timestamp()) + + +def _parse_providers(text: str) -> frozenset[str]: + """`raccoon, lingxi` → `{"raccoon", "lingxi"}`(小写、去空段)。 + + 宽容解析:写错一个渠道名不该让整轮签到停摆(`skip_providers` 本身是 + 止损手段,配置写错最多等于没配)。 + """ + return frozenset(part.strip().lower() for part in text.split(",") if part.strip()) + def parse_checkin_times(text: str) -> tuple[int, ...]: """`HH:MM,HH:MM` → 距 0 点的分钟数(升序去重)。坏段忽略 + warning。 @@ -49,17 +95,25 @@ class CheckinTask: def __init__(self, credentials: CredentialRepository, providers: dict, *, on_success: Callable[[str], None] | None = None, + events=None, pacer: Pacer | None = None, times: str | Callable[[], str] = "", jitter_minutes: int | Callable[[], int] = 0, + skip_providers: str | Callable[[], str] = "", rng: Callable[[int], int] | None = None) -> None: self._credentials = credentials self._providers = providers self._on_success = on_success + # 签到审计日志仓储(CheckinEventRepository):None 时(老调用方/测试) + # 不落库,只跑签到本身——生产路径总是传入。 + self._events = events self._pacer = pacer # 时点与抖动可热更:存取值器,due() 每次现读(不是装配快照)。 self._times = live(times) self._jitter = live(jitter_minutes) + # 跳过签到的渠道:每轮 run_once 现读。上游长期停发(raccoon 2026-11-05 + # 起 daily_grant 断供)时用它止损,不影响聊天与其它渠道。 + self._skip_providers = live(skip_providers) # 抖动偏移的随机源:`rng(上限)` 返回 0..上限。可注入,测试不依赖真随机。 self._rng = rng or (lambda span: random.randint(0, span)) # done_key → 封账截止的 epoch 秒。原先是 set(只有「当天封/不封」), @@ -68,6 +122,9 @@ def __init__(self, credentials: CredentialRepository, providers: dict, # 注意与下面的「时点放行」是两套键:这里是每个凭证/G 作用域的封账, # 那边是「今天这个时点已经触发过一轮」,语义不能合并。 self._done_scopes: dict[str, int] = {} + # done_key → (连续失败次数, 下次允许重试的 epoch 秒)。失败退避,成功清零; + # 与 `_done_scopes` 同键同生命周期,跨天 prune 时一起清。 + self._failures: dict[str, tuple[int, int]] = {} # 时点放行:键固定为「本地日期 + 时点分钟数」,抖动只改窗口、不改归属, # 所以执行时刻落在窗口里哪个分钟都不会让同一时点跑两次。 self._slot_offsets: dict[str, int] = {} # 当日该时点抽到的抖动偏移(分钟) @@ -77,7 +134,7 @@ def has_times(self) -> bool: """是否配置了签到时点(决定 runner 的醒转周期:配了就要 1 分钟精度)。""" return bool(parse_checkin_times(self._times())) - def due(self, *, now: time.struct_time | None = None) -> bool: + def due(self, *, now: time.struct_time | int | None = None) -> bool: """是否该跑一轮签到。 - `checkin_times` 留空 → 恒 True,保持「全天每 10 分钟一轮」的旧行为 @@ -95,7 +152,7 @@ def due(self, *, now: time.struct_time | None = None) -> bool: times = parse_checkin_times(self._times()) if not times: return True # 留空 = 旧行为 - current = now or time.localtime() + current = now if isinstance(now, time.struct_time) else _cn_now(now) day = self._day_key(current) self._prune_slots(day) moment = current.tm_hour * 60 + current.tm_min @@ -132,29 +189,75 @@ def _done_key(self, day: str, scope: str) -> str: return f"{day}:{scope}" def _day_end(self, now: time.struct_time) -> int: - """本地当日 24:00 的 epoch 秒(默认封账截止 = 当天结束)。""" - return int(time.mktime((now.tm_year, now.tm_mon, now.tm_mday + 1, - 0, 0, 0, 0, 1, -1))) + """北京时间当日 24:00 的 epoch 秒(默认封账截止 = 当天结束)。 + + 用 `datetime + timedelta` 而不是「tm_mday + 1」:后者在月末(31 号) + 构造出 32 号,`datetime` 不做溢出归一(`time.mktime` 才会),直接抛 + `ValueError: day is out of range for month`——每月最后一天签到全挂。 + """ + midnight = datetime(now.tm_year, now.tm_mon, now.tm_mday, tzinfo=_CN_TZ) + return int((midnight + timedelta(days=1)).timestamp()) def _prune(self, now: time.struct_time) -> None: """丢掉非当天的封账记录:done_key 以日期开头,跨天后不可能再命中, 长驻进程否则每天每账号各攒一条(无上限)。""" day = self._day_key(now) - stale = [key for key in self._done_scopes if not key.startswith(f"{day}:")] + prefix = f"{day}:" + stale = [key for key in self._done_scopes if not key.startswith(prefix)] for key in stale: del self._done_scopes[key] + for key in [key for key in self._failures if not key.startswith(prefix)]: + del self._failures[key] def _sealed(self, done_key: str, now: time.struct_time) -> bool: """该作用域是否已封账且封账未到期。""" until = self._done_scopes.get(done_key) - return until is not None and until > time.mktime(now) + return until is not None and until > _cn_epoch(now) + + def _backed_off(self, done_key: str, now: time.struct_time, + *, manual: bool) -> bool: + """失败退避未到期 → 本轮跳过;管理台「立即运行」不受退避限制。""" + if manual: + return False + state = self._failures.get(done_key) + return state is not None and state[1] > _cn_epoch(now) + + def _note_failure(self, done_key: str, now: time.struct_time, + *, retry_at: int | None = None) -> None: + """记一次失败并按连续失败次数翻倍退避(10m → 20m → 40m → 封顶 1h)。 + + `retry_at` 是渠道给出的「最早再试时刻」(如小浣熊每日积分账单要等到 + 01:30 才同步稳定)。与本地退避取较晚者:既保留指数退避,也绝不在 + 渠道点名的时间之前空锤。 + """ + streak = self._failures.get(done_key, (0, 0))[0] + 1 + delay = min(_FAIL_BACKOFF_BASE * 2 ** min(streak - 1, 6), _FAIL_BACKOFF_MAX) + until = _cn_epoch(now) + delay + if retry_at is not None: + until = max(until, retry_at) + self._failures[done_key] = (streak, until) + + def _clear_failure(self, done_key: str) -> None: + """成功即清零:下次再失败从 10 分钟重新起算,不继承旧的退避档位。""" + self._failures.pop(done_key, None) + + async def run_once(self, *, now: time.struct_time | int | None = None, + trigger: str = "auto") -> TaskReport: + """签到一轮:已成功(当日封账)的作用域直接跳过;失败的留到下轮重试。 + + 失败按连续次数做指数退避(10m → 20m → 40m → 封顶 1h),避免上游 + 一直回「积分尚未发放」时每 10 分钟空锤一次;`trigger="manual"` + (管理台立即运行)绕过退避。失败不封账,成功才封账。 - async def run_once(self, *, now: time.struct_time | None = None) -> TaskReport: - """签到一轮:已成功(当日封账)的作用域直接跳过;失败的留到下轮重试。""" - current = now or time.localtime() + `trigger` 只进审计日志(auto=定时 / manual=管理台「立即运行」), + 除绕过失败退避外不参与任何判定。 + """ + current = now if isinstance(now, time.struct_time) else _cn_now(now) day = self._day_key(current) report = TaskReport() seen: set[str] = set() + manual = trigger == "manual" + skipped_providers = _parse_providers(self._skip_providers()) self._prune(current) for candidate in self._credentials.candidates(): # `enabled=0`(用户软暂停)同样跳过,口径与调度器 is_selectable 一致。 @@ -163,9 +266,21 @@ async def run_once(self, *, now: time.struct_time | None = None) -> TaskReport: if candidate.disabled or not candidate.enabled: report.skipped += 1 continue + # 渠道级止损:上游长期停发时不必每轮空锤(仅跳过签到,聊天照常)。 + if candidate.provider in skipped_providers: + report.skipped += 1 + continue provider = self._providers.get(candidate.provider) checkin_scope = getattr(provider, "checkin_scope", None) - data = self._credentials.credential_data(candidate.credential_id) + # 解不开(APP_SECRET 换过 / 密文损坏)→ 跳过这条凭证:一条坏凭证 + # 不该让整轮签到失败(早先直接冒泡 → 整轮任务挂掉)。 + try: + data = self._credentials.credential_data(candidate.credential_id) + except CredentialDecryptError: + logger.error("签到跳过 %s:凭证解密失败(APP_SECRET 是否被更换过?)", + candidate.credential_id) + report.skipped += 1 + continue if provider is None or data is None or checkin_scope is None: report.skipped += 1 continue @@ -181,19 +296,26 @@ async def run_once(self, *, now: time.struct_time | None = None) -> TaskReport: report.skipped += 1 # 同一轮同上游账号只签一次 continue seen.add(scope) + if self._backed_off(done_key, current, manual=manual): + report.skipped += 1 # 退避未到期,不调上游 + continue report.attempted += 1 # 签到一轮含 status+claim+回查三次上游调用,与 quota_probe/growth # 共用同一 Pacer,避免绕开全局频率风控对策 if self._pacer is not None: await self._pacer.wait_turn() try: - result = await provider.checkin(data) + with credential_scope(candidate.credential_id): + result = await provider.checkin(data) except Exception as error: # noqa: BLE001 - logger.warning("checkin failed for %s: %s", candidate.credential_id, error) + logger.warning("checkin failed for %s: %s", candidate.credential_id, + error_text(error)) + self._note_failure(done_key, current) report.failed += 1 continue if result.ok: report.succeeded += 1 + self._clear_failure(done_key) # 成功即封账该作用域:封账期内不再重试,失败凭证不受影响可继续 # 重试。渠道给了 `seal_until` 就按它封(签到周期 ≠ 自然日, # 见 `CheckinResult.seal_until`),否则封到本地当日结束。 @@ -202,6 +324,12 @@ async def run_once(self, *, now: time.struct_time | None = None) -> TaskReport: self._on_success(candidate.credential_id) else: report.failed += 1 + self._note_failure(done_key, current, retry_at=result.retry_at) logger.warning("checkin failed for %s: %s", candidate.credential_id, result.message or "未知原因") + # 审计日志:成功/失败/已签都记一行(上游异常没有 result 时不记)。 + # 失败也要记是刻意的——「这个号连续几天签到都失败」正是要回看的东西。 + if self._events is not None: + self._events.record(credential_id=candidate.credential_id, + result=result, trigger=trigger) return report diff --git a/src/tasks/growth.py b/src/tasks/growth.py index 99a48638..5f4aab1b 100644 --- a/src/tasks/growth.py +++ b/src/tasks/growth.py @@ -11,7 +11,9 @@ import logging from ..config import live +from ..db.crypto import CredentialDecryptError from ..db.repo import CredentialRepository, GrowthRepository +from ..request_context import credential_scope from . import TaskReport from .pacer import Pacer @@ -50,10 +52,21 @@ async def run_once(self, *, trigger: str = "auto") -> TaskReport: if growth is None: report.skipped += 1 # 该渠道没有成长中心(TRAE) continue - if candidate.disabled: + # `enabled=0`(用户软暂停)同样跳过:口径与调度器 is_selectable、 + # 签到/刷新/额度探测一致。漏判会让「暂停」只停调度不停后台任务—— + # 号已经不想用了,成长中心还在替它领积分、活跃上报还在替它打卡。 + if candidate.disabled or not candidate.enabled: + report.skipped += 1 + continue + # 解不开(APP_SECRET 换过 / 密文损坏)→ 跳过这条凭证:一条坏凭证 + # 不该让整轮成长中心失败(早先直接冒泡 → 整轮任务挂掉)。 + try: + data = self._credentials.credential_data(candidate.credential_id) + except CredentialDecryptError: + logger.error("成长中心跳过 %s:凭证解密失败(APP_SECRET 是否被更换过?)", + candidate.credential_id) report.skipped += 1 continue - data = self._credentials.credential_data(candidate.credential_id) if data is None: report.skipped += 1 continue @@ -69,7 +82,8 @@ async def run_once(self, *, trigger: str = "auto") -> TaskReport: if self._pacer is not None: await self._pacer.wait_turn() try: - result = await growth(data, allow_irreversible=self.allow_irreversible) + with credential_scope(candidate.credential_id): + result = await growth(data, allow_irreversible=self.allow_irreversible) except Exception as error: # noqa: BLE001 - 一个凭证失败不拖累其余 logger.warning("growth failed for %s: %s", candidate.credential_id, error) report.failed += 1 diff --git a/src/tasks/model_probe.py b/src/tasks/model_probe.py index 18908085..e57a0485 100644 --- a/src/tasks/model_probe.py +++ b/src/tasks/model_probe.py @@ -1,15 +1,18 @@ -"""模型可用性主动探活:只探「已被负缓存」的 (渠道, 模型),成功即解除避让。 +"""模型可用性主动探活:探「已被负缓存」与「已判死」的 (渠道, 模型)。 -**为什么只探负缓存行**:遍历全量模型表 × 全量凭证要真发几百次对话,耗的是 +**为什么只探这两类**:遍历全量模型表 × 全量凭证要真发几百次对话,耗的是 用户额度。而负缓存(`credential_model_cooldowns.reason='blocked'`,11102「该 后端无此模型」)才是唯一会**静默锁死一个模型 6–24 小时**的状态——探它,就是 -回答「这个模型到底回来了没有」,同时也是唯一能让它提前恢复的入口。 +回答「这个模型到底回来了没有」,同时也是唯一能让它提前恢复的入口。**判死行** +(`model_probes.state='failed'`)同理必须探:判死会把该 (渠道, 模型) 从 +`/v1/models` 与 Playground 里摘掉(见 `api.models.merged_entries`),而隐藏 +本身没有到期时间——不探就永远是隐藏的。 **与生产完全隔离**:直连 provider 的 `stream_chat`,不经过 executor,因此 不写 `usage_events`、不动冷却表、不改健康度。探活结论写进独立的 `model_probes` 表;唯一的出口影响是列表展示——`failed` 期间该 (渠道, 模型) -从 `/v1/models` 里暂时摘掉(到期口径是那条负缓存的 `cooling_until`,见 -`api.models.merged_entries`),不影响选号与凭证健康度。 +从 `/v1/models` 里摘掉,`ok` 即恢复(见 `api.models.merged_entries`),不影响 +选号与凭证健康度。 **三态判定**(对应 `state`): @@ -31,6 +34,7 @@ import logging from typing import Any +from ..db.crypto import CredentialDecryptError from ..provider.base import ErrKind, EventKind, UpstreamHTTPError, event_error_kind from . import TaskReport @@ -57,7 +61,7 @@ def probe_payload() -> dict[str, Any]: class ModelProbeTask: - """探一轮仍被负缓存的 (渠道, 模型),成功即清负缓存。""" + """探一轮仍被负缓存或已判死的 (渠道, 模型),成功即清负缓存并恢复展示。""" def __init__(self, credentials, providers: dict, probes, *, pacer=None, timeout: float = PROBE_TIMEOUT_SECONDS, @@ -75,7 +79,7 @@ async def run_once(self) -> TaskReport: report = TaskReport() manual = self._probes.manual_marked() seen: set[tuple[str, str]] = set() - for row in self._credentials.blocked_cooldowns(): + for row in self._targets(): key = (row["provider"], row["model"]) if key in manual or key in seen: # 同一 (渠道, 模型) 可能挂着多个号的负缓存,只需探一次:结论 @@ -102,6 +106,27 @@ async def run_once(self) -> TaskReport: state=state, error=error) return report + def _targets(self) -> list[dict[str, Any]]: + """本轮要探的 (渠道, 模型):负缓存行 + 已判死行(去重)。 + + 判死行必须一起探:判死即从列表摘掉且**没有到期时间**,唯一的恢复入口 + 就是复探成功。顺序上先负缓存行、再判死行——判死行多半也挂着负缓存, + 去重后不会重复探;两类都去重后同一对只探一次。 + """ + targets: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for row in self._credentials.blocked_cooldowns(): + key = (row["provider"], row["model"]) + if key not in seen: + seen.add(key) + targets.append({"provider": row["provider"], "model": row["model"]}) + for row in self._probes.failed_rows(): + key = (row["provider"], row["model"]) + if key not in seen: + seen.add(key) + targets.append(row) + return targets + async def probe_now(self, provider_id: str, model: str) -> tuple[str, str]: """立即探一个 (渠道, 模型)(管理台「立即探活」/ 人工恢复前复核)。 @@ -134,7 +159,14 @@ def _probe_data(self, provider: str) -> dict[str, Any] | None: for candidates in (self._credentials.candidates([provider], selectable_only=True), self._credentials.candidates([provider])): for candidate in candidates: - data = self._credentials.credential_data(candidate.credential_id) + # 解不开(APP_SECRET 换过 / 密文损坏)→ 跳过这条凭证: + # 一条坏凭证不该让整个探活任务失败。 + try: + data = self._credentials.credential_data(candidate.credential_id) + except CredentialDecryptError: + logger.error("探活跳过 %s:凭证解密失败(APP_SECRET 是否被更换过?)", + candidate.credential_id) + continue if data is not None: return data return None diff --git a/src/tasks/pacer.py b/src/tasks/pacer.py index fae9593d..6437c11d 100644 --- a/src/tasks/pacer.py +++ b/src/tasks/pacer.py @@ -44,7 +44,8 @@ import hashlib import random import time -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager from ..config import live @@ -68,6 +69,28 @@ def stable_key(provider: str, identity: str | None) -> str: return f"{provider}:{digest}" +@asynccontextmanager +async def paced(pacer: Pacer | None, key: str | None = None) -> AsyncIterator[None]: + """`wait_turn` + `finally release` 的配对封装(7 个渠道共用)。 + + provider 的 `stream_chat` 都要「取名额 → 消费上游流 → 归还」,手写时最容易 + 漏的是 `finally`:`async for ... break` 不关闭 async generator,`finally` + 推迟到 GC 才跑,名额丢失会让该桶永久满载(见模块 docstring)。 + + `pacer=None` 表示不限速(无凭证渠道 / 测试),直接放行。 + 做成模块级函数而不是 `Pacer` 的方法:测试替身只需实现 `wait_turn` / + `release` 两个既有接口,不必跟着实现新方法。 + """ + if pacer is None: + yield + return + await pacer.wait_turn(key) + try: + yield + finally: + pacer.release(key) + + class Pacer: """上游请求节流器(严格 / 并发两种模式,见模块 docstring)。""" @@ -291,3 +314,5 @@ def release(self, key: str | None = None) -> None: else: return # 多余释放:不虚增名额 self._wake.set() # 唤醒可能正等这个名额的请求 + + diff --git a/src/tasks/quota_probe.py b/src/tasks/quota_probe.py index 2f9425b2..62e7d027 100644 --- a/src/tasks/quota_probe.py +++ b/src/tasks/quota_probe.py @@ -4,8 +4,12 @@ import logging +from ..db.crypto import CredentialDecryptError from ..db.repo import CredentialRepository +from ..error_text import error_text from ..provider.base import Quota +from ..provider.probe_failure import relogin_reason +from ..request_context import credential_scope from . import TaskReport from .pacer import Pacer @@ -24,11 +28,26 @@ def __init__(self, credentials: CredentialRepository, providers: dict, async def run_once(self, *, apply_pacing: bool = True) -> TaskReport: report = TaskReport() for candidate in self._credentials.candidates(): + # 硬禁用与用户软暂停(enabled=0)都跳过,口径与 refresh / checkin / + # growth / activity 一致:这些号一定探测失败,只会白打上游并把 + # 每小时一条 WARNING 灌进日志(实测容器实例对一条 10-15 就过期的 + # 号探了 160 次)。探测价值在「活号还有多少额度」,死号没有。 + if candidate.disabled or not candidate.enabled: + report.skipped += 1 + continue provider = self._providers.get(candidate.provider) if provider is None: report.skipped += 1 continue - data = self._credentials.credential_data(candidate.credential_id) + # 解不开(APP_SECRET 换过 / 密文损坏)→ 跳过这条凭证:一条坏凭证 + # 不该让整轮额度探测失败(早先直接冒泡 → 整轮任务挂掉)。 + try: + data = self._credentials.credential_data(candidate.credential_id) + except CredentialDecryptError: + logger.error("额度探测跳过 %s:凭证解密失败(APP_SECRET 是否被更换过?)", + candidate.credential_id) + report.skipped += 1 + continue if data is None: report.skipped += 1 continue @@ -36,11 +55,21 @@ async def run_once(self, *, apply_pacing: bool = True) -> TaskReport: if apply_pacing and self._pacer is not None: await self._pacer.wait_turn() try: - quota = await provider.probe_quota(data) + # 出口 IP 粘性:探活与推理必须走同一出口(见 provider/proxy.py) + with credential_scope(candidate.credential_id): + quota = await provider.probe_quota(data) except Exception as error: # noqa: BLE001 - 探测失败不能中断整批 - logger.warning("quota probe failed for %s: %s", candidate.credential_id, error) - # 探测失败 → health 置 NULL(unknown),绝不当作 0 分 - self._credentials.mark_probe_failed(candidate.credential_id) + logger.warning("quota probe failed for %s: %s", candidate.credential_id, + error_text(error)) + reason = relogin_reason(candidate.provider, error) + if reason: + # DEAD 是确定性的登录态死亡:端口探测已经证实,留着它 + # 只会每小时再撞一次。硬禁用后管理台显示可操作原因。 + self._credentials.mark_relogin_required( + candidate.credential_id, reason) + else: + # 其余探测失败 → health 置 NULL(unknown),绝不当作 0 分 + self._credentials.mark_probe_failed(candidate.credential_id) report.failed += 1 continue self._apply(candidate.credential_id, quota) diff --git a/src/tasks/refresh.py b/src/tasks/refresh.py index 8780b527..2b2516c8 100644 --- a/src/tasks/refresh.py +++ b/src/tasks/refresh.py @@ -12,8 +12,10 @@ import time from collections.abc import Callable +from ..db.crypto import CredentialDecryptError from ..db.repo import CredentialRepository from ..provider.base import UpstreamReloginRequired +from ..request_context import credential_scope from . import TaskReport from .pacer import Pacer @@ -45,7 +47,15 @@ async def run_once(self) -> TaskReport: report.skipped += 1 continue provider = self._providers.get(candidate.provider) - data = self._credentials.credential_data(candidate.credential_id) + # 解不开(APP_SECRET 换过 / 密文损坏)→ 跳过这条凭证:一条坏凭证 + # 不该让整轮预刷新失败(早先直接冒泡 → 整轮任务挂掉)。 + try: + data = self._credentials.credential_data(candidate.credential_id) + except CredentialDecryptError: + logger.error("预刷新跳过 %s:凭证解密失败(APP_SECRET 是否被更换过?)", + candidate.credential_id) + report.skipped += 1 + continue if provider is None or data is None: report.skipped += 1 continue @@ -62,12 +72,19 @@ async def run_once(self) -> TaskReport: async with self._credentials.edit_lock(candidate.credential_id): # 持锁后重读:等锁期间可能已被别的写者改过(如刚切了账号), # 用旧 data 刷新会把那次改动覆盖掉。 - data = self._credentials.credential_data(candidate.credential_id) + try: + data = self._credentials.credential_data(candidate.credential_id) + except CredentialDecryptError: + logger.error("预刷新跳过 %s:凭证解密失败(APP_SECRET 是否被更换过?)", + candidate.credential_id) + report.skipped += 1 + continue if data is None: report.skipped += 1 continue try: - refreshed = await provider.refresh(data) + with credential_scope(candidate.credential_id): + refreshed = await provider.refresh(data) except UpstreamReloginRequired as error: # 续期凭据已作废:重试无意义,且重试只会继续污染凭证状态。 # 标记成需重新登录后本任务下轮直接跳过(candidates 的 disabled diff --git a/src/tasks/retention.py b/src/tasks/retention.py index e46f091c..9c075a0b 100644 --- a/src/tasks/retention.py +++ b/src/tasks/retention.py @@ -2,15 +2,28 @@ from __future__ import annotations +import time + from ..stats.collector import StatsCollector +# 周期 rollup 只重算最近窗口(秒)。 +# +# `record()` 已经在同一事务里增量维护 `usage_hourly`,周期任务的重算只是 +# 对账。此前每 5 分钟做一次**全表** GROUP BY(最多 90 天明细):事务期间持有 +# SQLite 写锁,期间的 `record()` 撞上 busy_timeout 后异常被上层吞掉 → 这段 +# 时间的用量直接丢失。窗口取 2 小时且按整点对齐:边界小时必须整小时都进入 +# 重算,否则 REPLACE 会把该小时覆盖成「只剩窗口内那部分」的偏小值。 +# 历史全量重算走 `scripts/rollup_hourly.py`(诊断/修复用,不在周期路径上)。 +ROLLUP_OVERLAP_SECONDS = 2 * 3600 + class RetentionTask: """明细 90 天清理 + 小时汇总(永久保留)。 - `credentials` 非 None 时顺带清理已过期的 (凭证, 模型) 冷却行—— - 那些行只在限流/负缓存期间存在,不回收到期行会无限累积(管理台 - 每次列表都要整表扫一遍)。 + `credentials` 非 None 时顺带清理已过期的冷却行——模型级 + `credential_model_cooldowns` 整行删除、账号级 `credentials.cooling_until` + 置 NULL。那些值只在限流/负缓存期间有意义,不回收到期值会无限累积 + (管理台每次列表都要整表扫一遍,且陈旧值会让「冷却」排序失真)。 `credit_events` 非 None 时同样按保留期清理积分流水:它每轮探测都可能 落一行,不回收会长期增长(读侧有 LIMIT,但表本身会一直变大)。 @@ -23,11 +36,14 @@ class RetentionTask: `growth_events` 非 None 时按同一保留期清理成长中心运行记录:每轮探测 都可能落一行,不回收同样无界增长。 + + `checkin_events` 非 None 时按同一保留期清理签到审计日志:每轮签到都可能 + 落一行,不回收同样无界增长。 """ def __init__(self, collector: StatsCollector, *, retention_days: int = 90, credentials=None, credit_events=None, audit=None, - alerts=None, growth_events=None) -> None: + alerts=None, growth_events=None, checkin_events=None) -> None: self._collector = collector self.retention_days = retention_days self._credentials = credentials @@ -35,14 +51,29 @@ def __init__(self, collector: StatsCollector, *, retention_days: int = 90, self._audit = audit self._alerts = alerts self._growth_events = growth_events + self._checkin_events = checkin_events - def run_once(self) -> dict[str, int]: + @staticmethod + def rollup_since(now: int | None = None) -> int: + """本轮 rollup 的起点(整点对齐,见 `ROLLUP_OVERLAP_SECONDS`)。""" + raw = int(now if now is not None else time.time()) - ROLLUP_OVERLAP_SECONDS + if raw <= 0: + # 早期时间戳(测试/时钟异常):退回全量重算,绝不漏数据。 + return 0 + return (raw // 3600) * 3600 + + def run_once(self, *, now: int | None = None) -> dict[str, int]: """同步执行一轮(CPU/IO 密集,调用方负责放到线程池,见)。""" - rolled = self._collector.rollup_hourly() - purged = self._collector.purge_expired(self.retention_days) + rolled = self._collector.rollup_hourly(since=self.rollup_since(now)) + # now 同样传播给清理:调用方注入 now(测试/手动触发)时清理必须跟随, + # 否则这里会用真实时间删掉测试里的早期明细——测试输出依赖系统时钟。 + purged = self._collector.purge_expired(self.retention_days, now=now) expired_coolings = ( self._credentials.purge_expired_model_cooldowns() if self._credentials is not None else 0) + expired_account_coolings = ( + self._credentials.purge_expired_account_cooldowns() + if self._credentials is not None else 0) purged_credit_events = ( self._credit_events.prune(keep_days=self.retention_days) if self._credit_events is not None else 0) @@ -55,9 +86,14 @@ def run_once(self) -> dict[str, int]: purged_growth = ( self._growth_events.prune(keep_days=self.retention_days) if self._growth_events is not None else 0) + purged_checkin_events = ( + self._checkin_events.prune(keep_days=self.retention_days) + if self._checkin_events is not None else 0) return {"rolled_up": rolled, "purged": purged, "expired_coolings": expired_coolings, + "expired_account_coolings": expired_account_coolings, "purged_credit_events": purged_credit_events, "purged_audit": purged_audit, "purged_alerts": purged_alerts, - "purged_growth_events": purged_growth} + "purged_growth_events": purged_growth, + "purged_checkin_events": purged_checkin_events} diff --git a/src/tasks/runner.py b/src/tasks/runner.py index b0dede4f..f5aa1296 100644 --- a/src/tasks/runner.py +++ b/src/tasks/runner.py @@ -238,47 +238,42 @@ def _loop_specs(self, *, manual: bool = False) -> list[_LoopSpec]: `run_once` 自身返回 None,记 no-op。 """ specs: list[_LoopSpec] = [ - _LoopSpec("quota_probe", lambda: self._quota_probe.run_once(), - lambda: self._interval_seconds("quota_probe")), - _LoopSpec("refresh", self._refresh.run_once, - lambda: self._interval_seconds("refresh")), - _LoopSpec("retention", self._sync_retention, - lambda: self._interval_seconds("retention")), + self._spec("quota_probe", lambda: self._quota_probe.run_once()), + self._spec("refresh", self._refresh.run_once), + self._spec("retention", self._sync_retention), # 签到:留空时点 = 全天每 10 分钟一轮;有配置时点时仍按 10 分钟醒 # 一次,由 `due()` 自己判定是否落在抖动窗口内。**不能**把周期改成 # 「按最大时点间隔算」——服务恰在两个时点之间重启会直接漏签,而 # 10 分钟空转的代价只是读一次配置(与 activity 的取舍同理)。 - _LoopSpec("checkin", self._sync_checkin, - lambda: self._interval_seconds("checkin")), + self._spec("checkin", + ((lambda: self._sync_checkin(manual=True)) if manual + else self._sync_checkin)), ] if self._growth is not None: - specs.append(_LoopSpec("growth", self._sync_growth, - lambda: self._interval_seconds("growth"))) + specs.append(self._spec("growth", self._sync_growth)) if self._activity is not None: activity = ((lambda: self._sync_activity(manual=True)) if manual else self._sync_activity) - specs.append(_LoopSpec("activity", activity, - lambda: self._interval_seconds("activity"))) + specs.append(self._spec("activity", activity)) if self._model_catalog is not None: - specs.append(_LoopSpec("model_catalog", self._model_catalog, - lambda: self._interval_seconds("model_catalog"))) + specs.append(self._spec("model_catalog", self._model_catalog)) if self._model_probe is not None: - specs.append(_LoopSpec("model_probe", self._sync_model_probe, - lambda: self._interval_seconds("model_probe"))) + specs.append(self._spec("model_probe", self._sync_model_probe)) if self._openrouter_catalog is not None: - specs.append(_LoopSpec("openrouter_catalog", self._openrouter_catalog, - lambda: self._interval_seconds("openrouter_catalog"))) + specs.append(self._spec("openrouter_catalog", self._openrouter_catalog)) if self._alert is not None: - specs.append(_LoopSpec("alert", self._sync_alert, - lambda: self._interval_seconds("alert"))) + specs.append(self._spec("alert", self._sync_alert)) if self._backup is not None: - specs.append(_LoopSpec("backup", self._sync_backup, - lambda: self._interval_seconds("backup"))) + specs.append(self._spec("backup", self._sync_backup)) if self._upstream_status is not None: - specs.append(_LoopSpec("upstream_status", self._sync_upstream_status, - lambda: self._interval_seconds("upstream_status"))) + specs.append(self._spec("upstream_status", self._sync_upstream_status)) return specs + def _spec(self, key: str, + runner: Callable[[], Awaitable[object]]) -> _LoopSpec: + """拼一条循环描述:周期取值器统一走 `_interval_seconds`(热更现读)。""" + return _LoopSpec(key, runner, lambda: self._interval_seconds(key)) + async def run_task(self, key: str) -> dict[str, Any]: """手动运行一个后台任务一轮,返回该任务的最新运行态。 @@ -300,11 +295,21 @@ async def run_task(self, key: str) -> dict[str, Any]: _ok, ran = await self._run_guarded(spec.runner, TASK_BY_KEY[key].name, key=key) return {**self._task_item(key), "ran": ran, "busy": False} + @staticmethod + def _require(task: object | None, label: str) -> Any: + """取已装配的任务对象;未装配时显式报错(而不是静默 no-op)。 + + 这类失败只可能来自装配错误(可选任务未接线却出现在清单里), + 必须让 `_guarded` 记成失败而不是「未运行」。 + """ + if task is None: + raise RuntimeError(f"{label} task not configured") + return task + async def _sync_growth(self) -> object: """成长中心一轮:领礼物 / 派 Buddy / 任务 / 补登 / 兑换 / 抽奖 / 盲盒。""" - if self._growth is None: - raise RuntimeError("growth task not configured") - return await self._growth.run_once() + task = self._require(self._growth, "growth") + return await task.run_once() async def _sync_activity(self, *, manual: bool = False) -> object: """活跃上报:仅启用且处于配置小时窗口内才执行,成功凭证当日封账。 @@ -312,22 +317,22 @@ async def _sync_activity(self, *, manual: bool = False) -> object: 「是否启用」每轮现读:关掉再打开不需要重启,也不必重建循环。 `manual=True`(管理台「立即运行」)绕过时点窗口:手动就是立刻跑。 """ - if self._activity is None: - raise RuntimeError("activity task not configured") + task = self._require(self._activity, "activity") if not self._activity_enabled(): return None if not manual and not self._activity.due(): return None - return await self._activity.run_once() + return await task.run_once() - async def _sync_checkin(self) -> object: + async def _sync_checkin(self, *, manual: bool = False) -> object: """签到一轮:有配置时点时只在窗口内跑,留空则全天每 10 分钟一轮。 成功凭证当日封账(run_once 内跳过),失败凭证继续重试。 + `manual=True`(管理台「立即运行」)绕过时点窗口:手动就是立刻跑。 """ - if not self._checkin.due(): + if not manual and not self._checkin.due(): return None - return await self._checkin.run_once() + return await self._checkin.run_once(trigger="manual" if manual else "auto") async def _sync_retention(self) -> object: """明细清理:整表 GROUP BY / DELETE 是同步 SQLite,放线程池跑。 @@ -344,11 +349,10 @@ async def _sync_model_probe(self) -> object: 「模型探活周期」填上即生效,不必重启。关着时返回 None,`_guarded` 不记运行态——页面显示「未运行」,而不是「刚跑过」。 """ - if self._model_probe is None: - raise RuntimeError("model probe task not configured") + task = self._require(self._model_probe, "model probe") if int(self._model_probe_minutes()) <= 0: return None - return await self._model_probe.run_once() + return await task.run_once() async def _sync_alert(self) -> object: """运维告警一轮:评估四类规则、静默去重后落库 + 推送。 @@ -356,18 +360,16 @@ async def _sync_alert(self) -> object: 是否启用每轮现读:AlertTask.run_once 关着时返回 None, `_guarded` 不记运行态——页面显示「未运行」,而不是「刚跑过」。 """ - if self._alert is None: - raise RuntimeError("alert task not configured") - return await self._alert.run_once() + task = self._require(self._alert, "alert") + return await task.run_once() async def _sync_backup(self) -> object: """数据库备份一轮:VACUUM INTO 是同步磁盘 IO,放线程池跑。 直接在事件循环里执行会卡住所有并发请求(库越大越久)。 """ - if self._backup is None: - raise RuntimeError("backup task not configured") - return await asyncio.to_thread(self._backup.run_once) + task = self._require(self._backup, "backup") + return await asyncio.to_thread(task.run_once) async def _sync_upstream_status(self) -> object: """上游状态页一轮:并发抓全部源并原子替换内存快照。 @@ -375,9 +377,8 @@ async def _sync_upstream_status(self) -> object: 是否启用每轮现读:任务关着时 `run_once` 返回 None,`_guarded` 不记 运行态——页面显示「未运行」,而不是「刚跑过」。 """ - if self._upstream_status is None: - raise RuntimeError("upstream status task not configured") - return await self._upstream_status.run_once() + task = self._require(self._upstream_status, "upstream status") + return await task.run_once() async def _loop(self, name: str, runner: Callable[[], Awaitable[object]], interval: float | Callable[[], float], @@ -471,6 +472,7 @@ def _task_item(self, key: str) -> dict[str, Any]: "key": spec.key, "name": spec.name, "description": spec.description, + "group": spec.group, "interval_seconds": self._interval_seconds(spec.key), "enabled": self._task_enabled(spec.key), "running": spec.key in self._running, @@ -483,41 +485,41 @@ def _task_item(self, key: str) -> dict[str, Any]: } def _interval_seconds(self, key: str) -> float: - if key == "quota_probe": - return self._quota_interval - if key == "refresh": - return self._refresh_interval - if key == "checkin": - # 留空(旧行为)= 10 分钟醒一次即可;配了时点则必须 1 分钟醒一次—— - # 抖动=0 时窗口只有「恰好那一分钟」,10 分钟周期会大概率整天落空。 - # 醒来只是读一次配置 + 比较几个整数,代价可忽略。 - return self._checkin_interval - if key == "growth": - return self._growth_interval - if key == "activity": - return float(self._activity_interval) - if key == "model_catalog": - return self._model_catalog_interval - if key == "model_probe": - return self._model_probe_interval - if key == "openrouter_catalog": - return self._openrouter_catalog_interval - if key == "alert": - return self._alert_interval - if key == "backup": - return self._backup_interval - if key == "upstream_status": - return self._upstream_status_interval - return self._retention_interval + """按 key 取当前生效周期(秒)。property 是唯一真源,这里只做派发。""" + getter = self._interval_getters().get(key) + return float(getter()) if getter is not None else self._retention_interval + + def _interval_getters(self) -> dict[str, Callable[[], float]]: + """key → 周期 property 的派发表。 + + 与 `_task_enabled` 分开维护:周期是每个任务必有的一项,开关只有 + activity / alert / upstream_status 三个可关。用字典而非 if-chain, + 新增任务时漏接线会直接落进 `_interval_seconds` 的兜底分支(保留期), + 而不是静默返回别的任务的周期。 + """ + return { + "quota_probe": lambda: self._quota_interval, + "refresh": lambda: self._refresh_interval, + "retention": lambda: self._retention_interval, + "checkin": lambda: self._checkin_interval, + "growth": lambda: self._growth_interval, + "activity": lambda: self._activity_interval, + "model_catalog": lambda: self._model_catalog_interval, + "model_probe": lambda: self._model_probe_interval, + "openrouter_catalog": lambda: self._openrouter_catalog_interval, + "alert": lambda: self._alert_interval, + "backup": lambda: self._backup_interval, + "upstream_status": lambda: self._upstream_status_interval, + } def _task_enabled(self, key: str) -> bool: - if key == "activity": - return bool(self._activity_enabled()) - if key == "alert": - return bool(self._alert_enabled()) - if key == "upstream_status": - return bool(self._upstream_status_enabled()) - return True + """任务是否启用;只有三个可关的任务需要现读开关,其余恒 True。""" + getter = { + "activity": self._activity_enabled, + "alert": self._alert_enabled, + "upstream_status": self._upstream_status_enabled, + }.get(key) + return True if getter is None else bool(getter()) async def stop(self) -> None: for task in self._tasks: @@ -545,7 +547,7 @@ def _as_report(result: object) -> dict[str, Any]: def build_runner(credentials, providers: dict, stats_collector, config, - growth_events=None, credit_events=None, audit=None, + growth_events=None, credit_events=None, checkin_events=None, audit=None, model_catalog: Callable[[], Awaitable[object]] | None = None, openrouter_catalog: Callable[[], Awaitable[object]] | None = None, openrouter_catalog_minutes: int | Callable[[], int] = 1440, @@ -554,7 +556,8 @@ def build_runner(credentials, providers: dict, stats_collector, config, upstream_status: UpstreamStatusTask | None = None, model_probe: ModelProbeTask | None = None, model_probe_minutes: int | Callable[[], int] = 0, - status: TaskStatusStore | None = None) -> TaskRunner: + status: TaskStatusStore | None = None, + on_checkin_success: Callable[[str], None] | None = None) -> TaskRunner: """按配置装配后台任务(Pacer 由两个 provider 共享)。 growth_events 为 None 时(老调用方/测试)不装配成长中心任务:没有落库目标 @@ -563,6 +566,9 @@ def build_runner(credentials, providers: dict, stats_collector, config, credit_events 同理:为 None 时保留流水清理不启用(表仍会随探测增长, 但不影响功能;生产路径总是传入)。 + checkin_events 同理:为 None 时签到不落审计日志、也不清理该表(老调用方与 + 测试保持原行为);生产路径总是传入。 + audit 同理:为 None 时不清理审计流水(表仍会随登录/写操作增长; 生产路径总是传入)。 @@ -612,12 +618,30 @@ def build_runner(credentials, providers: dict, stats_collector, config, task_status=status_store, enabled=lambda: config.alert_enabled, webhook_url=lambda: config.alert_webhook_url, + feishu_url=lambda: config.alert_feishu_webhook_url, + slack_url=lambda: config.alert_slack_webhook_url, pool_ready_min=lambda: config.alert_pool_ready_min, task_failures=lambda: config.alert_task_failures, token_expiry_hours=lambda: config.alert_token_expiry_hours, error_rate_threshold=lambda: config.alert_error_rate_threshold, error_rate_min_requests=lambda: config.alert_error_rate_min_requests, error_rate_window_minutes=lambda: config.alert_error_rate_window_minutes, + key_burst_tokens=lambda: config.alert_key_burst_tokens, + key_burst_window_minutes=lambda: config.alert_key_burst_window_minutes, + off_hours_start=lambda: config.alert_off_hours_start, + off_hours_end=lambda: config.alert_off_hours_end, + off_hours_min_requests=lambda: config.alert_off_hours_min_requests, + budget_cny=lambda: config.alert_budget_cny, + budget_levels=lambda: config.alert_budget_levels, + channel_dead_min_requests=( + lambda: config.alert_channel_dead_min_requests), + channel_dead_window_minutes=( + lambda: config.alert_channel_dead_window_minutes), + checkin_fail_min_streak=( + lambda: config.alert_checkin_fail_min_streak), + checkin_fail_window_hours=( + lambda: config.alert_checkin_fail_window_hours), + checkins=checkin_events, silence_minutes=lambda: config.alert_silence_minutes, for_ticks=lambda: config.alert_for_ticks, clear_ticks=lambda: config.alert_clear_ticks, @@ -625,14 +649,18 @@ def build_runner(credentials, providers: dict, stats_collector, config, return TaskRunner( quota_probe=QuotaProbeTask(credentials, providers, pacer), checkin=CheckinTask(credentials, providers, pacer=pacer, + events=checkin_events, times=lambda: config.checkin_times, - jitter_minutes=lambda: config.checkin_jitter_minutes), + jitter_minutes=lambda: config.checkin_jitter_minutes, + skip_providers=lambda: config.checkin_skip_providers, + on_success=on_checkin_success), growth=growth, activity=activity, refresh=RefreshTask(credentials, providers, skew_seconds=config.refresh_skew_hours * 3600, now=lambda: int(time.time()), pacer=pacer), retention=RetentionTask(stats_collector, credentials=credentials, - credit_events=credit_events, audit=audit, + credit_events=credit_events, + checkin_events=checkin_events, audit=audit, alerts=alerts, growth_events=growth_events), quota_probe_minutes=lambda: config.quota_probe_minutes, refresh_interval_minutes=lambda: config.refresh_interval_minutes, diff --git a/src/tasks/status.py b/src/tasks/status.py index 070326e6..ff69d3b9 100644 --- a/src/tasks/status.py +++ b/src/tasks/status.py @@ -20,53 +20,76 @@ @dataclass(frozen=True) class TaskSpec: - """一个后台任务的静态描述(名称 + 一句话说明)。""" + """一个后台任务的静态描述(名称 + 一句话说明 + 业务分组)。""" key: str name: str description: str + group: str # 业务分组 key(见 TASK_GROUPS),管理台二级 tab + + +# 任务在管理台「任务与配置」里的业务分组:key → 二级 tab 名。顺序即展示顺序; +# 分组与归属由后端定义,前端不硬编码 key 列表,后端加任务/改归属不用动前端。 +TASK_GROUPS: tuple[tuple[str, str], ...] = ( + ("account", "账号维护"), + ("model", "模型与渠道"), + ("ops", "运维与数据"), +) # 与 TaskRunner.start() 里建立的循环一一对应。这里只描述「有哪些任务」, # 周期与开关是运行态(可热更),由 TaskRunner.task_status() 现算。 TASK_SPECS: tuple[TaskSpec, ...] = ( TaskSpec("quota_probe", "额度探测", - "探测上游剩余额度并写回凭证健康度;周期见「额度探测周期」。"), + "探测上游剩余额度并写回凭证健康度;周期见「额度探测周期」。", + group="account"), TaskSpec("refresh", "token 预刷新", - "到期前窗口内轮换 refresh token;只在进入窗口时真正调用上游。"), + "到期前窗口内轮换 refresh token;只在进入窗口时真正调用上游。", + group="account"), TaskSpec("checkin", "每日签到", "按配置时点(留空 = 全天每 10 分钟)检查;抖动提前 0..N 分钟," - "同一时点每天只跑一次。成功后该凭证当日封账,失败持续重试。"), + "同一时点每天只跑一次。成功后该凭证当日封账,失败持续重试。", + group="account"), TaskSpec("growth", "成长中心", "领旅行礼物 / 派 Buddy / 领任务奖 / 补登 / 兑换 / 抽奖 / 开盲盒;" - "周期见「成长中心周期」。"), + "周期见「成长中心周期」。", + group="account"), TaskSpec("activity", "活跃上报", - "按配置时点为 CodeBuddy 账号补发对话事件;关闭时每轮都是 no-op。"), + "按配置时点为 CodeBuddy 账号补发对话事件;关闭时每轮都是 no-op。", + group="account"), TaskSpec("retention", "明细清理", - "小时汇总 + 90 天明细清理,顺带回收过期冷却与积分流水。"), + "小时汇总 + 90 天明细清理,顺带回收过期冷却与积分流水。", + group="ops"), TaskSpec("model_catalog", "渠道模型列表刷新", "兜底重拉各渠道模型表:没有它,列表只在有人访问 /v1/models 或 " - "Playground 时才刷新,纯 API 用法的部署会让归属表与落盘快照变陈旧。"), + "Playground 时才刷新,纯 API 用法的部署会让归属表与落盘快照变陈旧。", + group="model"), TaskSpec("model_probe", "模型可用性探活", - "对仍被负缓存(「上游无此模型」)的 (渠道, 模型) 发一次最小对话:" - "成功即解除避让,额度/限流类失败只记 inconclusive 不禁用。" - "周期见「模型探活周期」。"), + "对仍被负缓存(「上游无此模型」)与已判死的 (渠道, 模型) 发一次最小" + "对话:成功即恢复展示并解除避让,额度/限流类失败只记 inconclusive" + " 不禁用。周期见「模型探活周期」。", + group="model"), TaskSpec("openrouter_catalog", "模型目录刷新(OpenRouter)", "一次抓取 OpenRouter 公开模型列表,落盘三份数据:刊例价(成本估算)、" "明细元数据(「模型列表」页)、Artificial Analysis 能力指数" "(Playground 与 /v1/models 的 benchmarks)。拉取失败只是成本与" - "分数显示 —,不影响模型列表。"), + "分数显示 —,不影响模型列表。", + group="model"), TaskSpec("alert", "运维告警", - "周期评估四类风险(池耗尽 / 任务连续失败 / token 临近到期 / 上游" - "错误率骤升),命中落站内记录并可推送 webhook;周期见「运维告警周期」。"), + "周期评估七类规则(池耗尽 / 任务连续失败 / token 临近到期 / 上游" + "错误率骤升 / 单 Key 用量暴涨 / 非工作时段请求 / 预算分层)," + "命中落站内记录并可推送 webhook;周期见「运维告警周期」。", + group="ops"), TaskSpec("backup", "数据库备份", "周期性把主库一致性快照(VACUUM INTO)写进 DATA_DIR/backups/," - "并只保留最近 N 份;周期与保留份数见「备份周期」「备份保留份数」。"), + "并只保留最近 N 份;周期与保留份数见「备份周期」「备份保留份数」。", + group="ops"), TaskSpec("upstream_status", "上游状态页", "并发抓各模型 / 网关提供方的官方状态接口(OpenCode Zen / Kilo / " "DeepSeek / Together / Moonshot / MiniMax / OpenAI / Anthropic)," "归一化后存内存供「上游状态」页展示。只做解释不做决策,不参与选号;" - "周期见「上游状态页周期」。"), + "周期见「上游状态页周期」。", + group="ops"), ) TASK_BY_KEY: dict[str, TaskSpec] = {spec.key: spec for spec in TASK_SPECS} diff --git a/src/version.py b/src/version.py index 538a6435..6f1beb78 100644 --- a/src/version.py +++ b/src/version.py @@ -14,7 +14,7 @@ from pathlib import Path # 回落值:仅在读不到 pyproject.toml 时使用。升版时与 pyproject 一起改。 -FALLBACK_VERSION = "0.9.1" +FALLBACK_VERSION = "0.13.1" # 相对本文件定位项目根:src/version.py → 上一级即项目根 _PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" diff --git a/src/webapp/handlers.py b/src/webapp/handlers.py index 6d3027a5..1c5420db 100644 --- a/src/webapp/handlers.py +++ b/src/webapp/handlers.py @@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse from starlette.requests import ClientDisconnect +from ..api.deps import ServicePaused from ..auth.csrf import CsrfRejectedError from ..auth.rbac import ( ForbiddenError, @@ -68,6 +69,13 @@ async def _no_health(_request, error: NoHealthyCredential): return JSONResponse(status_code=503, content=error_payload(str(error), "no_healthy_credential", 503)) + @app.exception_handler(ServicePaused) + async def _paused(_request, error: ServicePaused): + """kill switch:503 + 受控文案。原因只进日志(见 deps._ensure_not_paused), + 不回显——运维备注可能含事故编号或内部链接。""" + return JSONResponse(status_code=503, + content=error_payload(str(error), "service_paused", 503)) + @app.exception_handler(NoProviderForModel) async def _no_provider(_request, error: NoProviderForModel): return JSONResponse(status_code=400, diff --git a/src/webapp/limits.py b/src/webapp/limits.py index b9e7171f..423bff34 100644 --- a/src/webapp/limits.py +++ b/src/webapp/limits.py @@ -1,8 +1,8 @@ """请求体上限(纯 ASGI 中间件,PROPOSAL §6)。 -登录接口 8KB(PBKDF2 是 CPU 密集操作,超大 body 无意义),其余 16MB -(聊天请求可能带图片 base64)。必须挂在最外层:放在内层时下游会先读完 -body,限制就失去意义。 +登录接口 8KB(PBKDF2 是 CPU 密集操作,超大 body 无意义),备份恢复 512MB +(SQLite 快照本来就是几十到几百 MB),其余 16MB(聊天请求可能带图片 base64)。 +必须挂在最外层:放在内层时下游会先读完 body,限制就失去意义。 """ from __future__ import annotations @@ -11,12 +11,26 @@ # 其余 16MB(聊天请求可能带图片 base64)。 LOGIN_BODY_LIMIT = 8 * 1024 DEFAULT_BODY_LIMIT = 16 * 1024 * 1024 +# 备份恢复:与 `admin_backups.MAX_UPLOAD_BYTES` 必须同值。不同值时的表现是 +# 「端点声称支持 512MB,中间件在 16MB 就 413」——大库的「恢复备份」功能 +# 静默不可用,而错误文案说的是 request body too large,与备份毫无关联。 +BACKUP_UPLOAD_LIMIT = 512 * 1024 * 1024 + +# 按路径给上限:长的先比(登录路径本身很短,顺序无所谓,但显式排序更稳)。 +_PATH_LIMITS: tuple[tuple[str, int], ...] = ( + ("/api/auth/login", LOGIN_BODY_LIMIT), + ("/api/backups/restore", BACKUP_UPLOAD_LIMIT), +) def _body_limit(path: str) -> int: # rstrip 处理尾斜杠:/api/auth/login/ 同样按登录上限(8KB),否则会先被 # 按 16MB 读完再 307 重定向,绕过登录限流一次 - return LOGIN_BODY_LIMIT if path.rstrip("/") == "/api/auth/login" else DEFAULT_BODY_LIMIT + trimmed = path.rstrip("/") + for prefix, limit in _PATH_LIMITS: + if trimmed == prefix: + return limit + return DEFAULT_BODY_LIMIT async def _send_too_large(send) -> None: diff --git a/src/webapp/logging.py b/src/webapp/logging.py index 42164c3c..44f432de 100644 --- a/src/webapp/logging.py +++ b/src/webapp/logging.py @@ -27,9 +27,14 @@ import logging import sys +from ..request_context import get_request_id + # 带时间戳:launchd/newsyslog 重定向的文件里只有进程自己写的内容, -# 没有 journald/docker 那种外部时间戳,格式必须自立 -LOG_FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s" +# 没有 journald/docker 那种外部时间戳,格式必须自立。 +# `request_id` 让平台日志(journald / launchd 文件)也能按一次请求串起来: +# executor 每次换号、冷却、降级都带同一个上下文,与响应头 `X-Request-ID` +# 和日志页的过滤值是同一个 ID。后台任务没有请求上下文 → 显示 `-`。 +LOG_FORMAT = "%(asctime)s %(levelname)-7s [%(request_id)s] %(name)s: %(message)s" DATE_FORMAT = "%Y-%m-%d %H:%M:%S" # 第三方库降噪:root 升到 INFO 后 httpx/httpcore 会给每个上游请求打一条 @@ -46,6 +51,19 @@ class _RootHandler(logging.StreamHandler): """ +class _RequestIdFilter(logging.Filter): + """把请求上下文里的 `request_id` 注进每条记录(后台任务为 `-`)。 + + 不放进 formatter 的逻辑:`LogRecord` 是 logging 构造的,模块级 filter 是 + 唯一能在格式化前改记录的地方;放 filter 里也保证「代码随手 new 一个 + LogRecord 直接 format」不会因缺属性抛 KeyError(`defaults` 兜底)。 + """ + + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = get_request_id() or "-" + return True + + def configure_logging(level: str = "INFO") -> None: """配置 root logger:应用日志走 stderr(平台采集/轮转)。 @@ -60,8 +78,13 @@ def configure_logging(level: str = "INFO") -> None: handler = next((h for h in root.handlers if isinstance(h, _RootHandler)), None) if handler is None: handler = _RootHandler(sys.stderr) - handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT)) + # defaults:手工 format 一个没有 request_id 的 LogRecord 也能渲染 + handler.setFormatter(logging.Formatter( + LOG_FORMAT, DATE_FORMAT, defaults={"request_id": "-"})) root.addHandler(handler) + # 幂等:重复 configure 不能再叠加 filter(否则每条日志设两次,无害但脏) + if not any(isinstance(item, _RequestIdFilter) for item in handler.filters): + handler.addFilter(_RequestIdFilter()) handler.setLevel(normalized) # 降噪而非禁用:默认(INFO 及以上)把这两个库压到 WARNING; diff --git a/src/webapp/request_id.py b/src/webapp/request_id.py index b085ba88..1c4edd7c 100644 --- a/src/webapp/request_id.py +++ b/src/webapp/request_id.py @@ -62,3 +62,6 @@ async def send_with_header(message): # 调用,最可靠。 set_api_key_id(None) set_session_id(None) + # request_id 也要复原:超时/后台任务可能比请求活得久,残留旧 ID 会 + # 让后续写入落进别人的 request_id(归因的「换号救回」按它关联)。 + set_request_id(None) diff --git a/src/webapp/static.py b/src/webapp/static.py index c6ebc2a9..ec323628 100644 --- a/src/webapp/static.py +++ b/src/webapp/static.py @@ -98,3 +98,22 @@ async def spa(path: str): if not index.is_file(): return HTMLResponse(_FRONTEND_MISSING_HTML, status_code=503) return FileResponse(index, headers=_INDEX_CACHE_HEADERS) + + # 写方法同样兜到 JSON 404:只注册 GET 时,未匹配的 POST /api/** 会落到 + # Starlette 的 405 `{"detail":"Method Not Allowed"}`(FastAPI 的默认 + # 路由匹配行为),与 PROPOSAL §6「未匹配的 /api、/v1 返回 JSON 404」不符: + # 客户端拼错端点 + 错方法时拿到的是 HTML/裸 detail 形状,不是本服务的 + # 统一错误体(`error.code`),调用方按错误体解析会踩空。 + @app.api_route("/{path:path}", methods=["POST", "PUT", "PATCH", "DELETE"], + include_in_schema=False) + async def spa_write(path: str): + """写方法:/api、/v1 前缀回 JSON 404,其余按 SPA 处理。""" + if path.startswith(_API_PREFIXES) or path in ("api", "v1"): + return api_not_found(path) + dist = frontend_dist() + if dist is None: + return HTMLResponse(_FRONTEND_MISSING_HTML, status_code=503) + index = dist / "index.html" + if not index.is_file(): + return HTMLResponse(_FRONTEND_MISSING_HTML, status_code=503) + return FileResponse(index, headers=_INDEX_CACHE_HEADERS) diff --git a/tests/api/test_admin_auth.py b/tests/api/test_admin_auth.py index 945a4df0..cae4abc0 100644 --- a/tests/api/test_admin_auth.py +++ b/tests/api/test_admin_auth.py @@ -91,6 +91,23 @@ def test_session_endpoint_requires_login(tmp_path): with TestClient(app) as client: assert client.get("/api/auth/session").status_code == 401 +def test_session_from_previous_database_rejected(tmp_path): + """服务器重置 / 换机后整库重建:同 APP_SECRET 下旧 Cookie 不得再进后台。 + + issue #37:新库 session_epoch 从 0 重来,旧 Cookie 的 ep=0 会重新匹配; + 账号名字不变、只是密码变了的情况下,浏览器刷新就直接进了后台。 + 签发时间早于当前账号创建 → 必然是上一个库签的,按未认证处理。 + """ + from src.auth.session import create_session_token + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings) + with TestClient(app) as client: + stale = create_session_token("root", SECRET, issued_at=1) + client.cookies.set("coding2api_session", stale) + assert client.get("/api/auth/session").status_code == 401 + def test_build_app_fails_without_users_file(tmp_path, monkeypatch): """文件缺失**且**库为空才是致命错误(库非空时文件缺失可正常启动)。""" from src.auth.users import UsersFileError diff --git a/tests/api/test_admin_backups.py b/tests/api/test_admin_backups.py new file mode 100644 index 00000000..2c375ff8 --- /dev/null +++ b/tests/api/test_admin_backups.py @@ -0,0 +1,164 @@ +"""备份管理端点:列表 / 立即备份 / 下载 / 删除 / 上传恢复与启动应用。""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from fastapi.testclient import TestClient + +from src.auth.session import create_session_token +from src.config import Settings +from src.db.backup import backup_dir, pending_restore_path, stage_restore +from src.main import build_app +from tests.helpers.constants import SECRET + + +def _settings(tmp_path: Path) -> Settings: + return Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + + +def test_backups_require_admin(tmp_path): + app = build_app(_settings(tmp_path)) + with TestClient(app) as guest: + guest.cookies.set("coding2api_session", create_session_token("guest", SECRET)) + assert guest.get("/api/backups").status_code == 403 + assert guest.post("/api/backups").status_code == 403 + + +def test_list_create_download_delete(admin_client): + app, client = admin_client + directory = backup_dir(app.state.settings.db_path) + + assert client.get("/api/backups").json() == {"backups": [], "pending": None} + + created = client.post("/api/backups") + assert created.status_code == 200 + name = created.json()["backup"]["name"] + assert name.endswith(".sqlite3") and (directory / name).is_file() + + listed = client.get("/api/backups").json()["backups"] + assert [item["name"] for item in listed] == [name] + assert listed[0]["size"] > 0 + + download = client.get(f"/api/backups/{name}") + assert download.status_code == 200 + assert download.content.startswith(b"SQLite format 3") + + assert client.delete(f"/api/backups/{name}").json() == {"ok": True} + assert client.get("/api/backups").json()["backups"] == [] + # 再删同一条:已不存在 → 400 + assert client.delete(f"/api/backups/{name}").status_code == 400 + + +def test_download_and_delete_reject_illegal_names(admin_client): + _app, client = admin_client + # 路径分隔符要编码后才会进入 handler(未编码的 `..` 会被路由归一成 404) + for bad in ("notes.txt", "missing.sqlite3", "%2E%2E", "..%5Cx.sqlite3"): + assert client.get(f"/api/backups/{bad}").status_code == 400 + assert client.delete(f"/api/backups/{bad}").status_code == 400 + + +def test_restore_upload_validates_and_stages(admin_client): + app, client = admin_client + created = client.post("/api/backups").json()["backup"]["name"] + content = client.get(f"/api/backups/{created}").content + + response = client.post( + "/api/backups/restore?filename=my-backup.sqlite3", + content=content, + headers={"Content-Type": "application/octet-stream"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["staged"] is True and body["restart_required"] is True + assert body["filename"] == "my-backup.sqlite3" + pending = pending_restore_path(app.state.settings.db_path) + assert pending.is_file() + assert client.get("/api/backups").json()["pending"]["filename"] == "my-backup.sqlite3" + + +def test_restore_upload_rejects_bad_payloads(admin_client): + app, client = admin_client + assert client.post("/api/backups/restore", content=b"", + headers={"Content-Type": "application/octet-stream"}).status_code == 400 + assert client.post("/api/backups/restore", content=b"not a database", + headers={"Content-Type": "application/octet-stream"}).status_code == 400 + # 能打开、缺本服务核心表的 sqlite 也不能恢复 + foreign = app.state.settings.db_path + ".foreign" + conn = sqlite3.connect(foreign) + conn.execute("CREATE TABLE t (x TEXT)") + conn.commit() + conn.close() + assert client.post("/api/backups/restore", content=Path(foreign).read_bytes(), + headers={"Content-Type": "application/octet-stream"}).status_code == 400 + assert pending_restore_path(app.state.settings.db_path).exists() is False + + +def test_pending_restore_applies_on_next_startup(tmp_path): + settings = _settings(tmp_path) + app = build_app(settings) + with TestClient(app) as client: + client.cookies.set("coding2api_session", create_session_token("root", SECRET)) + client.put("/api/settings", json={"values": {"alert_interval_minutes": 9}}) + backup = client.post("/api/backups").json()["backup"]["name"] + content = client.get(f"/api/backups/{backup}").content + client.put("/api/settings", json={"values": {"alert_interval_minutes": 3}}) + + # 模拟「上传备份 → 重启」:直接把备份放进待恢复位置 + stage_restore(settings.db_path, content, filename=backup) + restarted = build_app(settings) + with TestClient(restarted) as client: + client.cookies.set("coding2api_session", create_session_token("root", SECRET)) + values = {item["key"]: item["value"] + for item in client.get("/api/settings").json()["settings"]} + assert values["alert_interval_minutes"] == 9 + assert pending_restore_path(settings.db_path).exists() is False + + +def test_list_when_backup_dir_is_gone(admin_client): + """备份目录被人工删掉:列表返回空数组,不报 500。""" + import shutil + app, client = admin_client + assert client.get("/api/backups").json()["backups"] == [] + shutil.rmtree(backup_dir(app.state.settings.db_path), ignore_errors=True) + assert client.get("/api/backups").json()["backups"] == [] + + +def test_create_backup_failure_is_reported(admin_client, monkeypatch): + """写盘失败(磁盘满等)→ 400 + 原始原因,不是 500。""" + from src.api import admin_backups + + def boom(*_args, **_kwargs): + raise RuntimeError("disk full") + + monkeypatch.setattr(admin_backups, "backup_database", boom) + _app, client = admin_client + response = client.post("/api/backups") + assert response.status_code == 400 + assert "disk full" in response.json()["error"]["message"] + + +def test_restore_upload_rejects_oversized_body(admin_client, monkeypatch): + from src.api import admin_backups + + monkeypatch.setattr(admin_backups, "MAX_UPLOAD_BYTES", 1) + _app, client = admin_client + response = client.post("/api/backups/restore", content=b"ab", + headers={"Content-Type": "application/octet-stream"}) + assert response.status_code == 400 + assert "不能超过" in response.json()["error"]["message"] + + +def test_list_skips_non_files_and_symlinks(admin_client, tmp_path): + """备份目录里混入子目录 / 符号链接时不展示(也不跟随链接)。""" + app, client = admin_client + directory = backup_dir(app.state.settings.db_path) + directory.mkdir(parents=True, exist_ok=True) + (directory / "sub.sqlite3").mkdir() + outside = tmp_path / "outside.sqlite3" + outside.write_bytes(b"x") + (directory / "link.sqlite3").symlink_to(outside) + + assert client.get("/api/backups").json()["backups"] == [] diff --git a/tests/api/test_admin_credentials.py b/tests/api/test_admin_credentials.py index 2208c915..4b6fe381 100644 --- a/tests/api/test_admin_credentials.py +++ b/tests/api/test_admin_credentials.py @@ -10,7 +10,7 @@ from src.auth.session import create_session_token from src.config import Settings from src.main import build_app -from src.provider.base import Quota +from src.provider.base import Quota, UpstreamHTTPError from src.provider.codebuddy.checkin import CheckinResult from src.provider.codebuddy.refresh import Account from tests.helpers.constants import SECRET @@ -78,6 +78,38 @@ def test_credential_probe_and_checkin_endpoints(admin_client): assert client.post("/api/credentials/ghost/probe").status_code == 400 assert client.post("/api/credentials/ghost/checkin").status_code == 400 +def test_credential_checkin_upstream_error_is_failed_result_not_500(admin_client): + """上游拒绝(账号被禁用 / 登录过期)→ `ok=False` 的正常结果,不是 500。 + + 实测 2026-10-26 14:47:管理台点「签到」回 500 + 一整屏 ASGI traceback, + 前端只弹「签到失败」,看不出真实原因(账号已被禁用)。上游对这条凭证的 + 答复是**正常结果**,前端本来就按 `ok` / `message` 渲染。 + """ + app, client = admin_client + + class FailingCheckin: + id = "codebuddy" + endpoint = "https://e" + + async def probe_quota(self, _data): + return Quota(remaining=7, total=10, probed_at=1) + + async def checkin(self, _data): + raise UpstreamHTTPError( + 403, '{"code":40302,"message":"账号已被禁用"}'.encode()) + + app.state.services.registry["codebuddy"] = FailingCheckin() + credential_id = app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t"}) + response = client.post(f"/api/credentials/{credential_id}/checkin") + assert response.status_code == 200 + body = response.json() + assert body["ok"] is False + assert body["already_checked_in"] is False + # 403 → 「凭证失效,需要重新登录」;且不得回显上游原文 + assert body["message"] == "签到失败:credential_rejected" + assert "账号已被禁用" not in body["message"] + def test_credential_account_endpoints_unsupported_for_trae(admin_client): app, client = admin_client credential_id = app.state.credentials.add(provider="trae", @@ -87,6 +119,33 @@ def test_credential_account_endpoints_unsupported_for_trae(admin_client): json={"account_id": "a"}).status_code == 400 assert client.get("/api/credentials/ghost/accounts").status_code == 400 + +def test_credential_export_returns_importable_json_and_audits(admin_client): + """单条导出:与批量导入同形的明文条目 + 存文件头 + 审计留痕。""" + from src.audit.actions import ACTION_CREDENTIAL_EXPORT + + app, client = admin_client + credential_id = app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "secret-token"}, + nickname="主账号", tags=["团队", "备用"]) + response = client.get(f"/api/credentials/{credential_id}/export") + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.headers["content-disposition"].startswith("attachment;") + assert response.json() == { + "provider": "codebuddy", "nickname": "主账号", "tags": ["团队", "备用"], + "credential": {"bearer_token": "secret-token"}, + } + rows = app.state.services.audit.query(action=ACTION_CREDENTIAL_EXPORT) + assert len(rows) == 1 + assert rows[0]["actor"] == "root" and rows[0]["target"] == credential_id + + assert client.get("/api/credentials/ghost/export").status_code == 400 + + # viewer 不能把令牌带出系统(与写操作同一道 require_operator) + client.cookies.set("coding2api_session", create_session_token("guest", SECRET)) + assert client.get(f"/api/credentials/{credential_id}/export").status_code == 403 + def test_select_account_rejects_vanished_credential(admin_client): """provider 支持切换但持锁后凭证已消失 → 400,不拿 None 去打上游。""" app, client = admin_client @@ -286,6 +345,132 @@ def test_upstream_auth_poll_unknown_provider(admin_client): json={"provider": "trae", "state": "x"}) assert response.status_code == 400 +def test_probe_endpoint_disables_on_dead_login(admin_client): + """额度探测拿到 401 → 硬禁用并写可操作原因,不再停在「未知」。""" + from src.provider.lingxi.client import UpstreamHTTPError as LingxiHTTPError + + app, client = admin_client + credential_id = app.state.credentials.add( + provider="lingxi", credential_data={"wps_sid": "sid", "uid": "u1"}) + + class Boom: + id = "lingxi" + endpoint = "https://e" + + async def probe_quota(self, _data): + raise LingxiHTTPError(401, b"") + + app.state.executor._deps.providers["lingxi"] = Boom() + body = client.post(f"/api/credentials/{credential_id}/probe").json() + + assert body["probed"] is False + row = next(r for r in app.state.credentials.list_all() if r["id"] == credential_id) + assert row["disabled"] + assert "WPS" in row["disabled_reason"] + + +def test_lingxi_import_merges_same_uid(admin_client): + """手工导入即灵犀的重新登录:uid 相同更新既有行,不新增重复凭证。""" + from src.provider.base import Quota + from src.provider.lingxi.credential import LingxiCredential + + app, client = admin_client + + class Stub: + id = "lingxi" + endpoint = "https://e" + + def import_credential(self, raw): + return LingxiCredential.from_import(raw).to_dict() + + async def probe_quota(self, _data): + return Quota(remaining=1, total=2, probed_at=1) + + app.state.executor._deps.providers["lingxi"] = Stub() + first = client.post("/api/credentials", json={ + "provider": "lingxi", "credential": {"cookie": "wps_sid=a; uid=42"}}).json() + second = client.post("/api/credentials", json={ + "provider": "lingxi", "credential": {"cookie": "wps_sid=b; uid=42"}}).json() + + assert first["merged"] is False + assert second["merged"] is True + assert second["id"] == first["id"] + rows = [r for r in app.state.credentials.list_all() if r["provider"] == "lingxi"] + assert len(rows) == 1 + assert app.state.credentials.credential_data(first["id"])["wps_sid"] == "b" + + +def test_lingxi_import_falls_back_when_merge_misses(admin_client, monkeypatch): + """命中了身份但合并失败(并发删除)→ 退回新增,不能丢导入。""" + from src.provider.base import Quota + from src.provider.lingxi.credential import LingxiCredential + + app, client = admin_client + + class Stub: + id = "lingxi" + endpoint = "https://e" + + def import_credential(self, raw): + return LingxiCredential.from_import(raw).to_dict() + + async def probe_quota(self, _data): + return Quota(remaining=1, total=2, probed_at=1) + + app.state.executor._deps.providers["lingxi"] = Stub() + client.post("/api/credentials", json={ + "provider": "lingxi", "credential": {"cookie": "wps_sid=a; uid=42"}}) + monkeypatch.setattr(app.state.credentials, "merge_login_credential", + lambda *_args, **_kwargs: False) + + second = client.post("/api/credentials", json={ + "provider": "lingxi", "credential": {"cookie": "wps_sid=b; uid=42"}}).json() + + assert second["merged"] is False + rows = [r for r in app.state.credentials.list_all() if r["provider"] == "lingxi"] + assert len(rows) == 2 + + +def test_lingxi_import_disables_kicked_siblings(admin_client): + """导入新会话后复探旧灵犀号:401 的旧号立刻硬禁用,不误伤仍有效的号。""" + from src.provider.base import Quota + from src.provider.lingxi.client import UpstreamHTTPError as LingxiHTTPError + from src.provider.lingxi.credential import LingxiCredential + + app, client = admin_client + dead = app.state.credentials.add( + provider="lingxi", credential_data={"wps_sid": "old", "uid": "u-old"}) + alive = app.state.credentials.add( + provider="lingxi", credential_data={"wps_sid": "alive", "uid": "u-alive"}) + + class Stub: + id = "lingxi" + endpoint = "https://e" + + def import_credential(self, raw): + return LingxiCredential.from_import(raw).to_dict() + + async def probe_quota(self, data): + if data.get("wps_sid") == "old": + raise LingxiHTTPError(401, b"") + return Quota(remaining=1, total=2, probed_at=1) + + app.state.executor._deps.providers["lingxi"] = Stub() + created = client.post("/api/credentials", json={ + "provider": "lingxi", "credential": {"cookie": "wps_sid=new; uid=u-new"}}) + assert created.status_code == 200 + + for _ in range(50): + row = next(r for r in app.state.credentials.list_all() if r["id"] == dead) + if row["disabled"]: + break + time.sleep(0.02) + assert row["disabled"] + assert "WPS" in row["disabled_reason"] + alive_row = next(r for r in app.state.credentials.list_all() if r["id"] == alive) + assert not alive_row["disabled"] + + def test_probe_endpoint_returns_quota_fields(admin_client): """探测成功路径的响应字段(main 271-272)。""" app, client = admin_client @@ -430,3 +615,24 @@ def test_schedule_probe_returns_early_when_credential_unreadable(admin_client): assert created.status_code == 200 time.sleep(0.05) assert len(app.state.pending_probes) == before + + +def test_schedule_probe_returns_early_when_credential_undecryptable(admin_client): + """凭证解不开(APP_SECRET 换过 / 密文损坏)→ 不建探测任务,也不抛。 + + 实测 2026-10-26 14:48:解密失败直接冒泡 → 导入凭证后的即时探测整个挂掉。 + """ + from src.db.crypto import CredentialDecryptError + + app, client = admin_client + before = len(app.state.pending_probes) + + def _boom(_cid): + raise CredentialDecryptError("credential decryption failed") + + app.state.credentials.credential_data = _boom # type: ignore[method-assign] + created = client.post("/api/credentials", json={ + "provider": "codebuddy", "credential": {"token": "x"}}) + assert created.status_code == 200 + time.sleep(0.05) + assert len(app.state.pending_probes) == before diff --git a/tests/api/test_admin_credentials_batch.py b/tests/api/test_admin_credentials_batch.py index 5870d5ec..4e52eb42 100644 --- a/tests/api/test_admin_credentials_batch.py +++ b/tests/api/test_admin_credentials_batch.py @@ -126,18 +126,23 @@ def test_batch_checkin_failure_keeps_ok_false(batch_app): assert body["results"][0]["ok"] is False assert body["succeeded"] == 0 -def test_batch_checkin_already_checked_in_does_not_schedule(batch_app): - """今天已签过:ok=True 但不触发探测(重复签到不发积分)。""" +def test_batch_checkin_already_checked_in_still_refreshes_quota(batch_app): + """今天已签过:ok=True 也要重探测——套餐明细/额度合计要跟着刷新。""" app, client = batch_app credential_id = _add(app) - app.state.executor._deps.providers["codebuddy"] = _StubProvider( + stub = _StubProvider( checkin=CheckinResult(ok=True, credit=0, already_checked_in=True)) - before = len(app.state.pending_probes) + app.state.executor._deps.providers["codebuddy"] = stub body = client.post("/api/credentials/batch", json={"action": "checkin", "ids": [credential_id]}).json() assert body["results"][0]["detail"]["already_checked_in"] is True - assert len(app.state.pending_probes) == before + # 即时探测是后台 Task,完成即摘除;看桩被调用而不是看 pending 列表 + for _ in range(50): + if stub.probed: + break + time.sleep(0.02) + assert stub.probed == ["called"] def test_batch_toggle_sets_explicit_state(batch_app): app, client = batch_app @@ -201,6 +206,31 @@ def test_batch_revive_reports_missing_credential(batch_app): events = app.state.audit.query(action="credential.revive") assert len(events) == 1 and events[0]["detail"] == "批量 revive:1/2 成功" +def test_batch_revive_force_clears_upstream_cooldown(batch_app): + """批量 revive 也支持 `force`:默认保留上游点名的冷却,force 才清。""" + import time + + app, client = batch_app + credential_id = _add(app) + future = int(time.time()) + 3600 + db = app.state.credentials._db + db.connect().execute( + "UPDATE credentials SET disabled = 1, cooling_until = ?, cooling_kind = 'soft' " + "WHERE id = ?", (future, credential_id)) + db.connect().commit() + + client.post("/api/credentials/batch", + json={"action": "revive", "ids": [credential_id]}) + rows = {row["id"]: row for row in app.state.credentials.list_all()} + assert rows[credential_id]["disabled"] == 0 + assert rows[credential_id]["cooling_until"] == future + + client.post("/api/credentials/batch", + json={"action": "revive", "ids": [credential_id], "force": True}) + rows = {row["id"]: row for row in app.state.credentials.list_all()} + assert rows[credential_id]["cooling_until"] is None + + def test_batch_toggle_resume_writes_enabled_direction(batch_app): """批量「取消暂停」(enabled=true)与「暂停」是同一动作,靠审计方向区分。 @@ -318,7 +348,13 @@ async def probe_quota(self, _data): assert "abc123" not in str(body) def test_batch_non_upstream_exception_is_contained(batch_app): - """非探测路径的意外异常(如 checkin 抛错)也逐条兜住,不整批 500。""" + """非探测路径的意外异常(如 checkin 抛错)也逐条兜住,不整批 500。 + + 签到失败现在由 `checkin_credential_once` 收成 `ok=False` 的**正常结果** + (见 `src/api/admin_credentials.py`):上游拒绝是这条凭证的真实答复, + 不是端点故障。故这里拿到的 detail 是失败结果而非 `action_failed`—— + 两种形态都不整批 500、都不回显上游原文。 + """ app, client = batch_app credential_id = _add(app) @@ -334,9 +370,36 @@ async def checkin(self, _data): json={"action": "checkin", "ids": [credential_id]}) assert response.status_code == 200 body = response.json() - assert body["results"][0] == {"id": credential_id, "ok": False, "error": "action_failed"} + row = body["results"][0] + assert row["id"] == credential_id and row["ok"] is False + # 失败原因可读,且不回显上游原文(可能带凭证片段) + assert row["detail"]["message"] == "签到失败:unknown_error" + assert "abc123" not in response.text + +def test_batch_unexpected_exception_is_contained_per_item(batch_app): + """非签到/非探测路径的意外异常逐条兜住:整批仍 200,不回显异常原文。""" + app, client = batch_app + credential_id = _add(app) + + def boom(_credential_id: str) -> bool: + raise RuntimeError("db exploded, secret abc123") + + original = app.state.credentials.set_enabled + app.state.credentials.set_enabled = boom + try: + response = client.post("/api/credentials/batch", + json={"action": "toggle", "ids": [credential_id], + "enabled": False}) + finally: + app.state.credentials.set_enabled = original + + assert response.status_code == 200 + body = response.json() + assert body["results"][0] == {"id": credential_id, "ok": False, + "error": "action_failed"} assert "abc123" not in response.text + def test_batch_rejects_viewer(tmp_path): """viewer(非 operator)不能批量写:403。""" settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), diff --git a/tests/api/test_admin_credentials_batch_import.py b/tests/api/test_admin_credentials_batch_import.py new file mode 100644 index 00000000..1b81c01d --- /dev/null +++ b/tests/api/test_admin_credentials_batch_import.py @@ -0,0 +1,107 @@ +"""批量导入端点:逐条校验、去重、失败隔离与非操作员拦截。""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from src.auth.session import create_session_token +from src.compat.openai.request import InvalidRequest +from src.config import Settings +from src.main import build_app +from src.provider.base import Quota +from tests.helpers.constants import SECRET + + +class _StubProvider: + """只验证端点接线:导入映射 + 探测桩,绝不出网。""" + + endpoint = "https://e" + + def import_credential(self, raw): + if raw.get("bad"): + raise InvalidRequest("bad credential") + return {"bearer_token": raw.get("token", "t")} + + async def probe_quota(self, _data): + return Quota(remaining=7, total=10, probed_at=1) + + +def _install(app) -> None: + app.state.services.registry["codebuddy"] = _StubProvider() + + +def _count(app) -> int: + return len(app.state.credentials.candidates(["codebuddy"])) + + +def test_batch_import_creates_and_skips_duplicates(admin_client): + app, client = admin_client + _install(app) + app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "dup"}) + + response = client.post("/api/credentials/batch-import", json={"items": [ + {"provider": "codebuddy", "credential": {"token": "dup"}}, # 与库里重复 + {"provider": "codebuddy", "credential": {"token": "new1"}, + "nickname": "一号", "tags": ["a", "b"]}, + {"provider": "codebuddy", "credential": {"token": "new1"}}, # 批内重复 + {"provider": "codebuddy", "credential": {"token": "new2"}}, + ]}) + + assert response.status_code == 200 + body = response.json() + assert [row["index"] for row in body["created"]] == [1, 3] + assert [row["index"] for row in body["skipped"]] == [0, 2] + assert body["skipped"][0]["reason"] == "duplicate" + assert body["failed"] == [] + assert _count(app) == 3 + + listed = {c.credential_id for c in app.state.credentials.candidates(["codebuddy"])} + assert {row["id"] for row in body["created"]} <= listed + + +def test_batch_import_isolates_invalid_entries(admin_client): + app, client = admin_client + _install(app) + + response = client.post("/api/credentials/batch-import", json={"items": [ + "not-an-object", + {"provider": "unknown", "credential": {"token": "x"}}, + {"credential": {"token": "x"}}, + {"provider": "codebuddy", "credential": {"bad": True}}, + {"provider": "codebuddy", "credential": {"token": "ok"}, "tags": {"no": 1}}, + {"provider": "codebuddy", "credential": {"token": "ok2"}, + "tags": ["fine", 1]}, + {"provider": "codebuddy", "credential": {"token": "ok3"}}, + ]}) + + body = response.json() + assert [row["index"] for row in body["failed"]] == [0, 1, 2, 3, 4, 5] + assert [row["index"] for row in body["created"]] == [6] + reasons = " ".join(row["reason"] for row in body["failed"]) + assert "unknown provider" in reasons + assert "tags" in reasons + assert _count(app) == 1 + + +def test_batch_import_validates_shape_and_limit(admin_client): + _app, client = admin_client + for payload in ( + {}, + {"items": []}, + {"items": "nope"}, + {"items": [{}] * 101}, + ): + assert client.post("/api/credentials/batch-import", + json=payload).status_code == 400 + + +def test_batch_import_requires_operator(tmp_path): + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) + app = build_app(settings) + with TestClient(app) as guest: + guest.cookies.set("coding2api_session", create_session_token("guest", SECRET)) + response = guest.post("/api/credentials/batch-import", json={"items": [ + {"provider": "codebuddy", "credential": {"token": "x"}}, + ]}) + assert response.status_code == 403 diff --git a/tests/api/test_admin_credentials_checkin_log.py b/tests/api/test_admin_credentials_checkin_log.py new file mode 100644 index 00000000..8a3b52b9 --- /dev/null +++ b/tests/api/test_admin_credentials_checkin_log.py @@ -0,0 +1,115 @@ +"""凭证运维端点:签到审计日志(手动签到落库 + 历史查询)。""" + +from __future__ import annotations + +import time + +from src.engine.limits import day_start +from src.provider.base import CheckinResult, Quota + + +class _Stub: + """签到桩:按队列依次返回结果;probe_quota 吸收签到成功后调度的探测。""" + + id = "codebuddy" + + def __init__(self, *results: CheckinResult) -> None: + self.queue = list(results) + + async def probe_quota(self, _data): + return Quota(remaining=1, total=2, probed_at=1) + + async def checkin(self, _data): + return self.queue.pop(0) + + +def test_manual_checkin_records_and_lists_history(admin_client): + """手动签到逐条落库(trigger=manual),历史端点倒序返回,limit 生效。""" + app, client = admin_client + credential_id = app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t", "account_uid": "u"}) + # 第一次成功、第二次业务失败:两条都应落库(失败也要能回看) + app.state.services.registry["codebuddy"] = _Stub( + CheckinResult(ok=True, credit=1000.0, code=0, message="领取成功"), + CheckinResult(ok=False, code=40001, message="尚未到达权益刷新时间")) + + assert client.post(f"/api/credentials/{credential_id}/checkin").json()["ok"] is True + assert client.post(f"/api/credentials/{credential_id}/checkin").json()["ok"] is False + + body = client.get(f"/api/credentials/{credential_id}/checkin-events").json() + events = body["events"] + assert len(events) == 2 + assert events[0]["ok"] == 0 and events[0]["code"] == 40001 + assert events[0]["message"] == "尚未到达权益刷新时间" + assert events[1]["ok"] == 1 and events[1]["credit"] == 1000.0 + assert all(event["trigger"] == "manual" for event in events) + + limited = client.get( + f"/api/credentials/{credential_id}/checkin-events?limit=1").json()["events"] + assert len(limited) == 1 and limited[0]["code"] == 40001 + + # 不存在的凭证 → 400(与其它历史端点同口径) + assert client.get("/api/credentials/ghost/checkin-events").status_code == 400 + + +def test_credentials_list_attaches_today_checkin_result(admin_client): + """列表按凭证下发「今日最近一条签到结果」:今日无记录/只有昨天记录都是 null。 + + 组头靠它汇总「已签 / 失败 / 未签」,所以边界必须准:昨天的成功不能冒充 + 今天(否则凌晨会显示已签、任务实际没跑);没有记录也不能硬填成失败。 + """ + app, client = admin_client + first = app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t", "account_uid": "u1"}) + second = app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t", "account_uid": "u2"}) + unsign = app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t", "account_uid": "u3"}) + events = app.state.checkin_events + midnight = day_start(int(time.time())) + # 失败后又补签成功:最新一条才是「签没签成功」的答案 + events.record(credential_id=first, now=midnight + 1, + result=CheckinResult(ok=False, code=40001, message="尚未刷新")) + events.record(credential_id=first, now=midnight + 2, + result=CheckinResult(ok=True, credit=100.0, code=0, message="领取成功")) + # 第二张只有昨天的记录(午夜前 1 秒)→ 今天应视为没有结论 + events.record(credential_id=second, now=midnight - 1, + result=CheckinResult(ok=True, credit=100.0, message="昨天领的")) + + rows = {row["id"]: row + for row in client.get("/api/credentials").json()["credentials"]} + assert rows[first]["checkin_today"]["ok"] == 1 + assert rows[first]["checkin_today"]["credit"] == 100.0 + assert rows[first]["checkin_today"]["message"] == "领取成功" + assert rows[second]["checkin_today"] is None + assert rows[unsign]["checkin_today"] is None + + +def test_checkin_history_empty_for_new_credential(admin_client): + """从未签过的凭证返回空数组(不是 404)。""" + app, client = admin_client + credential_id = app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t"}) + assert client.get( + f"/api/credentials/{credential_id}/checkin-events").json() == {"events": []} + + +def test_manual_checkin_upstream_exception_also_recorded(admin_client): + """上游抛异常时也落一行「签到失败」,而不只留在服务端日志里。""" + app, client = admin_client + credential_id = app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t", "account_uid": "u"}) + + class Boom: + id = "codebuddy" + + async def checkin(self, _data): + raise RuntimeError("boom") + + app.state.services.registry["codebuddy"] = Boom() + assert client.post(f"/api/credentials/{credential_id}/checkin").json()["ok"] is False + + events = client.get( + f"/api/credentials/{credential_id}/checkin-events").json()["events"] + assert len(events) == 1 and events[0]["ok"] == 0 + assert "签到失败" in events[0]["message"] \ No newline at end of file diff --git a/tests/api/test_admin_credentials_web_cookie.py b/tests/api/test_admin_credentials_web_cookie.py new file mode 100644 index 00000000..960a3b48 --- /dev/null +++ b/tests/api/test_admin_credentials_web_cookie.py @@ -0,0 +1,125 @@ +"""Qoder 网页 Cookie 录入端点:写入/清除、校验、审计、导出不泄漏。""" + +from __future__ import annotations + +from src.audit.actions import ACTION_CREDENTIAL_WEB_COOKIE + + +def _add_qoder(app): + return app.state.credentials.add( + provider="qoder", + credential_data={"access_token": "dt-1", "uid": "u-1", "auth_source": "oauth"}) + + +def _add_codebuddy(app): + return app.state.credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t"}) + + +def test_set_and_clear_web_cookie_round_trip(admin_client): + app, client = admin_client + credential_id = _add_qoder(app) + + response = client.put(f"/api/credentials/{credential_id}/web-cookie", + json={"cookie": " qoder_session_cookie=abc; other=1 "}) + assert response.status_code == 200 and response.json() == {"ok": True} + # 落库的是 trim 后的值;其余凭证字段保持原样 + data = app.state.credentials.credential_data(credential_id) + assert data["web_cookie"] == "qoder_session_cookie=abc; other=1" + assert data["access_token"] == "dt-1" and data["uid"] == "u-1" + # 返回体不回显 Cookie + assert "abc" not in response.text + + events = app.state.audit.query(action=ACTION_CREDENTIAL_WEB_COOKIE) + assert len(events) == 1 + assert events[0]["target"] == credential_id + assert events[0]["detail"] == "已设置" + assert "abc" not in str(events[0]) + + # 空串 = 清除,探测回退汇总口径 + cleared = client.put(f"/api/credentials/{credential_id}/web-cookie", + json={"cookie": ""}) + assert cleared.status_code == 200 + assert app.state.credentials.credential_data(credential_id)["web_cookie"] == "" + details = [e["detail"] for e in + app.state.audit.query(action=ACTION_CREDENTIAL_WEB_COOKIE)] + assert "已清除" in details + + +def test_web_cookie_validation_and_capability(admin_client): + app, client = admin_client + qoder = _add_qoder(app) + codebuddy = _add_codebuddy(app) + + # 非字符串 / 超长都 400(不做 Cookie 语法校验,上游结构会变) + assert client.put(f"/api/credentials/{qoder}/web-cookie", + json={"cookie": 1}).status_code == 400 + assert client.put(f"/api/credentials/{qoder}/web-cookie", + json={"cookie": "x" * 10_001}).status_code == 400 + # 缺失凭证 / 渠道不支持该能力 + assert client.put("/api/credentials/ghost/web-cookie", + json={"cookie": "sid=1"}).status_code == 400 + assert client.put(f"/api/credentials/{codebuddy}/web-cookie", + json={"cookie": "sid=1"}).status_code == 400 + + +def test_export_omits_web_cookie(admin_client): + """导出件本来就有明文 token,不该再多一份浏览器会话凭据。""" + app, client = admin_client + credential_id = _add_qoder(app) + client.put(f"/api/credentials/{credential_id}/web-cookie", + json={"cookie": "qoder_session_cookie=abc"}) + + exported = client.get(f"/api/credentials/{credential_id}/export").json() + assert "web_cookie" not in exported["credential"] + # 落库值仍在(导出只是脱这项) + assert app.state.credentials.credential_data(credential_id)["web_cookie"] == ( + "qoder_session_cookie=abc") + +def _add_codearts(app): + return app.state.credentials.add( + provider="codearts", + credential_data={"access_key_id": "AK", "secret_access_key": "SK"}) + + +def test_codearts_web_cookie_and_export_omits_it(admin_client): + """CodeArts 同样支持网页 Cookie;导出件不得带浏览器会话凭据。""" + app, client = admin_client + credential_id = _add_codearts(app) + + response = client.put(f"/api/credentials/{credential_id}/web-cookie", + json={"cookie": " devclouddevuibjJ_SESSION_ID=abc "}) + assert response.status_code == 200 and response.json() == {"ok": True} + assert "abc" not in response.text + assert app.state.credentials.credential_data(credential_id)["web_cookie"] == ( + "devclouddevuibjJ_SESSION_ID=abc") + + exported = client.get(f"/api/credentials/{credential_id}/export").json() + assert "web_cookie" not in exported["credential"] + assert exported["credential"]["access_key_id"] == "AK" + + +def test_list_exposes_web_cookie_state_and_setting_resets_it(admin_client): + """管理台提示走列表字段;重新粘贴后立即清掉旧标记(下轮探测再定)。""" + import time + + from src.provider.base import Quota + + app, client = admin_client + credential_id = _add_codearts(app) + app.state.credentials.save_quota( + credential_id, + Quota(remaining=1.0, total=2.0, probed_at=int(time.time()), + web_cookie_state="expired")) + + rows = client.get("/api/credentials").json()["credentials"] + row = next(item for item in rows if item["id"] == credential_id) + assert row["web_cookie_state"] == "expired" + + response = client.put(f"/api/credentials/{credential_id}/web-cookie", + json={"cookie": "sid=new"}) + assert response.status_code == 200 + assert app.state.credentials.credential_data(credential_id)["web_cookie"] == "sid=new" + rows = client.get("/api/credentials").json()["credentials"] + assert next(item for item in rows if item["id"] == credential_id)[ + "web_cookie_state"] is None diff --git a/tests/api/test_admin_credentials_weight.py b/tests/api/test_admin_credentials_weight.py new file mode 100644 index 00000000..acc71142 --- /dev/null +++ b/tests/api/test_admin_credentials_weight.py @@ -0,0 +1,73 @@ +"""凭证调度权重端点:设置/清除、校验、审计、列表回显。 + +权重只影响选号顺序(`Scheduler.select` 同健康档内重排),不改变可调度性, +因此端点不做任何缓存失效。 +""" + +from __future__ import annotations + +from src.audit.actions import ACTION_CREDENTIAL_WEIGHT + + +def _add_credential(app): + return app.state.credentials.add( + provider="trae", credential_data={"accessToken": "t-1"}) + + +def _weight_of(app, credential_id): + return next(row for row in app.state.credentials.list_all() + if row["id"] == credential_id)["routing_weight"] + + +def test_set_and_clear_weight(admin_client): + app, client = admin_client + credential_id = _add_credential(app) + assert _weight_of(app, credential_id) is None + + response = client.put(f"/api/credentials/{credential_id}/weight", + json={"weight": 42}) + assert response.status_code == 200 + assert response.json() == {"routing_weight": 42} + assert _weight_of(app, credential_id) == 42 + + events = app.state.audit.query(action=ACTION_CREDENTIAL_WEIGHT) + assert len(events) == 1 + assert events[0]["target"] == credential_id + assert events[0]["detail"] == "42" + + # null = 恢复默认 + cleared = client.put(f"/api/credentials/{credential_id}/weight", + json={"weight": None}) + assert cleared.status_code == 200 + assert cleared.json() == {"routing_weight": None} + assert _weight_of(app, credential_id) is None + assert sorted(e["detail"] for e in + app.state.audit.query(action=ACTION_CREDENTIAL_WEIGHT)) == sorted( + ["42", "(默认)"]) + + +def test_negative_weight_is_allowed(admin_client): + """负数 = 尽量靠后(备用号),与 0(默认)区分。""" + app, client = admin_client + credential_id = _add_credential(app) + assert client.put(f"/api/credentials/{credential_id}/weight", + json={"weight": -100}).status_code == 200 + assert _weight_of(app, credential_id) == -100 + + +def test_weight_validation(admin_client): + app, client = admin_client + credential_id = _add_credential(app) + url = f"/api/credentials/{credential_id}/weight" + + # 布尔是 int 的子类,必须单独拒掉({"weight": true} 不能当 1) + assert client.put(url, json={"weight": True}).status_code == 400 + assert client.put(url, json={"weight": "5"}).status_code == 400 + assert client.put(url, json={"weight": 1001}).status_code == 400 + assert client.put(url, json={"weight": -1001}).status_code == 400 + # 缺失凭证 + assert client.put("/api/credentials/ghost/weight", + json={"weight": 1}).status_code == 400 + # 失败的请求不写审计、不改库 + assert _weight_of(app, credential_id) is None + assert app.state.audit.query(action=ACTION_CREDENTIAL_WEIGHT) == [] diff --git a/tests/api/test_admin_stats.py b/tests/api/test_admin_stats.py index 8b73d04a..ae24420b 100644 --- a/tests/api/test_admin_stats.py +++ b/tests/api/test_admin_stats.py @@ -45,8 +45,10 @@ def test_stats_endpoints_scope_by_principal(admin_client): assert client.get("/api/stats/by-credential").json() == {"credentials": []} timeline = client.get("/api/stats/timeline").json() assert timeline["points"] - # 渠道列动态取自数据(本用例只写了 trae),不再固定为两渠道 - assert list(timeline["points"][0].keys()) == ["hour", "trae"] + # 渠道列动态取自数据(本用例只写了 trae),不再固定为两渠道; + # `__total` 是全渠道合计(均值/比率必须按同一小时的 sum/count 重新相除, + # 前端不能把各渠道的值相加) + assert list(timeline["points"][0].keys()) == ["hour", "__total", "trae"] def test_stats_endpoints_restrict_non_admin(tmp_path): settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) diff --git a/tests/api/test_admin_users.py b/tests/api/test_admin_users.py index 69ba7249..7bb117a0 100644 --- a/tests/api/test_admin_users.py +++ b/tests/api/test_admin_users.py @@ -403,3 +403,94 @@ def test_credential_writes_are_audited(tmp_path, monkeypatch): actions = [e["action"] for e in app.state.audit.query()] assert "credential.toggle" not in actions # 失败不记 assert "login.success" in actions + + +# ------------------------------------------------------------ 用户级策略 + +def test_set_user_policy_stores_and_audits(tmp_path, monkeypatch): + """PUT policy:写白名单 + 配额,回显规范化结果,并落一条审计。 + + 这是补上「viewer 用会话 Cookie 直调任意模型」的那个口子所依赖的配置入口, + 没有它 Playground 的按人判定只能靠改库。 + """ + from src.audit.actions import ACTION_USER_POLICY + + app = _app(tmp_path, monkeypatch, users=[ + ("root", "rootpw", ROLE_ADMIN, True), + ("alice", "alicepw", ROLE_VIEWER, True), + ]) + with TestClient(app) as client: + _login(client, "root", "rootpw") + response = client.put("/api/users/alice/policy", json={ + "allowed_models": "glm-5.3*, deepseek-v4.1-flash", + "quota": {"daily_requests": 200, "rpm": 5}, + }) + assert response.status_code == 200, response.text + assert response.json()["allowed_models"] == "glm-5.3*,deepseek-v4.1-flash" + assert response.json()["quota"] == { + "daily_requests": 200, "daily_tokens": 0, "daily_cost_usd": 0.0, + "rpm": 5, "tpm": 0, "max_concurrency": 0, + } + listed = [u for u in client.get("/api/users").json()["users"] + if u["username"] == "alice"][0] + assert listed["allowed_models"] == "glm-5.3*,deepseek-v4.1-flash" + assert '"rpm": 5' in listed["quota"] + + events = app.state.audit.query(action=ACTION_USER_POLICY) + assert [e["target"] for e in events] == ["alice"] + assert "模型白名单 glm-5.3*,deepseek-v4.1-flash" in events[0]["detail"] + assert "每日请求 200" in events[0]["detail"] and "RPM 5" in events[0]["detail"] + + +def test_set_user_policy_clears_with_empty_values(tmp_path, monkeypatch): + """空值 = 恢复「不限制」(与 Key 配额同一语义:'' 即不受约束)。""" + app = _app(tmp_path, monkeypatch, users=[("root", "rootpw", ROLE_ADMIN, True)]) + with TestClient(app) as client: + _login(client, "root", "rootpw") + assert client.put("/api/users/root/policy", json={ + "allowed_models": "glm-*", "quota": {"rpm": 3}}).status_code == 200 + cleared = client.put("/api/users/root/policy", + json={"allowed_models": "", "quota": None}) + assert cleared.status_code == 200 + assert cleared.json() == { + "ok": True, "allowed_models": "", + "quota": {"daily_requests": 0, "daily_tokens": 0, "daily_cost_usd": 0.0, + "rpm": 0, "tpm": 0, "max_concurrency": 0}, + } + + events = app.state.audit.query(action="user.policy") + # 同一秒内两条记录,顺序不保证;只校验两条明细都在 + assert sorted(e["detail"] for e in events) == [ + "模型不限;配额不限", + "模型白名单 glm-*;RPM 3", + ] + + +@pytest.mark.parametrize("payload, message", [ + ({"allowed_models": "x" * 300}, "过长的模型名"), + ({"quota": {"nope": 1}}, "未知配额项"), + ({"quota": "not json"}, "quota 必须是 JSON 对象"), + ({"quota": {"rpm": -1}}, "不能为负数"), +]) +def test_set_user_policy_validates(payload, message, tmp_path, monkeypatch): + """校验失败一律 400(含未知配额键):写错键名静默忽略会让用户以为配额生效。""" + app = _app(tmp_path, monkeypatch, users=[("root", "rootpw", ROLE_ADMIN, True)]) + with TestClient(app) as client: + _login(client, "root", "rootpw") + response = client.put("/api/users/root/policy", json=payload) + assert response.status_code == 400 + assert message in response.json()["error"]["message"] + + +def test_set_user_policy_unknown_user_and_non_admin(tmp_path, monkeypatch): + """不存在的用户 400;viewer 不能用(策略只能由 admin 设)。""" + app = _app(tmp_path, monkeypatch, users=[ + ("root", "rootpw", ROLE_ADMIN, True), + ("viewer", "viewerpw", ROLE_VIEWER, True), + ]) + with TestClient(app) as client: + _login(client, "root", "rootpw") + assert client.put("/api/users/ghost/policy", json={}).status_code == 400 + client.cookies.clear() + _login(client, "viewer", "viewerpw") + assert client.put("/api/users/root/policy", json={}).status_code == 403 diff --git a/tests/api/test_authorize.py b/tests/api/test_authorize.py index 99ec1851..57b3183a 100644 --- a/tests/api/test_authorize.py +++ b/tests/api/test_authorize.py @@ -6,6 +6,7 @@ import pytest from fastapi.testclient import TestClient +from starlette.requests import Request as StarletteRequest from src.config import Settings from src.main import build_app @@ -116,6 +117,10 @@ def handler(request: _httpx.Request) -> _httpx.Response: # 响应与列表都不得泄漏 token assert "ACCESS" not in response.text assert "data_enc" not in listed[0] + # OAuth 回调落库必须留审计(与手工导入同 action) + rows = app.state.services.audit.query(action="credential.import", limit=10) + assert any(row["target"] == listed[0]["id"] + and "OAuth" in (row["detail"] or "") for row in rows) # state 已消费,同一回调不可重放 with TestClient(app) as replay: @@ -292,7 +297,7 @@ def test_authorize_without_pending_login_still_rejected(tmp_path): assert response.status_code == 400 assert response.json()["error"]["code"] == "invalid_request" -def test_authorize_rejects_revoked_session(tmp_path, monkeypatch): +def test_authorize_rejects_revoked_session(tmp_path): """签名有效但发起人已禁用:不得完成兑换(与 principal_from_request 同口径)。""" settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), ADMIN_USERNAMES="root") @@ -301,8 +306,8 @@ def test_authorize_rejects_revoked_session(tmp_path, monkeypatch): client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) started = client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() - # Cookie 仍有效(签名/过期都 OK),但发起人已被禁用 - monkeypatch.setattr(app.state.services.users, "is_active", lambda _u: False) + # Cookie 仍有效(签名/过期都 OK),但发起人已被禁用(epoch 同步 +1) + app.state.user_repo.set_enabled("root", False) response = client.get(f"/authorize?refreshToken=RT&state={started['state']}") assert response.status_code == 400 assert response.json()["error"]["code"] == "invalid_request" @@ -352,3 +357,333 @@ def test_authorize_without_trae_provider_is_rejected(tmp_path): response = client.get("/authorize?refreshToken=RT") assert response.status_code == 400 assert response.json()["error"]["code"] == "invalid_request" + + +# ---------------------------------------------------------------- 回调可发现性 +# issue #21:「授权完成后不回调,也没报错」。两个成因都在这里固定: +# 回调基址指向浏览器打不开的主机(默认 127.0.0.1)、回调落在另一条入口而 +# 浏览器不带管理台 Cookie;以及浏览器只拿到 JSON 看不出发生了什么。 + + +def _request(host: str, *, scheme: str = "http", + xfp: str | None = None) -> StarletteRequest: + """构造一个最小 ASGI Request(只用到 host / scheme / 转发头)。""" + headers = [(b"host", host.encode())] + if xfp is not None: + headers.append((b"x-forwarded-proto", xfp.encode())) + return StarletteRequest({ + "type": "http", "method": "GET", "path": "/api/auth/upstream/start", + "query_string": b"", "root_path": "", "scheme": scheme, + "server": (host.split(":", 1)[0], 8000), "headers": headers, + }) + + +def test_resolve_callback_url_uses_request_origin_for_unreachable_base(): + """PUBLIC_BASE_URL 还是默认回环时,回调改用手上这条入口的 origin。 + + 默认值 http://127.0.0.1:8000 在远程/容器部署里指向**用户自己的机器**, + 浏览器跳过去连不上,授权完成也回不来。 + """ + from src.api.admin_auth import resolve_public_callback_url + + settings = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://127.0.0.1:8000") + # 从 LAN 地址打开管理台 → 回调用 LAN 地址 + assert resolve_public_callback_url(settings, _request("192.168.1.5:8000")) == \ + "http://192.168.1.5:8000/authorize" + # 隧道入口:采信 X-Forwarded-Proto + assert resolve_public_callback_url( + settings, _request("ai.example.com", xfp="https")) == \ + "https://ai.example.com/authorize" + # 非法 XFP 回落 socket 层协议 + assert resolve_public_callback_url( + settings, _request("ai.example.com", xfp="ftp")) == \ + "http://ai.example.com/authorize" + # 通配监听同样不可达 + wildcard = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://0.0.0.0:8000") + assert resolve_public_callback_url(wildcard, _request("10.0.0.2:8000")) == \ + "http://10.0.0.2:8000/authorize" + # localhost 字面量同理 + named = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://localhost:8000") + assert resolve_public_callback_url(named, _request("10.0.0.2:8000")) == \ + "http://10.0.0.2:8000/authorize" + + +def test_resolve_callback_url_keeps_configured_base(): + """本机访问、或显式配了可达基址时,不为请求 origin 让路。""" + from src.api.admin_auth import resolve_public_callback_url + + loopback = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://127.0.0.1:8000/") + assert resolve_public_callback_url(loopback, _request("127.0.0.1:8000")) == \ + "http://127.0.0.1:8000/authorize" + assert resolve_public_callback_url(loopback, _request("[::1]:8000")) == \ + "http://127.0.0.1:8000/authorize" + explicit = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="https://gw.example") + assert resolve_public_callback_url(explicit, _request("192.168.1.5:8000")) == \ + "https://gw.example/authorize" + # 非回环 IP 基址:本来就是浏览器可达的,不为请求 origin 让路 + lan_base = Settings(_env_file=None, APP_SECRET=SECRET, + PUBLIC_BASE_URL="http://10.0.0.9:8000") + assert resolve_public_callback_url(lan_base, _request("192.168.1.5:8000")) == \ + "http://10.0.0.9:8000/authorize" + + +def _mock_trae() -> TraeProvider: + import httpx as _httpx + + from src.provider.trae.client import TraeClient + + def handler(request: _httpx.Request) -> _httpx.Response: + if request.url.path.endswith("ExchangeToken"): + return _httpx.Response(200, json={"Result": {"Token": "ACCESS", + "RefreshToken": "RT2"}}) + return _httpx.Response(200, json={"Result": {"UserID": "u1", "ScreenName": "n"}}) + + transport = _httpx.MockTransport(handler) + return TraeProvider(client=TraeClient( + stream_client=_httpx.AsyncClient(transport=transport, timeout=None), + short_client=_httpx.AsyncClient(transport=transport, timeout=None))) + + +def test_authorize_binds_callback_by_trace_without_session_cookie(tmp_path): + """回调落在另一条入口时不带管理台 Cookie,靠 loginTraceID 仍能完成。 + + 同一实例常有 LAN 与隧道两条入口:在 LAN 发起、隧道回调时浏览器不会带 + LAN 域的会话 Cookie。trace 只出现在发起那次 auth_url 里,足以钉死登录。 + """ + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + started = client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() + trace = login_trace_id(*started["state"].split(":")) + + client.cookies.clear() # 换一条入口:浏览器没有管理台会话 + response = client.get(f"/authorize?refreshToken=RT1&loginTraceID={trace}") + assert response.status_code == 200, response.text + assert response.json()["captured"] is True + assert "root" not in app.state.pending_callbacks + + listed = app.state.services.credentials.list_all() + assert len(listed) == 1 + assert listed[0]["added_by"] == "root" # 归属仍是发起登录的管理员 + + +def test_authorize_ignores_malformed_pending_entries_when_matching_trace(tmp_path): + """pending 里的 state 损坏(缺 device_id)时按 trace 反查直接跳过。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + app.state.pending_callbacks["root"] = (time.monotonic(), "broken") + response = client.get("/authorize?refreshToken=RT&loginTraceID=abc") + assert response.status_code == 400 + assert response.json()["error"]["code"] == "invalid_request" + + +def test_authorize_rejects_expired_pending_login(tmp_path): + """pending 超过 TTL:作废并清槽,提示重新发起(不再无限期挂着)。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + started = client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() + trace = login_trace_id(*started["state"].split(":")) + app.state.pending_callbacks["root"] = (time.monotonic() - 601, started["state"]) + response = client.get(f"/authorize?refreshToken=RT&loginTraceID={trace}") + assert response.status_code == 400 + assert response.json()["error"]["message"] == "pending TRAE login expired, start a new one" + assert "root" not in app.state.pending_callbacks + + +def test_authorize_returns_html_page_for_browser_navigation(tmp_path): + """浏览器顶层导航(Accept: text/html)拿到说明页,不是一坨 JSON。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + started = client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() + trace = login_trace_id(*started["state"].split(":")) + response = client.get( + f"/authorize?refreshToken=RT1&loginTraceID={trace}", + headers={"accept": "text/html,application/xhtml+xml"}) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + assert "TRAE 登录成功" in response.text + assert "可以关闭此页面回到管理台" in response.text + + +def test_authorize_browser_failure_page_states_the_reason(tmp_path): + """失败也要有中文原因——「没回调也没报错」的另一半在这里补齐。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) + app = build_app(settings) + with TestClient(app) as client: + # 没有任何登录进行中(且请求不带 trace) + response = client.get("/authorize?refreshToken=RT", + headers={"accept": "text/html"}) + assert response.status_code == 400 + assert response.headers["content-type"].startswith("text/html") + assert "没有进行中的 TRAE 登录" in response.text + + # 结构化失败:JSON 契约不变 + json_response = client.get("/authorize?refreshToken=RT") + assert json_response.json()["error"]["message"] == "no pending TRAE login in progress" + + +# ---------------------------------------------------------------- 粘贴回调链接 +# issue #21 后半段:NAS / 远程部署里 TRAE 跳回调地址常直接「网络错误」, +# refreshToken 只在浏览器地址栏里,服务端永远收不到。把那条地址粘回管理台, +# 由 /upstream/complete 用同一条 pending 完成兑换。 + + +def _start_trae_login(app, client) -> dict: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + return client.post("/api/auth/upstream/start", json={"provider": "trae"}).json() + + +def test_paste_callback_completes_trae_login(tmp_path): + """粘贴整条回调地址 → 换 token → 落库 → pending 消费。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + + with TestClient(app) as client: + started = _start_trae_login(app, client) + trace = login_trace_id(*started["state"].split(":")) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", + "url": f"/authorize?refreshToken=RT1&loginTraceID={trace}"}) + + assert response.status_code == 200, response.text + assert response.json()["status"] == "success" + assert "root" not in app.state.pending_callbacks + + listed = app.state.services.credentials.list_all() + assert len(listed) == 1 and listed[0]["provider"] == "trae" + assert listed[0]["added_by"] == "root" + # 兑换出的 token 绝不出现在响应里(上面只断言了 status) + + +def test_paste_callback_accepts_explicit_state(tmp_path): + """带上发起时的 state 也能完成(前端会一并传回)。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + started = _start_trae_login(app, client) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "state": started["state"], + "url": "/authorize?refreshToken=RT1"}) + assert response.status_code == 200, response.text + + +def test_paste_callback_requires_pending_login(tmp_path): + """没有进行中的登录:直接 400,不给「凭一条链接往池子里塞凭证」的口子。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "url": "/authorize?refreshToken=RT1"}) + assert response.status_code == 400 + assert "没有进行中的登录" in response.json()["error"]["message"] + + +def test_paste_callback_rejects_expired_pending(tmp_path): + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + started = _start_trae_login(app, client) + app.state.pending_callbacks["root"] = (time.monotonic() - 601, started["state"]) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "url": "/authorize?refreshToken=RT1"}) + assert response.status_code == 400 + assert "10 分钟有效期" in response.json()["error"]["message"] + assert "root" not in app.state.pending_callbacks + + +def test_paste_callback_rejects_foreign_state_and_trace(tmp_path): + """贴错链接(另一次登录的 state / 别人那次登录的 trace)一律拒绝。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + started = _start_trae_login(app, client) + + foreign_state = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "state": "m:d", "url": "/authorize?refreshToken=RT1"}) + assert foreign_state.status_code == 400 + assert "不属于当前这次登录" in foreign_state.json()["error"]["message"] + + foreign_trace = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", + "url": "/authorize?refreshToken=RT1&loginTraceID=0000000000000000"}) + assert foreign_trace.status_code == 400 + # 被拒的粘贴不消费 pending:发起人仍能用正确链接走完 + assert "root" in app.state.pending_callbacks + assert started["state"] in app.state.pending_callbacks["root"][1] + + +def test_paste_callback_requires_provider_support(tmp_path): + """registry 里没有该渠道(或没有 complete_callback)→ 400。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={}) + with TestClient(app) as client: + client.post("/api/auth/login", json={"username": "root", "password": "rootpw"}) + response = client.post("/api/auth/upstream/complete", json={ + "provider": "trae", "url": "/authorize?refreshToken=RT1"}) + assert response.status_code == 400 + assert "does not support callback completion" in response.json()["error"]["message"] + + +def test_cancel_clears_pending_trae_login(tmp_path): + """取消 TRAE 登录会清掉待完成回调,不留僵尸 pending。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + started = _start_trae_login(app, client) + assert "root" in app.state.pending_callbacks + + # 贴错 state(另一次登录的)不能清掉当前 pending + foreign = client.post("/api/auth/upstream/cancel", + json={"provider": "trae", "state": "m:d"}) + assert foreign.status_code == 400 + assert "root" in app.state.pending_callbacks + + response = client.post("/api/auth/upstream/cancel", + json={"provider": "trae", "state": started["state"]}) + assert response.status_code == 200 + assert response.json() == {"cancelled": True} + assert "root" not in app.state.pending_callbacks + + # 再取消一次:没有 pending 可清,回到「不支持」的 400 + again = client.post("/api/auth/upstream/cancel", + json={"provider": "trae", "state": started["state"]}) + assert again.status_code == 400 + + +def test_cancel_clears_pending_trae_login_without_state(tmp_path): + """不带 state 的取消(前端拿不到 state 时)也清当前发起人的 pending。""" + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": _mock_trae()}) + with TestClient(app) as client: + _start_trae_login(app, client) + response = client.post("/api/auth/upstream/cancel", json={"provider": "trae"}) + assert response.status_code == 200 + assert "root" not in app.state.pending_callbacks + diff --git a/tests/api/test_billing.py b/tests/api/test_billing.py new file mode 100644 index 00000000..1f3aa202 --- /dev/null +++ b/tests/api/test_billing.py @@ -0,0 +1,139 @@ +"""月度账单端点:按人 / 按 Key 聚合、CSV / JSONL 导出、权限与参数校验。""" + +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from src.api.admin_billing import month_range +from src.auth.session import create_session_token +from src.compat.openai.request import InvalidRequest +from src.config import Settings +from src.main import build_app +from src.request_context import set_api_key_id +from tests.helpers.constants import SECRET + + +@pytest.fixture() +def billing_env(admin_client): + """admin 会话 + 一条 Key + 三条明细(含一个 Key 归属人)。""" + app, client = admin_client + key_id = app.state.api_keys.create(username="root", name="ci")["id"] + # 9 月一条、当月两条:验证月份过滤 + app.state.stats_collector.record( + username="root", provider="trae", model="m", ok=True, + input_tokens=10, output_tokens=5, now=1_700_000_000) + set_api_key_id(key_id) + try: + app.state.stats_collector.record( + username="root", provider="trae", model="m", ok=True, + input_tokens=100, output_tokens=50, now=1_790_000_000) + app.state.stats_collector.record( + username="bob", provider="kilo", model="m", ok=False, + input_tokens=1, output_tokens=0, now=1_790_000_100) + finally: + set_api_key_id(None) + app.state.stats_collector.rollup_hourly() + return app, client, key_id + + +def _month_of(epoch: int) -> str: + import time + + return time.strftime("%Y-%m", time.localtime(epoch)) + + +def test_month_range_starts_local_month(): + start, end = month_range("2026-10") + import time + + assert time.localtime(start).tm_mday == 1 + assert time.localtime(start).tm_hour == 0 + assert time.localtime(end).tm_mon == 11 + + +@pytest.mark.parametrize("bad", ["2026-1", "2026-13", "2026-00", "abc", + "20-10", "2026/10", "2026-10-01", "20a6-10"]) +def test_month_range_rejects_bad_input(bad): + with pytest.raises(InvalidRequest): + month_range(bad) + + +def test_month_range_rejects_out_of_epoch_year(): + with pytest.raises(InvalidRequest): + month_range("0000-01") + + +def test_billing_defaults_to_current_month_and_sums(billing_env): + app, client, key_id = billing_env + month = _month_of(1_790_000_000) + body = client.get("/api/billing", params={"month": month}).json() + assert body["month"] == month + assert body["totals"]["requests"] == 2 + assert body["totals"]["input_tokens"] == 101 + assert body["totals"]["success_rate"] == 0.5 + users = {row["username"]: row for row in body["users"]} + assert users["root"]["requests"] == 1 + assert users["bob"]["requests"] == 1 + # 9 月那条不在 10 月账单里 + assert body["totals"]["requests"] == 2 + + keys = {row["api_key_id"]: row for row in body["keys"]} + assert key_id in keys + assert keys[key_id]["username"] == "root" + assert keys[key_id]["key_name"] == "ci" + + +def test_billing_without_month_uses_current(billing_env): + _app, client, _key = billing_env + body = client.get("/api/billing").json() + import time + + assert body["month"] == time.strftime("%Y-%m", time.localtime()) + + +def test_billing_csv_exports_users_and_audits(billing_env): + app, client, _key = billing_env + month = _month_of(1_790_000_000) + response = client.get("/api/billing", params={"month": month, "format": "csv"}) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/csv") + text = response.text + assert text.startswith("\ufeff") # Excel 中文表头不乱码 + assert "username,requests" in text + assert "bob" in text + audit = app.state.audit.query(limit=20) + assert any("CSV 账单" in row["detail"] for row in audit) + + +def test_billing_jsonl_exports_events(billing_env): + app, client, _key = billing_env + month = _month_of(1_790_000_000) + response = client.get("/api/billing", params={"month": month, "format": "jsonl"}) + assert response.status_code == 200 + lines = [line for line in response.text.splitlines() if line] + assert len(lines) == 2 + assert all(json.loads(line)["model"] == "m" for line in lines) + assert response.headers["X-Export-Row-Count"] == "2" + audit = app.state.audit.query(limit=20) + assert any("JSONL 账单" in row["detail"] for row in audit) + + +def test_billing_rejects_unknown_format(billing_env): + _app, client, _key = billing_env + assert client.get("/api/billing", params={"format": "xml"}).status_code == 400 + + +def test_billing_rejects_bad_month(billing_env): + _app, client, _key = billing_env + assert client.get("/api/billing", params={"month": "2026-13"}).status_code == 400 + + +def test_billing_requires_operator(tmp_path): + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) + app = build_app(settings) + with TestClient(app) as client: + client.cookies.set("coding2api_session", create_session_token("guest", SECRET)) + assert client.get("/api/billing").status_code == 403 diff --git a/tests/api/test_chat_narrowing.py b/tests/api/test_chat_narrowing.py index edbd35bb..c4a71b8b 100644 --- a/tests/api/test_chat_narrowing.py +++ b/tests/api/test_chat_narrowing.py @@ -341,3 +341,91 @@ async def test_forced_provider_without_usable_credential_keeps_narrowing(dual_re with pytest.raises(NoHealthyCredential): await executor.complete(_request("deepseek-v4.1-flash@codebuddy"), username="u") assert (cb.calls, trae.calls) == (0, 0) + + +async def test_probe_failed_model_still_narrows_to_its_owner(dual_repo): + """探活判死的模型仍收窄到持有它的渠道 → 不扇出到无此模型的渠道。 + + 实测故障(2026-10-26 `deepseek-v4.1-flash-1017`):officeAce 的 + `/v2/models` 挂着它但推理端点直接 404 `ModelArts.81009`(上游目录撒谎)。 + 人工探活判死后,若别名登记跟着被摘掉,执行侧 `_narrow_providers` 因 + 「没有任何渠道登记该模型」回退成**全部渠道** → 请求扇出到 codebuddy / + codearts / codebuddy_intl,三家各回「无此模型」(11102 / not registered), + 并被写上 (凭证, 模型) 负缓存——污染的是从没有该模型的渠道。 + + 修好后:别名登记保留 → 收窄到 officeAce → 负缓存让该渠道的凭证不可选 + → 快速 503「该模型暂时不可用」,其余渠道零调用、零负缓存。 + """ + repo, db = dual_repo + officeace_id = repo.add(provider="officeace", credential_data={"t": "x"}) + repo.add(provider="codebuddy", credential_data={"bearer_token": "cb"}) + repo.add(provider="codearts", credential_data={"t": "x"}) + # 负缓存(判死的前提):officeAce 的这个模型被负缓存锁住 + repo.save_error(officeace_id, ErrorOutcome(model_cooldowns={ + "deepseek-v4.1-flash-1017": ModelCooldown( + cooling_until=int(time.time()) + 3600, hits=1, reason="blocked")})) + officeace = _RejectProvider("officeace", [GOOD]) + cb = _RejectProvider("codebuddy", [GOOD]) + codearts = _RejectProvider("codearts", [GOOD]) + executor = Executor(ExecutorDeps( + providers={"officeace": officeace, "codebuddy": cb, "codearts": codearts}, + credentials=repo, scheduler=Scheduler(), + default_model="deepseek-v4.1-flash-1017", + # 别名表保留判死渠道的归属(`api.models._hidden_aliases` 的产出) + model_aliases={"officeace": {"deepseek-v4.1-flash-1017": + "deepseek-v4.1-flash-1017"}}, + upstream_model_name=lambda pid, name: { + "officeace": {"deepseek-v4.1-flash-1017": "deepseek-v4.1-flash-1017"} + }.get(pid, {}).get(name.lower(), name))) + + with pytest.raises(NoHealthyCredential) as exc_info: + await executor.complete(_request("deepseek-v4.1-flash-1017"), username="u") + assert "temporarily unavailable on upstream" in str(exc_info.value) + # 只有持有该模型的渠道被考虑过;无此模型的渠道零调用 + assert (officeace.calls, cb.calls, codearts.calls) == (0, 0, 0) + # 负缓存零新增:没给任何渠道写「该后端无此模型」 + assert db.connect().execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + +async def test_catalog_x0_overridden_by_observed_charge(dual_repo): + """目录写 x0、但上游实际在扣费 → 按实测倍率纠正,免费档不再生效。 + + 实测故障(2026-11-03 起):`codebuddy_intl/deepseek-v4.1-flash` 目录仍标 + x0,每次请求却实扣 ~0.14 积分(`credit_estimated=0`)。免费档压过全部 + 到期积分 → officeace 15.8 小时内到期的 100 积分包整夜闲置。校准后 intl + 回到付费档,健康度重新决定选号。 + """ + repo, db = dual_repo + cb_id = repo.add(provider="codebuddy", credential_data={"bearer_token": "cb"}) + intl_id = repo.add(provider="codebuddy_intl", + credential_data={"bearer_token": "intl"}) + # 健康度拉开差距:校准前免费档赢,校准后健康度高者赢——两种结果可区分 + db.connect().execute("UPDATE credentials SET health = 100 WHERE id = ?", (cb_id,)) + db.connect().execute("UPDATE credentials SET health = 50 WHERE id = ?", (intl_id,)) + cb = _RejectProvider("codebuddy", [GOOD]) + intl = _RejectProvider("codebuddy_intl", [GOOD]) + + def deps(paid_models): + return ExecutorDeps( + providers={"codebuddy": cb, "codebuddy_intl": intl}, credentials=repo, + scheduler=Scheduler(), default_model="deepseek-v4.1-flash", + model_aliases={ + "codebuddy": {"deepseek-v4.1-flash": "deepseek-v4.1-flash"}, + "codebuddy_intl": {"deepseek-v4.1-flash": "deepseek-v4.1-flash"}}, + model_list_cache={ + "codebuddy": {"deepseek-v4.1-flash": Model( + id="deepseek-v4.1-flash", credit_rate=0.11)}, + "codebuddy_intl": {"deepseek-v4.1-flash": Model( + id="deepseek-v4.1-flash", credit_rate=0.0)}}, + paid_models=paid_models) + + # 无实扣记录 → 信目录:intl 是 x0,免费档压过健康度 + executor = Executor(deps(lambda: {})) + await executor.complete(_request("deepseek-v4.1-flash"), username="u") + assert (cb.calls, intl.calls) == (0, 1) + + # 有实扣记录 → intl 按实测倍率当付费,健康度 100 的 codebuddy 胜出 + cb.calls = intl.calls = 0 + executor = Executor(deps(lambda: {("codebuddy_intl", "deepseek-v4.1-flash"): 0.14})) + await executor.complete(_request("deepseek-v4.1-flash"), username="u") + assert (cb.calls, intl.calls) == (1, 0) diff --git a/tests/api/test_context.py b/tests/api/test_context.py index 173b84c6..dcff0597 100644 --- a/tests/api/test_context.py +++ b/tests/api/test_context.py @@ -13,12 +13,13 @@ from src.compat.openai.request import ChatRequest from src.engine.compress import ( _content_text, + _content_tokens, _truncate_content, compress_messages, estimate_messages, estimate_tokens, ) -from src.provider.base import Model +from src.provider.base import Model, Usage def _user(text: str) -> dict: @@ -68,6 +69,29 @@ def test_content_text_counts_image_url_bytes(): assert _content_text([{"type": "image_url"}]) == "" +def test_content_tokens_counts_images_by_length_without_per_char_loop(): + """token 估算对图片按长度一次算完(逐字符会在大 base64 上阻塞事件循环)。 + + 权重取 `_OTHER_WEIGHT`(base64 只含字母数字 `+/=`),与逐字符加权的偏差 + 在纯启发式估算里可忽略;口径仍是「图片计入、不按 0 / 固定占位」。 + """ + url = "data:image/png;base64," + "A" * 1000 + content = [{"type": "text", "text": "看图"}, + {"type": "image_url", "image_url": {"url": url}}] + assert _content_tokens(content) == pytest.approx(estimate_tokens("看图") + 0.25 * len(url)) + # 与 _message_tokens 的接线:整条消息的估算口径一致 + assert estimate_messages([_user(content)]) == pytest.approx( + estimate_tokens("看图") + 0.25 * len(url)) + # 畸形 part(缺 url / url 非字符串 / 非 dict / text 非字符串)记 0,不抛异常 + assert _content_tokens([{"type": "image_url", "image_url": {"url": 5}}]) == 0.0 + assert _content_tokens([{"type": "image_url"}]) == 0.0 + assert _content_tokens([{"text": 2}]) == 0.0 + assert _content_tokens([1]) == 0.0 + assert _content_tokens("x") == estimate_tokens("x") + assert _content_tokens(None) == 0.0 + assert _content_tokens(5) == estimate_tokens("5") + + def test_estimate_messages_counts_reasoning_tools_and_ids(): messages = [{ "role": "assistant", "content": "a", "reasoning_content": "bb", @@ -134,6 +158,15 @@ def test_compress_backfill_keeps_older_small_message_when_it_fits(): assert tighter == [_user("x" * 40)] # 只能容下最近一条 +def test_compress_keeps_only_contiguous_suffix(): + """保留集合不许带洞:跳过装不下的大消息去捞更老的小消息,前缀就复用不了。""" + messages = [_user("small"), _user("b" * 400), _user("new")] + result = compress_messages(messages, max_input_tokens=8, reserve_for_output=0, + min_keep_messages=1, safety_ratio=1.0) + # 装不下 "b"*400 就停:更老的 "small" 不再被捞进来 + assert result == [_user("new")] + + def test_compress_keeps_at_least_min_keep_messages_even_when_over_budget(): messages = [_user("a" * 100), _user("b" * 100), _user("c" * 100)] result = compress_messages(messages, max_input_tokens=1, reserve_for_output=0, @@ -209,11 +242,17 @@ def __init__(self, cache=None, aliases=None): class _SettingsStub: - def __init__(self, *, enabled=True, reserve=0, min_keep=4, ratio=1.0): + def __init__(self, *, enabled=True, reserve=0, min_keep=4, ratio=1.0, + prefix_debug=False, sticky=True, tool_compress=False, + tool_max_chars=8000): self.context_compress_enabled = enabled self.context_compress_reserve_tokens = reserve self.context_compress_min_keep_messages = min_keep self.context_compress_safety_ratio = ratio + self.context_compress_sticky_enabled = sticky + self.prefix_debug_enabled = prefix_debug + self.tool_output_compress_enabled = tool_compress + self.tool_output_compress_max_chars = tool_max_chars def test_context_window_looks_up_by_normalized_and_raw_name(): @@ -309,6 +348,45 @@ def test_compressor_skips_when_window_unknown_or_messages_missing(): assert compress2("codebuddy", "m", payload2) is payload2 +def test_tool_compression_applies_even_when_context_compression_off(): + """tool 输出压缩与窗口无关:上下文压缩关闭时也要生效(省 token 不复用裁剪)。""" + noisy = {"role": "tool", "content": "\x1b[31mfatal\x1b[0m\n" * 200} + payload = {"messages": [_user("hi"), noisy]} + compress = build_context_compressor( + {}, {}, _SettingsStub(enabled=False, tool_compress=True, + tool_max_chars=100)) + + out = compress("codebuddy", "m", payload) + + assert out is not payload + assert "\x1b[" not in out["messages"][1]["content"] + assert "fatal" in out["messages"][1]["content"] + # 入参不被修改 + assert "\x1b[" in noisy["content"] + + +def test_tool_compression_off_keeps_payload_identity(): + noisy = {"role": "tool", "content": "fatal\n" * 200} + payload = {"messages": [noisy]} + compress = build_context_compressor({}, {}, _SettingsStub()) + assert compress("codebuddy", "m", payload) is payload + + +def test_tool_compression_noop_when_under_threshold(): + """开关打开但 tool 结果没超阈值:零拷贝返回(不能为此重建 payload)。""" + payload = {"messages": [{"role": "tool", "content": "ok"}]} + compress = build_context_compressor( + {}, {}, _SettingsStub(enabled=False, tool_compress=True, tool_max_chars=100)) + assert compress("codebuddy", "m", payload) is payload + + +def test_tool_compression_ignores_non_list_messages(): + payload = {"messages": "not-a-list"} + compress = build_context_compressor( + {}, {}, _SettingsStub(enabled=False, tool_compress=True)) + assert compress("codebuddy", "m", payload) is payload + + def test_compressor_under_budget_is_noop(): messages = [_user("short")] cache = {"codebuddy": {"m": Model(id="m", max_input_tokens=100000)}} @@ -375,6 +453,36 @@ def _big_request(): raw={"messages": messages}) +# --------------------------------------------------------- 粘性裁剪(默认开) + +def test_sticky_compressor_keeps_prefix_across_turns(): + """追加消息后前缀不变——这才是上游缓存能命中的前提。""" + cache = {"qoder": {"m": Model(id="m", max_input_tokens=400)}} + compress = build_context_compressor( + cache, {"qoder": {"m": "m"}}, _SettingsStub(min_keep=1, ratio=1.0, reserve=0)) + history = [_user(f"{index}-" + "x" * 400) for index in range(20)] + first = compress("qoder", "m", {"messages": history})["messages"] + second = compress("qoder", "m", {"messages": [*history, _user("next")]})["messages"] + assert second[:len(first)] == first + assert len(second) == len(first) + 1 + + +def test_non_sticky_compressor_rewrites_prefix_every_turn(): + """对照:关掉粘性就是旧行为——每轮窗口边界都跟着新消息前移。""" + cache = {"qoder": {"m": Model(id="m", max_input_tokens=400)}} + compress = build_context_compressor( + cache, {"qoder": {"m": "m"}}, + _SettingsStub(min_keep=1, ratio=1.0, reserve=0, sticky=False)) + history = [_user(f"{index}-" + "x" * 400) for index in range(20)] + first = compress("qoder", "m", {"messages": history})["messages"] + second = compress("qoder", "m", + {"messages": [*history, _user("y" * 400)]})["messages"] + assert second[:len(first)] != first + + +# ------------------------------------------------------------- executor 接线 + + @pytest.mark.asyncio async def test_executor_compresses_after_pick_with_provider_window(): """executor 在选号后按该渠道窗口压缩,上游收到的是压缩后的载荷。""" @@ -428,3 +536,86 @@ async def stream_chat(self, credential_data, payload, model): pass # 未压缩时上游收到的就是原文 messages(_with_model 只补 model 键的浅拷) assert seen[0]["messages"] is request.raw["messages"] + + +# ------------------------------------------------------------ 前缀诊断接线 + +def test_compressor_logs_trim_when_prefix_debug_enabled(caplog): + """压缩每轮裁一条 = 前缀边界每轮前移:诊断开启时必须留下证据。""" + messages = [_user("old " * 500), _user("new")] + cache = {"codebuddy": {"m": Model(id="m", max_input_tokens=50)}} + compress = build_context_compressor( + cache, {"codebuddy": {"m": "m"}}, _SettingsStub(min_keep=1, prefix_debug=True)) + with caplog.at_level("INFO"): + compress("codebuddy", "m", {"messages": messages}) + lines = [line for line in caplog.text.splitlines() if "前缀诊断·压缩" in line] + assert len(lines) == 1 + assert "provider=codebuddy" in lines[0] + assert "window=50" in lines[0] + assert "messages=2→1" in lines[0] + + +def test_compressor_logs_nothing_when_prefix_debug_disabled(caplog): + messages = [_user("old " * 500), _user("new")] + cache = {"codebuddy": {"m": Model(id="m", max_input_tokens=50)}} + compress = build_context_compressor( + cache, {"codebuddy": {"m": "m"}}, _SettingsStub(min_keep=1)) + with caplog.at_level("INFO"): + compress("codebuddy", "m", {"messages": messages}) + assert "前缀诊断" not in caplog.text + + +@pytest.mark.asyncio +async def test_executor_reports_prefix_after_compression(): + """诊断报告的是**发往上游**的那份载荷(压缩后),不是客户端原文。""" + from src.engine.executor import Executor, ExecutorDeps + from src.provider.base import Event, EventKind + + reports = [] + + class Provider: + id = "codebuddy" + + async def stream_chat(self, credential_data, payload, model): + yield Event(kind=EventKind.CONTENT, content="ok") + + cache = {"codebuddy": {"m": Model(id="m", max_input_tokens=50)}} + compress = build_context_compressor( + cache, {"codebuddy": {"m": "m"}}, _SettingsStub(min_keep=1)) + executor = Executor(ExecutorDeps( + providers={"codebuddy": Provider()}, credentials=_FakeCredentials(), + scheduler=_Scheduler(), context_compress=compress, + prefix_debug=lambda *args: reports.append(args))) + async for _frame in executor.stream(_big_request(), username="alice"): + pass + assert len(reports) == 1 + credential_id, provider_id, model, payload = reports[0] + assert (credential_id, provider_id, model) == ("c1", "codebuddy", "m") + assert payload["messages"] == [_user("new")] + + +@pytest.mark.asyncio +async def test_executor_reports_prefix_on_nonstream_path(): + """非流式聚合路径同样上报(两条主链各一次,不重复报同一轮)。""" + from src.engine.executor import Executor, ExecutorDeps + from src.provider.base import Event, EventKind + + reports = [] + + class Provider: + id = "codebuddy" + + async def stream_chat(self, credential_data, payload, model): + yield Event(kind=EventKind.CONTENT, content="ok") + yield Event(kind=EventKind.USAGE, + usage=Usage(input_tokens=1, output_tokens=1)) + + messages = [_user("old " * 500), _user("new")] + request = ChatRequest(model="m", messages=messages, stream=False, + raw={"messages": messages}) + executor = Executor(ExecutorDeps( + providers={"codebuddy": Provider()}, credentials=_FakeCredentials(), + scheduler=_Scheduler(), prefix_debug=lambda *args: reports.append(args))) + await executor.complete(request, username="alice") + assert len(reports) == 1 + assert reports[0][3]["messages"] is messages diff --git a/tests/api/test_credential_login.py b/tests/api/test_credential_login.py new file mode 100644 index 00000000..2b729c34 --- /dev/null +++ b/tests/api/test_credential_login.py @@ -0,0 +1,170 @@ +"""OAuth 登录落库:同账号重新登录合并既有凭证。""" + +from __future__ import annotations + +import asyncio + +from src.api.credential_login import save_login_credential +from src.provider.base import AuthResult, Quota + + +class _Provider: + """只实现入口用到的方法(提交后由 `schedule_probe` 调 probe)。""" + + id = "codearts" + + async def probe_quota(self, _data: dict) -> Quota: + return Quota(remaining=1, total=1, probed_at=1) + + +def _services(admin_client) -> object: + app, _client = admin_client + app.state.services.registry["codearts"] = _Provider() + return app.state.services + + +async def test_save_login_credential_merges_same_identity(admin_client): + services = _services(admin_client) + old_id = services.credentials.add( + provider="codearts", credential_data={"uid": "u1", "token": "old"}, + nickname="old") + + credential_id, merged = await save_login_credential( + services, provider_id="codearts", + credential_data={"uid": "u1", "token": "new"}, + nickname="new", added_by="root") + + assert merged is True and credential_id == old_id + assert services.credentials.credential_data(old_id) == {"uid": "u1", "token": "new"} + assert [row["id"] for row in services.credentials.list_all() + if row["provider"] == "codearts"] == [old_id] + + +async def test_save_login_credential_adds_for_unsupported_or_unknown_identity( + admin_client): + services = _services(admin_client) + + # 渠道没有声明身份(TRAE 等)→ 两次都新增 + first, merged = await save_login_credential( + services, provider_id="trae", credential_data={"accessToken": "t"}, + nickname="n", added_by="root") + assert merged is False + second, merged = await save_login_credential( + services, provider_id="trae", credential_data={"accessToken": "t"}, + nickname="n", added_by="root") + assert merged is False and second != first + + # 声明了身份但这次凭证身份为空(token 响应没带 uid)→ 不合并 + blank, merged = await save_login_credential( + services, provider_id="codearts", credential_data={"access_key_id": "AK"}, + nickname="n", added_by="root") + assert merged is False + + # 有身份但没有既有凭证 → 新增 + fresh, merged = await save_login_credential( + services, provider_id="codearts", credential_data={"uid": "nobody"}, + nickname="n", added_by="root") + assert merged is False and fresh != blank + + +async def test_save_login_credential_merges_when_only_fallback_identity_matches(admin_client): + """身份补全失败、只能给出兜底键时,仍然合并到老行(不新增重复凭证)。 + + 老行有 `uid`,这次登录的 token 响应没带 uid(`_fill_identity` 网络失败), + 只能推出 `domain_id|user_name`。单键比较必然错开 → 重复行。 + """ + services = _services(admin_client) + old_id = services.credentials.add( + provider="codearts", + credential_data={"uid": "u1", "domain_id": "d", "user_name": "n"}, + nickname="old") + + credential_id, merged = await save_login_credential( + services, provider_id="codearts", + credential_data={"domain_id": "d", "user_name": "n", "token": "new"}, + nickname="new", added_by="root") + + assert merged is True and credential_id == old_id + assert [row["id"] for row in services.credentials.list_all() + if row["provider"] == "codearts"] == [old_id] + + +async def test_save_login_credential_serializes_concurrent_same_account_logins( + admin_client): + """同一渠道并发登录串行化:不会各查到 None 再各插一行。 + + 没有渠道锁时两个 coroutine 都会在 `find_login_credential` 里读到空库, + 然后各走 `add()` —— 池子里两条同账号凭证(本功能要消除的重复)。 + """ + services = _services(admin_client) + + first, second = await asyncio.gather( + save_login_credential( + services, provider_id="codearts", + credential_data={"uid": "u1", "token": "a"}, + nickname="n", added_by="root"), + save_login_credential( + services, provider_id="codearts", + credential_data={"uid": "u1", "token": "b"}, + nickname="n", added_by="root")) + + rows = [row for row in services.credentials.list_all() + if row["provider"] == "codearts"] + assert len(rows) == 1 + # 先落库的那次新增,后一次命中同一条 → 合并 + assert {first[0], second[0]} == {rows[0]["id"]} + assert sorted([first[1], second[1]]) == [False, True] + + +async def test_save_login_credential_falls_back_when_merge_row_vanished(admin_client): + """查到 id 到加锁更新之间被删除 → 不能报成功,退回新增。""" + services = _services(admin_client) + original = services.credentials.find_login_credential + + services.credentials.find_login_credential = lambda *_a, **_kw: "cred_ghost" + try: + credential_id, merged = await save_login_credential( + services, provider_id="codearts", credential_data={"uid": "u1"}, + nickname="n", added_by="root") + finally: + services.credentials.find_login_credential = original + assert merged is False + assert credential_id != "cred_ghost" + assert services.credentials.provider_of(credential_id) == "codearts" + + +def test_upstream_poll_merges_relogin_and_revives_credential(admin_client): + """两次登录同一 uid:第二次复用原 id(而不是新增),并解除硬禁用。""" + app, client = admin_client + app.state.services.registry["codearts"] = _Provider() + credential_id = app.state.credentials.add( + provider="codearts", credential_data={"uid": "u1", "token": "old"}) + app.state.credentials.mark_relogin_required(credential_id, "refresh token revoked") + + current = {"token": "old"} + + class _OAuth: + async def poll(self, _state, _username): + return AuthResult(credential_data={"uid": "u1", "token": current["token"]}, + nickname="nick") + + app.state.upstream_auth["codearts"] = _OAuth() + + first = client.post("/api/auth/upstream/poll", + json={"provider": "codearts", "state": "s1"}).json() + assert first == {"status": "success", "credential_id": credential_id, + "merged": True} + rows = [row for row in app.state.credentials.list_all() + if row["provider"] == "codearts"] + assert len(rows) == 1 + assert rows[0]["disabled"] == 0 and rows[0]["disabled_reason"] is None + assert app.state.credentials.credential_data(credential_id)["token"] == "old" + + # 再登录一次(token 轮换)→ 仍是同一条凭证,内容更新 + current["token"] = "new" + second = client.post("/api/auth/upstream/poll", + json={"provider": "codearts", "state": "s2"}).json() + assert second["credential_id"] == credential_id and second["merged"] is True + assert len([row for row in app.state.credentials.list_all() + if row["provider"] == "codearts"]) == 1 + assert app.state.credentials.credential_data(credential_id)["token"] == "new" \ No newline at end of file diff --git a/tests/api/test_credentials_tos.py b/tests/api/test_credentials_tos.py new file mode 100644 index 00000000..5d8b174b --- /dev/null +++ b/tests/api/test_credentials_tos.py @@ -0,0 +1,66 @@ +"""渠道 ToS 风险登记:导入时返回评级、列表页透出全量、审计留痕。""" + +from __future__ import annotations + +import logging + +from src.engine.model_resolver import KNOWN_PROVIDERS +from src.provider.tos import CHANNEL_TOS, tos_for + + +def test_every_known_provider_is_registered(): + """新增渠道必须同时登记风险等级(模块导入时已强校验,这里兜住重构)。""" + assert set(KNOWN_PROVIDERS) == set(CHANNEL_TOS) + + +def test_unknown_provider_falls_back_to_gray(): + risk = tos_for("nope") + assert risk.level == "gray" + assert "未登记" in risk.basis + + +def test_known_levels(): + assert tos_for("zen").level == "green" + assert tos_for("kilo").level == "green" + for provider in ("codebuddy", "codebuddy_intl", "trae", "qoder", + "codearts", "officeace"): + assert tos_for(provider).level == "gray" + assert tos_for(provider).reason + + +def test_import_returns_tos_and_audits_level(admin_client, caplog): + app, client = admin_client + with caplog.at_level(logging.WARNING): + created = client.post("/api/credentials", json={ + "provider": "codebuddy", "credential": {"token": "abc"}}) + assert created.status_code == 200 + tos = created.json()["tos"] + assert tos["level"] == "gray" + assert tos["reason"] + # 灰区导入在日志里留一条(列表页走脚本批量导入时也能事后追溯) + assert any("ToS 评级 gray" in r.getMessage() for r in caplog.records) + + audit = app.state.audit.query(limit=20) + row = next(item for item in audit if item["action"] == "credential.import") + assert "合规=gray" in row["detail"] + + +def test_green_import_does_not_warn(admin_client, caplog): + """green 渠道照常导入,不产生灰区告警日志。""" + app, client = admin_client + with caplog.at_level(logging.WARNING): + created = client.post("/api/credentials", json={ + "provider": "zen", "credential": {}}) + assert created.status_code == 200 + assert created.json()["tos"]["level"] == "green" + assert not any("ToS 评级" in r.getMessage() for r in caplog.records) + + +def test_credentials_list_exposes_all_risks(admin_client): + _app, client = admin_client + payload = client.get("/api/credentials").json() + risks = payload["tos_risks"] + assert set(risks) == set(KNOWN_PROVIDERS) + assert risks["zen"]["level"] == "green" + assert risks["trae"]["level"] == "gray" + assert all(item["reason"] for item in risks.values()) diff --git a/tests/api/test_key_quota.py b/tests/api/test_key_quota.py index c76393f8..5e6b7bb2 100644 --- a/tests/api/test_key_quota.py +++ b/tests/api/test_key_quota.py @@ -225,6 +225,60 @@ def test_unlimited_key_is_untouched(client, app): assert _chat(client, created["api_key"]).status_code == 200 +# ------------------------------------------------ 用户级配额(/v1 也要判) + +def test_user_quota_applies_to_v1_even_with_unlimited_key(client, app): + """管理员按人设的配额必须在 `/v1` 生效。 + + 自助建 Key 时配额由本人填写,只看 Key 自己的 quota 就等于让用户造一把 + 空配额的 Key 绕开人均配额(Playground 早已判,`/v1` 原来漏了)。 + """ + app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + app.state.user_repo.set_policy("root", allowed_models="", + quota=json.dumps({"daily_requests": 1})) + created = _key(app) # Key 本身不限额 + _record_via_collector(app, created["id"]) + blocked = _chat(client, created["api_key"]) + assert blocked.status_code == 429 + assert "user" in blocked.json()["error"]["message"] + + +def test_user_quota_rpm_blocks_v1(client, app): + """用户级 rpm 同样作用于 /v1:换一把 Key 也不能绕过。""" + app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + app.state.user_repo.set_policy("root", allowed_models="", + quota=json.dumps({"rpm": 1})) + first = _key(app) + second = _key(app) + assert _chat(client, first["api_key"]).status_code == 200 + blocked = _chat(client, second["api_key"]) # 另一把 Key,同一用户 + assert blocked.status_code == 429 + assert "user" in blocked.json()["error"]["message"] + + +def test_user_quota_release_returns_both_layers(client, app): + """两层租约必须同时归还:否则用户级并发名额会被自己占死。""" + app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + app.state.user_repo.set_policy("root", allowed_models="", + quota=json.dumps({"max_concurrency": 1})) + created = _key(app) + for _ in range(3): # 串行请求:每次都归还两层名额 + assert _chat(client, created["api_key"]).status_code == 200 + assert app.state.services.user_quota.inflight("root") == 0 + + +def test_user_quota_rejected_releases_key_slot(client, app): + """用户级拒绝时 Key 级的在途名额要立刻归还,不能泄漏。""" + app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + app.state.user_repo.set_policy("root", allowed_models="", + quota=json.dumps({"max_concurrency": 1})) + created = _key(app, quota=json.dumps({"max_concurrency": 5})) + app.state.services.user_quota.admit("root", KeyQuota(max_concurrency=1)) + blocked = _chat(client, created["api_key"]) + assert blocked.status_code == 429 + assert app.state.services.key_quota.inflight(created["id"]) == 0 + + def test_dirty_quota_text_degrades_to_unlimited(client, app, caplog): app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) created = _key(app, quota="{not json") @@ -263,3 +317,21 @@ def test_non_streaming_upstream_failure_releases_lease(client, app): # 池为空 → 503,名额仍须归还 assert _chat(client, created["api_key"]).status_code == 503 assert app.state.services.key_quota.inflight(created["id"]) == 0 + + +# ------------------------------------------------------- 用户级聚合(Playground) + +def test_for_user_aggregates_across_entry_points(db, usage): + """按用户名聚合:该用户经任何入口的用量都计入用户级配额。 + + Playground 的会话直连 `api_key_id` 为 NULL,若照 Key 那样过滤 + `api_key_id IS NOT NULL`,它自己的流量就不吃自己那份配额——配额形同虚设。 + """ + _record(db, key_id="key_a", ts=1000, username="alice") + _record(db, key_id=None, ts=1001, username="alice") # Playground 直连 + _record(db, key_id="key_b", ts=1002, username="bob") # 别人的流量不计 + alice = usage.for_user("alice", since=0) + assert (alice.requests, alice.tokens) == (2, 30) + # until 上界与 since 下界都生效 + assert usage.for_user("alice", since=0, until=1001).requests == 1 + assert usage.for_user("alice", since=2000).requests == 0 diff --git a/tests/api/test_kill_switch_and_audit.py b/tests/api/test_kill_switch_and_audit.py new file mode 100644 index 00000000..13a178ae --- /dev/null +++ b/tests/api/test_kill_switch_and_audit.py @@ -0,0 +1,159 @@ +"""kill switch(全站暂停)+ 关键写操作的审计留痕。 + +守两条: +1. **暂停面**:`gateway_paused` 打开后所有 API Key 鉴权的 /v1 端点返 503 + (`type=service_paused`),而**管理台照常可用**——否则打开开关就再也关不掉。 +2. **留痕面**:发 / 删 API Key、导出明细这些「造凭据 / 搬数据」的动作必须进审计, + 否则事后回答不了「谁在什么时候做了什么」。 +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from src.audit.actions import ( + ACTION_API_KEY_CREATE, + ACTION_API_KEY_DELETE, + ACTION_DATA_EXPORT, + ACTION_SETTINGS_UPDATE, +) +from src.main import build_app +from tests.helpers.auth import isolated_settings, set_session_cookie + + +@pytest.fixture() +def app(tmp_path): + return build_app(isolated_settings(tmp_path)) + + +@pytest.fixture() +def client(app): + with TestClient(app) as test_client: + set_session_cookie(test_client, "root") + yield test_client + + +def _make_key(client, **payload) -> dict: + response = client.post("/api/api-keys", json={"name": "k", **payload}) + assert response.status_code == 200, response.text + return response.json() + + +# ------------------------------------------------------------------ 暂停面 + + +def test_gateway_pause_returns_503_on_v1_but_keeps_admin_console(client, app): + """/v1 一律 503,管理台(会话鉴权)仍可用 —— 一键止血 + 还能关上它。""" + created = _make_key(client) + headers = {"Authorization": f"Bearer {created['api_key']}"} + assert client.get("/v1/models", headers=headers).status_code == 200 + + paused = client.put("/api/settings", json={"values": {"gateway_paused": "true", + "gateway_pause_reason": "上游事故"}}) + assert paused.status_code == 200, paused.text + blocked = client.get("/v1/models", headers=headers) + assert blocked.status_code == 503 + assert blocked.json()["error"]["code"] == "service_paused" + # 原因只进日志/审计,不回显给调用方 + assert "上游事故" not in blocked.text + # 管理台仍可读可写:这是「能自己把开关关掉」的前提 + assert client.get("/api/api-keys").status_code == 200 + assert client.get("/api/settings").status_code == 200 + + resumed = client.put("/api/settings", json={"values": {"gateway_paused": "false"}}) + assert resumed.status_code == 200 + assert client.get("/v1/models", headers=headers).status_code == 200 + + +def test_gateway_pause_blocks_anthropic_entry_too(client): + """Anthropic 入口(x-api-key)同样被暂停面覆盖:它走同一个鉴权依赖。""" + created = _make_key(client) + client.put("/api/settings", json={"values": {"gateway_paused": "true"}}) + blocked = client.post("/v1/messages", headers={"x-api-key": created["api_key"]}, + json={"model": "glm-5.2", "max_tokens": 1, + "messages": [{"role": "user", "content": "hi"}]}) + assert blocked.status_code == 503 + assert blocked.json()["error"]["code"] == "service_paused" + + +def test_gateway_pause_state_is_audited_and_logged(client, app, caplog): + """开关动作进审计(settings.update 带 key)并单独记一条 WARNING。""" + with caplog.at_level("WARNING"): + client.put("/api/settings", json={"values": {"gateway_paused": "true", + "gateway_pause_reason": "演练"}}) + assert [event["action"] for event in app.state.audit.query()] == [ + ACTION_SETTINGS_UPDATE] + assert "gateway_paused" in app.state.audit.query()[0]["detail"] + assert any("网关暂停" in record.getMessage() for record in caplog.records) + + +def test_dump_toggle_is_audited_and_warned(client, app, caplog): + """请求体落盘开关是热更项:改动进审计 + 单独 WARNING(排查完没关是事故)。""" + with caplog.at_level("WARNING"): + response = client.put("/api/settings", + json={"values": {"dump_request_bodies": "true"}}) + assert response.status_code == 200 + assert "dump_request_bodies" in app.state.audit.query( + action=ACTION_SETTINGS_UPDATE)[0]["detail"] + assert any("请求体诊断落盘" in record.getMessage() for record in caplog.records) + # 生效值随响应下发(管理台据此回显),关掉后不再 dump + assert client.put("/api/settings", + json={"values": {"dump_request_bodies": "false"}}).status_code == 200 + + +# ------------------------------------------------------------------ 留痕面 + + +def test_api_key_create_and_delete_are_audited(client, app): + """发 Key / 删 Key 必须留痕:明文只回显一次,事后只能靠审计行追责。""" + created = _make_key(client, allowed_models="glm-5.3*", + provider_binding="trae", allowed_ips="10.0.0.1", + expires_at=4102444800, quota={"rpm": 5}) + events = app.state.audit.query(action=ACTION_API_KEY_CREATE) + assert events[0]["target"] == created["id"] + detail = events[0]["detail"] + assert "模型白名单 glm-5.3*" in detail and "绑定渠道 trae" in detail + assert "来源 IP 10.0.0.1" in detail and "RPM 5" in detail + assert detail.endswith("永不过期") or "到期 4102444800" in detail + + # 删掉的 Key 名字要留在审计里(删完就查不到名字了) + with_key_name = _make_key(client, ) + client.delete(f"/api/api-keys/{with_key_name['id']}") + deletes = app.state.audit.query(action=ACTION_API_KEY_DELETE) + assert deletes[0]["target"] == with_key_name["id"] + assert deletes[0]["detail"] == "名称 k" + + # 不存在的 Key / 别人的 Key:400 且不落审计(没有发生任何删除) + assert client.delete("/api/api-keys/key_nope").status_code == 400 + assert len(app.state.audit.query(action=ACTION_API_KEY_DELETE)) == 1 + + +def test_create_key_without_policy_is_audited_as_unlimited(client, app): + """.env 式默认(无白名单/IP/到期)也要有一条可读明细,不能只留个 id。""" + _make_key(client) + detail = app.state.audit.query(action=ACTION_API_KEY_CREATE)[0]["detail"] + assert detail == "名称 k;永不过期;清除配额" + + +def test_export_events_csv_is_audited(client, app): + """整份明细导出留痕(谁把多少行拉走了)。""" + response = client.get("/api/stats/events?format=csv") + assert response.status_code == 200 + events = app.state.audit.query(action=ACTION_DATA_EXPORT) + assert events[0]["target"] == "usage_events" + assert events[0]["detail"] == "CSV 明细 0 行" + + +def test_export_audit_marks_truncation(client, app, monkeypatch): + """截断也要写进明细(否则导出方以为拿到的是全部)。""" + from src.api import admin_stats + from src.stats.query import StatsQuery + + monkeypatch.setattr(StatsQuery, "export_events", + lambda self, **kwargs: [{"ts": 1, "model": "m"}, + {"ts": 2, "model": "m"}]) + monkeypatch.setattr(admin_stats, "EXPORT_MAX_ROWS", 1) + assert client.get("/api/stats/events?format=csv").status_code == 200 + assert app.state.audit.query(action=ACTION_DATA_EXPORT)[0]["detail"] \ + == "CSV 明细 1 行(已截断)" \ No newline at end of file diff --git a/tests/api/test_metrics.py b/tests/api/test_metrics.py index f0da29bd..e649517c 100644 --- a/tests/api/test_metrics.py +++ b/tests/api/test_metrics.py @@ -129,6 +129,41 @@ def test_metrics_without_runner_omits_task_family(tmp_path): assert "coding2api_task_failures_total{" not in response.text +def test_metrics_token_is_optional_and_enforced_when_set(tmp_path): + """空令牌 = 保持公开(与 /healthz 同口径);设了就必须带对的 Bearer。""" + public = build_app(isolated_settings(tmp_path)) + with TestClient(public) as http: + assert http.get("/metrics").status_code == 200 + + guarded = build_app(isolated_settings(tmp_path, METRICS_TOKEN="s3cret")) + with TestClient(guarded) as http: + assert http.get("/metrics").status_code == 401 + assert http.get("/metrics", headers={ + "Authorization": "Bearer wrong"}).status_code == 401 + assert http.get("/metrics", headers={ + "Authorization": "Basic s3cret"}).status_code == 401 + ok = http.get("/metrics", headers={"Authorization": "Bearer s3cret"}) + assert ok.status_code == 200 + assert "coding2api_credentials" in ok.text + + +def test_metrics_token_header_is_case_insensitive_and_trimmed(tmp_path): + """Prometheus 发 `Bearer`,但代理/客户端可能改大小写或带尾随空格。""" + app = build_app(isolated_settings(tmp_path, METRICS_TOKEN="tok")) + with TestClient(app) as http: + response = http.get("/metrics", headers={ + "Authorization": "bearer tok "}) + assert response.status_code == 200 + + +def test_metrics_token_disabled_still_wins(tmp_path): + """关闭优先于鉴权:关了就是 404,不暴露「这里有东西但要令牌」。""" + app = build_app(isolated_settings(tmp_path, METRICS_ENABLED="false", + METRICS_TOKEN="tok")) + with TestClient(app) as http: + assert http.get("/metrics").status_code == 404 + + def test_metrics_enabled_is_hot_updatable(tmp_path): """关闭后立即 404、恢复默认后立即 200(无需重启)。""" settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), diff --git a/tests/api/test_models.py b/tests/api/test_models.py index 45817bd0..2dcecf50 100644 --- a/tests/api/test_models.py +++ b/tests/api/test_models.py @@ -49,6 +49,45 @@ def import_credential(self, raw): # pragma: no cover # CB 的 list_models 抛错 → 该上游被跳过(不拖垮整个列表),TRAE 正常返回 assert by_id["glm-5.3"] == ["trae"] +def test_models_endpoint_survives_undecryptable_credential(tmp_path): + """凭证解不开(APP_SECRET 换过 / 密文损坏)→ 该上游跳过,整页不 500。 + + 实测 2026-10-26 14:48:解密失败直接冒泡 → 整页模型列表 500,一条坏 + 凭证连累所有渠道。解不开的凭证只影响**它自己那个上游**。 + """ + from src.db.crypto import CredentialCipher + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) + + class TraeStub: + id = "trae" + + async def list_models(self, _data): + from src.provider.base import Model + + return [Model(id="glm-5.3")] + + def import_credential(self, raw): # pragma: no cover + return raw + + app = build_app(settings, providers={"trae": TraeStub()}) + credential_id = app.state.credentials.add(provider="trae", + credential_data={"accessToken": "a"}) + # 换密钥加密 → 当前 APP_SECRET 解不开 + other = CredentialCipher("a-completely-different-secret") + db = app.state.credentials._db + db.connect().execute("UPDATE credentials SET data_enc = ? WHERE id = ?", + (other.encrypt(b'{"accessToken": "a"}'), credential_id)) + db.connect().commit() + + key = app.state.api_keys.create("root")["api_key"] + with TestClient(app) as client: + response = client.get("/v1/models", headers={"Authorization": f"Bearer {key}"}) + assert response.status_code == 200 + # 该上游没有可用凭证 → 不展示它的模型,但**整页不报错** + assert response.json()["data"] == [] + + def test_models_endpoint_skips_providers_without_credentials(tmp_path): """模型列表按渠道凭证加载——没凭证的渠道既不拉取也不展示。 @@ -172,7 +211,7 @@ class NoisyProvider: async def list_models(self, _data): from src.provider.base import Model - return [Model(id="glm-5.3"), Model(id="kimi-k3"), + return [Model(id="glm-5.3"), Model(id="kimi-k3.1-pro"), Model(id="custom_model_claude"), Model(id="explore_sub_agent_v13"), Model(id="summary"), Model(id="file_search_agent"), Model(id="default"), @@ -194,26 +233,27 @@ def import_credential(self, raw): assert "file_search_agent" not in ids assert "default" not in ids assert "hunyuan-image-alpha" not in ids - assert {"glm-5.3", "kimi-k3"} <= ids + assert {"glm-5.3", "kimi-k3.1-pro"} <= ids # 覆盖黑名单:额外滤在售模型(完全替换语义,需重写噪音规则) settings2 = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), - MODEL_BLOCKLIST="custom_model_*,*sub*agent*,summary,kimi-k3") + MODEL_BLOCKLIST="custom_model_*,*sub*agent*,summary,kimi-k3.1-pro") app2 = build_app(settings2, providers={"trae": NoisyProvider()}) app2.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) key2 = app2.state.api_keys.create("root")["api_key"] with TestClient(app2) as client: ids2 = {item["id"] for item in client.get( "/v1/models", headers={"Authorization": f"Bearer {key2}"}).json()["data"]} - assert "kimi-k3" not in ids2 and "glm-5.3" in ids2 + assert "kimi-k3.1-pro" not in ids2 and "glm-5.3" in ids2 def test_models_blocklist_default_covers_retired_upstream_models(tmp_path): - """默认黑名单含 2026-10-10 复测补入的三条「上游已下架」。 + """默认黑名单滤掉已下架 / 已退役的模型,且不误伤在售近邻。 `hy4-preview-x`(CB 400 11102)与 `hy4-preview` 只差后缀,默认两条都滤 - (`hy4-preview` 已停售);`qwen3.8-flash` 只命中 Qoder 的 qfmodel, - CB/TRAE 的 `qwen3.8-max` 不受影响;`glyph-cluster` 按 kilo 展示名清洗出的 - 归一键命中,不误伤同渠道其它模型。 + (`hy4-preview` 已停售);`glyph-cluster` 按 kilo 展示名清洗出的归一键命中; + `space-bunny` 是 Kilo 已退役的免费档;`kimi-k3` 已被 `kimi-k3.1-pro` 取代。 + `qwen3.8-flash`(Qoder qfmodel)2026-10-27 实测 chat 成功,已从默认黑名单 + 移出,故此处必须保留。 """ settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) @@ -237,9 +277,13 @@ def import_credential(self, raw): Model(id="qmodel_38max", name="Qwen3.8-Max")]), "kilo": Stub("kilo", [Model(id="stealth/glyph-cluster", name="Stealth: Glyph Cluster (free)"), + Model(id="stealth/space-bunny-alpha", + name="Space Bunny"), Model(id="kilo-auto/free", name="Auto Free")]), + "trae": Stub("trae", [Model(id="kimi-k3", name="Kimi-K3"), + Model(id="kimi-k3.1-pro", name="Kimi-K3.1-Pro")]), }) - for pid in ("codebuddy", "qoder", "kilo"): + for pid in ("codebuddy", "qoder", "kilo", "trae"): app.state.credentials.add(provider=pid, credential_data={"accessToken": "a"}) key = app.state.api_keys.create("root")["api_key"] with TestClient(app) as client: @@ -248,10 +292,12 @@ def import_credential(self, raw): assert "hy4-preview-x" not in ids assert "hy4-preview" not in ids # 已停售,默认一并滤掉 - assert "qwen3.8-flash" not in ids assert "glyph-cluster" not in ids - # 近邻模型不受牵连:qwen3.8-max 仍在售,`auto` 只命中占位符本身 - assert "qwen3.8-max" in ids + assert "space-bunny" not in ids # Kilo 已退役 + assert "kimi-k3" not in ids # 已被 kimi-k3.1-pro 取代 + # 近邻模型不受牵连:qwen3.8-flash 实测可用、qwen3.8-max 在售、k3.1 是新代, + # `auto` 只命中占位符本身 + assert {"qwen3.8-flash", "qwen3.8-max", "kimi-k3.1-pro"} <= ids def test_models_blocklist_matches_normalized_id_and_display_name(tmp_path): """黑名单按**归一后的对外写法**匹配,且两边忽略大小写。 @@ -315,7 +361,7 @@ async def list_models(self, _data): if self.fail: raise RuntimeError("upstream down") - return [Model(id="glm-5.3"), Model(id="kimi-k3")] + return [Model(id="glm-5.3"), Model(id="kimi-k3.1-pro")] def import_credential(self, raw): return raw @@ -328,12 +374,13 @@ def import_credential(self, raw): with TestClient(app) as client: client.cookies.set("coding2api_session", create_session_token("root", SECRET)) first = client.get("/v1/models", headers=auth).json()["data"] - assert {item["id"] for item in first} == {"glm-5.3", "kimi-k3"} + assert {item["id"] for item in first} == {"glm-5.3", "kimi-k3.1-pro"} # 缓存存的是**未过滤**表(两条都在),否则热更在 TTL 内无法生效 - assert set(app.state.services.model_list_cache["trae"]) == {"glm-5.3", "kimi-k3"} + assert set(app.state.services.model_list_cache["trae"]) == { + "glm-5.3", "kimi-k3.1-pro"} # TTL 未过(刚拉过),此时改黑名单 - client.put("/api/settings", json={"values": {"model_blocklist": "kimi-k3"}}, + client.put("/api/settings", json={"values": {"model_blocklist": "kimi-k3.1-pro"}}, headers={"Referer": "http://testserver/"}) second = client.get("/v1/models", headers=auth).json()["data"] assert {item["id"] for item in second} == {"glm-5.3"} # 立即生效 @@ -347,7 +394,7 @@ def import_credential(self, raw): client.put("/api/settings", json={"values": {"model_blocklist": ""}}, headers={"Referer": "http://testserver/"}) fourth = client.get("/v1/models", headers=auth).json()["data"] - assert {item["id"] for item in fourth} == {"glm-5.3", "kimi-k3"} + assert {item["id"] for item in fourth} == {"glm-5.3", "kimi-k3.1-pro"} def test_codebuddy_import_via_api(tmp_path): settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), @@ -409,10 +456,15 @@ def test_models_failure_backoff_stretches_retry_interval(tmp_path): now=1000.0 + MODEL_LIST_BACKOFF_MAX_SECONDS + 1) def test_failed_probe_hides_model_for_that_channel_only(tmp_path): - """探活判死 + 仍被 blocked 负缓存锁着 → 只把该 (渠道, 模型) 从该渠道抽出。 + """探活判死 → 只把该 (渠道, 模型) 从该渠道抽出,别的渠道照常展示。 + + 其它渠道正常时同一模型照常展示(多渠道条目退化成单渠道条目);恢复入口 + 只有复探成功(`ModelProbeTask` 每轮探判死行),隐藏没有到期时间——不再 + 挂在 blocked 负缓存的 `cooling_until` 上。 - 其它渠道正常时同一模型照常展示(多渠道条目退化成单渠道条目);负缓存 - 到期后自动回到列表(那之后没人再探,只看探活结论会永久隐藏)。 + **但别名登记必须保留**(2026-10-26 实测故障):摘掉别名后执行侧 + `_narrow_providers` 找不到任何登记该模型的渠道 → 回退成全部渠道扇出, + 请求被发到一堆不认该模型的上游,各回「无此模型」并被写上负缓存。 """ import time @@ -455,18 +507,223 @@ def import_credential(self, raw): # pragma: no cover - 未使用 # 只剩 zen 提供:条目仍在,但 providers 里没有 trae entry = client.get("/v1/models", headers=auth).json()["data"][0] assert (entry["id"], entry["providers"]) == ("glm-5.3", ["zen"]) - # 别名表同步收窄:执行侧 `_narrow_providers` 不再选 trae - assert "trae" not in services.model_aliases + # 别名表**保留** trae 的归属:执行侧 `_narrow_providers` 仍收窄到 + # 真正持有该模型的渠道,不会因「没人登记」回退成全部渠道扇出 + assert services.model_aliases["trae"]["glm-5.3"] == "glm-5.3" - # 负缓存到期:自动回到列表(不依赖再次探活) + # 负缓存到期**不**自动回列表:隐藏口径只看探活结论(没有到期时间) with services.credentials._db.transaction() as conn: conn.execute("UPDATE credential_model_cooldowns SET cooling_until = 1") entry = client.get("/v1/models", headers=auth).json()["data"][0] + assert entry["providers"] == ["zen"] + + # 复探成功 → 立即回到列表 + services.model_probes.record(provider="trae", model="glm-5.3", state="ok") + entry = client.get("/v1/models", headers=auth).json()["data"][0] assert entry["providers"] == ["trae", "zen"] +def test_failed_probe_keeps_alias_so_narrowing_does_not_fan_out(tmp_path): + """判死模型的别名登记必须保留:否则执行侧收窄失效 → 全部渠道扇出。 + + 实测故障(2026-10-26 `deepseek-v4.1-flash-1017`):officeAce 的 + `/v2/models` 挂着它(26 个之一)但推理端点直接 404 `ModelArts.81009`; + 人工探活判死后别名表丢了 officeAce 的归属 → `_narrow_providers` 因 + 「没有任何渠道登记该模型」回退成**全部渠道** → 请求扇出到 codebuddy / + codearts / codebuddy_intl,三家各回「无此模型」(11102 / not registered), + 并被写上 (凭证, 模型) 负缓存——8 条负缓存落在从没有该模型的渠道上。 + + 本用例断言:判死后别名表仍有 officeAce 的归属(收窄依据在),而展示 + 里该模型已消失。 + """ + import time + + from src.provider.base import Model + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") + + class Stub: + def __init__(self, pid: str): + self.id = pid + + async def list_models(self, _data): + return [Model(id="deepseek-v4.1-flash-1017")] + + def import_credential(self, raw): # pragma: no cover - 未使用 + return raw + + app = build_app(settings, providers={"officeace": Stub("officeace"), + "codebuddy": Stub("codebuddy")}) + for provider_id in ("officeace", "codebuddy"): + app.state.credentials.add(provider=provider_id, credential_data={"t": "x"}) + key = app.state.api_keys.create("root")["api_key"] + auth = {"Authorization": f"Bearer {key}"} + with TestClient(app) as client: + services = app.state.services + # officeAce 独有该模型(codebuddy 的模型表里没有它) + services.model_list_cache["officeace"] = { + "deepseek-v4.1-flash-1017": Model(id="deepseek-v4.1-flash-1017")} + services.model_list_cache["codebuddy"] = {"deepseek-v4.1-flash": Model( + id="deepseek-v4.1-flash")} + + services.model_probes.record(provider="officeace", + model="deepseek-v4.1-flash-1017", state="failed") + credential_id = services.credentials.candidates(["officeace"])[0].credential_id + with services.credentials._db.transaction() as conn: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) " + "VALUES (?,?,?,?,'blocked')", + (credential_id, "deepseek-v4.1-flash-1017", int(time.time()) + 3600, 1)) + + client.get("/v1/models", headers=auth) # 触发一次 publish + + # 展示里该模型消失(判死 + 负缓存生效) + ids = {item["id"] for item in + client.get("/v1/models", headers=auth).json()["data"]} + assert "deepseek-v4.1-flash-1017" not in ids + # 但别名登记保留 → 执行侧仍收窄到 officeAce,不扇出到 codebuddy + assert services.model_aliases["officeace"]["deepseek-v4.1-flash-1017"] \ + == "deepseek-v4.1-flash-1017" + + +def test_hidden_alias_not_registered_for_channel_without_credentials(tmp_path): + """没有可用凭证的渠道不登记判死别名(与 `merged_entries` 同口径)。 + + 给一个根本打不通的渠道登记归属,等于把请求指向一个死渠道。 + """ + import time + + from src.provider.base import Model + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") + + class Stub: + id = "officeace" + + async def list_models(self, _data): + return [Model(id="m-only")] + + def import_credential(self, raw): # pragma: no cover - 未使用 + return raw + + app = build_app(settings, providers={"officeace": Stub()}) + key = app.state.api_keys.create("root")["api_key"] + auth = {"Authorization": f"Bearer {key}"} + with TestClient(app) as client: + services = app.state.services + # 渠道在缓存里,但没有凭证 → 不在 `connected` 里 + services.model_list_cache["officeace"] = {"m-only": Model(id="m-only")} + services.model_probes.record(provider="officeace", model="m-only", + state="failed") + credential_id = app.state.credentials.add( + provider="officeace", credential_data={"t": "x"}) + with services.credentials._db.transaction() as conn: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) " + "VALUES (?,?,?,?,'blocked')", + (credential_id, "m-only", int(time.time()) + 3600, 1)) + # 凭证被硬禁用 → 渠道不在 `connected`(无**可用**凭证) + with services.credentials._db.transaction() as conn: + conn.execute("UPDATE credentials SET disabled = 1 WHERE id = ?", + (credential_id,)) + + client.get("/v1/models", headers=auth) + assert "officeace" not in services.model_aliases + + +def test_hidden_alias_does_not_override_real_entry(tmp_path): + """判死补登记**不覆盖**真实条目的映射(真实条目优先)。""" + import time + + from src.provider.base import Model + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") + + class Stub: + id = "trae" + + async def list_models(self, _data): + return [Model(id="glm-5.3")] + + def import_credential(self, raw): # pragma: no cover - 未使用 + return raw + + app = build_app(settings, providers={"trae": Stub()}) + app.state.credentials.add(provider="trae", credential_data={"t": "x"}) + key = app.state.api_keys.create("root")["api_key"] + auth = {"Authorization": f"Bearer {key}"} + with TestClient(app) as client: + services = app.state.services + services.model_probes.record(provider="trae", model="glm-5.3", state="failed") + credential_id = services.credentials.candidates(["trae"])[0].credential_id + with services.credentials._db.transaction() as conn: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) " + "VALUES (?,?,?,?,'blocked')", + (credential_id, "glm-5.3", int(time.time()) + 3600, 1)) + + client.get("/v1/models", headers=auth) + # 真实条目的映射原样保留(值仍是该渠道的原 id) + assert services.model_aliases["trae"]["glm-5.3"] == "glm-5.3" + + +def test_hidden_alias_falls_back_to_raw_id_when_catalog_dropped_it(tmp_path): + """上游把模型从目录里撤了 → 判死别名按**原 id**登记(仍能收窄)。 + + 目录里查不到该模型时无从得知它的对外 id 与展示名,退回按原 id 登记: + 用户按原 id 请求仍收窄到该渠道;按展示名请求则收窄不到(那条路径本来 + 就没有「这个模型属于哪」的信息)。 + """ + import time + + from src.provider.base import Model + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") + + class Stub: + id = "officeace" + + async def list_models(self, _data): + return [Model(id="m-gone")] + + def import_credential(self, raw): # pragma: no cover - 未使用 + return raw + + app = build_app(settings, providers={"officeace": Stub()}) + app.state.credentials.add(provider="officeace", credential_data={"t": "x"}) + key = app.state.api_keys.create("root")["api_key"] + auth = {"Authorization": f"Bearer {key}"} + with TestClient(app) as client: + services = app.state.services + services.model_probes.record(provider="officeace", model="m-gone", + state="failed") + credential_id = services.credentials.candidates(["officeace"])[0].credential_id + with services.credentials._db.transaction() as conn: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) " + "VALUES (?,?,?,?,'blocked')", + (credential_id, "m-gone", int(time.time()) + 3600, 1)) + # 上游已把该模型从目录里撤掉:缓存里查不到 + services.model_list_cache["officeace"] = {} + + client.get("/v1/models", headers=auth) + assert services.model_aliases["officeace"]["m-gone"] == "m-gone" + + def test_probe_ok_does_not_hide_model(tmp_path): - """探活成功(清了负缓存)不影响列表;判死但负缓存已清同样不隐藏。""" + """探活成功不影响列表;判死后隐藏不看负缓存是否还在。 + + 旧口径要求「判死 + 负缓存仍生效」,负缓存过期后模型会自己回到列表—— + 那等于隐藏带到期时间。现在判死即隐藏,恢复只看复探成功。 + """ import time from src.provider.base import Model @@ -493,7 +750,7 @@ def import_credential(self, raw): # pragma: no cover - 未使用 assert client.get("/v1/models", headers=auth).json()["data"][0]["providers"] \ == ["trae"] - # failed 但负缓存已过期(有人手动清了):不当隐藏依据 + # failed + 负缓存已过期:仍然隐藏(判死即隐藏,没有到期时间) services.model_probes.record(provider="trae", model="glm-5.3", state="failed") credential_id = services.credentials.candidates(["trae"])[0].credential_id with services.credentials._db.transaction() as conn: @@ -502,6 +759,10 @@ def import_credential(self, raw): # pragma: no cover - 未使用 "(credential_id, model, cooling_until, hits, reason) " "VALUES (?,?,?,?,'blocked')", (credential_id, "glm-5.3", int(time.time()) - 1, 1)) + assert client.get("/v1/models", headers=auth).json()["data"] == [] + + # 复探成功(`ok`)才回到列表 + services.model_probes.record(provider="trae", model="glm-5.3", state="ok") assert client.get("/v1/models", headers=auth).json()["data"][0]["providers"] \ == ["trae"] diff --git a/tests/api/test_models_alias.py b/tests/api/test_models_alias.py index 48bcdd4a..bc09ee82 100644 --- a/tests/api/test_models_alias.py +++ b/tests/api/test_models_alias.py @@ -46,8 +46,10 @@ def _app(tmp_path, aliases: str = ""): settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), ADMIN_USERNAMES="root", MODEL_ALIASES=aliases) app = build_app(settings, providers={ - "trae": _Stub("trae", [("glm-5.3", "GLM 5.3"), ("kimi-k3-1", "Kimi-K3")]), - "kilo": _Stub("kilo", [("stealth/space-bunny-alpha", "Space Bunny")]), + "trae": _Stub("trae", [("glm-5.3", "GLM 5.3"), + ("kimi-k3.1-pro", "Kimi-K3.1-Pro")]), + "kilo": _Stub("kilo", [("recursive/flint-v1.0:free", + "Recursive: Flint (free)")]), }) for provider in ("trae", "kilo"): app.state.credentials.add(provider=provider, @@ -98,21 +100,21 @@ def test_real_entries_do_not_carry_custom_alias_flag(tmp_path): def test_alias_without_provider_keeps_all_target_channels(tmp_path): """不钉渠道:目标模型在哪些渠道,别名就在哪些渠道(列表显示并集)。""" - app = _app(tmp_path, "bunny=space-bunny") + app = _app(tmp_path, "team-flint=flint") with TestClient(app) as client: items = _ids(client, app) - assert items["bunny"]["providers"] == ["kilo"] - assert items["bunny"]["custom_alias"] is True + assert items["team-flint"]["providers"] == ["kilo"] + assert items["team-flint"]["custom_alias"] is True def test_alias_target_may_be_raw_upstream_id_or_display_name(tmp_path): """目标写成任一渠道原 id / 展示名都能命中(与 `_match_entry` 同口径)。""" app = _app(tmp_path, - "by-raw=stealth/space-bunny-alpha;by-name=Kimi K3") + "by-raw=recursive/flint-v1.0:free;by-name=Kimi K3.1 Pro") with TestClient(app) as client: items = _ids(client, app) - assert items["by-raw"]["alias_target"] == "space-bunny" - assert items["by-name"]["alias_target"] == "kimi-k3" + assert items["by-raw"]["alias_target"] == "flint" + assert items["by-name"]["alias_target"] == "kimi-k3.1-pro" # ---------------------------------------------------------------- 执行侧 @@ -124,11 +126,11 @@ def test_alias_routes_chat_to_pinned_provider(tmp_path): kilo = RejectProvider("kilo", [GOOD]) app = build_app(settings, providers={"trae": trae, "kilo": kilo}) app.state.runtime_settings.set("model_aliases", - "team-bunny=space-bunny-alpha@kilo") + "team-flint=flint-v1.0@kilo") services = app.state.services services.model_list_cache["trae"] = {"glm-5.3": Model(id="glm-5.3")} services.model_list_cache["kilo"] = { - "stealth/space-bunny-alpha": Model(id="stealth/space-bunny-alpha")} + "recursive/flint-v1.0:free": Model(id="recursive/flint-v1.0:free")} app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) app.state.credentials.add(provider="kilo", credential_data={"token": "t"}) key = app.state.api_keys.create("root")["api_key"] @@ -136,10 +138,10 @@ def test_alias_routes_chat_to_pinned_provider(tmp_path): with TestClient(app) as client: # 先拉一次列表把别名表 publish 出来(真实流程也是这样:客户端先列模型) listed = client.get("/v1/models", headers={"Authorization": f"Bearer {key}"}) - assert "team-bunny" in {item["id"] for item in listed.json()["data"]} + assert "team-flint" in {item["id"] for item in listed.json()["data"]} response = client.post("/v1/chat/completions", headers={"Authorization": f"Bearer {key}"}, - json={"model": "team-bunny", + json={"model": "team-flint", "messages": [{"role": "user", "content": "hi"}]}) assert response.status_code == 200 assert response.json()["choices"][0]["message"]["content"] == "ok" @@ -162,22 +164,22 @@ async def stream_chat(self, credential_data, payload, model): kilo = _Recording("kilo", [GOOD]) executor = Executor(ExecutorDeps( providers={"kilo": kilo}, credentials=repo, scheduler=Scheduler(), - default_model="team-bunny", - model_aliases={"kilo": {"team-bunny": "stealth/space-bunny-alpha"}}, + default_model="team-flint", + model_aliases={"kilo": {"team-flint": "recursive/flint-v1.0:free"}}, upstream_model_name=lambda provider_id, model: ( - {"kilo": {"team-bunny": "stealth/space-bunny-alpha"}} + {"kilo": {"team-flint": "recursive/flint-v1.0:free"}} .get(provider_id, {}).get(model.lower(), model)))) - result = await executor.complete(chat_request("team-bunny"), username="u") + result = await executor.complete(chat_request("team-flint"), username="u") assert result["choices"][0]["message"]["content"] == "ok" - assert seen == ["stealth/space-bunny-alpha"] + assert seen == ["recursive/flint-v1.0:free"] # ------------------------------------------------------------------ 撞车 def test_alias_colliding_with_real_id_is_skipped(tmp_path, caplog): """与真实对外 id 撞车:跳过该段、记错误,**不覆盖**真实条目。""" - app = _app(tmp_path, "glm-5.3=kimi-k3;good=glm-5.3@trae") + app = _app(tmp_path, "glm-5.3=kimi-k3.1-pro;good=glm-5.3@trae") with caplog.at_level(logging.ERROR), TestClient(app) as client: items = _ids(client, app) assert "custom_alias" not in items["glm-5.3"] # 真实条目未被别名覆盖 @@ -188,7 +190,7 @@ def test_alias_colliding_with_real_id_is_skipped(tmp_path, caplog): def test_alias_colliding_with_auto_alias_is_skipped(tmp_path, caplog): """与已存在的自动别名撞车(这里是展示名键)同样跳过并记错误。""" - app = _app(tmp_path, "GLM 5.3=kimi-k3;good=glm-5.3@trae") + app = _app(tmp_path, "GLM 5.3=kimi-k3.1-pro;good=glm-5.3@trae") with caplog.at_level(logging.ERROR), TestClient(app) as client: items = _ids(client, app) assert "GLM 5.3" not in items @@ -243,7 +245,7 @@ def test_broken_segment_does_not_break_other_aliases(tmp_path, caplog): assert "good" in items assert "bad" not in items # 整个列表仍然完整(真实模型一条不少) - assert {"glm-5.3", "kimi-k3", "space-bunny"} <= set(items) + assert {"glm-5.3", "kimi-k3.1-pro", "flint"} <= set(items) # ------------------------------------------------------------------ 热更 @@ -253,13 +255,13 @@ def test_alias_config_is_hot_reloadable(tmp_path): app = _app(tmp_path) with TestClient(app) as client: assert "later" not in _ids(client, app) - app.state.runtime_settings.set("model_aliases", "later=kimi-k3@trae") + app.state.runtime_settings.set("model_aliases", "later=kimi-k3.1-pro@trae") assert "later" in _ids(client, app) assert client.get("/v1/models/later", headers=_key(app)).status_code == 200 # 清空覆盖 → 别名消失,真实模型仍在 app.state.runtime_settings.set("model_aliases", "") items = _ids(client, app) - assert "later" not in items and "kimi-k3" in items + assert "later" not in items and "kimi-k3.1-pro" in items def test_find_target_entry_blank_target_is_none(): @@ -280,7 +282,7 @@ def test_alias_listed_for_key_allowlist(tmp_path): ids = {item["id"] for item in client.get("/v1/models", headers=headers).json()["data"]} assert ids == {"team-fast"} # 白名单外的真实模型单查 404(不能拿单查探测池子) - assert client.get("/v1/models/kimi-k3", headers=headers).status_code == 404 + assert client.get("/v1/models/kimi-k3.1-pro", headers=headers).status_code == 404 # 用目标渠道原 id 单查:命中的是白名单内的别名条目(与列表同一份) by_raw = client.get("/v1/models/glm-5.3", headers=headers) assert by_raw.status_code == 200 and by_raw.json()["id"] == "team-fast" diff --git a/tests/api/test_models_availability.py b/tests/api/test_models_availability.py index ac4c8d07..ba1da58d 100644 --- a/tests/api/test_models_availability.py +++ b/tests/api/test_models_availability.py @@ -114,6 +114,21 @@ def test_ignores_other_error_types_and_successes(admin_client): assert _rows(client)["rows"] == [] +def test_model_not_found_counts_as_model_level_failure(admin_client): + """「上游无此模型」(`model_not_found`)计入模型级失败。 + + 2026-10-26 起该类从 `invalid_request` 里独立出来(此前被读成「客户端 + 参数写错了」),但**仍属**模型级失败,必须继续进可用性视图。 + """ + app, client = admin_client + _insert(app, provider="officeace", model="deepseek-v4.1-flash-1017", + error_type="model_not_found") + rows = _rows(client)["rows"] + assert len(rows) == 1 + assert rows[0]["provider"] == "officeace" + assert rows[0]["failures"] == 1 + + def test_window_excludes_old_failures(admin_client): app, client = admin_client now = int(time.time()) @@ -199,7 +214,8 @@ def test_blocked_matches_normalized_key_and_display_name(admin_client): def test_error_types_reported(admin_client): """响应里带口径说明,前端不必硬编码「哪几类算下线」。""" _app, client = admin_client - assert _rows(client)["error_types"] == ["upstream_blocked", "invalid_request"] + assert _rows(client)["error_types"] == [ + "upstream_blocked", "model_not_found", "model_capability", "invalid_request"] # ------------------------------------------------------- 探活结论与人工接管 diff --git a/tests/api/test_models_merge.py b/tests/api/test_models_merge.py index 06e4eb0e..6b23a400 100644 --- a/tests/api/test_models_merge.py +++ b/tests/api/test_models_merge.py @@ -53,8 +53,12 @@ def test_models_merge_by_readable_name_across_channels(tmp_path): 各渠道同一模型内部代号互不相同(`kimi-k3-1` / `kimi-k3` / `kmodel_latest`),只有可读名能对齐;请求转发到某渠道时再经别名表 换回该渠道自己的原 id(Qoder 仍发 `kmodel_latest`)。 + + `kimi-k3` 已被默认黑名单滤掉(→ `kimi-k3.1-pro`),本用例只验合并口径, + 故清空黑名单。 """ - settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") class Stub: def __init__(self, pid: str, models): @@ -348,9 +352,10 @@ def test_models_single_channel_outward_id_is_normalized(tmp_path): """单渠道条目的对外 id 也是归一键(Qoder `kmodel_latest` → `kimi-k3`)。 原代号只留在别名表与 `by_provider.raw_id`,转发时换回;用户按原代号直连 - 仍能命中。 + 仍能命中。`kimi-k3` 已被默认黑名单滤掉,本用例只验归一口径,故清空黑名单。 """ - settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + MODEL_BLOCKLIST="") class Stub: def __init__(self, pid: str, models): @@ -375,6 +380,8 @@ def import_credential(self, raw): # pragma: no cover - 未使用 "Authorization": f"Bearer {key}"}).json()["data"] assert [m["id"] for m in data] == ["kimi-k3"] # 对外 id 用归一键 assert data[0]["name"] == "Kimi K3" # 展示名统一清洗 + # 单渠道也下发 by_provider(原代号对外可见,PROPOSAL §3.4) + assert data[0]["by_provider"] == {"qoder": {"raw_id": "kmodel_latest"}} aliases = app.state.services.model_aliases assert aliases["qoder"]["kimi-k3"] == "kmodel_latest" # 对外 id → 原代号 assert aliases["qoder"]["kmodel_latest"] == "kmodel_latest" # 原代号也能直连 @@ -558,3 +565,23 @@ def test_models_outward_id_collision_falls_back_to_numeric_suffix(): ] _disambiguate(entries) assert [e["id"] for e in entries] == ["x", "y", "y-p1", "x-2", "x-3"] + + +def test_entry_response_omits_by_provider_when_empty(): + """`provider_meta` 为空 → 不下发 `by_provider` 键(而不是给空对象)。 + + 空对象会被前端读成「这个模型没有任何渠道」,与「渠道信息缺失」混淆。 + """ + from src.api.model_merge import _entry_response + + entry = { + "id": "m", "providers": {"codebuddy"}, + "meta": {"name": "M", "credit_rate": None, "max_input_tokens": None, + "max_output_tokens": None, "supports_images": None, + "supports_tool_call": None, "supports_reasoning": None, + "default_effort": None}, + "provider_meta": {}, "raw_ids": {}, "labels": {"M"}, + } + result = _entry_response(entry) + assert "by_provider" not in result + assert result["id"] == "m" diff --git a/tests/api/test_models_rank.py b/tests/api/test_models_rank.py index aedc4f8b..34117e26 100644 --- a/tests/api/test_models_rank.py +++ b/tests/api/test_models_rank.py @@ -67,15 +67,18 @@ def import_credential(self, raw): # pragma: no cover - 未使用 # 故意让 zen 的模型名字典序最小、CB 的最大,证明排序按渠道权重而非名字 "codebuddy": Stub("codebuddy", ["z-cb", "a-cb"]), "trae": Stub("trae", ["m-trae"]), + "lingxi": Stub("lingxi", ["aaa-lingxi"]), "zen": Stub("zen", ["aaa-zen"]), }) - for provider_id in ("codebuddy", "trae", "zen"): + for provider_id in ("codebuddy", "trae", "lingxi", "zen"): app.state.credentials.add(provider=provider_id, credential_data={"t": "x"}) key = app.state.api_keys.create("root")["api_key"] with TestClient(app) as client: ids = [item["id"] for item in client.get( "/v1/models", headers={"Authorization": f"Bearer {key}"}).json()["data"]] - assert ids == ["a-cb", "z-cb", "m-trae", "aaa-zen"] + # lingxi 有专属权重(7)→ 排在 zen/kilo 这类「其余」渠道(8)之前, + # 即使名字字典序更大 + assert ids == ["a-cb", "z-cb", "m-trae", "aaa-lingxi", "aaa-zen"] def test_models_list_orders_dual_provider_by_highest_rank(tmp_path): """多渠道模型按最高优先级渠道归位:含 CB 进第一段,仅 TR 的进第二段。""" diff --git a/tests/api/test_nonstream_keepalive.py b/tests/api/test_nonstream_keepalive.py new file mode 100644 index 00000000..381c10ae --- /dev/null +++ b/tests/api/test_nonstream_keepalive.py @@ -0,0 +1,386 @@ +"""F5:非流式出口保活(`nonstream_keepalive_seconds`,默认 0 = 关闭)。 + +口径: + +- **宽限期**:`grace` 秒内完成 → 普通 `JSONResponse`,状态码 / `Content-Length` + / 字节内容与不开保活时**完全一致**,异常照常抛给异常处理器; +- **超时才提交 200**:转 chunked,先吐空白字节(RFC 8259 §2 允许 JSON 前导 + 空白)再补正文; +- **默认关**:本部署 nginx `proxy_read_timeout 3600s` + frp TCP,链路上没有 + 短空闲超时,实测 30 天 >60s 的成功请求 1741 个全部成功。 +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +from starlette.responses import JSONResponse + +from src.api.streaming import ( + JSON_KEEPALIVE_BYTES, + LATE_FAILURE_PAYLOAD, + respond_with_keepalive, +) + + +def _collect(response) -> list[bytes]: + async def run(): + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + return frames + + return run() + + +async def test_fast_path_returns_plain_json_response(): + async def work(): + return {"ok": 1} + + response = await respond_with_keepalive(work, grace=5) + assert isinstance(response, JSONResponse) + assert response.status_code == 200 + assert json.loads(bytes(response.body)) == {"ok": 1} + # 有 Content-Length:字节级与不开保活完全一致 + assert b"content-length" in b"".join( + k.lower() + b": " + v for k, v in response.headers.raw) + + +async def test_fast_path_applies_convert(): + async def work(): + return {"ok": 1} + + response = await respond_with_keepalive(work, grace=5, + convert=lambda d: {"wrapped": d}) + assert json.loads(bytes(response.body)) == {"wrapped": {"ok": 1}} + + +async def test_fast_path_releases_once(): + released: list[int] = [] + + async def work(): + return {"ok": 1} + + await respond_with_keepalive(work, grace=5, release=lambda: released.append(1)) + assert released == [1] + + +async def test_fast_path_propagates_exception(): + """宽限期内失败:异常照常抛给上层,状态码仍能正确(400/503 不变)。""" + + async def work(): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + await respond_with_keepalive(work, grace=5) + + +async def test_fast_path_releases_on_exception(): + released: list[int] = [] + + async def work(): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + await respond_with_keepalive(work, grace=5, + release=lambda: released.append(1)) + assert released == [1] + + +async def test_slow_path_streams_whitespace_then_body(): + """超时后才转 chunked:先空白,再合法 JSON。""" + gate = asyncio.Event() + + async def work(): + await gate.wait() + return {"ok": 1} + + response = await respond_with_keepalive(work, grace=0.01) + assert not isinstance(response, JSONResponse) + assert response.media_type == "application/json" + assert response.headers["x-accel-buffering"] == "no" + + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + if len(frames) == 2: # 先收到心跳,再放行上游 + gate.set() + assert frames[0] == JSON_KEEPALIVE_BYTES + assert frames[-1] == b'{"ok":1}' + # 前导空白不影响解析:这是整套设计的合法性依据 + assert json.loads(b"".join(frames)) == {"ok": 1} + + +async def test_slow_path_applies_convert(): + gate = asyncio.Event() + + async def work(): + await gate.wait() + return {"ok": 1} + + response = await respond_with_keepalive( + work, grace=0.01, convert=lambda d: {"wrapped": d}) + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + if len(frames) == 2: + gate.set() + assert json.loads(b"".join(frames)) == {"wrapped": {"ok": 1}} + + +async def test_slow_path_late_failure_yields_error_payload(): + """200 已提交后上游才失败:正文降级为可解析的错误体(改不回状态码)。""" + gate = asyncio.Event() + + async def work(): + await gate.wait() + raise RuntimeError("late boom") + + response = await respond_with_keepalive(work, grace=0.01) + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + if len(frames) == 2: + gate.set() + payload = json.loads(b"".join(frames)) + assert payload == LATE_FAILURE_PAYLOAD + + +async def test_slow_path_releases_when_done(): + released: list[int] = [] + gate = asyncio.Event() + + async def work(): + await gate.wait() + return {"ok": 1} + + response = await respond_with_keepalive( + work, grace=0.01, release=lambda: released.append(1)) + assert released == [] # 流还没跑完,不能提前释放 + frames: list[bytes] = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + if len(frames) == 2: + gate.set() + assert released == [1] + + +async def test_slow_path_client_disconnect_cancels_work(): + """客户端断开(生成器提前关闭):取消工作协程并释放租约,不留孤儿。""" + cancelled: list[int] = [] + released: list[int] = [] + + async def work(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.append(1) + raise + return {"ok": 1} + + response = await respond_with_keepalive( + work, grace=0.01, release=lambda: released.append(1)) + iterator = response.body_iterator.__aiter__() + assert await anext(iterator) == JSON_KEEPALIVE_BYTES + await response.body_iterator.aclose() + await asyncio.sleep(0) + assert cancelled == [1] + assert released == [1] + + +async def test_outer_cancel_cancels_work(): + """宽限期内被取消(客户端断开 / 上层取消):工作协程一并收掉。""" + cancelled: list[int] = [] + released: list[int] = [] + + async def work(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.append(1) + raise + return {"ok": 1} + + task = asyncio.ensure_future( + respond_with_keepalive(work, grace=10, + release=lambda: released.append(1))) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert cancelled == [1] + assert released == [1] + + +async def test_outer_cancel_without_release_is_silent(): + """宽限期内被取消且没传 release:只收掉工作协程,不额外做什么。""" + cancelled: list[int] = [] + + async def work(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.append(1) + raise + return {"ok": 1} + + task = asyncio.ensure_future(respond_with_keepalive(work, grace=10)) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert cancelled == [1] + + +async def test_slow_path_cancelled_work_still_raises(monkeypatch): + """200 已提交后工作协程被取消:照常抛 CancelledError,不冒充上游失败。""" + captured: dict[str, asyncio.Future] = {} + real_ensure = asyncio.ensure_future + + def capture(coro, *args, **kwargs): + future = real_ensure(coro, *args, **kwargs) + captured.setdefault("task", future) + return future + + monkeypatch.setattr(asyncio, "ensure_future", capture) + released: list[int] = [] + gate = asyncio.Event() + + async def work(): + await gate.wait() + return {"ok": 1} + + response = await respond_with_keepalive( + work, grace=0.01, release=lambda: released.append(1)) + iterator = response.body_iterator.__aiter__() + assert await anext(iterator) == JSON_KEEPALIVE_BYTES + captured["task"].cancel() + with pytest.raises(asyncio.CancelledError): + await anext(iterator) + assert released == [1] + + +def test_dumps_matches_json_response_rendering(): + """保活正文的序列化必须与 `JSONResponse.render` 逐字节一致。""" + from src.api.streaming import _dumps + + payload = {"a": 1, "中文": "值", "b": [1, 2]} + assert _dumps(payload) == JSONResponse(payload).render(payload) + + +# ------------------------------------------------------------ 出口接线 + + +def _client(tmp_path, keepalive: float, script=None): + from fastapi.testclient import TestClient + + from src.config import Settings + from src.main import build_app + from tests.helpers.constants import SECRET + from tests.helpers.events import TRAE_GOOD + from tests.helpers.providers import ScriptedProvider + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root", + nonstream_keepalive_seconds=keepalive) + app = build_app(settings, + providers={"trae": ScriptedProvider(script or [TRAE_GOOD])}) + return TestClient(app) + + +def _headers(client) -> dict[str, str]: + client.app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + created = client.app.state.api_keys.create("root", "test") + return {"Authorization": f"Bearer {created['api_key']}"} + + +def test_endpoint_keepalive_disabled_by_default(tmp_path): + """默认 0 = 关闭:走原来的 `JSONResponse` 分支(有 Content-Length)。""" + with _client(tmp_path, 0) as client: + headers = _headers(client) + response = client.post("/v1/chat/completions", headers=headers, + json={"messages": [{"role": "user", "content": "hi"}], + "model": "glm-5.2"}) + assert response.status_code == 200 + assert "content-length" in response.headers + assert response.json()["choices"][0]["message"]["content"] == "hi" + + +def test_endpoint_keepalive_enabled_fast_request_stays_plain(tmp_path): + """打开保活但请求很快完成:仍是普通 JSON,字节级不变。""" + with _client(tmp_path, 5) as client: + headers = _headers(client) + response = client.post("/v1/chat/completions", headers=headers, + json={"messages": [{"role": "user", "content": "hi"}], + "model": "glm-5.2"}) + assert response.status_code == 200 + assert "content-length" in response.headers + assert response.json()["choices"][0]["message"]["content"] == "hi" + + +def test_endpoint_keepalive_enabled_slow_request_streams(tmp_path): + """打开保活且超过宽限期:200 + chunked + 空白前导,正文仍是合法 JSON。""" + import asyncio as aio + + from tests.helpers.events import TRAE_GOOD + + class SlowProvider: + id = "trae" + + async def stream_chat(self, _credential_data, _payload, _model): + await aio.sleep(0.05) + for event in TRAE_GOOD: + yield event + + async def list_models(self, _credential_data): + return [] + + from fastapi.testclient import TestClient + + from src.config import Settings + from src.main import build_app + from tests.helpers.constants import SECRET + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root", nonstream_keepalive_seconds=0.01) + app = build_app(settings, providers={"trae": SlowProvider()}) + with TestClient(app) as client: + headers = _headers(client) + response = client.post("/v1/chat/completions", headers=headers, + json={"messages": [{"role": "user", "content": "hi"}], + "model": "glm-5.2"}) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-accel-buffering"] == "no" + assert "content-length" not in response.headers + assert response.content.startswith(JSON_KEEPALIVE_BYTES) + assert json.loads(response.content)["choices"][0]["message"]["content"] == "hi" + + +def test_endpoint_uses_keepalive_when_enabled(tmp_path): + """配置打开后非流式出口走保活包装;宽限期内完成仍返回普通 JSON。""" + from fastapi.testclient import TestClient + + from src.config import Settings + from src.main import build_app + from tests.helpers.constants import SECRET + from tests.helpers.events import TRAE_GOOD + from tests.helpers.providers import ScriptedProvider + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root", nonstream_keepalive_seconds=5) + app = build_app(settings, providers={"trae": ScriptedProvider([TRAE_GOOD])}) + with TestClient(app) as client: + app.state.credentials.add(provider="trae", + credential_data={"accessToken": "a"}) + key = app.state.api_keys.create("root")["api_key"] + response = client.post( + "/v1/chat/completions", + headers={"Authorization": f"Bearer {key}"}, + json={"messages": [{"role": "user", "content": "hi"}]}) + assert response.status_code == 200 + assert response.json()["choices"][0]["message"]["content"] == "hi" + # 宽限期内完成:普通 JSONResponse,带 Content-Length(chunked 才没有) + assert response.headers.get("content-length") diff --git a/tests/api/test_playground.py b/tests/api/test_playground.py index ab8296d2..3e734ee9 100644 --- a/tests/api/test_playground.py +++ b/tests/api/test_playground.py @@ -104,3 +104,87 @@ def test_v1_models_and_playground_models_are_consistent(tmp_path): client.cookies.set("coding2api_session", create_session_token("root", SECRET)) playground = client.get("/api/playground/models").json() assert v1 == playground + + +# ----------------------------------------------- 按人判定策略(白名单 + 配额) + +def _policy_app(tmp_path, *, models="", quota=None): + """预置 root 的用户级策略(仅约束 Playground 会话直连)。""" + app = _playground_app(tmp_path, [GOOD_EVENTS]) + app.state.user_repo.set_policy("root", allowed_models=models, + quota=quota or "") + return app + + +def _post_chat(client, model=None): + body = {"messages": [{"role": "user", "content": "hi"}]} + if model: + body["model"] = model + return client.post("/api/playground/chat/completions", json=body) + + +def test_playground_rejects_model_outside_user_allowlist(tmp_path): + """用户白名单不通过 → 400(与 /v1 的 Key 白名单同一判据、同一文案主体)。 + + 缺这一步时任何登录用户(含 viewer)都能直调任意模型,把 Key 的模型白名单 + 整体绕过——Playground 走 Cookie,不经过 Key 鉴权。 + """ + app = _policy_app(tmp_path, models="glm-5.3") + with TestClient(app) as client: + client.cookies.set("coding2api_session", create_session_token("root", SECRET)) + denied = _post_chat(client, "trae-only") + assert denied.status_code == 400 + assert "not allowed for this user" in denied.json()["error"]["message"] + assert _post_chat(client, "glm-5.3").status_code == 200 + + +def test_playground_applies_user_daily_quota(tmp_path): + """用户配额超限 → 429 `quota_exceeded`(带 Retry-After),不消耗上游调用。""" + app = _policy_app(tmp_path, quota='{"daily_requests": 1}') + with TestClient(app) as client: + client.cookies.set("coding2api_session", create_session_token("root", SECRET)) + assert _post_chat(client, "glm-5.3").status_code == 200 + limited = _post_chat(client, "glm-5.3") + assert limited.status_code == 429 + assert limited.json()["error"]["type"] == "quota_exceeded" + assert limited.json()["error"]["message"].startswith("user quota exceeded") + assert int(limited.headers["Retry-After"]) > 0 + # 只有第一次真的打到了上游 + assert app.state.services.executor is not None + provider = app.state.services.registry["trae"] + assert provider.calls == 1 + + +def test_playground_stream_releases_concurrency_lease(tmp_path): + """流式收尾必须归还并发名额:否则第二个请求永远撞 429。""" + app = _policy_app(tmp_path, quota='{"max_concurrency": 1}') + with TestClient(app) as client: + client.cookies.set("coding2api_session", create_session_token("root", SECRET)) + first = client.post("/api/playground/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], + "stream": True}) + assert first.status_code == 200 + assert "data:" in first.text + # 名额已归还:第二个请求不该因为 max_concurrency 被拒 + assert _post_chat(client, "glm-5.3").status_code == 200 + + +def test_playground_preflight_failure_releases_lease(tmp_path, monkeypatch): + """流式前置校验失败(模型不属于任何已注册上游)也要归还名额。""" + from src.compat.openai.request import InvalidRequest + from src.engine.executor import Executor as _Executor + + app = _policy_app(tmp_path, quota='{"max_concurrency": 1}') + + def boom(self, request, provider_binding=None): + raise InvalidRequest("unknown provider for model") + + monkeypatch.setattr(_Executor, "preflight", boom) + with TestClient(app) as client: + client.cookies.set("coding2api_session", create_session_token("root", SECRET)) + bad = client.post("/api/playground/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], + "stream": True}) + assert bad.status_code == 400 + monkeypatch.undo() + assert _post_chat(client, "glm-5.3").status_code == 200 diff --git a/tests/api/test_stats_errors.py b/tests/api/test_stats_errors.py index 80cffda9..4f13257c 100644 --- a/tests/api/test_stats_errors.py +++ b/tests/api/test_stats_errors.py @@ -50,7 +50,7 @@ def test_errors_groups_and_orders_by_failures(admin_client): now = _seed_failures(app) rows = client.get(PATH, params={"since": now - 3600}).json()["errors"] assert rows[0] == {"provider": "trae", "model": "glm", "error_type": "rate_limit", - "failures": 3, "last_ts": now - 8} + "failures": 3, "rescued": 0, "last_ts": now - 8} assert rows[1]["error_type"] == "upstream_error" and rows[1]["failures"] == 1 # 成功请求不进归因 assert sum(row["failures"] for row in rows) == 4 diff --git a/tests/auth/test_api_key_policy.py b/tests/auth/test_api_key_policy.py index a3e41d60..f4a53d1b 100644 --- a/tests/auth/test_api_key_policy.py +++ b/tests/auth/test_api_key_policy.py @@ -272,6 +272,35 @@ def test_model_allowlist_blocks_chat_and_responses(client): assert responses.status_code == 400 assert "not allowed" in responses.json()["error"]["message"] +def test_user_model_policy_bounds_chat_and_model_list(client): + """管理员按人设的白名单在 /v1 也生效:自签空白名单的 Key 不能越权。 + + 用户策略(users.allowed_models)此前只在 Playground 判;/v1 只看 Key 自己的 + 白名单,于是用户删掉 Key 白名单即可绕开管理员设的范围。 + """ + services = client.app.state.services + services.user_repo.set_policy("root", allowed_models="glm-*", quota="") + key = _create_key(client)["api_key"] # Key 自己不设白名单 + headers = {"Authorization": f"Bearer {key}"} + + chat = client.post("/v1/chat/completions", headers=headers, + json={"model": "kimi-k3", "messages": [{"role": "user", + "content": "hi"}]}) + assert chat.status_code == 400 + assert chat.json()["error"]["message"].endswith("not allowed for this user") + + # 列表同口径:不放行的模型不该出现在 /v1/models(否则「看得到用不了」) + listed = client.get("/v1/models", headers=headers).json()["data"] + assert all(item["id"].startswith("glm-") for item in listed) + + +def test_user_model_policy_absent_keeps_key_scope(client): + """没有用户策略行时按不限制放行(脏数据不该把出口锁死)。""" + key = _create_key(client, allowed_models="glm-*")["api_key"] + response = client.get("/v1/models", headers={"Authorization": f"Bearer {key}"}) + assert response.status_code == 200 + + def test_expired_key_is_rejected(client): key = _create_key(client, expires_at=1)["api_key"] response = client.get("/v1/models", headers={"Authorization": f"Bearer {key}"}) diff --git a/tests/auth/test_bootstrap.py b/tests/auth/test_bootstrap.py index 7859f36d..9ca7274e 100644 --- a/tests/auth/test_bootstrap.py +++ b/tests/auth/test_bootstrap.py @@ -90,7 +90,7 @@ def test_require_admin_accepts_legacy_positional(): def test_session_embeds_and_returns_epoch(): token = create_session_token("alice", "sec", epoch=7, issued_at=1000, ttl_seconds=60) - assert verify_session_token(token, "sec", now=1030) == ("alice", 7) + assert verify_session_token(token, "sec", now=1030) == ("alice", 7, 1000) def test_session_token_without_epoch_defaults_to_zero(): @@ -104,7 +104,7 @@ def test_session_token_without_epoch_defaults_to_zero(): separators=(",", ":")).encode("utf-8") payload_b64 = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") legacy = f"{payload_b64}.{session_mod._sign(payload_b64, 'sec')}" - assert verify_session_token(legacy, "sec", now=1500) == ("alice", 0) + assert verify_session_token(legacy, "sec", now=1500) == ("alice", 0, 1000) @pytest.mark.parametrize("epoch", ["7", True, -1]) @@ -150,6 +150,69 @@ def test_db_user_store_rejects_disabled(tmp_path): db.close() +def test_db_user_store_session_valid(tmp_path): + """整库重建后旧 Cookie 的 ep 会重新匹配,必须靠签发时间兜住(issue #37)。""" + db, repo = _repo(tmp_path) + repo.create("alice", create_password_hash("pw"), now=1000) + store = DbUserStore(repo) + assert store.session_valid("alice", 0, 1000) is True + assert store.session_valid("alice", 0, 940) is True # 60s 时钟余量内 + assert store.session_valid("alice", 0, 939) is False # 早于账号创建 + assert store.session_valid("alice", 0, None) is True # 老 Cookie 跳过时间校验 + assert store.session_valid("alice", 1, 1000) is False # epoch 已作废 + assert store.session_valid("ghost", 0, 1000) is False # 账号不存在 + repo.set_enabled("alice", False) + assert store.session_valid("alice", 1, 1000) is False # 已禁用 + db.close() + + +def test_db_user_store_verify_runs_pbkdf2_for_missing_user(tmp_path, monkeypatch): + """不存在/禁用的响应必须与「存在但密码错」同耗时,否则可计时枚举用户名。 + + 600k 迭代 PBKDF2 约 45ms:直接 return False 比真实验证快 ~7000×。修复是 + 给不存在/禁用用户跑一次 dummy 哈希(进程内构造一次,随机口令永远对不上)。 + """ + import src.auth.users as users_mod + + db, repo = _repo(tmp_path) + repo.create("alice", create_password_hash("pw"), enabled=False, now=1) + store = DbUserStore(repo) + + hash_calls: list[str] = [] + verify_calls: list[str] = [] + real_hash = users_mod.create_password_hash + real_verify = users_mod.verify_password + + def spy_hash(password): + hash_calls.append(password) + return real_hash(password) + + def spy_verify(password, encoded): + verify_calls.append(encoded) + return real_verify(password, encoded) + + monkeypatch.setattr(users_mod, "create_password_hash", spy_hash) + monkeypatch.setattr(users_mod, "verify_password", spy_verify) + monkeypatch.setattr(users_mod, "_DUMMY_HASH_CACHE", []) + + # 不存在:照常跑了一次完整 PBKDF2(dummy 哈希,存进缓存) + assert store.verify("ghost", "pw") is False + dummy = users_mod._DUMMY_HASH_CACHE[0] + assert len(hash_calls) == 1 # 生成 dummy(随机口令) + assert verify_calls == [dummy] + + # 第二次不存在的用户:dummy 从缓存来,只有 verify + assert store.verify("ghost", "pw") is False + assert len(hash_calls) == 1 + assert verify_calls == [dummy, dummy] + + # 禁用用户同口径:不试真实哈希(不透出「存在但被禁用」) + disabled_hash = repo.get("alice")["password_hash"] + assert store.verify("alice", "pw") is False + assert disabled_hash not in verify_calls + db.close() + + def test_db_user_store_reports_must_change(tmp_path): db, repo = _repo(tmp_path) repo.create("alice", create_password_hash("pw"), must_change_password=True, now=1) @@ -181,6 +244,29 @@ def test_bootstrap_imports_viewer_then_promotes_env_admin(tmp_path): db.close() +def test_bootstrap_skips_promotion_when_already_admin(tmp_path): + """提权名单里的用户**本来就是 admin** → 不重复改角色。 + + 重复 `update_role` 会 bump epoch 把人踢下线,故已是 admin 的必须跳过。 + 构造:库里没有活跃 admin(走提权分支),名单里一个已是 admin(禁用态)、 + 一个是 viewer —— 前者命中「跳过」,后者被提权,兜住「无活跃 admin」的报错。 + """ + db, repo = _repo(tmp_path) + repo.create("aaa", create_password_hash("a"), role=ROLE_ADMIN, now=1) + repo.set_enabled("aaa", False) + repo.create("zzz", create_password_hash("z"), role=ROLE_VIEWER, now=1) + store = _file(tmp_path, [("aaa", create_password_hash("a")), + ("zzz", create_password_hash("z"))]) + logs: list[str] = [] + bootstrap_users(_Config(["aaa", "zzz"]), repo, file_store=store, + log=lambda msg, *args: logs.append(msg % args)) + assert repo.get("aaa")["role"] == ROLE_ADMIN + assert repo.get("zzz")["role"] == ROLE_ADMIN + # 只提权了 zzz;aaa 已是 admin 不重复提权 + assert any("提权为 admin:zzz" in line for line in logs) + db.close() + + def test_bootstrap_is_idempotent_and_keeps_managed_changes(tmp_path): """重复引导不得把管理台改过的密码/角色冲回文件值。""" db, repo = _repo(tmp_path) @@ -418,6 +504,74 @@ def test_change_password_rejects_short_new_password(tmp_path, monkeypatch): db.close() +def test_change_password_runs_pbkdf2_off_the_event_loop(tmp_path, monkeypatch): + """PBKDF2 必须走线程池:同步执行会独占事件循环,SSE/健康检查全被拖住。 + + 实测 10 次同步 `verify_password` 期间 1ms 心跳一次都没跑起来;20 并发改密 + 足以让容器探针判 unhealthy。登录路径早已用 `throttle.verify`,这里同口径。 + """ + monkeypatch.setenv("USERS_FILE", str(tmp_path / "none.txt")) + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + db = Database(settings.db_path) + apply_schema(db.connect()) + UserRepository(db).create("root", create_password_hash("rootpw"), + role=ROLE_ADMIN, now=1) + token = create_session_token("root", SECRET, epoch=0) + + from src.auth import throttle as throttle_module + + calls: list[str] = [] + real_to_thread = throttle_module.asyncio.to_thread + + async def spy(func, *args): + calls.append(getattr(func, "__name__", "?")) + return await real_to_thread(func, *args) + + monkeypatch.setattr(throttle_module.asyncio, "to_thread", spy) + app = build_app(settings) + with TestClient(app) as client: + client.cookies.set("coding2api_session", token) + response = client.post("/api/auth/password", + json={"current_password": "rootpw", + "new_password": "brand-new-pw"}) + assert response.status_code == 200 + # 校验旧密码与生成新哈希都必须离开事件循环 + assert "verify_password" in calls + assert "create_password_hash" in calls + db.close() + + +def test_change_password_counts_failures_for_throttling(tmp_path, monkeypatch): + """猜当前密码要计入限流:会话被盗后不能无限次试。 + + 与登录共用 `LoginThrottle` 的用户名窗口(max_per_user=5 / 60s),超限后 + 直接 429,不再跑 PBKDF2。 + """ + monkeypatch.setenv("USERS_FILE", str(tmp_path / "none.txt")) + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + db = Database(settings.db_path) + apply_schema(db.connect()) + UserRepository(db).create("root", create_password_hash("rootpw"), + role=ROLE_ADMIN, now=1) + token = create_session_token("root", SECRET, epoch=0) + + app = build_app(settings) + with TestClient(app) as client: + client.cookies.set("coding2api_session", token) + for _ in range(5): + response = client.post("/api/auth/password", + json={"current_password": "wrong", + "new_password": "brand-new-pw"}) + assert response.status_code == 400 + blocked = client.post("/api/auth/password", + json={"current_password": "wrong", + "new_password": "brand-new-pw"}) + assert blocked.status_code == 429 + db.close() + + def test_login_response_exposes_role_and_flag(tmp_path, monkeypatch): monkeypatch.setenv("USERS_FILE", str(tmp_path / "none.txt")) settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), diff --git a/tests/auth/test_session.py b/tests/auth/test_session.py index 7e63b562..36d913dd 100644 --- a/tests/auth/test_session.py +++ b/tests/auth/test_session.py @@ -9,7 +9,7 @@ def test_session_roundtrip_and_expiry(): token = session_mod.create_session_token("alice", "sec", issued_at=1000, ttl_seconds=60) - assert session_mod.verify_session_token(token, "sec", now=1030) == ("alice", 0) + assert session_mod.verify_session_token(token, "sec", now=1030) == ("alice", 0, 1000) assert session_mod.verify_session_token(token, "sec", now=2000) is None assert session_mod.verify_session_token(token, "wrong", now=1030) is None @@ -25,3 +25,28 @@ def test_session_rejects_malformed(bad): def test_session_rejects_non_ascii_cookie_without_raising(bad): """非 ASCII Cookie 由客户端任意构造,必须归一为「非法」而不是冒泡 500。""" assert session_mod.verify_session_token(bad, "sec", now=1) is None + + +@pytest.mark.parametrize("issued_at", ["1000", True, -1]) +def test_session_rejects_invalid_issued_at(issued_at): + """显式写了非法 `iat` 才拒绝;缺失(老 Cookie)走 None 由调用方跳过校验。""" + import base64 + import json + + raw = json.dumps({"u": "alice", "ep": 0, "iat": issued_at, "exp": 2000}, + separators=(",", ":")).encode("utf-8") + payload_b64 = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + token = f"{payload_b64}.{session_mod._sign(payload_b64, 'sec')}" + assert session_mod.verify_session_token(token, "sec", now=1500) is None + + +def test_session_without_issued_at_returns_none(): + """缺 `iat` 的老 Cookie:返回 None 让 `session_valid` 跳过创建时间校验。""" + import base64 + import json + + raw = json.dumps({"u": "alice", "ep": 0, "exp": 2000}, + separators=(",", ":")).encode("utf-8") + payload_b64 = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + token = f"{payload_b64}.{session_mod._sign(payload_b64, 'sec')}" + assert session_mod.verify_session_token(token, "sec", now=1500) == ("alice", 0, None) diff --git a/tests/compat/test_anthropic.py b/tests/compat/test_anthropic.py index cb825fdd..5758bab3 100644 --- a/tests/compat/test_anthropic.py +++ b/tests/compat/test_anthropic.py @@ -116,6 +116,36 @@ def test_endpoint_non_stream(tmp_path): assert body["usage"] == {"input_tokens": 2, "output_tokens": 3} assert provider.last_payload["messages"][0] == {"role": "user", "content": "hi"} +def test_endpoint_stream_keepalive_uses_ping_event(tmp_path, monkeypatch): + """空闲心跳走 `event: ping`(不是注释帧):只解析 event 行的客户端才收得到。 + + 这是端到端契约:`dispatch_compat` 必须把 translator 的心跳帧交给 + `with_keepalive`,否则翻译器上的 `keepalive()` 永远不会被调用。 + """ + import asyncio + + from src.api import streaming + + monkeypatch.setattr(streaming, "KEEPALIVE_SECONDS", 0.01) + + class SlowProvider(_Provider): + async def stream_chat(self, credential_data, payload, model): + await asyncio.sleep(0.08) + async for event in super().stream_chat(credential_data, payload, model): + yield event + + app = _app(tmp_path, SlowProvider()) + with TestClient(app) as client: + response = client.post("/v1/messages", headers=_auth(app), + json=_messages(stream=True)) + assert response.status_code == 200 + assert "event: ping" in response.text + assert ": keepalive" not in response.text + # ping 必须是合法 JSON 事件,不能只有事件名 + assert any(name == "ping" and data == {"type": "ping"} + for name, data in _events(response.text)) + + def test_endpoint_stream_reports_cache_read(tmp_path): app = _app(tmp_path, _Provider("cache")) with TestClient(app) as client: diff --git a/tests/compat/test_anthropic_request.py b/tests/compat/test_anthropic_request.py index e0362ed5..6d6d661c 100644 --- a/tests/compat/test_anthropic_request.py +++ b/tests/compat/test_anthropic_request.py @@ -61,17 +61,64 @@ def test_parse_string_content_by_role(): assert [m["role"] for m in parsed.messages] == ["user", "assistant"] def test_parse_rejects_unknown_role_and_bad_message(): - with pytest.raises(InvalidRequest, match="role 'system' is not supported"): - parse_messages_request(_body(messages=[{"role": "system", "content": "x"}])) + with pytest.raises(InvalidRequest, match="role 'tool' is not supported"): + parse_messages_request(_body(messages=[{"role": "tool", "content": "x"}])) # 数组 content 下的非法 role 走另一分支 - with pytest.raises(InvalidRequest, match="role 'system' is not supported"): - parse_messages_request(_body(messages=[{"role": "system", "content": [ + with pytest.raises(InvalidRequest, match="role 'tool' is not supported"): + parse_messages_request(_body(messages=[{"role": "tool", "content": [ {"type": "text", "text": "x"}]}])) with pytest.raises(InvalidRequest, match=r"messages\[0\] must be an object"): parse_messages_request(_body(messages=["x"])) with pytest.raises(InvalidRequest, match="string or an array"): parse_messages_request(_body(messages=[{"role": "user", "content": 5}])) + +def test_parse_mid_conversation_system_role(): + """Claude Code 2.1.318 起在新会话首轮发 `role:"system"`(特性 + `mid-conversation-system-2026-04-07`)。就地转成 system 消息,不重排。 + + 此前 400 会让客户端白打一次往返(它收到 400 就重发去掉该消息并永久 + 关掉特性)。 + """ + parsed = parse_messages_request(_body(messages=[ + {"role": "user", "content": "hi"}, + {"role": "system", "content": "be terse"}, + {"role": "assistant", "content": "ok"}, + ])) + assert parsed.messages == [ + {"role": "user", "content": "hi"}, + {"role": "system", "content": "be terse"}, + {"role": "assistant", "content": "ok"}, + ] + + +def test_parse_mid_conversation_system_block_content(): + parsed = parse_messages_request(_body(messages=[ + {"role": "system", "content": [{"type": "text", "text": "a"}, + {"type": "text", "text": "b"}]}, + ])) + assert parsed.messages == [{"role": "system", "content": "ab"}] + + parsed = parse_messages_request(_body(messages=[ + {"role": "system", "content": None}, + {"role": "user", "content": "hi"}, + ])) + assert parsed.messages == [{"role": "system", "content": ""}, + {"role": "user", "content": "hi"}] + + +def test_parse_mid_conversation_system_rejects_bad_shapes(): + with pytest.raises(InvalidRequest, match="string or an array"): + parse_messages_request(_body(messages=[{"role": "system", "content": 5}])) + with pytest.raises(InvalidRequest, match=r"content\[0\] must be an object"): + parse_messages_request(_body(messages=[{"role": "system", "content": [1]}])) + with pytest.raises(InvalidRequest, match="not supported in a system message"): + parse_messages_request(_body(messages=[{"role": "system", "content": [ + {"type": "image"}]}])) + with pytest.raises(InvalidRequest, match="text must be a string"): + parse_messages_request(_body(messages=[{"role": "system", "content": [ + {"type": "text", "text": 1}]}])) + def test_parse_user_blocks_split_text_and_tool_results(): parsed = parse_messages_request(_body(messages=[{"role": "user", "content": [ {"type": "text", "text": "look"}, @@ -102,9 +149,6 @@ def test_parse_user_blocks_reject_bad_entries(): with pytest.raises(InvalidRequest, match="tool_use_id must be a non-empty"): parse_messages_request(_body(messages=[{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": ""}]}])) - with pytest.raises(InvalidRequest, match="not supported"): - parse_messages_request(_body(messages=[{"role": "user", "content": [ - {"type": "document"}]}])) with pytest.raises(InvalidRequest, match="unknown content block type"): parse_messages_request(_body(messages=[{"role": "user", "content": [ {"type": "bogus"}]}])) @@ -166,10 +210,6 @@ def test_parse_tool_result_content_bad_shapes(): with pytest.raises(InvalidRequest, match=r"content\[0\] must be an object"): parse_messages_request(_body(messages=[{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t", "content": [1]}]}])) - with pytest.raises(InvalidRequest, match="not supported"): - parse_messages_request(_body(messages=[{"role": "user", "content": [ - {"type": "tool_result", "tool_use_id": "t", - "content": [{"type": "document"}]}]}])) with pytest.raises(InvalidRequest, match="unknown tool_result.content type"): parse_messages_request(_body(messages=[{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t", @@ -179,6 +219,42 @@ def test_parse_tool_result_content_bad_shapes(): {"type": "tool_result", "tool_use_id": "t", "content": 5}]}])) +def test_parse_document_and_tool_reference_become_placeholders(): + """`document` / `tool_reference` 不再整轮 400。 + + chat 没有「文档」与「服务端 tool_search 结果」的等价物;显式 400 会让 + 客户端整轮重发一次(`tool_reference` 实测 28/30 次 400 都出在它身上)。 + 改成中性占位文本,模型至少知道这里原本有个附件 / 工具引用。 + """ + parsed = parse_messages_request(_body(messages=[ + {"role": "user", "content": [ + {"type": "document", "title": "resume.pdf", + "source": {"type": "base64", "media_type": "application/pdf", + "data": "AAA"}}, + {"type": "tool_reference", "tool_name": "mcp__fs__read"}, + {"type": "tool_reference"}, + ]}, + {"role": "assistant", "content": [ + {"type": "tool_reference"}, + {"type": "text", "text": "done"}, + ]}, + ])) + # 全是文本(占位也是文本)→ 按既有规则折成字符串,与旧行为一致 + assert parsed.messages[0] == {"role": "user", "content": ( + "[resume.pdf omitted: this gateway cannot forward document content]" + "[tool available: mcp__fs__read][tool available]")} + # assistant 侧的 tool_reference 直接丢(不占上下文) + assert parsed.messages[1] == {"role": "assistant", "content": "done"} + + +def test_parse_document_inside_tool_result_is_placeholder(): + parsed = parse_messages_request(_body(messages=[{"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t", "content": [ + {"type": "document", "source": {"type": "text", "data": "x"}}]}]}])) + assert parsed.messages[0]["content"] == ( + "[document omitted: this gateway cannot forward document content]") + + def test_parse_tool_result_with_image(): """`tool_result` 内图片 → tool 消息 content 数组(chat 的 tool 消息带 image_url 已实测被上游接受)。纯文本结果仍返回字符串。""" diff --git a/tests/compat/test_anthropic_translate.py b/tests/compat/test_anthropic_translate.py index 7f22f90f..c3e0304f 100644 --- a/tests/compat/test_anthropic_translate.py +++ b/tests/compat/test_anthropic_translate.py @@ -93,10 +93,14 @@ def test_translator_error_without_message_uses_default(): frames = list(translator.translate(Event(kind=EventKind.ERROR))) assert b"upstream error" in frames[0] -def test_translator_keepalive_is_comment_frame(): - from src.engine.sse import SSE_COMMENT - - assert AnthropicStreamTranslator("m").keepalive() == SSE_COMMENT +def test_translator_keepalive_is_ping_event(): + """Anthropic 心跳是具名 `ping` 事件,不是 SSE 注释帧。 + + 官方流式规范里 ping 带 `event: ping` + `data: {"type": "ping"}`;只解析 + `event:` 行的客户端会忽略注释帧,长静默下仍会判超时断开。 + """ + assert (AnthropicStreamTranslator("m").keepalive() + == b'event: ping\ndata: {"type": "ping"}\n\n') def test_translator_stop_reason_mapping(): from src.compat.anthropic.response import _stop_reason diff --git a/tests/compat/test_dsml.py b/tests/compat/test_dsml.py new file mode 100644 index 00000000..a6e00df9 --- /dev/null +++ b/tests/compat/test_dsml.py @@ -0,0 +1,328 @@ +"""DSML 工具调用泄漏回读 + 坏参数 fail-closed。""" + +from __future__ import annotations + +import json + +import pytest + +from src.compat.openai.dsml import ( + DsmlStreamFilter, + extract, + is_valid_arguments, + looks_like_marker, +) +from src.compat.openai.errors import InvalidToolArguments +from src.compat.openai.response import StreamTranslator, aggregate +from src.engine.sse import SSE_DONE +from src.provider.base import ErrKind, Event, EventKind + +_B = "\uff5c" # 全角竖线 +_S = f"<{_B}{_B}DSML{_B}{_B}" # 真实的双竖线开标记 + +_LEAK = ( + f"{_S}tool_calls>\n" + f'{_S}invoke name="shell">\n' + f'{_S}parameter name="command" string="true">Get-Content a.csv\n' + f"\n" + f"" +) + + +def test_extract_single_quote_dialect(): + calls, visible = extract("前文" + _LEAK + "后文") + assert visible == "前文后文" + assert len(calls) == 1 + assert calls[0]["function"]["name"] == "shell" + assert json.loads(calls[0]["function"]["arguments"]) == { + "command": "Get-Content a.csv"} + assert calls[0]["index"] == 0 + + +def test_extract_single_bar_dialect(): + """单竖线形态(旧版标记)同样回读。""" + text = (f'<{_B}DSML{_B}invoke name="read">' + f'<{_B}DSML{_B}parameter name="path">a.txt' + f'') + calls, visible = extract(text) + assert visible == "" + assert calls[0]["function"]["name"] == "read" + assert json.loads(calls[0]["function"]["arguments"]) == {"path": "a.txt"} + + +def test_extract_v4_nested_dialect(): + """V4 Flash:参数是嵌套自闭合 invoke,值在 string 属性里。""" + text = (f"{_S}tool_calls>\n" + f'{_S}invoke name="exec_command">\n' + f'{_S}invoke name="cmd" string="echo alpha" />\n' + f"\n" + f"") + calls, _ = extract(text) + assert [c["function"]["name"] for c in calls] == ["exec_command"] + assert json.loads(calls[0]["function"]["arguments"]) == {"cmd": "echo alpha"} + + +def test_extract_number_parameter_is_json(): + text = (f'{_S}invoke name="f">' + f'{_S}parameter name="n">42' + f'') + calls, _ = extract(text) + assert json.loads(calls[0]["function"]["arguments"]) == {"n": 42} + + +def test_extract_multiple_calls_indexed_in_order(): + one = f'{_S}invoke name="a">' + two = f'{_S}invoke name="b">' + calls, _ = extract(one + two) + assert [c["function"]["name"] for c in calls] == ["a", "b"] + assert [c["index"] for c in calls] == [0, 1] + + +def test_extract_leaves_plain_text_untouched(): + assert extract("普通正文") == ([], "普通正文") + + +def test_extract_unclosed_marker_kept_in_text(): + text = "前缀" + _S + 'invoke name="x"' + calls, visible = extract(text) + assert calls == [] + assert visible == text + + +def test_extract_ignores_marker_without_name(): + text = f'{_S}invoke >' + assert extract(text) == ([], text) + + +# ------------------------------------------------------------------ 流式过滤 + + +def test_stream_filter_passes_plain_text_immediately(): + f = DsmlStreamFilter() + assert f.feed("hello") == ("hello", []) + + +def test_stream_filter_buffers_across_chunks(): + f = DsmlStreamFilter() + assert f.feed("前") == ("前", []) + assert f.feed(_S) == ("", []) # 标记开始,缓冲 + assert f.feed('invoke name="a"') == ("", []) + visible, calls = f.feed(f'>') + assert visible == "" + assert calls[0]["function"]["name"] == "a" + + +def test_stream_filter_split_inside_partial_marker(): + """标记前缀被切成两片(`<` + `|DSML|...`)也不能漏成正文。""" + f = DsmlStreamFilter() + assert f.feed("x<") == ("", []) + visible, _ = f.feed(f"{_B}{_B}DSML{_B}{_B}invoke name=\"b\"" + f">") + assert visible == "x" + + +def test_stream_filter_flush_returns_incomplete_block(): + f = DsmlStreamFilter() + assert f.feed("a" + _S + 'invoke name="x"') == ("", []) + assert f.flush() == "a" + _S + 'invoke name="x"' + assert f.flush() == "" + + +def test_stream_filter_flush_empty_when_clean(): + assert DsmlStreamFilter().flush() == "" + + +def test_stream_filter_empty_feed(): + assert DsmlStreamFilter().feed("") == ("", []) + + +def test_looks_like_marker_variants(): + assert looks_like_marker("文本" + _S) is True + assert looks_like_marker("<") is True # 可能是跨 chunk 标记的前半片 + assert looks_like_marker("<" + _B) is True + assert looks_like_marker("DSML") is False + + +# ------------------------------------------------------- 出口集成(流式/聚合) + + +def _frames(frames: list[bytes]) -> list[dict]: + out = [] + for frame in frames: + if frame == SSE_DONE: + continue + out.append(json.loads(frame.decode().removeprefix("data: ").strip())) + return out + + +def test_stream_translator_recovers_dsml_and_rewrites_finish(): + t = StreamTranslator("m") + frames = list(t.translate(Event(kind=EventKind.CONTENT, content="看:" + _LEAK))) + frames += list(t.translate(Event(kind=EventKind.FINISH, finish_reason="stop"))) + chunks = _frames(frames) + deltas = [c["choices"][0]["delta"] for c in chunks if c["choices"]] + assert deltas[0] == {"role": "assistant", "content": "看:"} + tool_delta = next(d for d in deltas if "tool_calls" in d) + assert tool_delta["tool_calls"][0]["function"]["name"] == "shell" + finish = next(c for c in chunks if c["choices"] + and c["choices"][0]["finish_reason"]) + assert finish["choices"][0]["finish_reason"] == "tool_calls" + assert frames[-1] == SSE_DONE + + +def test_stream_translator_flushes_pending_text_on_finish(): + t = StreamTranslator("m") + assert list(t.translate(Event(kind=EventKind.CONTENT, + content="x" + _S + 'invoke name="a"'))) == [] + frames = list(t.translate(Event(kind=EventKind.FINISH, finish_reason="stop"))) + chunks = _frames(frames) + assert chunks[0]["choices"][0]["delta"]["content"].startswith("x" + _S) + assert chunks[0]["choices"][0]["delta"]["role"] == "assistant" + + +def test_stream_translator_keeps_non_stop_finish_reason(): + t = StreamTranslator("m") + list(t.translate(Event(kind=EventKind.CONTENT, content=_LEAK))) + frames = _frames(list(t.translate(Event(kind=EventKind.FINISH, + finish_reason="length")))) + assert frames[-1]["choices"][0]["finish_reason"] == "length" + + +def test_aggregate_recovers_dsml_from_content(): + events = [ + Event(kind=EventKind.CONTENT, content="看:" + _LEAK), + Event(kind=EventKind.FINISH, finish_reason="stop"), + ] + message = aggregate(events, "m")["choices"][0]["message"] + assert message["content"] == "看:" + assert message["tool_calls"][0]["function"]["name"] == "shell" + assert aggregate(events, "m")["choices"][0]["finish_reason"] == "tool_calls" + + +def test_aggregate_merges_dsml_with_structured_calls(): + """已有结构化调用时,回读的调用接在后面,索引不撞。""" + events = [ + Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"id": "c1", "index": 0, + "function": {"name": "f", "arguments": '{"a": 1}'}}]), + Event(kind=EventKind.CONTENT, content=_LEAK), + Event(kind=EventKind.FINISH, finish_reason="tool_calls"), + ] + calls = aggregate(events, "m")["choices"][0]["message"]["tool_calls"] + assert [c["function"]["name"] for c in calls] == ["f", "shell"] + assert [c["index"] for c in calls] == [0, 1] + + +def test_aggregate_fail_closed_on_bad_arguments(): + events = [ + Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"id": "c1", "index": 0, + "function": {"name": "f", "arguments": "{bad"}}]), + Event(kind=EventKind.FINISH, finish_reason="tool_calls"), + ] + with pytest.raises(InvalidToolArguments) as caught: + aggregate(events, "m") + assert caught.value.function == "f" + assert caught.value.kind() is ErrKind.INVALID + + +def test_aggregate_keeps_valid_arguments_bytes(): + """合法参数原样透传(不重新序列化,空格/键序保持不变)。""" + raw = '{"b": 2, "a": 1}' + events = [ + Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"id": "c1", "index": 0, + "function": {"name": "f", "arguments": raw}}]), + Event(kind=EventKind.FINISH, finish_reason="tool_calls"), + ] + message = aggregate(events, "m")["choices"][0]["message"] + assert message["tool_calls"][0]["function"]["arguments"] == raw + + +@pytest.mark.parametrize("arguments,valid", [ + (None, True), + ("", True), + ("{}", True), + ('{"a": 1}', True), + ("{bad", False), + ("[1, 2]", False), + ("42", False), + (42, False), +]) +def test_is_valid_arguments(arguments, valid): + assert is_valid_arguments(arguments) is valid + + +# --------------------------------------------------- 方言细节 / 过滤分支覆盖 + + +def test_extract_self_closing_parameter_uses_attribute(): + text = (f'{_S}invoke name="f">' + f'{_S}parameter name="p" string="raw" />' + f'') + calls, _ = extract(text) + assert json.loads(calls[0]["function"]["arguments"]) == {"p": "raw"} + + +def test_extract_plain_string_parameter_kept_verbatim(): + """非 `string="true"` 且不是合法 JSON 的值:原样当字符串,不丢参数。""" + text = (f'{_S}invoke name="f">' + f'{_S}parameter name="p">hello world' + f'') + calls, _ = extract(text) + assert json.loads(calls[0]["function"]["arguments"]) == {"p": "hello world"} + + +def test_extract_parameter_without_name_skipped(): + text = (f'{_S}invoke name="f">' + f'{_S}parameter string="x">ignored' + f'') + calls, _ = extract(text) + assert json.loads(calls[0]["function"]["arguments"]) == {} + + +def test_extract_nested_invoke_without_name_skipped(): + text = (f'{_S}invoke name="f">' + f'{_S}invoke string="v" />' + f'') + calls, _ = extract(text) + assert json.loads(calls[0]["function"]["arguments"]) == {} + + +def test_extract_top_level_self_closing_without_string(): + text = f'{_S}invoke name="f" />' + calls, _ = extract(text) + assert json.loads(calls[0]["function"]["arguments"]) == {} + + +def test_stream_filter_partial_letter_prefix_buffered(): + """`' + f'{_S}invoke name="inner">' + f'') + calls, _ = extract(text) + assert [call["function"]["name"] for call in calls] == ["outer"] + assert json.loads(calls[0]["function"]["arguments"]) == {} + + +def test_stream_filter_self_closed_marker_releases_text(): + """无 name 的自闭合 invoke 解析不出调用:标记已闭合,正文照常放行。""" + f = DsmlStreamFilter() + visible, calls = f.feed(f"{_S}invoke />junk") + assert calls == [] + assert "junk" in visible diff --git a/tests/compat/test_openai_response.py b/tests/compat/test_openai_response.py index 858acc3a..77bbb1f8 100644 --- a/tests/compat/test_openai_response.py +++ b/tests/compat/test_openai_response.py @@ -50,6 +50,36 @@ def test_stream_translator_missing_tool_index_gets_stable_position(): ["choices"][0]["delta"]["tool_calls"]] assert indices == [0, 1] +def test_stream_translator_missing_tool_id_gets_stable_id(): + """`id` 缺失时按 index 补稳定 id:OpenAI 客户端按 id 聚合分片。 + + 同一个 index 的后续分片必须沿用同一个 id,否则一次调用会被拆成多次。 + """ + t = StreamTranslator("m") + list(t.translate(Event(kind=EventKind.CONTENT, content="x"))) + first = list(t.translate(Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"index": 0, + "function": {"name": "f", + "arguments": "{"}}]))) + second = list(t.translate(Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"index": 0, + "function": {"arguments": "}"}}]))) + id_a = json.loads(first[0].decode()[6:])["choices"][0]["delta"]["tool_calls"][0]["id"] + id_b = json.loads(second[0].decode()[6:])["choices"][0]["delta"]["tool_calls"][0]["id"] + assert id_a == id_b + assert id_a.startswith("call_0_") + + +def test_stream_translator_keeps_upstream_tool_id(): + """上游给了 id 就沿用,不覆盖。""" + t = StreamTranslator("m") + frames = list(t.translate(Event(kind=EventKind.TOOL_CALLS, + tool_calls=[{"id": "call_up", "index": 0, + "function": {"name": "f"}}]))) + assert json.loads(frames[0].decode()[6:])["choices"][0]["delta"][ + "tool_calls"][0]["id"] == "call_up" + + def test_stream_translator_error_frame(): t = StreamTranslator("m") frames = list(t.translate(Event(kind=EventKind.ERROR, error_code=1005, diff --git a/tests/compat/test_responses.py b/tests/compat/test_responses.py index 95a4d4c8..7d7a6b96 100644 --- a/tests/compat/test_responses.py +++ b/tests/compat/test_responses.py @@ -67,6 +67,30 @@ def test_endpoint_stream_events(tmp_path): assert names[-1] == "response.completed" assert "response.output_text.delta" in names +def test_endpoint_stream_keepalive_stays_comment(tmp_path, monkeypatch): + """Responses 出口的心跳必须保持注释帧:该协议没有 ping 事件类型。""" + import asyncio + + from src.api import streaming + + monkeypatch.setattr(streaming, "KEEPALIVE_SECONDS", 0.01) + + class SlowProvider(_Provider): + async def stream_chat(self, credential_data, payload, model): + await asyncio.sleep(0.08) + async for event in super().stream_chat(credential_data, payload, model): + yield event + + app = _app(tmp_path, SlowProvider()) + with TestClient(app) as client: + response = client.post("/v1/responses", headers=_auth(app), json={ + "model": "glm-5.2@codebuddy", "input": "hi", "stream": True, + }) + assert response.status_code == 200 + assert ": keepalive" in response.text + assert "event: ping" not in response.text + + def test_endpoint_non_stream(tmp_path): provider = _Provider() app = _app(tmp_path, provider) diff --git a/tests/compat/test_responses_request.py b/tests/compat/test_responses_request.py index dc6b9e42..4ab56078 100644 --- a/tests/compat/test_responses_request.py +++ b/tests/compat/test_responses_request.py @@ -141,6 +141,101 @@ def test_empty_tools_are_omitted(): request = parse_responses_request({"input": "hi", "tools": []}) assert "tools" not in request.raw + +# ------------------------------------------------- 托管 / 命名空间工具(Codex) + + +def test_namespace_tools_flatten_only_default_functions_group(): + """Codex 0.135+ 对自定义 responses provider 默认下发 `namespace` 工具组。 + + 实测 0.135→0.167 全版本必发;此前非 `function` 一律 400,整轮请求失败 + 且客户端不降级(它只会重试同一请求两次)。 + + **只展平默认命名空间 `functions`**:客户端按 `(namespace, name)` 查 + 登记表(`codex-rs/core/src/tools/registry.rs`),chat 上游只回扁平名字、 + 带不回 namespace——展平成 `{ns}__{name}` 会被客户端当成「默认命名空间 + 里不存在的新工具」,模型调用即 `unsupported call`,比不提供更糟。 + """ + request = parse_responses_request({"input": "hi", "tools": [ + {"type": "function", "name": "exec_command"}, + {"type": "namespace", "name": "functions", "tools": [ + {"type": "function", "name": "write_stdin", "description": "d"}, + {"type": "custom", "name": "apply_patch"}, + ]}, + {"type": "namespace", "name": "multi_agent_v1", "tools": [ + {"type": "function", "name": "send_input", + "parameters": {"type": "object"}}, + ]}, + ]}) + assert request.raw["tools"] == [ + {"type": "function", "function": {"name": "exec_command"}}, + {"type": "function", "function": {"name": "write_stdin", + "description": "d"}}, + ] + + +def test_hosted_tools_are_skipped_not_rejected(): + """`web_search` / `tool_search` / `custom` 是托管或 freeform 工具。 + + 本网关没有服务端搜索能力,chat 也表达不了 freeform 的「整段自由文本」; + 跳过是降级,400 会打死整轮(Codex 默认配置必带 `web_search`)。 + """ + request = parse_responses_request({"input": "hi", "tools": [ + {"type": "web_search"}, + {"type": "tool_search", "execution": "local", "description": "d", + "parameters": {}}, + {"type": "custom", "name": "apply_patch", + "format": {"type": "grammar", "syntax": "lark", "definition": "x"}}, + {"type": "function", "name": "keep"}, + ]}) + assert request.raw["tools"] == [{"type": "function", + "function": {"name": "keep"}}] + + +def test_only_hosted_tools_means_no_tools_key(): + """全是托管工具时不要塞空数组(上游对 `tools: []` 的处理不一致)。""" + request = parse_responses_request( + {"input": "hi", "tools": [{"type": "web_search"}]}) + assert "tools" not in request.raw + + +def test_duplicate_tool_names_keep_first(): + """命名空间展平后可能与顶层同名:保留先出现的,避免上游 schema 冲突。""" + request = parse_responses_request({"input": "hi", "tools": [ + {"type": "function", "name": "dup", "description": "first"}, + {"type": "function", "name": "dup", "description": "second"}, + {"type": "namespace", "name": "functions", + "tools": [{"type": "function", "name": "dup", "description": "third"}]}, + ]}) + assert request.raw["tools"] == [{"type": "function", "function": { + "name": "dup", "description": "first"}}] + + +def test_namespace_without_members_is_empty(): + """`namespace` 没带 `tools` 键 → 空组(不报错、不产出空壳工具)。""" + request = parse_responses_request({"input": "hi", "tools": [ + {"type": "namespace", "name": "empty"}, + {"type": "function", "name": "keep"}, + ]}) + assert request.raw["tools"] == [{"type": "function", + "function": {"name": "keep"}}] + + +@pytest.mark.parametrize("tools, fragment", [ + ([{"type": "namespace"}], r"tools\[0\]\.name"), + ([{"type": "namespace", "name": ""}], r"tools\[0\]\.name"), + ([{"type": "namespace", "name": "functions", "tools": {}}], + r"tools\[0\]\.tools"), + ([{"type": "namespace", "name": "functions", "tools": [5]}], + r"tools\[0\]\.tools\[0\]"), + ([{"type": "namespace", "name": "functions", + "tools": [{"type": "function"}]}], r"name must be a non-empty"), +]) +def test_malformed_namespace_tools_rejected(tools, fragment): + """结构性错误仍 400:放过它们会让上游看到畸形工具表。""" + with pytest.raises(InvalidRequest, match=fragment): + parse_responses_request({"input": "hi", "tools": tools}) + def test_text_format_text_and_json_object_pass_through(): for kind in ("text", "json_object"): request = parse_responses_request( @@ -195,7 +290,6 @@ def test_stream_flag_flows_through(): ({"input": "hi", "model": 5}, "model must be a string"), ({"input": "hi", "stream": "yes"}, "stream must be a boolean"), ({"input": "hi", "messages": "x", "tools": {}}, "tools must be an array"), - ({"input": "hi", "tools": [{"type": "web_search"}]}, "web_search"), ({"input": "hi", "tools": [5]}, "tools[0] must be an object"), ({"input": "hi", "tools": [{"type": "function"}]}, "name must be a non-empty"), ({"input": "hi", "tool_choice": {"type": "mcp"}}, "tool_choice form"), diff --git a/tests/compat/test_responses_translate.py b/tests/compat/test_responses_translate.py index 97097cd1..eb5a63dd 100644 --- a/tests/compat/test_responses_translate.py +++ b/tests/compat/test_responses_translate.py @@ -210,6 +210,7 @@ def test_empty_events_do_not_create_items(): assert frames[-1][1]["response"]["usage"]["input_tokens"] == 1 def test_keepalive_is_sse_comment(): + """Responses 出口必须用注释帧:该协议没有 ping 事件类型。""" from src.engine.sse import SSE_COMMENT assert ResponsesStreamTranslator("m").keepalive() is SSE_COMMENT diff --git a/tests/config/test_lingxi_wiring.py b/tests/config/test_lingxi_wiring.py new file mode 100644 index 00000000..eb99e474 --- /dev/null +++ b/tests/config/test_lingxi_wiring.py @@ -0,0 +1,54 @@ +"""灵犀接线:Settings 字段、端点白名单、热更节流与注册表。""" + +from __future__ import annotations + +from src.config import validate_lingxi_endpoint_allowed +from src.engine.model_resolver import KNOWN_PROVIDERS +from src.provider.login_identity import ( + lingxi_login_identities, + lingxi_login_identity, +) +from src.provider.tos import tos_for +from src.runtime_settings import HOT_BY_KEY, RuntimeSettings +from tests.helpers.auth import settings_with as _settings +from tests.helpers.stores import MemoryStore as _MemoryStore + + +def test_lingxi_defaults_and_allowed_parsing(): + settings = _settings() + assert settings.lingxi_api_endpoint == "https://lingxi.wps.cn" + assert settings.lingxi_allowed == ("https://lingxi.wps.cn", + "https://lingxi.kdocs.cn") + blank = _settings(lingxi_allowed_endpoints=" https://a.example.com/ , , ") + assert blank.lingxi_allowed == ("https://a.example.com",) + + +def test_lingxi_endpoint_whitelist(): + settings = _settings() + assert validate_lingxi_endpoint_allowed("https://lingxi.wps.cn", settings) + assert validate_lingxi_endpoint_allowed("https://lingxi.wps.cn/", settings) + assert validate_lingxi_endpoint_allowed("https://lingxi.kdocs.cn", settings) + assert not validate_lingxi_endpoint_allowed("https://evil.example.com", settings) + + +def test_lingxi_chat_min_interval_is_hot(): + assert "lingxi_chat_min_interval" in HOT_BY_KEY + runtime = RuntimeSettings(_settings(), _MemoryStore()) + assert runtime.lingxi_chat_min_interval == 0 + runtime.set("lingxi_chat_min_interval", 2) + assert runtime.lingxi_chat_min_interval == 2 + + +def test_lingxi_registered_everywhere(): + assert "lingxi" in KNOWN_PROVIDERS + assert tos_for("lingxi").level == "gray" + + +def test_login_identity_prefers_uid_and_falls_back_to_sid_hash(): + assert lingxi_login_identity({"wps_sid": "abc", "uid": "42"}) == "42" + hashed = lingxi_login_identity({"wps_sid": "abc"}) + assert hashed.startswith("sid-") + assert lingxi_login_identities({"wps_sid": "abc", "uid": "42"}) == { + "42", hashed} + assert lingxi_login_identity({}) == "" + assert lingxi_login_identities({}) == frozenset() \ No newline at end of file diff --git a/tests/config/test_provider_wiring_oauth.py b/tests/config/test_provider_wiring_oauth.py index ef66e055..03ff6069 100644 --- a/tests/config/test_provider_wiring_oauth.py +++ b/tests/config/test_provider_wiring_oauth.py @@ -455,6 +455,12 @@ async def complete_callback(self, url, state, username): "provider": "codearts", "state": fake.state, "url": "http://127.0.0.1/cb?code=abc"}) assert response.status_code == 200 assert response.json()["status"] == "success" + # OAuth 落库必须写审计:否则「谁什么时候把号塞进池子」在审计页上查不到 + # (手工导入 `credential.import` 一直有,登录进来的号原来完全缺失)。 + credential_id = response.json()["credential_id"] + rows = app.state.services.audit.query(action="credential.import", limit=10) + assert any(row["target"] == credential_id + and "OAuth" in (row["detail"] or "") for row in rows) # 未注册该能力的渠道 → 400 assert client.post("/api/auth/upstream/complete", json={ "provider": "unknown", "state": "x", "url": "y"}).status_code == 400 diff --git a/tests/config/test_runtime_settings.py b/tests/config/test_runtime_settings.py index 8e68e1e9..857dcb12 100644 --- a/tests/config/test_runtime_settings.py +++ b/tests/config/test_runtime_settings.py @@ -89,6 +89,18 @@ def test_grouping_is_partitioned_between_tasks_and_gateway_groups(): assert item.group in group_keys, item.key +def test_sections_only_inside_task_cards(): + """卡片内分节只用于任务项;告警 21 项必须有节标题,否则又变回一长列。""" + for item in HOT_SETTINGS: + if item.section is not None: + assert item.task is not None, item.key + assert item.section.strip(), item.key + alert_sections = [item.section for item in HOT_SETTINGS if item.task == "alert"] + assert alert_sections == [ + "基础与迟滞"] * 8 + ["规则阈值"] * 6 + ["单 Key 与时段"] * 5 + \ + ["月度预算"] * 2 + ["渠道与签到"] * 4 + + # ------------------------------------------------------------ 值解析与校验 def test_parse_value_parses_each_supported_kind(): diff --git a/tests/db/test_backup.py b/tests/db/test_backup.py index bef4181e..76d6b160 100644 --- a/tests/db/test_backup.py +++ b/tests/db/test_backup.py @@ -12,11 +12,18 @@ from src.db.backup import ( BACKUP_DIRNAME, DEFAULT_KEEP, + apply_pending_restore, backup_database, backup_dir, + pending_restore_info, + pending_restore_path, restore_database, + stage_restore, + verify_app_database, verify_database, ) +from src.db.conn import Database +from src.db.migrate import SCHEMA_VERSION, apply_schema def _seed(db_path: Path, value: str = "hello") -> None: @@ -259,3 +266,132 @@ def failing_write(self, data): def test_default_keep_is_seven(): assert DEFAULT_KEEP == 7 + + +# ---------------------------------------------------------------- 管理台恢复 + + +def _app_db(tmp_path: Path, name: str = "app.sqlite3") -> Path: + path = tmp_path / name + db = Database(path) + apply_schema(db.connect()) + db.close() + return path + + +def test_verify_app_database_accepts_real_schema(tmp_path): + verify_app_database(_app_db(tmp_path)) + + +def test_verify_app_database_rejects_foreign_sqlite(tmp_path): + path = tmp_path / "plain.sqlite3" + _seed(path) + with pytest.raises(RuntimeError, match="缺少表"): + verify_app_database(path) + + +def test_verify_app_database_rejects_newer_schema(tmp_path): + path = _app_db(tmp_path) + conn = sqlite3.connect(str(path)) + conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION + 1}") + conn.commit() + conn.close() + with pytest.raises(RuntimeError, match="高于本程序支持"): + verify_app_database(path) + + +def test_stage_restore_validates_and_writes_pending(tmp_path): + source = _app_db(tmp_path) + db_path = tmp_path / "main.sqlite3" + meta = stage_restore(db_path, source.read_bytes(), filename="../evil.sqlite3", + now=1234) + assert meta == {"filename": "evil.sqlite3", "size": source.stat().st_size, + "uploaded_at": 1234} + pending = pending_restore_path(db_path) + assert pending.is_file() + assert pending.stat().st_mode & stat.S_IRWXO == 0 + assert pending_restore_info(db_path)["filename"] == "evil.sqlite3" + + +def test_stage_restore_rejects_empty_and_corrupt(tmp_path): + db_path = tmp_path / "main.sqlite3" + with pytest.raises(RuntimeError, match="为空"): + stage_restore(db_path, b"") + with pytest.raises(RuntimeError): + stage_restore(db_path, b"not a database") + assert not pending_restore_path(db_path).exists() + + +def test_pending_restore_info_falls_back_without_or_with_broken_meta(tmp_path): + db_path = tmp_path / "main.sqlite3" + assert pending_restore_info(db_path) is None + pending = pending_restore_path(db_path) + pending.parent.mkdir(parents=True) + pending.write_bytes(b"abc") + info = pending_restore_info(db_path) # 没有 meta → 用文件大小/mtime + assert info["filename"] == pending.name and info["size"] == 3 + pending.with_name("pending.json").write_text("{broken", encoding="utf-8") + assert pending_restore_info(db_path)["size"] == 3 + + +def test_apply_pending_restore_replaces_db_and_keeps_safety_copy(tmp_path): + db_path = _app_db(tmp_path, "main.sqlite3") + source = _app_db(tmp_path, "source.sqlite3") + conn = sqlite3.connect(str(source)) + conn.execute("CREATE TABLE marker (x TEXT)") + conn.execute("INSERT INTO marker VALUES ('restored')") + conn.commit() + conn.close() + + stage_restore(db_path, source.read_bytes(), now=5) + safety = apply_pending_restore(db_path, now=6) + + assert safety is not None and safety.is_file() + assert not pending_restore_path(db_path).exists() + conn = sqlite3.connect(str(db_path)) + assert conn.execute("SELECT x FROM marker").fetchone()[0] == "restored" + conn.close() + + +def test_apply_pending_restore_rejects_corrupt_without_blocking_start(tmp_path): + db_path = _app_db(tmp_path, "main.sqlite3") + pending = pending_restore_path(db_path) + pending.parent.mkdir(parents=True) + pending.write_bytes(b"corrupt") + + assert apply_pending_restore(db_path, now=7) is None + assert not pending.exists() + rejected = list(pending.parent.glob("pending.sqlite3.rejected-*")) + assert len(rejected) == 1 + # 老库仍在,且能正常打开 + conn = sqlite3.connect(str(db_path)) + assert conn.execute("SELECT 1").fetchone()[0] == 1 + conn.close() + + +def test_pending_restore_info_ignores_non_object_meta(tmp_path): + db_path = tmp_path / "main.sqlite3" + pending = pending_restore_path(db_path) + pending.parent.mkdir(parents=True) + pending.write_bytes(b"abc") + pending.with_name("pending.json").write_text("[]", encoding="utf-8") + info = pending_restore_info(db_path) # 顶层不是对象 → 回落文件信息 + assert info["filename"] == pending.name and info["size"] == 3 + + +def test_verify_app_database_reports_integrity_failure(tmp_path, monkeypatch): + """`PRAGMA integrity_check` 返回非 ok(文件能打开但有逻辑损坏)→ RuntimeError。""" + class _Result: + def fetchone(self): + return ("wrong page type",) + + class _Conn: + def execute(self, _sql, *_args): + return _Result() + + def close(self): + pass + + monkeypatch.setattr("src.db.backup.sqlite3.connect", lambda *_a, **_k: _Conn()) + with pytest.raises(RuntimeError, match="完整性校验未通过"): + verify_app_database(tmp_path / "x.sqlite3") diff --git a/tests/db/test_crypto.py b/tests/db/test_crypto.py index e2483afd..92f6ac38 100644 --- a/tests/db/test_crypto.py +++ b/tests/db/test_crypto.py @@ -40,3 +40,27 @@ def test_cipher_rejects_low_entropy_secret(): def test_cipher_accepts_high_entropy_secret(): """字符集足够大的长密钥正常通过。""" assert CredentialCipher("x9Q2mZ7pL4vT8wR1sK6n").encrypt(b"ok") + + +def test_cipher_rejects_known_placeholder_secret(): + """已知占位值必须直接拒启动,即使长度与熵都过关。 + + `local-dev-secret-change-me` 是 26 字符 / 15 种字符,`_looks_low_entropy` + 放行;实测用它可解开真实库里的全部凭证(详见 `KNOWN_WEAK_SECRETS`)。 + """ + from src.db.crypto import KNOWN_WEAK_SECRETS, WeakSecretError + + assert "local-dev-secret-change-me" in KNOWN_WEAK_SECRETS + for weak in KNOWN_WEAK_SECRETS: + with pytest.raises(WeakSecretError, match="placeholder"): + CredentialCipher(weak) + + +def test_cipher_placeholder_check_ignores_case_and_whitespace(): + """`.env` 里带引号/首尾空白也算命中,不能靠大小写绕过。""" + from src.db.crypto import WeakSecretError + + for variant in (" local-dev-secret-change-me ", + "LOCAL-DEV-SECRET-CHANGE-ME"): + with pytest.raises(WeakSecretError, match="placeholder"): + CredentialCipher(variant) diff --git a/tests/db/test_paid_models.py b/tests/db/test_paid_models.py new file mode 100644 index 00000000..d4e38972 --- /dev/null +++ b/tests/db/test_paid_models.py @@ -0,0 +1,93 @@ +"""实测扣费校准:目录 x0 vs `usage_events` 里的真实扣费。""" + +from __future__ import annotations + +import time + +import pytest + +from src.db.conn import Database +from src.db.migrate import apply_schema +from src.db.paid_models import ( + OBSERVATION_WINDOW_SECONDS, + SNAPSHOT_TTL_SECONDS, + PaidModelRepository, +) +from src.stats.collector import StatsCollector + + +@pytest.fixture() +def db(tmp_path): + database = Database(tmp_path / "paid.sqlite3") + apply_schema(database.connect()) + yield database + database.close() + + +def _record(db, *, provider="codebuddy_intl", model="deepseek-v4.1-flash", + credit=0.14, estimated=False, now=None) -> None: + StatsCollector(db).record( + username="u", provider=provider, model=model, ok=True, + credit=credit, credit_estimated=estimated, + now=int(now if now is not None else time.time())) + + +def test_observed_rates_only_counts_upstream_reported_credit(db): + """只认上游明确扣费:推算值(credit_estimated=1)与 0 都不算证据。""" + now = int(time.time()) + _record(db, credit=0.2, now=now) + _record(db, credit=0.4, now=now) + # 推算值:TRAE 按价表反解,不是上游报的 + _record(db, provider="trae", credit=5.0, estimated=True, now=now) + # 明确报 0:真免费,不是收费证据 + _record(db, provider="zen", credit=0.0, now=now) + # credit 缺失 + _record(db, provider="kilo", credit=None, now=now) + + rates = PaidModelRepository(db).observed_rates() + assert rates == {("codebuddy_intl", "deepseek-v4.1-flash"): pytest.approx(0.3)} + + +def test_observed_rates_window_excludes_stale_rows(db): + """窗口外的历史扣费不算:上游改回免费后不能一直被当收费晾着。""" + now = int(time.time()) + _record(db, now=now - OBSERVATION_WINDOW_SECONDS - 60) + assert PaidModelRepository(db).observed_rates() == {} + _record(db, now=now) + assert PaidModelRepository(db).observed_rates() == { + ("codebuddy_intl", "deepseek-v4.1-flash"): pytest.approx(0.14)} + + +def test_observed_rates_merges_raw_id_variants(db): + """同一归一键的多种写法(大小写 / free 尾缀)先累加再平均。""" + now = int(time.time()) + _record(db, model="DeepSeek-V4.1-Flash", credit=0.2, now=now) + _record(db, model="deepseek-v4.1-flash", credit=0.4, now=now) + _record(db, model="deepseek-v4.1-flash-free", credit=0.6, now=now) + assert PaidModelRepository(db).observed_rates() == { + ("codebuddy_intl", "deepseek-v4.1-flash"): pytest.approx(0.4)} + + +def test_observed_rates_snapshot_reused_until_ttl(db): + """快照按 TTL 复用:选号是热路径,不能每个请求都做一次聚合。""" + clock = [1000.0] + repo = PaidModelRepository(db, now_fn=lambda: clock[0]) + now = int(time.time()) + _record(db, credit=0.2, now=now) + assert repo.observed_rates() == { + ("codebuddy_intl", "deepseek-v4.1-flash"): pytest.approx(0.2)} + + # TTL 内新增的扣费看不到(快照命中) + _record(db, credit=0.4, now=now) + assert repo.observed_rates() == { + ("codebuddy_intl", "deepseek-v4.1-flash"): pytest.approx(0.2)} + + clock[0] += SNAPSHOT_TTL_SECONDS + assert repo.observed_rates() == { + ("codebuddy_intl", "deepseek-v4.1-flash"): pytest.approx(0.3)} + + # invalidate 立即失效(刚写完明细的调用方) + _record(db, credit=0.6, now=now) + repo.invalidate() + assert repo.observed_rates() == { + ("codebuddy_intl", "deepseek-v4.1-flash"): pytest.approx(0.4)} diff --git a/tests/db/test_repo_checkin.py b/tests/db/test_repo_checkin.py new file mode 100644 index 00000000..78e38fec --- /dev/null +++ b/tests/db/test_repo_checkin.py @@ -0,0 +1,129 @@ +"""签到审计日志仓储:记录、倒序查询、裁剪与索引。""" + +from __future__ import annotations + +from src.db.conn import Database +from src.db.migrate import apply_schema +from src.db.repo import CheckinEventRepository +from src.provider.base import CheckinResult + + +def test_checkin_events_record_recent_and_prune(tmp_path): + """成功/失败/已签各记一行;倒序读取;裁剪只删超出保留期的行。""" + db = Database(tmp_path / "t.sqlite3") + apply_schema(db.connect()) + events = CheckinEventRepository(db) + now = 1_700_000_000 + + events.record(credential_id="c1", + result=CheckinResult(ok=True, credit=1000.0, code=0, + message="签到成功"), + now=now) + events.record(credential_id="c1", + result=CheckinResult(ok=False, code=40001, message="尚未到达权益刷新时间"), + now=now + 60) + events.record(credential_id="c2", trigger="manual", + result=CheckinResult(ok=True, already_checked_in=True, code=0, + message="今日已签到"), + now=now + 120) + + rows = events.recent("c1") + assert [row["ok"] for row in rows] == [0, 1] # 倒序:最新在前 + assert rows[0]["message"] == "尚未到达权益刷新时间" and rows[0]["code"] == 40001 + assert rows[1]["credit"] == 1000.0 and rows[1]["already"] == 0 + # trigger 默认 auto(定时任务) + assert rows[1]["trigger"] == "auto" and rows[1]["credential_id"] == "c1" + + manual = events.recent("c2") + assert manual[0]["trigger"] == "manual" and manual[0]["already"] == 1 + # 已签行的 credit 为 None 时如实存 None,不猜 0 + assert manual[0]["credit"] is None + + # limit 生效并收敛到 [1, 200] + assert len(events.recent("c1", limit=1)) == 1 + assert len(events.recent("c1", limit=0)) == 1 # 下限 1 + assert len(events.recent("c1", limit=10_000)) == 2 # 上限不影响本就很少的行 + + # prune:cutoff = now - keep_days*86400,严格早于 cutoff 的删 + assert events.prune(keep_days=0, now=now + 3600) == 3 + assert events.recent("c1") == [] + db.close() + + +def test_checkin_events_index_exists(tmp_path): + """(credential_id, ts) 索引必须建出来(历史查询走它)。""" + db = Database(tmp_path / "index.sqlite3") + apply_schema(db.connect()) + indexes = {row[0] for row in db.connect().execute( + "SELECT name FROM sqlite_master WHERE type = 'index'")} + assert "idx_checkin_cred_ts" in indexes + db.close() + +def test_checkin_events_latest_since_picks_newest_per_credential(tmp_path): + """只取窗口内每凭证最新一条:带结果给列表页「今日签到」,缺席 = 没结论。 + + 窗口前的行不参与(昨天的成功不能冒充今天);同凭证多行取 ts 最大那条 + ——签到失败后重试成功是常见序列,取错会把已签的号显示成失败。 + """ + db = Database(tmp_path / "latest.sqlite3") + apply_schema(db.connect()) + events = CheckinEventRepository(db) + now = 1_700_000_000 + + events.record(credential_id="c1", + result=CheckinResult(ok=False, message="昨天的失败"), + now=now - 10) + events.record(credential_id="c1", + result=CheckinResult(ok=False, code=40001, message="尚未到达权益刷新时间"), + now=now) + events.record(credential_id="c1", + result=CheckinResult(ok=True, credit=100.0, code=0, message="领取成功"), + now=now + 60) + events.record(credential_id="c2", result=CheckinResult(ok=True), + now=now - 5) + + latest = events.latest_since(now) + assert set(latest) == {"c1"} # c2 只有窗口前的行 → 缺席 + assert latest["c1"]["ok"] == 1 and latest["c1"]["credit"] == 100.0 + assert latest["c1"]["message"] == "领取成功" and latest["c1"]["ts"] == now + 60 + # 空窗口返回空字典(不抛错、不返回 None) + assert events.latest_since(now + 10_000) == {} + db.close() + + +def test_consecutive_failures_counts_only_trailing_streak(tmp_path): + """只数最近一段连续失败:中间成功一次即清零,窗口外不参与。""" + db = Database(tmp_path / "streak.sqlite3") + apply_schema(db.connect()) + events = CheckinEventRepository(db) + now = 1_700_000_000 + + def row(credential_id: str, ok: bool, offset: int, message: str = "") -> None: + events.record(credential_id=credential_id, + result=CheckinResult(ok=ok, message=message), now=now + offset) + + # c1:失败、失败、失败(连续 3) + row("c1", False, 0) + row("c1", False, 10) + row("c1", False, 20) + # c2:失败、成功、失败 → 最近一次成功 → 不算 + row("c2", False, 0) + row("c2", True, 10) + row("c2", False, 20) + # c3:只有 1 次失败(不到阈值) + row("c3", False, 30, "上游繁忙") + # c4:窗口外的连续失败不计 + row("c4", False, -10_000) + row("c4", False, -9_000) + + rows = events.consecutive_failures(min_streak=2, since=now) + assert rows == [{"credential_id": "c1", "streak": 3, "last_ts": now + 20, + "message": ""}] + # 阈值 1:c3 也进来(c2 最近一次成功仍排除) + short = events.consecutive_failures(min_streak=1, since=now) + assert {item["credential_id"] for item in short} == {"c1", "c3"} + assert short[0]["credential_id"] == "c1" # 按 streak 降序 + assert "上游繁忙" in {item["message"] for item in short} + # 规则关闭:阈值 0 恒空 + assert events.consecutive_failures(min_streak=0, since=now) == [] + db.close() diff --git a/tests/db/test_repo_cooldown.py b/tests/db/test_repo_cooldown.py index be6bcac0..f7f7086a 100644 --- a/tests/db/test_repo_cooldown.py +++ b/tests/db/test_repo_cooldown.py @@ -80,6 +80,35 @@ def test_repo_account_cooldown_clears_model_entries(repo): assert db.connect().execute( "SELECT COUNT(*) AS c FROM credential_model_cooldowns").fetchone()["c"] == 0 + +def test_repo_low_threshold_error_keeps_existing_cooldown(repo): + """低于阈值的 OTHER 错误(cooling_until=None)不得抹掉已有账号级冷却。 + + `note_error` 对低于阈值的 OTHER 返回 `ErrorOutcome(err_count=count)`, + `cooling_until` 是 None。原实现无条件 `SET cooling_until = ?`,于是两个 + 并发请求落在同一凭证时,后到的这次会把前一次刚写入的 RESTRICTED/SOFT + 冷却(2h~7d)擦成 NULL,账号立刻回池继续撞上游风控。 + """ + credentials, db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.save_error(cid, ErrorOutcome(cooling_until=7200, err_count=1)) + credentials.save_error(cid, ErrorOutcome(cooling_until=None, err_count=1)) + row = db.connect().execute( + "SELECT cooling_until, err_count FROM credentials WHERE id = ?", + (cid,)).fetchone() + assert row["cooling_until"] == 7200 # 冷却保留 + assert row["err_count"] == 1 # 计数照常更新 + + +def test_repo_new_cooldown_overwrites_older(repo): + """显式给冷却时照常覆盖(COALESCE 只拦 None,不影响正常升级)。""" + credentials, db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.save_error(cid, ErrorOutcome(cooling_until=7200, err_count=1)) + credentials.save_error(cid, ErrorOutcome(cooling_until=9999, err_count=0)) + assert db.connect().execute( + "SELECT cooling_until FROM credentials WHERE id = ?", (cid,)).fetchone()[0] == 9999 + def test_repo_save_success_clears_blocked_only(repo): """成功清除 negative cache,但不清 6004 模型级限流(对齐上游重置)。""" from src.engine.scheduler import ModelCooldown @@ -116,6 +145,56 @@ def test_repo_revive_and_delete_clear_model_cooldowns(repo): assert db.connect().execute( "SELECT COUNT(*) AS c FROM credential_model_cooldowns").fetchone()["c"] == 0 +def test_repo_revive_keeps_upstream_cooldown_unless_forced(repo): + """`revive` 保留「上游点名恢复时刻」的冷却,`force=True` 才清。 + + 回归 2026-11-03 12:22–12:23:管理员连续三次批量恢复 zen 凭证,每次都在 + 恢复的同时顺手解掉刚写入的 429 冷却(上游已点名到次日 08:00),下一次 + 请求立刻再撞一次同一个 429。本地冷却(`cooling_kind` 为 NULL / other) + 照旧清除,与补列前行为一致。 + """ + credentials, _db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + future = int(time.time()) + 3600 + credentials.save_error(cid, ErrorOutcome( + cooling_until=future, cooling_kind=ErrKind.SOFT.value, err_count=0)) + + assert credentials.revive(cid) is True + row = credentials.candidates()[0] + assert row.disabled is False + assert row.cooling_until == future # 上游冷却被保留 + + assert credentials.revive(cid, force=True) is True + assert credentials.candidates()[0].cooling_until is None + +def test_repo_revive_clears_local_cooldown_and_missing_credential(repo): + """本地冷却(cooling_kind NULL / other)在恢复时一并清掉;不存在的凭证返回 False。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.save_error(cid, ErrorOutcome( + cooling_until=int(time.time()) + 3600, cooling_kind=ErrKind.OTHER.value)) + + assert credentials.revive(cid) is True + assert credentials.candidates()[0].cooling_until is None + assert credentials.revive("cred_missing") is False + +def test_repo_account_cooling_kind_is_recorded_and_purged(repo): + """账号级冷却写入来源标签;过期回收时标签一起清掉(避免陈旧来源误导 revive)。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + past = int(time.time()) - 10 + credentials.save_error(cid, ErrorOutcome( + cooling_until=past, cooling_kind=ErrKind.CREDIT.value)) + row = _db.connect().execute( + "SELECT cooling_kind FROM credentials WHERE id = ?", (cid,)).fetchone() + assert row["cooling_kind"] == ErrKind.CREDIT.value + + assert credentials.purge_expired_account_cooldowns() == 1 + row = _db.connect().execute( + "SELECT cooling_kind, cooling_until FROM credentials WHERE id = ?", + (cid,)).fetchone() + assert row["cooling_until"] is None and row["cooling_kind"] is None + def test_repo_purges_only_expired_model_cooldowns(repo): from src.engine.scheduler import ModelCooldown @@ -153,6 +232,40 @@ def test_retention_task_purges_expired_model_cooldowns(repo): # 不传 credentials(旧调用方)时不报错,计数为 0 assert RetentionTask(collector).run_once()["expired_coolings"] == 0 +def test_retention_task_clears_expired_account_cooldown(repo): + """账号级冷却过期后置 NULL:否则陈旧值会永久留在库里。 + + 选号用 `cooling_until > now` 判断,过期值不挡凭证,但管理台列表下发的是 + 原始值——一条几周前的冷却会让该行在「冷却」维度排序失真。 + """ + credentials, _db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + now = int(time.time()) + credentials.save_error(cid, ErrorOutcome(cooling_until=now - 10, err_count=0)) + assert credentials.candidates()[0].cooling_until == now - 10 + + collector = StatsCollector(_db) + report = RetentionTask(collector, credentials=credentials).run_once() + assert report["expired_account_coolings"] == 1 + assert credentials.candidates()[0].cooling_until is None + # 再跑一轮:已经没有过期值,计数归零(幂等) + assert RetentionTask(collector, credentials=credentials).run_once()[ + "expired_account_coolings"] == 0 + +def test_retention_task_keeps_active_account_cooldown(repo): + """仍在冷却期内的账号级冷却不能被清掉:清掉等于提前解冻一个被限流的号。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + future = int(time.time()) + 3600 + credentials.save_error(cid, ErrorOutcome(cooling_until=future, err_count=0)) + + collector = StatsCollector(_db) + report = RetentionTask(collector, credentials=credentials).run_once() + assert report["expired_account_coolings"] == 0 + assert credentials.candidates()[0].cooling_until == future + # 未注入凭证仓储时同样不报错 + assert RetentionTask(collector).run_once()["expired_account_coolings"] == 0 + def test_retention_task_prunes_audit_events(repo): """留存任务按保留期回收审计流水;未注入时计数为 0(README「审计保留 90 天」)。""" from src.db.repo import AuditRepository @@ -176,3 +289,49 @@ def test_credential_edit_lock_is_reused_per_id(repo): assert credentials.edit_lock("cred_a") is first assert credentials.edit_lock("cred_b") is not first db.close() + +def test_save_error_disabled_uses_actionable_reason(repo): + """硬禁用优先写 outcome 的可操作原因,缺失才回落 "session dead"。 + + 管理台直接展示 `disabled_reason`:11140「账号被上游限制」写成 session dead + 会把人引向「重新登录」,而实际动作是申诉或换号。 + """ + credentials, db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.save_error(cid, ErrorOutcome(disabled=True, + disabled_reason="账号被上游限制,需申诉")) + row = db.connect().execute( + "SELECT disabled, disabled_reason FROM credentials WHERE id = ?", (cid,)).fetchone() + assert (row["disabled"], row["disabled_reason"]) == (1, "账号被上游限制,需申诉") + + other = credentials.add(provider="codebuddy", credential_data={"bearer_token": "u"}) + credentials.save_error(other, ErrorOutcome(disabled=True)) + row = db.connect().execute( + "SELECT disabled_reason FROM credentials WHERE id = ?", (other,)).fetchone() + assert row["disabled_reason"] == "session dead" + +def test_cool_provider_cools_whole_channel_without_disabling(repo): + """渠道级冷却:同渠道所有号一起推后,但不写 disabled(到期自动回来)。 + + 11140 这类账号/IP 级风控不是单号问题,逐个号冷却挡不住下一个请求换号继续 + 撞;同时绝不能落 disabled——那是人工状态,会把「等上游解除」变成「必须有人 + 去点恢复」。 + """ + credentials, db = repo + first = credentials.add(provider="codebuddy_intl", credential_data={"bearer_token": "t"}) + second = credentials.add(provider="codebuddy_intl", credential_data={"bearer_token": "u"}) + other = credentials.add(provider="codebuddy", credential_data={"bearer_token": "v"}) + # 已有更晚冷却的号不能被渠道级暂停缩短 + far = int(time.time()) + 30 * 86400 + db.connect().execute("UPDATE credentials SET cooling_until = ? WHERE id = ?", (far, second)) + db.connect().commit() + + until = int(time.time()) + 24 * 3600 + assert credentials.cool_provider("codebuddy_intl", until=until) == 1 + + rows = {row["id"]: row for row in db.connect().execute( + "SELECT id, disabled, cooling_until FROM credentials").fetchall()} + assert rows[first]["cooling_until"] == until + assert rows[second]["cooling_until"] == far # 更晚的冷却不被缩短 + assert rows[other]["cooling_until"] is None # 其它渠道不受影响 + assert all(row["disabled"] == 0 for row in rows.values()) diff --git a/tests/db/test_repo_credential_data.py b/tests/db/test_repo_credential_data.py new file mode 100644 index 00000000..716f930e --- /dev/null +++ b/tests/db/test_repo_credential_data.py @@ -0,0 +1,75 @@ +"""`CredentialRepository.credential_data`:解密失败的口径。 + +**背景(2026-10-26 14:48 实测)**:一条凭证的密文解不开(APP_SECRET 换过 +或密文损坏)时,`credential_data` 抛 `CredentialDecryptError` → 一条坏凭证 +就能让**整个聊天请求**回 500(栈顶「凭证解密失败:APP_SECRET 是否被更换 +过?」),其余好凭证白白连坐。 + +两套口径并存,各有用途: + +* `credential_data` **照旧抛** —— 单条探测端点要把它如实报给管理员 + (可行动的 500,见 `webapp.handlers` 与 `tests/regression/test_hardening.py`); +* 批处理路径(选号、模型列表、后台任务)**捕获它并跳过该凭证**, + 见 `executor._safe_credential_data` 与 `api.models`。 +""" + +from __future__ import annotations + +import pytest + +from src.db.crypto import CredentialCipher, CredentialDecryptError + + +def test_credential_data_raises_for_undecryptable_credential(repo): + """`credential_data` 照旧抛:单条探测端点要如实报给管理员。""" + credentials, db = repo + credential_id = credentials.add(provider="trae", + credential_data={"accessToken": "a"}) + # 换成另一个密钥加密 → 密文与当前 APP_SECRET 不匹配 + other = CredentialCipher("a-completely-different-secret") + with db.transaction() as conn: + conn.execute("UPDATE credentials SET data_enc = ? WHERE id = ?", + (other.encrypt(b'{"accessToken": "a"}'), credential_id)) + + with pytest.raises(CredentialDecryptError): + credentials.credential_data(credential_id) + + +def test_credential_data_round_trip(repo): + credentials, _db = repo + credential_id = credentials.add(provider="trae", + credential_data={"accessToken": "a"}) + assert credentials.credential_data(credential_id) == {"accessToken": "a"} + + +def test_credential_data_returns_none_for_missing_credential(repo): + credentials, _db = repo + assert credentials.credential_data("cred_missing") is None + + +def test_has_same_credential_matches_provider_and_content(repo): + """批量导入去重:同渠道 + 解密后内容完全一致才命中。""" + credentials, _db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + credentials.add(provider="qoder", credential_data={"accessToken": "a"}) + + assert credentials.has_same_credential("trae", {"accessToken": "a"}) is True + assert credentials.has_same_credential("trae", {"accessToken": "b"}) is False + # 同内容不同渠道不算重复 + assert credentials.has_same_credential("codebuddy", {"accessToken": "a"}) is False + + +def test_has_same_credential_skips_undecryptable_rows(repo): + """坏密文行跳过,不阻断整批导入(继续比对后面的好行)。""" + credentials, db = repo + other = CredentialCipher("a-completely-different-secret") + with db.transaction() as conn: + conn.execute( + "INSERT INTO credentials (id, provider, nickname, data_enc, created_at, " + "added_by, tags) VALUES ('cred_bad', 'trae', '', ?, 1, '', '')", + (other.encrypt(b'{"accessToken": "broken"}'),), + ) + credentials.add(provider="trae", credential_data={"accessToken": "good"}) + + assert credentials.has_same_credential("trae", {"accessToken": "good"}) is True + assert credentials.has_same_credential("trae", {"accessToken": "nope"}) is False diff --git a/tests/db/test_repo_credit.py b/tests/db/test_repo_credit.py index 425e6c4d..eb148952 100644 --- a/tests/db/test_repo_credit.py +++ b/tests/db/test_repo_credit.py @@ -66,6 +66,8 @@ def test_quota_ledger_maps_codearts_to_credits(): from src.db.repo import quota_ledger assert quota_ledger("codearts") == "credits" + # 小浣熊按积分扣费,与 CodeArts 同账 + assert quota_ledger("raccoon") == "credits" assert quota_ledger("codebuddy") == "pool" assert quota_ledger("trae") == "pool" @@ -314,10 +316,12 @@ def test_retention_task_prunes_credit_events(tmp_path): credentials.save_quota(cid, Quota(remaining=2, total=2, probed_at=now)) class FakeCollector: - def rollup_hourly(self) -> int: + # 周期 rollup 现在带增量窗口(since);替身按新签名实现 + def rollup_hourly(self, since: int | None = None) -> int: return 3 - def purge_expired(self, days: int) -> int: + # `now` 由 run_once 传播;本用例不传 now(真实时间路径) + def purge_expired(self, days: int, now: int | None = None) -> int: return 7 task = RetentionTask(FakeCollector(), retention_days=90, @@ -329,3 +333,45 @@ def purge_expired(self, days: int) -> int: bare = RetentionTask(FakeCollector(), retention_days=90, credentials=credentials) assert bare.run_once()["purged_credit_events"] == 0 db.close() + + +def test_save_quota_persists_web_cookie_state(tmp_path): + """网页 Cookie 状态随额度探测落库,供管理台提示;可单独清除。""" + db = Database(tmp_path / "t.sqlite3") + apply_schema(db.connect()) + credentials = CredentialRepository(db, CredentialCipher(SECRET)) + now = int(time.time()) + cid = credentials.add(provider="codearts", credential_data={"x": 1}, now=now) + + credentials.save_quota(cid, Quota(remaining=1.0, total=2.0, probed_at=now, + web_cookie_state="ok")) + assert credentials.list_all()[0]["web_cookie_state"] == "ok" + + credentials.save_quota(cid, Quota(remaining=1.0, total=2.0, probed_at=now + 60, + web_cookie_state="expired")) + assert credentials.list_all()[0]["web_cookie_state"] == "expired" + + # 重新粘贴/清除 Cookie 时由 API 复位为 NULL,下一轮探测再写 + credentials.set_web_cookie_state(cid, None) + assert credentials.list_all()[0]["web_cookie_state"] is None + + +def test_routing_weight_round_trip(tmp_path): + """权重随凭证落库:列表与调度候选都带出同值,None = 恢复默认。""" + db = Database(tmp_path / "t.sqlite3") + apply_schema(db.connect()) + credentials = CredentialRepository(db, CredentialCipher(SECRET)) + cid = credentials.add(provider="trae", credential_data={"accessToken": "t"}) + + assert credentials.candidates()[0].routing_weight is None + assert credentials.set_routing_weight(cid, 7) is True + assert credentials.candidates()[0].routing_weight == 7 + assert credentials.list_all()[0]["routing_weight"] == 7 + # 负数 = 备用(靠后),与默认 0 区分 + credentials.set_routing_weight(cid, -3) + assert credentials.candidates()[0].routing_weight == -3 + credentials.set_routing_weight(cid, None) + assert credentials.candidates()[0].routing_weight is None + assert credentials.list_all()[0]["routing_weight"] is None + assert credentials.set_routing_weight("ghost", 1) is False + db.close() diff --git a/tests/db/test_repo_login_merge.py b/tests/db/test_repo_login_merge.py new file mode 100644 index 00000000..9947458c --- /dev/null +++ b/tests/db/test_repo_login_merge.py @@ -0,0 +1,168 @@ +"""重新登录合并:按上游账号身份找到既有凭证并就地更新。""" + +from __future__ import annotations + +from src.db.crypto import CredentialCipher +from src.engine.scheduler import ErrorOutcome, ModelCooldown +from src.provider.login_identity import login_identities_for + + +def _identity(data: dict) -> frozenset[str]: + uid = data.get("uid") + return frozenset({str(uid)}) if uid else frozenset() + + +def _codearts_aliases(data: dict) -> frozenset[str]: + return login_identities_for("codearts", data) + + +def _add(credentials, *, uid: str, disabled: int = 0, pinned: int = 0, + nickname: str = "", token: str = "old") -> str: + cid = credentials.add(provider="codearts", + credential_data={"uid": uid, "token": token}, + nickname=nickname) + if disabled or pinned: + with credentials._db.transaction() as conn: # noqa: SLF001 - 测试直写状态 + conn.execute("UPDATE credentials SET disabled = ?, pinned = ? WHERE id = ?", + (disabled, pinned, cid)) + return cid + + +def test_find_login_credential_matches_identity_and_skips_bad_rows(repo): + """身份相同才命中;密文坏掉的行跳过,不能让一条坏数据挡住登录。""" + credentials, db = repo + good = _add(credentials, uid="u1") + _add(credentials, uid="u2") + # 一条解不开的凭证:换密钥加密,模拟 APP_SECRET 变更 / 密文损坏 + broken = _add(credentials, uid="u1", token="broken") + other = CredentialCipher("a-completely-different-secret") + with db.transaction() as conn: + conn.execute("UPDATE credentials SET data_enc = ? WHERE id = ?", + (other.encrypt(b'{"uid": "u1"}'), broken)) + + assert credentials.find_login_credential("codearts", _identity, {"u1"}) == good + assert credentials.find_login_credential("codearts", _identity, {"u3"}) is None + # 空目标 = 没有可合并的身份,直接不猜 + assert credentials.find_login_credential("codearts", _identity, frozenset()) is None + # 别的渠道不参与匹配 + assert credentials.find_login_credential("officeace", _identity, {"u1"}) is None + + +def test_find_login_credential_matches_any_alias_of_the_same_account(repo): + """老行只有 uid、新登录只有兜底键 → 仍命中(否则会新增重复凭证)。 + + 身份补全接口(caller-identity / current-user)尽力而为,网络失败只记日志, + 那次登录就只能给出 `domain_id|user_name`。单键比较必然错开,交集才合并。 + """ + credentials, _db = repo + old = credentials.add(provider="codearts", credential_data={"uid": "u1"}) + aliases = {"u1", "d|n"} + assert credentials.find_login_credential("codearts", _identity, aliases) == old + + # 反向:新登录只有兜底键,老行同时有两个别名 + newer = credentials.add(provider="codearts", + credential_data={"uid": "u2", "domain_id": "d", + "user_name": "n"}) + assert credentials.find_login_credential( + "codearts", _codearts_aliases, {"d|n"}) == newer + + +def test_find_login_credential_prefers_disabled_then_pinned_then_oldest(repo): + """历史重复凭证的选取顺序:disabled(本次重登要救的)→ pinned → 最早。""" + credentials, _db = repo + oldest = _add(credentials, uid="u1") + pinned = _add(credentials, uid="u1", pinned=1) + disabled = _add(credentials, uid="u1", disabled=1) + + assert credentials.find_login_credential("codearts", _identity, {"u1"}) == disabled + + # 无 disabled → pinned + credentials.revive(disabled) + assert credentials.find_login_credential("codearts", _identity, {"u1"}) == pinned + + # 都普通 → 最早创建的一条(统计流水挂在这个 id 上) + with credentials._db.transaction() as conn: # noqa: SLF001 - 测试直写状态 + conn.execute("UPDATE credentials SET pinned = 0 WHERE id = ?", (pinned,)) + assert credentials.find_login_credential("codearts", _identity, {"u1"}) == oldest + + +def test_find_login_credential_identity_raising_row_is_skipped(repo): + """身份解析抛错的行跳过,不让一条畸形数据挡住整次登录。""" + credentials, _db = repo + + def identity(data: dict) -> frozenset[str]: + if data.get("bad"): + raise ValueError("malformed credential") + uid = data.get("uid") + return frozenset({str(uid)}) if uid else frozenset() + + credentials.add(provider="codearts", credential_data={"bad": True}) + credentials.add(provider="codearts", credential_data={"uid": "u1"}) + assert credentials.find_login_credential("codearts", identity, {"u1"}) is not None + + +def test_merge_login_credential_replaces_data_and_clears_failure_state(repo): + """合并只换凭证内容 + 解除失败状态;运维字段与用户暂停状态必须保留。""" + credentials, db = repo + cid = credentials.add(provider="codearts", + credential_data={"uid": "u1", "token": "old"}, + nickname="old-name", added_by="root", + tags=["keep"]) + credentials.set_enabled(cid, False) + credentials.mark_relogin_required(cid, "refresh token revoked") + credentials.save_error(cid, ErrorOutcome(cooling_until=9999, cooling_kind="other")) + credentials.save_error(cid, ErrorOutcome(model_cooldowns={ + "m": ModelCooldown(cooling_until=9999, hits=1, reason="model")})) + with db.transaction() as conn: + conn.execute("UPDATE credentials SET err_count = 3 WHERE id = ?", (cid,)) + + assert credentials.merge_login_credential( + cid, {"uid": "u1", "token": "new"}, "new-name") is True + + assert credentials.credential_data(cid) == {"uid": "u1", "token": "new"} + row = db.connect().execute( + "SELECT nickname, enabled, disabled, disabled_reason, cooling_until, " + "cooling_kind, err_count, added_by, tags FROM credentials WHERE id = ?", + (cid,)).fetchone() + assert row["nickname"] == "new-name" + assert row["enabled"] == 0 # 用户主动暂停保留 + assert row["disabled"] == 0 and row["disabled_reason"] is None + # 本地 OTHER 冷却(非上游点名)照旧清除,与 revive(force=False) 同口径 + assert row["cooling_until"] is None and row["cooling_kind"] is None + assert row["err_count"] == 0 + assert row["added_by"] == "root" and row["tags"] == "keep" + assert credentials.model_cooldowns_for(cid, now=1000) == {} + + # 昵称为空时保留旧值,不能把展示名清空 + assert credentials.merge_login_credential( + cid, {"uid": "u1", "token": "newer"}, "") is True + assert credentials.credential_data(cid)["token"] == "newer" + assert db.connect().execute( + "SELECT nickname FROM credentials WHERE id = ?", (cid,)).fetchone()[0] == "new-name" + + +def test_merge_login_credential_keeps_upstream_named_cooldown(repo): + """上游点名的冷却(429/余额/权益/风控)重登不清除。 + + 重登换的是凭证,不是配额:2026-11-03 实测「恢复顺手解掉刚写入的 429 冷却 + → 下一个请求立刻再撞同一个 429」,重复重登本身就是上游封号的自动化信号。 + `revive(force=False)` 已经保留这几类,重登必须同口径。 + """ + credentials, db = repo + cid = credentials.add(provider="codearts", credential_data={"uid": "u1"}) + + credentials.save_error(cid, ErrorOutcome(cooling_until=9999, cooling_kind="soft")) + assert credentials.merge_login_credential(cid, {"uid": "u1"}, "n") is True + row = db.connect().execute( + "SELECT cooling_until, cooling_kind FROM credentials WHERE id = ?", + (cid,)).fetchone() + assert row["cooling_until"] == 9999 and row["cooling_kind"] == "soft" + # 硬禁用仍然解除:冷却留着,但「需重新登录」这一条已经解决了 + assert db.connect().execute( + "SELECT disabled FROM credentials WHERE id = ?", (cid,)).fetchone()[0] == 0 + + +def test_merge_login_credential_missing_row_returns_false(repo): + credentials, _db = repo + assert credentials.merge_login_credential( + "cred_missing", {"uid": "u1"}, "n") is False diff --git a/tests/db/test_repo_migrate.py b/tests/db/test_repo_migrate.py index 00e84423..88dde8aa 100644 --- a/tests/db/test_repo_migrate.py +++ b/tests/db/test_repo_migrate.py @@ -134,6 +134,8 @@ def test_migrate_adds_cached_tokens_to_legacy_db(tmp_path): event_indexes = {row[1] for row in db.connect().execute( "PRAGMA index_list(usage_events)")} assert "idx_usage_key" in event_indexes + # 失败归因的「换号救回」按 request_id 关联(request_id 是第 19 版补的列) + assert "idx_usage_request" in event_indexes alert_columns = {row[1] for row in db.connect().execute("PRAGMA table_info(alert_events)")} # 告警恢复时刻:老库补列后历史行 NULL(视为未恢复,不影响既有记录展示) assert "resolved_at" in alert_columns @@ -377,6 +379,37 @@ def test_legacy_db_upgrade_adds_credential_tags(tmp_path): db.close() +def test_legacy_db_upgrade_adds_cooling_kind(tmp_path): + """老库升级:credentials 无 cooling_kind 列时幂等补列,历史行为 NULL。 + + NULL = 冷却来源未知(老库 / 本地 OTHER),`revive` 照旧清除该冷却,与补列前 + 完全一致;新写入的冷却带上 ErrKind 来源,`revive` 才会保留「上游点名恢复 + 时刻」的那些(见 `UPSTREAM_COOLDOWN_KINDS`)。 + """ + from src.db.migrate import SCHEMA_VERSION + + db = Database(tmp_path / "legacy.sqlite3") + conn = sqlite3.connect(db.path) + conn.execute( + "CREATE TABLE credentials (id TEXT PRIMARY KEY, provider TEXT NOT NULL," + " data_enc BLOB NOT NULL, created_at INTEGER NOT NULL," + " cooling_until INTEGER)") + conn.execute("INSERT INTO credentials (id, provider, data_enc, created_at," + " cooling_until) VALUES ('cred_old', 'trae', x'00', 1, 9999)") + conn.commit() + conn.close() + + apply_schema(db.connect()) + columns = {row[1] for row in db.connect().execute("PRAGMA table_info(credentials)")} + assert "cooling_kind" in columns + row = db.connect().execute( + "SELECT cooling_kind, cooling_until FROM credentials WHERE id = 'cred_old'" + ).fetchone() + assert row[0] is None and row[1] == 9999 + assert db.connect().execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION + db.close() + + def test_legacy_db_upgrade_drops_credits_columns(tmp_path): """老库升级:credentials 上遗留的三列积分账被删列,版本推进到 29。 @@ -509,3 +542,121 @@ def test_legacy_db_upgrade_adds_api_key_policy_columns(tmp_path): assert db.connect().execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION db.close() + + +def test_legacy_db_upgrade_adds_user_policy_columns(tmp_path): + """老库升级:users 无 allowed_models / quota 时幂等补列(Playground 按人判定用)。 + + 两列都是 `NOT NULL DEFAULT ''`:补列后老用户立刻回到「不限制」的原行为—— + Playground 的按人判定读的就是它们,缺列会让启动直接失败。 + """ + db = Database(tmp_path / "legacy_users.sqlite3") + conn = sqlite3.connect(db.path) + conn.execute(""" + CREATE TABLE users ( + username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', enabled INTEGER NOT NULL DEFAULT 1, + must_change_password INTEGER NOT NULL DEFAULT 0, + session_epoch INTEGER NOT NULL DEFAULT 0, + activation_digest TEXT, activation_expires_at INTEGER, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, created_by TEXT) + """) + conn.execute("INSERT INTO users (username, password_hash, created_at, updated_at) " + "VALUES ('alice', 'pbkdf2$old', 1, 1)") + conn.commit() + conn.close() + + apply_schema(db.connect()) + columns = {row[1] for row in db.connect().execute("PRAGMA table_info(users)")} + assert {"allowed_models", "quota"} <= columns + legacy = db.connect().execute( + "SELECT allowed_models, quota FROM users WHERE username = 'alice'").fetchone() + assert tuple(legacy) == ("", "") + # 补列后可写可读 + db.connect().execute( + "UPDATE users SET allowed_models = 'glm-*', quota = '{\"rpm\": 3}' " + "WHERE username = 'alice'") + db.connect().commit() + assert tuple(db.connect().execute( + "SELECT allowed_models, quota FROM users WHERE username = 'alice'").fetchone()) \ + == ("glm-*", '{"rpm": 3}') + from src.db.migrate import SCHEMA_VERSION + + assert db.connect().execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION + db.close() + + +def test_legacy_db_upgrade_creates_checkin_events(tmp_path): + """老库升级:缺 checkin_events 表时补齐(新表走 CREATE TABLE IF NOT EXISTS)。 + + 该表是「每次签到一行」的审计日志:升级后表为空 =「升级前没有日志」, + 不影响签到本身(去重仍是 CheckinTask 的当日作用域集合)。二次执行幂等。 + """ + db = Database(tmp_path / "legacy_checkin.sqlite3") + # 先建到当前形状,再把新表与版本号退回到第 30 版,模拟升级前的老库 + apply_schema(db.connect()) + db.connect().execute("DROP TABLE checkin_events") + db.connect().execute("PRAGMA user_version = 30") + db.connect().commit() + + apply_schema(db.connect()) + tables = {row[0] for row in db.connect().execute( + "SELECT name FROM sqlite_master WHERE type = 'table'")} + assert "checkin_events" in tables + columns = {row[1] for row in db.connect().execute( + "PRAGMA table_info(checkin_events)")} + assert {"id", "credential_id", "ts", "ok", "already", "credit", "code", + "message", "trigger"} <= columns + from src.db.migrate import SCHEMA_VERSION + + assert db.connect().execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION + apply_schema(db.connect()) # 二次执行不抛错 + db.close() + + +def test_legacy_db_upgrade_adds_web_cookie_state(tmp_path): + """老库升级:credentials 无 web_cookie_state 时补列,历史行为 NULL(未知)。""" + db = Database(tmp_path / "legacy.sqlite3") + conn = sqlite3.connect(db.path) + conn.execute( + "CREATE TABLE credentials (id TEXT PRIMARY KEY, provider TEXT NOT NULL," + " data_enc BLOB NOT NULL, created_at INTEGER NOT NULL)") + conn.execute("INSERT INTO credentials (id, provider, data_enc, created_at)" + " VALUES ('cred_old', 'codearts', x'00', 1)") + conn.commit() + conn.close() + + apply_schema(db.connect()) + columns = {row[1] for row in db.connect().execute("PRAGMA table_info(credentials)")} + assert "web_cookie_state" in columns + assert db.connect().execute( + "SELECT web_cookie_state FROM credentials WHERE id = 'cred_old'").fetchone()[0] is None + db.connect().execute( + "UPDATE credentials SET web_cookie_state = 'expired' WHERE id = 'cred_old'") + db.connect().commit() + assert db.connect().execute( + "SELECT web_cookie_state FROM credentials WHERE id = 'cred_old'").fetchone()[0] == "expired" + from src.db.migrate import SCHEMA_VERSION + + assert db.connect().execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION + db.close() + + +def test_legacy_db_upgrade_adds_routing_weight(tmp_path): + """老库升级:credentials 无 routing_weight 时补列,历史行 NULL = 默认权重。""" + db = Database(tmp_path / "legacy.sqlite3") + conn = sqlite3.connect(db.path) + conn.execute( + "CREATE TABLE credentials (id TEXT PRIMARY KEY, provider TEXT NOT NULL," + " data_enc BLOB NOT NULL, created_at INTEGER NOT NULL)") + conn.execute("INSERT INTO credentials (id, provider, data_enc, created_at)" + " VALUES ('cred_old', 'trae', x'00', 1)") + conn.commit() + conn.close() + + apply_schema(db.connect()) + columns = {row[1] for row in db.connect().execute("PRAGMA table_info(credentials)")} + assert "routing_weight" in columns + assert db.connect().execute( + "SELECT routing_weight FROM credentials WHERE id = 'cred_old'").fetchone()[0] is None + db.close() diff --git a/tests/db/test_users_repo.py b/tests/db/test_users_repo.py index 677e7d31..87d7087c 100644 --- a/tests/db/test_users_repo.py +++ b/tests/db/test_users_repo.py @@ -224,5 +224,27 @@ def test_schema_v14_adds_users_and_audit_tables(tmp_path): "SELECT name FROM sqlite_master WHERE type = 'table'")} assert {"users", "audit_events"} <= tables assert db.connect().execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION - assert SCHEMA_VERSION == 29 + assert SCHEMA_VERSION == 34 + db.close() + + +def test_set_policy_replaces_models_and_quota(tmp_path): + """`set_policy` 整体替换用户级策略(Playground 的模型白名单 + 配额)。 + + 只动这两列:角色/启停/密码都不受影响——策略写入不该顺带踢掉会话或改权限。 + """ + db, repo, _audit = _open(tmp_path) + repo.create("alice", "pbkdf2$hash", role="viewer", now=1) + assert repo.set_policy("alice", allowed_models="glm-5.3*", + quota='{"rpm": 5}', now=2) is True + row = repo.get("alice") + assert row["allowed_models"] == "glm-5.3*" and row["quota"] == '{"rpm": 5}' + assert row["role"] == "viewer" and row["enabled"] == 1 + assert row["session_epoch"] == 0 # 策略变更不 bump epoch + assert row["updated_at"] == 2 + # 空串 = 回到「不限制」 + assert repo.set_policy("alice", allowed_models="", quota="", now=3) is True + assert repo.get("alice")["allowed_models"] == "" and repo.get("alice")["quota"] == "" + # 用户不存在 → False(端点据此回 400) + assert repo.set_policy("ghost", allowed_models="", quota="") is False db.close() diff --git a/tests/deploy/test_assets.py b/tests/deploy/test_assets.py index 0745ae22..6a039e6f 100644 --- a/tests/deploy/test_assets.py +++ b/tests/deploy/test_assets.py @@ -238,7 +238,7 @@ def test_ci_compose_job_builds_fresh_image_with_admin_user(): LAUNCHD_TEMPLATE = ROOT / "deploy" / "launchd" / "com.coding2api.plist" FRPC_PLIST_TEMPLATE = ROOT / "deploy" / "launchd" / "com.coding2api.frpc.plist" -# newsyslog 的日志路径可以来自任一个 launchd 模板(本体 / frpc 隧道) +# 日志路径来自任一个 launchd 模板(本体 / frpc 隧道),由轮转脚本覆盖 LAUNCHD_TEMPLATES = (LAUNCHD_TEMPLATE, FRPC_PLIST_TEMPLATE) LAUNCHD_AGENTS = Path.home() / "Library" / "LaunchAgents" BUILD_WEB = ROOT / "scripts" / "build-web.sh" @@ -254,8 +254,12 @@ def test_launchd_template_exists_and_is_valid_plist(): data = plistlib.load(handle) assert data["Label"] == "com.coding2api" assert data["RunAtLoad"] is True - assert data["KeepAlive"] is True - # 日志路径必须与本机 plist / newsyslog 规则一致(改一处漏一处最难查) + # KeepAlive 必须是条件式:SuccessfulExit=false = 只在非零退出时重拉。 + # 无条件 true 会让「另一个实例占用 DATA_DIR」(退出码 2)每 10s 重拉一次。 + keep_alive = data["KeepAlive"] + assert isinstance(keep_alive, dict), "KeepAlive 应为条件字典,不能无条件重拉" + assert keep_alive["SuccessfulExit"] is False + # 日志路径必须与轮转脚本覆盖的一致(改一处漏一处最难查) assert data["StandardOutPath"].endswith("logs/launchd.out.log") assert data["StandardErrorPath"].endswith("logs/launchd.err.log") @@ -272,7 +276,7 @@ def test_deploy_templates_contain_no_personal_absolute_paths(): """全仓库不得混入开发机的个人家目录路径(macOS 的 Users、Linux 的 home)。 这类路径只在开发机成立,进仓库后别人无法复现;需要绝对路径的场景 - (launchd / newsyslog 不展开环境变量)改用 __PROJECT_ROOT__ 占位符, + (launchd 不展开环境变量)改用 __PROJECT_ROOT__ 占位符, 由 scripts/install-*.sh 在安装时渲染。 """ import subprocess @@ -310,13 +314,6 @@ def test_launchd_install_script_renders_placeholder(): assert "--uninstall" in body -def test_newsyslog_install_script_renders_placeholders(): - """newsyslog 安装脚本必须同时替换路径与属主占位符。""" - body = (ROOT / "scripts" / "install-newsyslog.sh").read_text(encoding="utf-8") - assert PROJECT_ROOT_TOKEN in body, "脚本必须替换 __PROJECT_ROOT__" - assert LOG_OWNER_TOKEN in body, "脚本必须替换 __LOG_OWNER__" - - def test_launchd_template_starts_via_wrapper_not_bare_uvicorn(): """入口必须是 wrapper:它负责先构建前端产物。 @@ -467,6 +464,10 @@ def test_describe_probe_failure_handles_timeout_and_unknown(): NEWSYSLOG = ROOT / "deploy" / "newsyslog" / "coding2api.conf" LOGROTATE = ROOT / "deploy" / "logrotate" / "coding2api" SYSTEMD_UNIT = ROOT / "deploy" / "systemd" / "coding2api.service" +# macOS 截断式轮转:newsyslog 只做 rename,会丢日志(见 scripts/rotate-logs.sh) +ROTATE_SCRIPT = ROOT / "scripts" / "rotate-logs.sh" +INSTALL_ROTATE = ROOT / "scripts" / "install-logrotate.sh" +ROTATE_PLIST = ROOT / "deploy" / "launchd" / "com.coding2api.logrotate.plist" # 部署模板里的占位符(安装脚本渲染成实际值,见 scripts/install-*.sh)。 # 模板不得写死开发机路径,否则克隆到别处就不可用。 @@ -501,43 +502,231 @@ def _plist_label(template: Path) -> str: return str(plistlib.load(handle)["Label"]) -def test_newsyslog_rule_matches_launchd_plist_paths(): - """轮转路径必须与 launchd 实际写的文件一致,否则规则永远不生效。""" - # 模板对模板:两处用的是同一个 __PROJECT_ROOT__ 占位符,渲染后必须一致。 - # 路径可能来自任一 launchd 模板(本体或 frpc 隧道),逐个模板查。 - template = NEWSYSLOG.read_text(encoding="utf-8") - plist_templates = "\n".join(t.read_text(encoding="utf-8") for t in LAUNCHD_TEMPLATES) - template_paths = [line.split()[0] for line in template.splitlines() - if line.strip() and not line.startswith("#")] - assert template_paths, "newsyslog 模板里没有有效条目" - for path in template_paths: - assert path in plist_templates, f"newsyslog 模板里的 {path} 不在任何 launchd 模板中" - - # 渲染后与本机实际安装的 plist 对齐:只校验「该路径所属的模板已安装」的那些 - # (例如 frpc 尚未启用时其 plist 不存在,无法对照,跳过而不是误报)。 - # CI 上两个 plist 都不存在,整段跳过。 - rendered = _render_template(template) - paths = [line.split()[0] for line in rendered.splitlines() - if line.strip() and not line.startswith("#")] - for launchd_template in LAUNCHD_TEMPLATES: - label = _plist_label(launchd_template) - installed = LAUNCHD_AGENTS / f"{label}.plist" - if not installed.is_file(): - continue - owned = [p for p in paths - if p in _render_template(launchd_template.read_text(encoding="utf-8"))] - installed_text = installed.read_text(encoding="utf-8") - for path in owned: - assert path in installed_text, \ - f"newsyslog 规则里的 {path} 不在已安装的 {label} plist 中" - # 每条规则必须有 7 个字段(路径 属主 权限 份数 大小 时间 标志) - for line in rendered.splitlines(): - if line.strip() and not line.startswith("#"): - assert len(line.split()) == 7, f"newsyslog 字段数不对(需 7 列): {line}" +def test_rotate_script_covers_every_launchd_log_path(): + """轮转脚本必须覆盖 launchd 实际写的每个日志,否则那个文件会无限涨。 + + 口径:从 launchd 模板取 StandardOutPath / StandardErrorPath,逐个断言 + scripts/rotate-logs.sh 里有对应的文件名。改名一处漏一处是最难查的坑。 + """ + import plistlib + + script = ROTATE_SCRIPT.read_text(encoding="utf-8") + covered = 0 + for template in LAUNCHD_TEMPLATES: + with template.open("rb") as handle: + data = plistlib.load(handle) + for key in ("StandardOutPath", "StandardErrorPath"): + path = data.get(key) + if not path: + continue + name = path.rsplit("/", 1)[-1] + assert name in script, f"rotate-logs.sh 未覆盖 {name}(来自 {template.name})" + covered += 1 + assert covered >= 2, "launchd 模板里没有找到日志路径" + + +def test_rotate_script_uses_truncate_not_rename(): + """macOS 只能截断式轮转:rename 会让进程继续写旧 inode,日志直接丢。 + + 实测(2026-10-30):mv app.log app.log.old 后进程仍写 app.log.old, + 新路径文件不存在;newsyslog 的 flags 只有 B C D G J N U Z,没有 + copytruncate。所以本项目自建脚本,必须看到「拷贝 → 压缩 → 截断」的顺序。 + """ + script = ROTATE_SCRIPT.read_text(encoding="utf-8") + code = _shell_code_lines(ROTATE_SCRIPT) + # 必须截断当前文件(: > file),不能只 mv/rename + assert ": > " in code, "缺少截断步骤(rename 会丢日志)" + assert "cp -p" in code, "缺少拷贝步骤(先归档再截断)" + assert "bzip2" in code, "缺少压缩步骤" + # 截断必须晚于压缩成功:否则压缩失败就把未归档的日志清掉了 + assert code.index("bzip2 -f") < code.index(": > "), "截断早于压缩成功" + # 不得出现 rename 式轮转 + assert "mv " not in code, "不得使用 mv/rename 轮转" + # 轮转目标目录可覆盖:否则测试会把真实 logs/ 轮转掉 + assert "ROTATE_LOGS_DIR" in script, "缺少 ROTATE_LOGS_DIR 覆盖(测试隔离用)" + + +def test_rotate_script_avoids_platform_specific_stat(): + """不能依赖 `stat -f %z`:GNU 的 `stat -f` 会**成功**返回文件系统信息。 + + CI 在 ubuntu 上真跑本脚本(上面的行为用例),BSD/GNU 差异会直接红。 + 这条与 bash 3.2 兼容性同理:脚本本体只服务 macOS,测试却跨平台。 + """ + code = _shell_code_lines(ROTATE_SCRIPT) + assert "stat -f" not in code, "BSD 专用的 stat -f 在 GNU 上不报错但结果错" + assert "wc -c" in code, "文件大小用 wc -c(两边行为一致)" + + +def test_rotate_script_is_bash32_compatible(): + """launchd 用 /bin/bash(macOS 自带 3.2),不能用 mapfile / readarray。""" + code = _shell_code_lines(ROTATE_SCRIPT) + assert "mapfile" not in code, "bash 3.2 没有 mapfile" + assert "readarray" not in code, "bash 3.2 没有 readarray" + assert "${archives[@]}" not in code, "数组切片在 3.2 上行为不同,用 while read" + + +def test_rotate_plist_schedules_without_keepalive(): + """定时任务必须跑完即退:KeepAlive 会让它被立刻反复重拉。""" + import plistlib + + with ROTATE_PLIST.open("rb") as handle: + data = plistlib.load(handle) + assert data["Label"] == "com.coding2api.logrotate" + assert not data.get("KeepAlive"), "轮转是一次性任务,不能 KeepAlive" + assert data.get("RunAtLoad") is False, "登录时不必立刻轮转(阈值未到)" + intervals = data.get("StartCalendarInterval") + assert intervals, "缺少定时触发" + assert len(intervals) >= 2, "至少两个时点,避免只在一个时段轮转" + # 走 /bin/bash 而不是 exec 脚本本身:脚本 shebang 用 bash,但显式声明更稳 + assert data["ProgramArguments"][0] == "/bin/bash" + assert data["ProgramArguments"][-1].endswith("scripts/rotate-logs.sh") + + +def test_rotate_install_script_renders_placeholder(): + """安装脚本必须渲染 __PROJECT_ROOT__ 并写进 LaunchAgents。""" + assert INSTALL_ROTATE.is_file() + assert INSTALL_ROTATE.stat().st_mode & 0o111, "安装脚本没有执行位" + body = INSTALL_ROTATE.read_text(encoding="utf-8") + assert PROJECT_ROOT_TOKEN in body + assert "Library/LaunchAgents" in body + assert "--uninstall" in body + assert "launchctl bootstrap" in body and "launchctl bootout" in body + + +def test_newsyslog_rules_removed(): + """macOS 不再用 newsyslog:它只做 rename,会让服务继续写旧 inode。 + + 回归护栏——如果有人把 newsyslog 规则加回来,说明又踩回丢日志的坑。 + """ + assert not NEWSYSLOG.exists(), "macOS 侧已改用 scripts/rotate-logs.sh,不应再有 newsyslog 规则" + assert not (ROOT / "scripts" / "install-newsyslog.sh").exists() + + +def test_launchd_wrapper_warns_about_installed_newsyslog_copy(): + """本机 /etc 下的残留规则仓库删不掉,启动时必须吼一声。 + + 仓库侧删干净了(上一用例),但用老脚本装到 /etc 的副本会一直留在本机: + 它继续 rename 轮转 → 日志静默丢失且无任何报错(2026-11-09 扫日志发现 + 11-07 15:30~11-08 10:29 整整 19h 空白)。只在启动 stderr 提醒,不阻断启动。 + """ + code = _shell_code_lines(ROOT / "scripts" / "launchd-server.sh") + assert "/etc/newsyslog.d/coding2api.conf" in code + assert "sudo rm" in code + + +def test_rotate_script_actually_rotates_in_isolation(tmp_path): + """真跑一次:内容归档、当前文件被截断、超额归档被清理。 + + **必须用 ROTATE_LOGS_DIR 指到临时目录**:脚本按自身位置解析仓库根, + 在别的 cwd 里跑也照样作用于真实 logs/(这条坑是 2026-10-30 实际踩过的, + 当场轮转掉本机日志并误删了 4 份历史归档)。 + """ + import subprocess + + logs = tmp_path / "logs" + logs.mkdir() + payload = "x" * 4096 + (logs / "launchd.err.log").write_text(payload, encoding="utf-8") + env = { + **__import__("os").environ, + "ROTATE_LOGS_DIR": str(logs), + "ROTATE_MAX_KB": "1", + "ROTATE_KEEP": "2", + } + result = subprocess.run(["bash", str(ROTATE_SCRIPT)], env=env, + capture_output=True, text=True, check=True) + assert "已轮转" in result.stdout + # 当前文件被截断为空,归档里有完整内容 + assert (logs / "launchd.err.log").read_text(encoding="utf-8") == "" + archives = list(logs.glob("launchd.err.log.*.bz2")) + assert len(archives) == 1 + import bz2 + + assert bz2.decompress(archives[0].read_bytes()).decode() == payload + + +def test_rotate_script_dry_run_leaves_files_untouched(tmp_path): + """--dry-run 只报告,不归档也不截断。""" + import subprocess + + logs = tmp_path / "logs" + logs.mkdir() + (logs / "launchd.err.log").write_text("y" * 4096, encoding="utf-8") + env = { + **__import__("os").environ, + "ROTATE_LOGS_DIR": str(logs), + "ROTATE_MAX_KB": "1", + } + result = subprocess.run(["bash", str(ROTATE_SCRIPT), "--dry-run"], env=env, + capture_output=True, text=True, check=True) + assert "[dry-run]" in result.stdout + assert (logs / "launchd.err.log").read_text(encoding="utf-8") == "y" * 4096 + assert not list(logs.glob("*.bz2")) + + +def test_rotate_script_skips_empty_and_small_files(tmp_path): + """空文件与未超阈值的文件都不轮转(notifempty / size 语义)。""" + import subprocess + + logs = tmp_path / "logs" + logs.mkdir() + (logs / "launchd.err.log").write_text("", encoding="utf-8") + (logs / "launchd.out.log").write_text("z" * 512, encoding="utf-8") + env = { + **__import__("os").environ, + "ROTATE_LOGS_DIR": str(logs), + "ROTATE_MAX_KB": "10240", + } + subprocess.run(["bash", str(ROTATE_SCRIPT)], env=env, + capture_output=True, text=True, check=True) + assert not list(logs.glob("*.bz2")) + + +def test_rotate_script_prunes_old_archives(tmp_path): + """超过 KEEP 的归档按 mtime 倒序清理,只留最近 KEEP 份。""" + import subprocess + import time + + logs = tmp_path / "logs" + logs.mkdir() + # 先造 3 份旧归档,再触发一次轮转(KEEP=2 → 最后应剩 2 份) + for index in range(3): + archive = logs / f"launchd.err.log.2026010{index + 1}-000000.bz2" + archive.write_bytes(b"old") + time.sleep(0.01) + (logs / "launchd.err.log").write_text("w" * 4096, encoding="utf-8") + env = { + **__import__("os").environ, + "ROTATE_LOGS_DIR": str(logs), + "ROTATE_MAX_KB": "1", + "ROTATE_KEEP": "2", + } + subprocess.run(["bash", str(ROTATE_SCRIPT)], env=env, + capture_output=True, text=True, check=True) + assert len(list(logs.glob("launchd.err.log.*.bz2"))) == 2 + + +def test_rotate_script_rejects_unknown_flag(): + """未知参数直接退 2,避免拼错的 --dryrun 被当成真跑。""" + import subprocess + + result = subprocess.run(["bash", str(ROTATE_SCRIPT), "--nope"], + capture_output=True, text=True) + assert result.returncode == 2 + assert "未知参数" in result.stderr + + +def test_systemd_restart_policy_skips_already_running_exit(): + """systemd 只在失败时重拉:退出码 0(另一实例持锁)不该触发重启循环。 + + 与 launchd 的 `KeepAlive.SuccessfulExit=false` 同口径。 + """ + text = SYSTEMD_UNIT.read_text(encoding="utf-8") + assert "Restart=on-failure" in text, "Restart=always 会把「另一实例在跑」也重拉" def test_logrotate_rule_uses_copytruncate(): - """进程持有 fd(>> 重定向)时 rename 不会让它换文件,必须 copytruncate。""" + """Linux 侧:进程持有 fd(>> 重定向)时 rename 不会让它换文件,必须 copytruncate。""" text = LOGROTATE.read_text(encoding="utf-8") assert "copytruncate" in text assert "rotate " in text and "maxsize" in text @@ -549,7 +738,7 @@ def test_systemd_unit_sends_logs_to_journal(): text = SYSTEMD_UNIT.read_text(encoding="utf-8") assert "StandardOutput=journal" in text assert "StandardError=journal" in text - assert "ExecStart=" in text and "build_app" in text + assert "ExecStart=" in text and "src.main" in text # 密钥不得写进 unit(systemctl cat 会暴露给所有用户) assert "APP_SECRET=" not in text @@ -566,15 +755,16 @@ def test_graceful_shutdown_timeout_consistent_across_entrypoints(): from src.main import GRACEFUL_SHUTDOWN_SECONDS assert GRACEFUL_SHUTDOWN_SECONDS > 0 - # launchd 与 Docker 都走 src.main:run,flag 由 run() 内部传入,脚本里没有 - # 这个数字可对;systemd 仍直接跑 uvicorn CLI,必须显式带同一个 flag。 + # 三条路径(Docker CMD / launchd wrapper / systemd ExecStart)现在统一走 + # src.main:run,排空时长由 run() 内部传入,任何一处再出现 uvicorn CLI + # 就会让数字分裂,且绕过单实例锁。 text = SYSTEMD_UNIT.read_text(encoding="utf-8") - found = _re.search(r"--timeout-graceful-shutdown (\d+)", text) - assert found, f"{SYSTEMD_UNIT.name} 缺 --timeout-graceful-shutdown" - assert int(found.group(1)) == GRACEFUL_SHUTDOWN_SECONDS, SYSTEMD_UNIT.name - # launchd 走 run() 的前提:脚本代码里不得再出现 uvicorn CLI(否则数字又分裂) + assert "uvicorn" not in text, "systemd 应走 src.main:run(含单实例锁)" + assert "-m src.main" in text, SYSTEMD_UNIT.name launchd = _shell_code_lines(ROOT / "scripts" / "launchd-server.sh") assert "uvicorn" not in launchd, "launchd 改走 src.main:run 后不应再出现 uvicorn CLI" + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + assert "src.main" in dockerfile and "uvicorn src.main" not in dockerfile # Docker:stop_grace_period 是容器给进程的停机窗口,必须 >= 应用排空窗口 compose = COMPOSE.read_text(encoding="utf-8") grace = _re.search(r"stop_grace_period: (\d+)s", compose) @@ -584,44 +774,23 @@ def test_graceful_shutdown_timeout_consistent_across_entrypoints(): unit = SYSTEMD_UNIT.read_text(encoding="utf-8") stop = _re.search(r"TimeoutStopSec=(\d+)", unit) assert stop and int(stop.group(1)) >= GRACEFUL_SHUTDOWN_SECONDS + # launchd 的 ExitTimeOut 同理:默认约 20s,短于排空窗口就会 SIGKILL 长流 + plist = LAUNCHD_TEMPLATE.read_text(encoding="utf-8") + exit_timeout = _re.search(r"ExitTimeOut\s*(\d+)", plist) + assert exit_timeout, f"{LAUNCHD_TEMPLATE.name} 缺 ExitTimeOut" + assert int(exit_timeout.group(1)) >= GRACEFUL_SHUTDOWN_SECONDS -def test_newsyslog_install_script_is_executable(): - """安装脚本必须可执行,否则 README 里的用法会失败。""" - script = ROOT / "scripts" / "install-newsyslog.sh" - assert script.is_file() - assert script.stat().st_mode & 0o111, "安装脚本没有执行位" - body = script.read_text(encoding="utf-8") - assert "/etc/newsyslog.d/" in body # 装到 newsyslog 会读的目录 - assert "--uninstall" in body - - -def test_newsyslog_owner_matches_actual_log_owner(): - """owned:group 必须与文件实际属主一致,否则 newsyslog 会跳过该条目。 - - 本机日志由 launchd 以当前用户身份写入,规则写成 root:wheel 就永远不会 - 轮转(newsyslog 静默跳过),是很容易埋下的坑。 - """ - text = _render_template(NEWSYSLOG.read_text(encoding="utf-8")) - entries = [line.split() for line in text.splitlines() - if line.strip() and not line.startswith("#")] - assert entries - for path, owner in ((e[0], e[1]) for e in entries): - target = Path(path) - if not target.exists(): - continue # 未部署到本机的路径跳过 - import grp - import pwd - - stat = target.stat() - actual = f"{pwd.getpwuid(stat.st_uid).pw_name}:{grp.getgrgid(stat.st_gid).gr_name}" - assert owner == actual, f"{path} 规则写 {owner},实际是 {actual}" +def test_rotate_script_preserves_file_owner(): + """截断式轮转用 cp -p:属主/权限跟着归档走,不因轮转变成 root 或丢权限。""" + code = _shell_code_lines(ROTATE_SCRIPT) + assert "cp -p" in code, "必须保留属主与权限(cp -p),否则归档属主错乱" def test_webapp_logging_module_does_not_open_files(): """应用只写 stderr:不得引入 RotatingFileHandler / FileHandler。 - 文件与轮转交给平台(launchd+newsyslog / systemd+journald / docker json-file)。 + 文件与轮转交给平台(launchd+rotate-logs.sh / systemd+journald / docker json-file)。 应用自己写文件会与平台轮转争抢同一文件,容器里还会写进镜像层重启即丢。 """ src = (ROOT / "src" / "webapp" / "logging.py").read_text(encoding="utf-8") @@ -797,11 +966,26 @@ def test_nginx_read_timeout_covers_longest_conversation(): def test_nginx_body_limit_aligns_with_app_middleware(): """两侧同值才能让超限统一由应用裁决(否则 nginx 先返回 HTML 形状的 413)。""" - from src.webapp.limits import DEFAULT_BODY_LIMIT + from src.webapp.limits import BACKUP_UPLOAD_LIMIT, DEFAULT_BODY_LIMIT text = NGINX_CONF.read_text(encoding="utf-8") expected = f"client_max_body_size {DEFAULT_BODY_LIMIT // (1024 * 1024)}m;" assert expected in text, f"nginx 上传上限应与应用对齐({expected})" + # 备份恢复是例外:端点 512MB,nginx 必须单独放宽,否则公网入口下永远 413 + backup = f"client_max_body_size {BACKUP_UPLOAD_LIMIT // (1024 * 1024)}m;" + assert backup in text, f"备份恢复路径的上传上限应与应用对齐({backup})" + + +def test_backup_upload_limit_matches_middleware(): + """端点声明的上限必须真的可达:中间件先裁决,不同值即静默失效。""" + from src.api.admin_backups import MAX_UPLOAD_BYTES + from src.webapp.limits import BACKUP_UPLOAD_LIMIT, _body_limit + + assert MAX_UPLOAD_BYTES == BACKUP_UPLOAD_LIMIT + assert _body_limit("/api/backups/restore") == BACKUP_UPLOAD_LIMIT + assert _body_limit("/api/backups/restore/") == BACKUP_UPLOAD_LIMIT + # 其余端点不受影响 + assert _body_limit("/v1/chat/completions") != BACKUP_UPLOAD_LIMIT def test_nginx_uses_domain_placeholder_not_hardcoded_host(): @@ -846,13 +1030,13 @@ def test_frpc_plist_template_is_valid_and_renders(): "plist 只应注入 token 文件路径,不得注入 token 值" -def test_frpc_plist_writes_logs_covered_by_newsyslog(): - """launchd 写的 frpc 日志必须与 newsyslog 规则一致,否则日志无限增长。""" +def test_frpc_plist_writes_logs_covered_by_rotate_script(): + """launchd 写的 frpc 日志必须被轮转脚本覆盖,否则日志无限增长。""" plist_text = _render_template(FRPC_PLIST_TEMPLATE.read_text(encoding="utf-8")) - newsyslog = NEWSYSLOG.read_text(encoding="utf-8") + rotate = ROTATE_SCRIPT.read_text(encoding="utf-8") for suffix in ("frpc.out.log", "frpc.err.log"): assert f"logs/{suffix}" in plist_text, f"plist 未把日志写到 logs/{suffix}" - assert f"logs/{suffix}" in newsyslog, f"newsyslog 缺少 logs/{suffix} 的轮转规则" + assert suffix in rotate, f"rotate-logs.sh 缺少 logs/{suffix} 的轮转" def test_frpc_run_resolves_binary_and_execs(): @@ -876,3 +1060,146 @@ def test_install_frpc_renders_placeholders_and_installs_agent(): assert 'TOKEN_FILE="$ROOT/secrets/frpc-token"' in text assert "umask 077" in text, "token 文件必须按 600 落盘" assert INSTALL_FRPC.stat().st_mode & 0o111, "安装脚本必须可执行" + + +# ------------------------------------------------- 公网部署(Docker + Caddy 自动 HTTPS) +# +# 有公网 IP 的 VPS 用覆盖文件在基础 compose 上叠一个 Caddy 入口:Caddy 终止 TLS、 +# 应用端口只绑回环、证书长期落具名卷。这里锁死的是「改了就出事且 CI 不一定报」 +# 的不变量:回环绑定(不给明文旁路)、ALLOWED_HOSTS 补回环(否则 healthcheck +# 永远 400 → 应用永远 unhealthy → Caddy 永远不启动)、XFF 覆盖式写入(否则 +# 客户端可伪造来源 IP 绕过 Key 的 allowed_ips)、default_sni(否则 IP 字面量 +# 客户端握手直接失败)。 + +PUBLIC_COMPOSE = ROOT / "docker-compose.public.yml" +CADDY_DIR = ROOT / "deploy" / "caddy" +CADDYFILE = CADDY_DIR / "Caddyfile" +CADDYFILE_ACME = CADDY_DIR / "tls_acme.caddyfile" +CADDYFILE_INTERNAL = CADDY_DIR / "tls_internal.caddyfile" + + +def test_public_compose_needs_compose_tls_override_tag(): + """`!override` 需要 Compose ≥ 2.24;老版本读不懂会直接报错。""" + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert "ports: !override" in text, "替换基础 compose 的端口映射必须用 !override" + assert "2.24" in text, "依赖的 Compose 最低版本要写在文件头注释里" + + +def test_public_compose_binds_app_port_to_loopback_only(): + """应用端口只绑 127.0.0.1:绑 0.0.0.0 会留一条明文 http 旁路。 + + TRUST_PROXY=true 后直连还能自带伪造的 X-Forwarded-For 绕过 Key 的 + allowed_ips,所以这条旁路必须彻底关掉。 + """ + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert '"127.0.0.1:${PORT:-8000}:${PORT:-8000}"' in text + bindings = re.findall(r'^\s+-\s+"?([^"\n]*\$\{PORT[^"\n]*)"?$', text, re.MULTILINE) + assert bindings, "覆盖文件里应有应用端口映射" + assert not [b for b in bindings if b.startswith("0.0.0.0:")], \ + f"应用端口不得绑 0.0.0.0(明文旁路): {bindings}" + + +def test_public_compose_trusts_proxy_and_pins_allowed_hosts(): + """HOST/PORT 重心、ALLOWED_HOSTS 必须显式补域名与回环。 + + 非空即「完全替换」语义:漏掉域名 → 全站 400 invalid host header; + 漏掉 127.0.0.1 → 容器 healthcheck 判 400 → 永远 unhealthy, + 而 Caddy `depends_on: service_healthy` 也就永远不启动。 + """ + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert 'TRUST_PROXY: "true"' in text + assert "HOST: 0.0.0.0" in text + allowed = re.search(r"^\s+ALLOWED_HOSTS: (.+)$", text, re.MULTILINE) + assert allowed, "覆盖文件必须显式覆盖 ALLOWED_HOSTS" + value = allowed.group(1) + assert "${PUBLIC_DOMAIN:?" in value and "127.0.0.1" in value, value + + +def test_public_compose_does_not_require_acme_email_for_self_signed_mode(): + """ACME_EMAIL 只在域名模式必填:自签模式(公网 IP)用不到它。 + + 要求写成 `${ACME_EMAIL:-}`(可空)——写成 `${ACME_EMAIL:?}` 会让「没有域名」 + 的部署也必须编一个邮箱,而 tls_internal 片段根本不引用它。 + """ + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert "${ACME_EMAIL:-}" in text + assert "${ACME_EMAIL:?" not in text, "自签模式不该被 ACME_EMAIL 卡住" + assert "TLS_MODE: ${TLS_MODE:-acme}" in text, "TLS_MODE 默认必须是 acme" + + +def test_public_compose_requires_public_domain(): + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert "${PUBLIC_DOMAIN:?" in text, "缺 PUBLIC_DOMAIN 必须直接失败,不能静默用默认值" + + +def test_public_compose_mounts_whole_caddy_dir_and_cert_volumes(): + """整个 deploy/caddy 必须挂进去(站点块要按 TLS_MODE import 片段)。""" + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert "./deploy/caddy:/etc/caddy:ro" in text + assert "caddy_data:/data" in text and "caddy_config:/config" in text + # 证书落具名卷才不随重建重新签发(撞 Let's Encrypt 每周 5 次限额) + assert re.search(r"^volumes:\s*$", text, re.MULTILINE) + + +def test_public_compose_waits_for_healthy_app_and_rotates_caddy_logs(): + text = PUBLIC_COMPOSE.read_text(encoding="utf-8") + assert re.search(r"coding2api:\s*\n\s+condition: service_healthy", text), \ + "Caddy 必须等应用 healthy,否则预热期持续 502" + assert 'max-size: "10m"' in text, "Caddy 也要有 json-file 日志上限(默认无上限)" + assert 'max-file: "5"' in text + + +def test_caddyfile_hides_metrics_and_streams_sse(): + text = CADDYFILE.read_text(encoding="utf-8") + assert "@metrics path /metrics" in text and "respond @metrics 404" in text + assert "flush_interval -1" in text, "不关响应缓冲会让流式首字等到缓冲区满" + assert "encode zstd gzip" in text + + +def test_caddyfile_overwrites_forwarded_for_and_proto(): + """客户端可自带伪造的 X-Forwarded-For:必须覆盖式写入单值。""" + text = CADDYFILE.read_text(encoding="utf-8") + assert "header_up X-Forwarded-For {http.request.remote.host}" in text + assert "header_up X-Forwarded-Proto {scheme}" in text + + +def test_caddyfile_sets_default_sni_for_ip_literal_clients(): + """浏览器 / curl 对 IP 字面量不发 SNI(RFC 6066 只允许主机名)。 + + 自签模式(站点是公网 IP)没有 default_sni 就选不出证书,客户端只看到 + `tlsv1 alert internal error` ——握手直接失败,不是「证书不受信任」。 + """ + text = CADDYFILE.read_text(encoding="utf-8") + assert "default_sni {$PUBLIC_DOMAIN}" in text + + +def test_caddyfile_selects_tls_snippet_by_mode(): + text = CADDYFILE.read_text(encoding="utf-8") + assert "import /etc/caddy/tls_{$TLS_MODE}.caddyfile" in text + + +def test_caddy_tls_snippets_cover_both_modes(): + """acme 走 Let's Encrypt、internal 自签;两个片段都要在,且互不越界。""" + acme = CADDYFILE_ACME.read_text(encoding="utf-8") + internal = CADDYFILE_INTERNAL.read_text(encoding="utf-8") + assert "issuer acme" in acme and "email {$ACME_EMAIL}" in acme + assert "tls internal" in internal + # 片段只该管 TLS,别把站点级指令也塞进来(两处配置必须同源) + assert "reverse_proxy" not in acme + internal + # internal 片段不得引用 ACME_EMAIL:自签模式没配它,引用就会解析失败 + assert "{$ACME_EMAIL}" not in internal + + +def test_env_example_documents_public_deploy_knobs(): + text = (ROOT / ".env.example").read_text(encoding="utf-8") + for name in ("PUBLIC_DOMAIN=", "ACME_EMAIL=", "TLS_MODE="): + assert name in text, f".env.example 缺少 {name}" + + +def test_ci_validates_public_deploy_in_both_tls_modes(): + """CI 必须把两种 TLS 模式都 validate 一遍,否则改了片段 CI 也不会报。""" + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + assert "for mode in acme internal" in workflow + assert 'TLS_MODE="$mode"' in workflow + assert "-v \"$PWD/deploy/caddy:/etc/caddy:ro\"" in workflow + assert "TLS_MODE=internal" in workflow, "compose 覆盖文件也要按自签模式拼一遍" diff --git a/tests/engine/_fallback_support.py b/tests/engine/_fallback_support.py index ab93a245..99938afa 100644 --- a/tests/engine/_fallback_support.py +++ b/tests/engine/_fallback_support.py @@ -14,7 +14,9 @@ __all__ = [ "GOOD", "INVALID", + "HttpInvalid", "ModelAwareProvider", + "RaisingProvider", "ScriptedProvider", "add_credential", "aliases", @@ -25,6 +27,34 @@ error_message="service info not found", error_kind=ErrKind.INVALID)] +class HttpInvalid(Exception): + """HTTP 级请求无效(如 400 模型不存在)。 + + 真实 provider 的 `UpstreamHTTPError` 带 `kind()`:同样是 INVALID,但走 + executor 的 `except` 分支而不是流内事件分支,两条路径的收尾逻辑不同。 + """ + + def kind(self) -> ErrKind: + return ErrKind.INVALID + + +class RaisingProvider: + """首次取流就抛出指定异常(模拟 HTTP 非 2xx 在建立流时失败)。""" + + def __init__(self, provider_id: str, error: Exception) -> None: + self.id = provider_id + self.error = error + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + raise self.error + yield # 让本方法成为异步生成器:异常在首次 anext 时抛出 + + def list_models(self, _credential_data): + return [] + + class ScriptedProvider: """每次调用按顺序回放 `script` 里的事件列表(越界后重复最后一个)。""" diff --git a/tests/engine/test_affinity.py b/tests/engine/test_affinity.py index 504f93ad..38c67b56 100644 --- a/tests/engine/test_affinity.py +++ b/tests/engine/test_affinity.py @@ -103,9 +103,11 @@ def test_eviction_drops_expired_first_then_oldest(): ("metadata", "conversation_id"), ("metadata", "conversationId"), ("metadata", "prompt_cache_key"), + ("metadata", "session_id"), ("top", "conversation_id"), ("top", "conversationId"), ("top", "prompt_cache_key"), + ("top", "session_id"), ]) def test_explicit_key_sources(container, key): from src.engine.affinity import explicit_key @@ -120,6 +122,9 @@ def test_explicit_key_priority_and_container_order(): # conversation_id 优先于 conversationId / prompt_cache_key assert explicit_key({"conversation_id": "a", "conversationId": "b", "prompt_cache_key": "c"}) == "conversation_id:a" + # session_id 是最低优先级:前面任一键存在时不看它 + assert explicit_key({"prompt_cache_key": "p", "session_id": "s"}) == "prompt_cache_key:p" + assert explicit_key({"session_id": "s"}) == "session_id:s" # metadata 优先于顶层(同键名) assert explicit_key({"metadata": {"conversation_id": "m"}, "conversation_id": "t"}) == "conversation_id:m" diff --git a/tests/engine/test_cooldown_wait.py b/tests/engine/test_cooldown_wait.py index 6213e210..98416c7e 100644 --- a/tests/engine/test_cooldown_wait.py +++ b/tests/engine/test_cooldown_wait.py @@ -113,12 +113,12 @@ def _unit_executor(credentials, **deps) -> Executor: async def test_no_wait_when_limit_disabled(repo, clock): - """上限 0(默认)= 关闭:直接返回 False,一秒都不等。""" + """上限 0 = 关闭:直接返回 None,一秒都不等。""" credentials, db = repo cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) _cool(credentials, cid, 3) - executor = _unit_executor(credentials) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + executor = _unit_executor(credentials, short_cooldown_wait_seconds=0) + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -129,7 +129,7 @@ async def test_no_wait_when_no_registered_provider(repo, clock): executor = Executor(ExecutorDeps(providers={}, credentials=credentials, scheduler=Scheduler(), default_model="glm-5.2", short_cooldown_wait_seconds=10)) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -142,7 +142,7 @@ async def test_waits_for_shortest_cooldown(repo, clock): _cool(credentials, slow, 8) _cool(credentials, fast, 3) executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, set()) is True + assert await executor._wait_short_cooldown(_TARGET) == fast assert clock.sleeps == [3] db.close() @@ -155,7 +155,7 @@ async def test_shorter_first_then_longer_keeps_the_minimum(repo, clock): _cool(credentials, fast, 2) _cool(credentials, slow, 9) executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, set()) is True + assert await executor._wait_short_cooldown(_TARGET) == fast assert clock.sleeps == [2] db.close() @@ -166,7 +166,7 @@ async def test_waits_only_when_remaining_within_limit(repo, clock): cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) _cool(credentials, cid, 30) executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -179,9 +179,9 @@ async def test_wait_limit_is_read_per_call(repo, clock): limit = {"value": 0} executor = _unit_executor(credentials, short_cooldown_wait_seconds=lambda: limit["value"]) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + assert await executor._wait_short_cooldown(_TARGET) is None limit["value"] = 5 - assert await executor._wait_short_cooldown(_TARGET, set()) is True + assert await executor._wait_short_cooldown(_TARGET) is not None assert clock.sleeps == [5] db.close() @@ -192,7 +192,7 @@ async def test_model_level_cooldown_counts_toward_wait(repo, clock): cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) _cool_model(credentials, cid, "glm-5.2", 4) executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, set()) is True + assert await executor._wait_short_cooldown(_TARGET) is not None assert clock.sleeps == [4] db.close() @@ -204,18 +204,36 @@ async def test_account_and_model_cooldown_take_the_later_one(repo, clock): _cool(credentials, cid, 2) _cool_model(credentials, cid, "glm-5.2", 6) executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, set()) is True + assert await executor._wait_short_cooldown(_TARGET) is not None assert clock.sleeps == [6] db.close() -async def test_tried_credentials_are_ignored(repo, clock): - """本轮已经试过的凭证不再算进等待(它刚失败,等它也续不上)。""" +async def test_tried_credential_in_cooldown_still_counts(repo, clock): + """本轮轮换试过、但已进短冷却的号**仍然参与等待**(B1)。 + + 旧口径按 `tried` 排除,导致「一轮试完、全池都在软冷却」这个最常见的 + 耗尽场景直接 503,等待机制永远触发不到。 + """ credentials, db = repo cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) _cool(credentials, cid, 3) executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, {cid}) is False + assert await executor._wait_short_cooldown(_TARGET) == cid + assert clock.sleeps == [3] + db.close() + + +async def test_tried_dead_credential_with_long_cooldown_fails_fast(repo, clock): + """DEAD / INVALID 这类长冷却仍在 `tried` 里:剩余时间远超上限 → 不等。 + + 不需要按错误类型特判——`remaining > limit` 自然挡掉。 + """ + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _cool(credentials, cid, 3600) + executor = _unit_executor(credentials, short_cooldown_wait_seconds=60) + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -231,7 +249,7 @@ async def test_disabled_credentials_are_ignored(repo, clock): _cool(credentials, hard, 3) _cool(credentials, soft, 3) executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -248,7 +266,7 @@ def blocked(self, candidate, model): executor = _unit_executor(credentials, short_cooldown_wait_seconds=10, limits=AllBlocked()) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -262,7 +280,7 @@ async def test_inflight_blocked_credentials_are_ignored(repo, clock): guard.acquire(cid) executor = _unit_executor(credentials, short_cooldown_wait_seconds=10, inflight=guard) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -273,7 +291,7 @@ async def test_expired_cooldown_is_not_waited_for(repo, clock): cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) _cool(credentials, cid, -5) # 已过期 executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -282,7 +300,7 @@ async def test_no_cooldown_at_all_is_not_waited_for(repo, clock): """候选既无冷却也未被试过,但池子为空(无凭证):没有「等到就好」的目标。""" credentials, db = repo executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) - assert await executor._wait_short_cooldown(_TARGET, set()) is False + assert await executor._wait_short_cooldown(_TARGET) is None assert clock.sleeps == [] db.close() @@ -333,13 +351,13 @@ async def test_long_cooldown_still_fails_fast(repo, clock): db.close() -async def test_disabled_wait_keeps_old_behaviour(repo, clock): - """上限 0(默认)→ 与历史版本一致:全池短冷却时立刻 503。""" +async def test_disabled_wait_keeps_fast_failure(repo, clock): + """显式 0 → 关闭:全池短冷却时立刻 503(不睡、不排队)。""" credentials, db = repo cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) _cool(credentials, cid, 3) provider = _Provider() - executor = _executor(credentials, provider) + executor = _executor(credentials, provider, short_cooldown_wait_seconds=0) with pytest.raises(NoHealthyCredential): await executor.complete(_request()) assert provider.calls == 0 @@ -466,3 +484,32 @@ async def test_inflight_tracker_counts_released_after_wait(repo, clock): await executor.complete(_request()) assert tracker.count(cid) == 0 db.close() + + +async def test_pick_reuses_tried_credential_after_wait(repo, clock): + """`_pick` 等到号后必须先从 `tried` 解禁,否则重取仍选不到它(B1 核心)。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _cool(credentials, cid, 3) + executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) + tried = {cid} + picked = await executor._pick(_TARGET, tried) + assert picked is not None and picked[0] == cid + assert clock.sleeps == [3] + db.close() + + +async def test_pick_waits_only_once_when_tried(repo, clock): + """`tried` 号等完仍不可用:只等一次就返回 None(不二次排队)。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _cool(credentials, cid, 3) + executor = _unit_executor(credentials, short_cooldown_wait_seconds=10) + + def _freeze() -> None: + clock.now -= clock.sleeps[-1] + + clock.on_sleep = _freeze + assert await executor._pick(_TARGET, {cid}) is None + assert clock.sleeps == [3] + db.close() diff --git a/tests/engine/test_credential_proxy_context.py b/tests/engine/test_credential_proxy_context.py new file mode 100644 index 00000000..e82ad98e --- /dev/null +++ b/tests/engine/test_credential_proxy_context.py @@ -0,0 +1,95 @@ +"""凭证上下文 → 出口代理:engine 设置、provider 读取、轮换不串号。""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import httpx +import pytest + +from src.engine.executor import Executor, ExecutorDeps +from src.engine.scheduler import Scheduler +from src.provider.base import Event, EventKind +from src.provider.lazy_clients import LazyClients +from src.provider.proxy import CredentialProxyPool, set_proxy_pool +from src.request_context import get_credential_id + + +class _RecordingProvider: + """记录每次 stream_chat 迭代时上下文里的凭证 id。""" + + id = "stub" + + def __init__(self) -> None: + self.seen: list[str | None] = [] + + async def stream_chat(self, credential_data, payload, model) -> AsyncIterator[Event]: + self.seen.append(get_credential_id()) + yield Event(kind=EventKind.CONTENT, content="ok") + + +def _executor(provider) -> Executor: + return Executor(ExecutorDeps(providers={provider.id: provider}, + credentials=object(), scheduler=Scheduler(), + default_model="m")) + + +async def _drain(source) -> None: + async for _ in source: + pass + + +async def test_stream_source_scopes_credential_id(): + provider = _RecordingProvider() + executor = _executor(provider) + await _drain(executor._stream_source("stub", {}, {}, "m", "cred_1")) + assert provider.seen == ["cred_1"] + + +async def test_credential_switch_does_not_leak_previous_id(): + """换号重试:每次调用都重设上下文,不会沿用上一个凭证的出口。""" + provider = _RecordingProvider() + executor = _executor(provider) + await _drain(executor._stream_source("stub", {}, {}, "m", "cred_1")) + await _drain(executor._stream_source("stub", {}, {}, "m", "cred_2")) + assert provider.seen == ["cred_1", "cred_2"] + + +async def test_missing_credential_id_clears_context(): + provider = _RecordingProvider() + executor = _executor(provider) + await _drain(executor._stream_source("stub", {}, {}, "m", None)) + assert provider.seen == [None] + + +def test_provider_pool_follows_context_credential(monkeypatch): + """端到端:同一凭证 → 同一出口池;不同凭证可命中不同出口。""" + set_proxy_pool(CredentialProxyPool(("http://a:1", "http://b:2"))) + used: list[str | None] = [] + + def fake(**kwargs): + used.append(kwargs.get("proxy")) + return httpx.AsyncClient() + + monkeypatch.setattr("src.provider.lazy_clients.build_client", fake) + + class _Client(LazyClients): + def __init__(self) -> None: + self._stream_client = None + self._short_client = None + self._proxy = None + + client = _Client() + from src.request_context import credential_scope + + with credential_scope("cred_a"): + first = client._stream + assert client._stream is first + assert used and used[0] in ("http://a:1", "http://b:2") + set_proxy_pool(CredentialProxyPool()) + + +@pytest.fixture(autouse=True) +def _reset_pool(): + yield + set_proxy_pool(CredentialProxyPool()) diff --git a/tests/engine/test_effort_downgrade.py b/tests/engine/test_effort_downgrade.py new file mode 100644 index 00000000..f87a6987 --- /dev/null +++ b/tests/engine/test_effort_downgrade.py @@ -0,0 +1,245 @@ +"""推理档位自适应降级(`reasoning_effort` 被上游拒收时原号降一档重试)。 + +覆盖两条入口(流式 / 非流式)× 两种错误形态(HTTP 异常 / 流内 error 事件), +以及不该降的场景:已降过一次、客户端没给档位、错误与档位无关、已是最低档。 +""" + +from __future__ import annotations + +import pytest + +from src.compat.openai.request import parse_chat_request +from src.engine.executor import Executor, ExecutorDeps +from src.engine.reasoning import downgrade_effort, is_effort_rejection +from src.engine.scheduler import Scheduler +from src.provider.base import ErrKind, Event, EventKind, Usage + + +class _EffortRejected(Exception): + """HTTP 400:上游不认这个推理档位。""" + + def kind(self): + return ErrKind.INVALID + + +def _request(effort: str | None = "high", *, stream: bool = True): + payload: dict = {"messages": [{"role": "user", "content": "hi"}], + "stream": stream} + if effort is not None: + payload["reasoning_effort"] = effort + return parse_chat_request(payload) + + +class _Provider: + """按脚本逐次响应,并记录每次出站载荷里的档位。""" + + id = "codebuddy" + + def __init__(self, script) -> None: + self.script = script + self.calls = 0 + self.efforts: list[str | None] = [] + + async def stream_chat(self, _data, payload, _model): + self.efforts.append(payload.get("reasoning_effort")) + index = min(self.calls, len(self.script) - 1) + self.calls += 1 + for item in self.script[index]: + if isinstance(item, Exception): + raise item + yield item + + def list_models(self, _data): # pragma: no cover - 本用例不调用 + return [] + + +class _Collector: + def __init__(self) -> None: + self.events: list[dict] = [] + + def record(self, **fields) -> None: + self.events.append(fields) + + +def _executor(repo, provider: _Provider, collector: _Collector) -> Executor: + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + return Executor(ExecutorDeps( + providers={"codebuddy": provider}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", stats=collector)) + + +_GOOD = [Event(kind=EventKind.CONTENT, content="ok"), + Event(kind=EventKind.USAGE, usage=Usage(1, 2, 0)), + Event(kind=EventKind.FINISH, finish_reason="stop")] + +_REJECT = [_EffortRejected("400 unsupported reasoning_effort: high")] + +_STREAM_REJECT = [Event(kind=EventKind.ERROR, error_kind=ErrKind.INVALID, + error_message="invalid effort level high")] + + +# ------------------------------------------------------------------ 纯函数 + +def test_downgrade_effort_walks_one_step_down(): + assert downgrade_effort("max") == "xhigh" + assert downgrade_effort("xhigh") == "high" + assert downgrade_effort("high") == "medium" + assert downgrade_effort("medium") == "low" + assert downgrade_effort("low") == "minimal" + assert downgrade_effort(" HIGH ") == "medium" + # 已是链底 / 未知档位:不猜,直接放弃重试 + assert downgrade_effort("minimal") is None + assert downgrade_effort("turbo") is None + + +def test_downgrade_effort_aliases_codex_ultra_to_max(): + """`ultra` 是 Codex 对最高档的另一种叫法(ReasoningEffort::Ultra)。 + + 不映射的话「ultra 被上游拒收」会直接放弃重试,用户只看到 400。 + `persistent` 在 Codex 里序列化成线上值 `disabled`,不是更高档,仍不映射。 + """ + assert downgrade_effort("ultra") == "xhigh" + assert downgrade_effort(" ULTRA ") == "xhigh" + assert downgrade_effort("persistent") is None + + +def test_is_effort_rejection_matches_only_explicit_markers(): + assert is_effort_rejection("unsupported reasoning_effort: xhigh") + assert is_effort_rejection("Invalid effort level: high") + assert is_effort_rejection("thinking budget_tokens too large") + assert is_effort_rejection("不支持的推理档位") + # 普通参数错误不降档(否则白打一次上游) + assert not is_effort_rejection("model not found") + assert not is_effort_rejection("") + assert not is_effort_rejection(None) + + +# ---------------------------------------------------------------- 流式:异常 + +async def test_stream_http_effort_rejection_retries_lower_on_same_credential(repo): + provider = _Provider([_REJECT, _GOOD]) + collector = _Collector() + executor = _executor(repo, provider, collector) + + frames = [f async for f in executor.stream(_request("high"), username="alice")] + + # 原号重试:两次都打到同一个 provider,档位从 high 降到 medium + assert provider.efforts == ["high", "medium"] + assert b"[DONE]" in b"".join(frames) + assert collector.events[-1]["ok"] is True + assert collector.events[-1].get("error_type") is None + + +async def test_stream_effort_rejection_at_lowest_effort_is_not_retried(repo): + provider = _Provider([[_EffortRejected("bad effort")], _GOOD]) + collector = _Collector() + executor = _executor(repo, provider, collector) + + frames = [f async for f in executor.stream(_request("minimal"), username="alice")] + + # 没有更低档位可降 → 走既有 INVALID 路径(一次尝试后 400 收尾) + assert provider.efforts == ["minimal"] + assert b"invalid_request" in b"".join(frames) + + +async def test_stream_effort_rejection_retries_only_once(repo): + provider = _Provider([[_EffortRejected("unsupported reasoning_effort")]] * 2) + collector = _Collector() + executor = _executor(repo, provider, collector) + + frames = [f async for f in executor.stream(_request("high"), username="alice")] + + # 只降一档:第二次仍被拒时不再循环(high → medium → 结束) + assert provider.efforts == ["high", "medium"] + assert b"invalid_request" in b"".join(frames) + + +async def test_stream_invalid_without_effort_is_not_retried(repo): + provider = _Provider([[_EffortRejected("unsupported reasoning_effort")], _GOOD]) + collector = _Collector() + executor = _executor(repo, provider, collector) + + await anext(executor.stream(_request(None), username="alice")) + + # 客户端没给档位:没有可降的东西,不重试 + assert provider.efforts == [None] + + +async def test_stream_invalid_non_effort_message_is_not_retried(repo): + provider = _Provider([[_EffortRejected("model not found")], _GOOD]) + collector = _Collector() + executor = _executor(repo, provider, collector) + + frames = [f async for f in executor.stream(_request("high"), username="alice")] + + assert provider.efforts == ["high"] + assert b"invalid_request" in b"".join(frames) + + +# ------------------------------------------------------------ 流式:error 事件 + +async def test_stream_event_effort_rejection_retries_lower(repo): + provider = _Provider([_STREAM_REJECT, _GOOD]) + collector = _Collector() + executor = _executor(repo, provider, collector) + + frames = [f async for f in executor.stream(_request("medium"), username="alice")] + + assert provider.efforts == ["medium", "low"] + assert b"[DONE]" in b"".join(frames) + assert collector.events[-1]["ok"] is True + + +# -------------------------------------------------------------------- 非流式 + +async def test_complete_http_effort_rejection_retries_lower(repo): + provider = _Provider([_REJECT, _GOOD]) + collector = _Collector() + executor = _executor(repo, provider, collector) + + result = await executor.complete(_request("high", stream=False), username="alice") + + assert provider.efforts == ["high", "medium"] + assert result["choices"][0]["message"]["content"] == "ok" + assert collector.events[-1]["ok"] is True + + +async def test_complete_event_effort_rejection_retries_lower(repo): + provider = _Provider([_STREAM_REJECT, _GOOD]) + collector = _Collector() + executor = _executor(repo, provider, collector) + + result = await executor.complete(_request("high", stream=False), username="alice") + + assert provider.efforts == ["high", "medium"] + assert result["choices"][0]["message"]["content"] == "ok" + + +async def test_complete_effort_rejection_retries_only_once(repo): + provider = _Provider([_REJECT] * 2) + collector = _Collector() + executor = _executor(repo, provider, collector) + + from src.compat.openai.request import InvalidRequest + + with pytest.raises(InvalidRequest): + await executor.complete(_request("high", stream=False), username="alice") + + assert provider.efforts == ["high", "medium"] + + +# ------------------------------------------------------------ 私有口径直测 + +def test_clamp_effort_ignores_missing_or_equal_effort(repo): + collector = _Collector() + provider = _Provider([_GOOD]) + executor = _executor(repo, provider, collector) + payload = {"reasoning_effort": "high"} + + assert executor._clamp_effort(payload, None) is payload + assert executor._clamp_effort({"messages": []}, "low") == {"messages": []} + assert executor._clamp_effort(payload, "high") is payload + assert executor._clamp_effort(payload, "medium")["reasoning_effort"] == "medium" + # 不改入参 + assert payload["reasoning_effort"] == "high" diff --git a/tests/engine/test_executor_stats.py b/tests/engine/test_executor_stats.py index 2dfe3ca1..0d2db5fb 100644 --- a/tests/engine/test_executor_stats.py +++ b/tests/engine/test_executor_stats.py @@ -37,6 +37,9 @@ class Streaming: def __init__(self) -> None: self.calls = 0 + # DEAD 现场复探的注入点:默认不提供 `probe_quota`(= 无探测能力), + # 需要的用例通过 `probe` 参数挂一个假实现。 + self.probe = kw.pop("probe", None) async def stream_chat(self, _data, _payload, _model): index = min(self.calls, len(script) - 1) @@ -49,8 +52,11 @@ async def stream_chat(self, _data, _payload, _model): def list_models(self, _data): # pragma: no cover return [] + streaming = Streaming() + if streaming.probe is not None: + streaming.probe_quota = streaming.probe executor = Executor(ExecutorDeps( - providers={"codebuddy": Streaming()}, credentials=credentials, + providers={"codebuddy": streaming}, credentials=credentials, scheduler=Scheduler(**kw), default_model="glm-5.2", stats=collector)) return executor @@ -75,6 +81,38 @@ async def test_stream_records_success_with_usage_and_username(repo): assert event["latency_ms"] is not None assert event["ttfb_ms"] is not None +async def test_stream_without_finish_frame_records_truncated_stream(repo): + """上游正常关闭但没走到终态:不冷却凭证,但按 truncated_stream 留痕。 + + 口径(2026-10-28):连接正常结束 ≠ 响应完整。凭证照常 save_success + (上游没报错,冷却只会误伤健康号),统计里记 ok=False + truncated_stream, + 客户端仍收到收尾帧。 + """ + credentials, _db = repo + credential_id = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + executor = _exec_with_stats( + repo, [[Event(kind=EventKind.CONTENT, content="hi"), + Event(kind=EventKind.USAGE, usage=Usage(11, 22, 3))]], collector) + + from src.compat.openai.request import parse_chat_request + + frames = [frame async for frame in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}), + username="alice")] + + event = collector.events[-1] + assert event["ok"] is False and event["error_type"] == "truncated_stream" + # tokens 如实记录(已收到的用量仍要计费) + assert event["input_tokens"] == 11 and event["output_tokens"] == 22 + # 凭证健康不受影响:不计错、不冷却 + candidate = next(c for c in credentials.candidates() + if c.credential_id == credential_id) + assert candidate.err_count == 0 and candidate.cooling_until is None + # 客户端仍收到 [DONE] 收尾帧 + assert b"[DONE]" in b"".join(frames) + async def test_stream_without_usage_frame_records_null_tokens(repo): """上游没给 usage 时统计字段为 None,不能崩也不能写 0。""" credentials, _db = repo @@ -177,6 +215,39 @@ async def test_stream_records_failure_when_credentials_exhausted(repo): assert event["ok"] is False and event["error_type"] == "no_healthy_credential" assert event["username"] == "bob" +async def test_stream_skips_undecryptable_credential_and_uses_another(repo): + """一条凭证解不开 → 跳过它换下一个号,**不让整个请求 500**。 + + 实测 2026-10-26 14:48:解密失败(APP_SECRET 换过 / 密文损坏)早先直接 + 冒泡 → 整条聊天请求回 500,栈顶「凭证解密失败:APP_SECRET 是否被更换 + 过?」,其余好凭证白白连坐。 + """ + from src.compat.openai.request import parse_chat_request + from src.db.crypto import CredentialCipher + + credentials, db = repo + broken = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + good = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t2"}) + # 换密钥加密 → 当前 APP_SECRET 解不开 + other = CredentialCipher("a-completely-different-secret") + with db.transaction() as conn: + conn.execute("UPDATE credentials SET data_enc = ? WHERE id = ?", + (other.encrypt(b'{"bearer_token": "t"}'), broken)) + # **pin 住坏的那条**:选号必须先试它,否则「跳过解不开的凭证」这条分支 + # 根本不会执行——而选号顺序取决于健康度 / 在途数 / 到期额度等一堆 + # 动态因素,测试不能靠它们碰巧(CI 上曾因此漏测)。 + assert credentials.set_pinned(broken) is True + + collector = RecordingCollector() + executor = _exec_with_stats(repo, [GOOD_EVENTS], collector) + chunks = [chunk async for chunk in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}))] + assert chunks + event = collector.events[-1] + assert event["ok"] is True + # 实际用的是那个好凭证(解不开的被跳过) + assert event["credential_id"] == good + def _model_cool_one(credentials, model): """给唯一凭证写一条未过期的模型级冷却(耗尽轮换的前置条件)。""" from src.engine.scheduler import ErrorOutcome, ModelCooldown @@ -225,6 +296,58 @@ async def test_complete_all_model_cooled_records_model_unavailable(repo): {"messages": [{"role": "user", "content": "hi"}]})) assert collector.events[-1]["error_type"] == "model_unavailable" +async def test_stream_exhaustion_without_any_pick_records_candidate_provider(repo): + """一次都没选中凭证(候选全在冷却 / 池子为空)时,明细要落**候选渠道**。 + + `_pick` 首轮返回 None 时渠道名还停在占位 `"-"`:归因面板只剩一个横杠, + 看不出是哪个渠道挂的(实测 2026-10-29 10:18 `deepseek-v4.1-flash-sg` + 落成 `provider="-"`,而它只在 codebuddy_intl 上有)。 + """ + credentials, _db = repo + collector = RecordingCollector() + executor = _exec_with_stats(repo, [GOOD_EVENTS], collector) + + from src.compat.openai.request import parse_chat_request + + async for _ in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True})): + pass + event = collector.events[-1] + assert event["ok"] is False and event["provider"] == "codebuddy" + assert event["credential_id"] is None + +async def test_complete_exhaustion_without_any_pick_records_candidate_provider(repo): + """非流式同口径:没有选中凭证时明细仍能指出是哪个渠道。""" + credentials, _db = repo + collector = RecordingCollector() + executor = _exec_with_stats(repo, [GOOD_EVENTS], collector) + + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + assert collector.events[-1]["provider"] == "codebuddy" + +async def test_exhaustion_after_a_tried_credential_keeps_actual_provider(repo): + """真打过上游时明细记**实际**渠道,不被候选渠道顶掉。""" + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Soft(Exception): + def kind(self): + return ErrKind.SOFT + + executor = _exec_with_stats(repo, [[Soft()]], collector, max_rotate=1) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + assert collector.events[-1]["provider"] == "codebuddy" + assert collector.events[-1]["credential_id"] is not None + async def test_complete_records_success_and_failure(repo): credentials, _db = repo credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) @@ -284,8 +407,9 @@ def kind(self): with pytest.raises(NoHealthyCredential): await executor.complete(parse_chat_request( {"messages": [{"role": "user", "content": "hi"}]})) - # 会话失效 → 硬禁用,轮换耗尽后归类为无可用凭证 - assert collector.events[-1]["error_type"] == "no_healthy_credential" + # 会话失效 → 硬禁用;尝试行已给出真实归因(凭证失效),轮换耗尽的收尾行 + # 不再重复落(否则一次请求两行,requests 与 Key 日额度双计) + assert collector.events[-1]["error_type"] == "credential_unavailable" assert credentials.candidates()[0].disabled is True def test_error_type_mapping_covers_all_kinds(): @@ -296,8 +420,456 @@ def test_error_type_mapping_covers_all_kinds(): assert _error_type_for(ErrKind.MODEL) == "rate_limit" assert _error_type_for(ErrKind.CONCURRENCY) == "rate_limit" assert _error_type_for(ErrKind.DEAD) == "credential_unavailable" - assert _error_type_for(ErrKind.SOFT) == "upstream_error" + assert _error_type_for(ErrKind.RESTRICTED) == "account_restricted" + # SOFT 是上游限流/软限流(429 等):记 rate_limit 而不是笼统的 upstream_error, + # 运维一眼能看出是「等额度」而不是「查渠道配置」。链路层瞬态由 `_failure_type` + # 按异常类型先判成 upstream_timeout,不会落到这里。 + assert _error_type_for(ErrKind.SOFT) == "rate_limit" assert _error_type_for(ErrKind.OTHER) == "upstream_error" + # 「上游没这个模型」独立成类:与「请求无效」分开(2026-10-26) + assert _error_type_for(ErrKind.BLOCKED) == "model_not_found" + assert _error_type_for(ErrKind.INVALID) == "invalid_request" + assert _error_type_for(ErrKind.REQUEST) == "invalid_request" + # 上下文超限 / 模型能力不匹配(2026-11-03 从 invalid_request 里独立出来) + assert _error_type_for(ErrKind.CONTEXT) == "context_overflow" + assert _error_type_for(ErrKind.CAPABILITY) == "model_capability" + # 本机 DNS/连接失败独立成类;否则归因面板会把它读成「上游超时」。 + assert _error_type_for(ErrKind.NETWORK) == "network_error" + + +def test_blocked_error_type_is_controlled(): + """新枚举必须在受控白名单里,否则 `record` 会把它置 None(统计里看不到)。""" + from src.stats.collector import CONTROLLED_ERROR_TYPES + + assert "model_not_found" in CONTROLLED_ERROR_TYPES + assert "account_restricted" in CONTROLLED_ERROR_TYPES + assert "truncated_stream" in CONTROLLED_ERROR_TYPES + assert "network_error" in CONTROLLED_ERROR_TYPES + + +def test_network_connect_error_is_classified_and_not_penalized(): + """DNS/建连失败 → NETWORK/network_error;包成 UpstreamTransportError 也认。""" + import httpx + + from src.engine.executor import _classify, _failure_type + from src.provider.base import UpstreamTransportError + + error = httpx.ConnectError("[Errno 8] nodename nor servname provided") + assert _classify(error) is ErrKind.NETWORK + assert _failure_type(ErrKind.NETWORK, error) == "network_error" + wrapped = UpstreamTransportError("connect failed") + wrapped.__cause__ = error + assert _classify(wrapped) is ErrKind.NETWORK + assert _failure_type(ErrKind.NETWORK, wrapped) == "network_error" + # ConnectTimeout 不是建连失败:可能是上游慢,沿用 upstream_timeout。 + assert _classify(httpx.ConnectTimeout("boom")) is ErrKind.SOFT + + +async def test_dead_credential_also_records_attribution_row(repo): + """DEAD 除硬禁用外必须落一条 `credential_unavailable` 明细。 + + 硬禁用只写 `credentials.disabled`,归因卡片读的是 `usage_events`——不落 + 明细就完全看不到(实测 2026-10-23 officeace 两次 401 `AppKey or + AppSecret is invalid` 在统计页零痕迹)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + kinds = [event["error_type"] for event in collector.events] + # 只落尝试级(DEAD 归因):轮换耗尽的收尾行不再重复落,否则同一请求 + # 在 requests 里算两次、成功率虚低,按 Key 计的日额度也会被扣两次。 + assert kinds == ["credential_unavailable"] + dead_row = collector.events[0] + assert dead_row["ok"] is False + assert dead_row["provider"] == "codebuddy" + assert dead_row["latency_ms"] is not None + + +async def test_stream_dead_error_does_not_duplicate_terminal_row(repo): + """流式同理:DEAD 已落尝试行,选号耗尽时不再落收尾行(一次请求一行)。""" + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector) + from src.compat.openai.request import parse_chat_request + + chunks = [chunk async for chunk in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}))] + assert chunks # 以错误帧收尾,不是静默断开 + assert [event["error_type"] for event in collector.events] == [ + "credential_unavailable"] + + +def test_dead_as_credit_switches_only_when_quota_proven_exhausted(): + """判据只有两条分支:探明耗尽 → CREDIT,其余 → DEAD(不猜)。""" + from src.engine.executor import _dead_as_credit + + assert _dead_as_credit(quota_exhausted=True) is ErrKind.CREDIT + assert _dead_as_credit(quota_exhausted=False) is ErrKind.DEAD + + +def test_quota_exhausted_is_three_state_and_tolerates_deleted_credential(repo): + """`_quota_exhausted` 三态:未探测 ≠ 耗尽;探到 0 / health=-1 才算。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + executor = _exec_with_stats(repo, [], None) + + # 没探测过:未知,不能当「用完了」的证据(否则真坏的 AppKey 会被静默成等签到) + assert executor._quota_exhausted(cid, "codebuddy") is False + credentials.save_quota(cid, Quota(remaining=100, total=4100, probed_at=1)) + assert executor._quota_exhausted(cid, "codebuddy") is False + credentials.save_quota(cid, Quota(remaining=0, total=4100, probed_at=2)) + assert executor._quota_exhausted(cid, "codebuddy") is True + # 总量缺失(上游没给分母)= `health == -1` 已耗尽 + credentials.save_quota(cid, Quota(remaining=None, total=None, probed_at=3)) + assert executor._quota_exhausted(cid, "codebuddy") is True + # 凭证被并发删除(请求途中):占位候选额度未知 → 不降级 + assert executor._quota_exhausted("cred_gone", "codebuddy") is False + + +async def test_dead_with_exhausted_quota_cools_instead_of_hard_disabling(repo): + """额度已探明耗尽的 401 按 CREDIT 处理:不硬禁用,冷却到次日 04:00。 + + 回归 2026-11-02 officeace:三条凭证 `quota_remaining = 0 / quota_total = + 4100`,上游回 `401 APIG.1009 AppKey or AppSecret is invalid`(同一条错误 + 也出现在模型列表接口上)。旧口径判 DEAD → 硬禁用 + 落 + `credential_unavailable`,面板读成「凭证不可用」→ 运维 revive 5 次 + + OAuth 重登 1 次,401 照旧(积分是 0,重登无解)。判 CREDIT 后冷却到次日 + 04:00 等签到恢复,凭证留在池子里。 + """ + from src.engine.scheduler import next_credit_reset + + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + credentials.save_quota(cid, Quota(remaining=0, total=4100, + probed_at=int(time.time()))) + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + row = credentials.candidates()[0] + assert row.disabled is False # 没被硬禁用 + assert row.cooling_until == next_credit_reset(int(time.time())) + # 收尾行按**最后一次错误的真实归因**落:改判 CREDIT(额度耗尽)→ rate_limit, + # 不再笼统 `no_healthy_credential`(见 `_exhaust_error_type`)。 + kinds = [event["error_type"] for event in collector.events] + assert kinds == ["rate_limit"] + assert "credential_unavailable" not in kinds + + +async def test_stream_dead_with_exhausted_quota_records_rate_limit(repo): + """流式同样不因「额度耗尽的 401」硬禁用凭证(与尝试行同一判据)。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + credentials.save_quota(cid, Quota(remaining=0, total=4100, + probed_at=int(time.time()))) + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector) + from src.compat.openai.request import parse_chat_request + + chunks = [chunk async for chunk in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}))] + assert chunks + kinds = [event["error_type"] for event in collector.events] + assert kinds == ["rate_limit"] + assert credentials.candidates()[0].disabled is False + + +async def test_dead_probes_quota_now_when_local_evidence_is_stale(repo): + """DEAD 且本地额度还是正数(证据陈旧)→ 现场复探一次再判。 + + 回归 2026-11-03 officeace:三条凭证 07:39/08:00/08:25 收到 + `401 APIG.1009`,401 前最后一次探额分别是 56.66 / 100 / 51.37(周期探额 + 60 分钟一轮,重负载几分钟就烧光),`_dead_as_credit` 因证据为「正数」 + 收不了口 → 硬禁用 + `credential_unavailable`,只能人工 revive。 + 现场复探得 0 → 改判 CREDIT,凭证留在池子里等次日签到。 + """ + from src.engine.scheduler import next_credit_reset + from src.request_context import get_credential_id + + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + credentials.save_quota(cid, Quota(remaining=56.66, total=4100, probed_at=1)) + seen: list[str | None] = [] + + async def probe(_data): + # 复探必须走该凭证自己的出口(一号一出口) + seen.append(get_credential_id()) + return Quota(remaining=0, total=4100, probed_at=int(time.time())) + + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector, probe=probe) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + assert seen == [cid] # 复探在凭证作用域内执行 + row = credentials.candidates()[0] + assert row.disabled is False + assert row.cooling_until == next_credit_reset(int(time.time())) + assert row.quota_remaining == 0 # 新鲜证据已写回 + assert "credential_unavailable" not in [ + event["error_type"] for event in collector.events] + + +async def test_dead_probe_keeps_dead_when_quota_still_positive(repo): + """复探仍是正数 → AppKey 真坏了,保持 DEAD(不静默成「等签到」)。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + credentials.save_quota(cid, Quota(remaining=100, total=4100, probed_at=1)) + + async def probe(_data): + return Quota(remaining=100, total=4100, probed_at=int(time.time())) + + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector, probe=probe) + from src.compat.openai.request import parse_chat_request + + chunks = [chunk async for chunk in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}))] + assert chunks + assert credentials.candidates()[0].disabled is True + assert "credential_unavailable" in [ + event["error_type"] for event in collector.events] + + +async def test_dead_probe_failure_keeps_dead_and_keeps_known_quota(repo): + """复探抛错 / 探测失败 / 无探测能力 / 凭证已删 → 一律保持 DEAD。 + + 未知不等于耗尽:宁可硬禁用一条额度不明的凭证,也不能把「AppKey 真坏了」 + 静默成「等签到」。探测失败也不写库——一次网络抖动不该把已探明的正数抹成未知。 + """ + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + credentials.save_quota(cid, Quota(remaining=100, total=4100, probed_at=1)) + + async def boom(_data): + raise RuntimeError("upstream down") + + async def failed(_data): + return Quota(probe_failed=True, probed_at=int(time.time())) + + executor = _exec_with_stats(repo, [], None, probe=boom) + assert await executor._credit_if_exhausted(ErrKind.DEAD, cid, + "codebuddy") is ErrKind.DEAD + # 探测失败:额度保持上次已知值(不被抹成未知) + executor = _exec_with_stats(repo, [], None, probe=failed) + assert await executor._credit_if_exhausted(ErrKind.DEAD, cid, + "codebuddy") is ErrKind.DEAD + assert credentials.candidates()[0].quota_remaining == 100 + # provider 没有 probe_quota(zen / kilo 之外的自定义装配) + executor = _exec_with_stats(repo, [], None) + assert await executor._credit_if_exhausted(ErrKind.DEAD, cid, + "codebuddy") is ErrKind.DEAD + # 凭证在请求途中被并发删除:取不到明文,不能凭空探一个不存在的号 + executor = _exec_with_stats(repo, [], None, probe=failed) + assert await executor._credit_if_exhausted( + ErrKind.DEAD, "cred_gone", "codebuddy") is ErrKind.DEAD + + +async def test_dead_probe_is_deduplicated_per_credential(repo): + """同号并发 401 共用一次复探:积分烧光时同一账号会同时收到多个 401。 + + 不去重就是对着同一账号并发打 N 次订阅接口——正是最容易触发上游风控的形态。 + 复探结论为 DEAD 时也必须去重(结论相同,重复探测毫无收益)。 + """ + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + credentials.save_quota(cid, Quota(remaining=100, total=4100, probed_at=1)) + calls: list[dict] = [] + + async def probe(_data): + calls.append({}) + return Quota(remaining=100, total=4100, probed_at=int(time.time())) + + executor = _exec_with_stats(repo, [], None, probe=probe) + for _ in range(3): + assert await executor._credit_if_exhausted( + ErrKind.DEAD, cid, "codebuddy") is ErrKind.DEAD + assert len(calls) == 1 # 三次 401 只探一次 + + +async def test_dead_probe_cache_expires(repo): + """去重窗口过后重新探测:额度会随签到恢复,结论不能永久缓存。""" + from src.engine import executor as executor_module + + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + credentials.save_quota(cid, Quota(remaining=100, total=4100, probed_at=1)) + calls: list[dict] = [] + + async def probe(_data): + calls.append({}) + return Quota(remaining=100, total=4100, probed_at=int(time.time())) + + executor = _exec_with_stats(repo, [], None, probe=probe) + await executor._credit_if_exhausted(ErrKind.DEAD, cid, "codebuddy") + # 把缓存时刻拨到窗口之外(等价于「上一次复探已过 TTL」) + executor._dead_quota_probes[cid] = ( + time.monotonic() - executor_module.DEAD_QUOTA_PROBE_TTL_SECONDS - 1, False) + await executor._credit_if_exhausted(ErrKind.DEAD, cid, "codebuddy") + assert len(calls) == 2 + + +async def test_credit_if_exhausted_short_circuits_non_dead_and_known_exhaustion(repo): + """非 DEAD 与「本地已探明耗尽」都不出网:复探只为补陈旧的额度证据。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + calls: list[dict] = [] + + async def probe(_data): + calls.append({}) + return Quota(remaining=0, total=4100, probed_at=1) + + executor = _exec_with_stats(repo, [], None, probe=probe) + # 非 DEAD:原样返回,连候选都不查(其它分类的冷却口径不受影响) + assert await executor._credit_if_exhausted(ErrKind.SOFT, cid, + "codebuddy") is ErrKind.SOFT + assert await executor._credit_if_exhausted(None, cid, "codebuddy") is None + assert calls == [] + # 本地已探明耗尽:直接降级,不再出网 + credentials.save_quota(cid, Quota(remaining=0, total=4100, probed_at=1)) + assert await executor._credit_if_exhausted(ErrKind.DEAD, cid, + "codebuddy") is ErrKind.CREDIT + assert calls == [] + + +async def test_stream_dead_with_stale_quota_probes_and_records_rate_limit(repo): + """流式同判据:证据陈旧时先复探,收尾行按复探结果记 rate_limit。""" + credentials, _db = repo + cid = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "t"}) + credentials.save_quota(cid, Quota(remaining=51.37, total=4100, probed_at=1)) + + async def probe(_data): + return Quota(remaining=0, total=4100, probed_at=int(time.time())) + + collector = RecordingCollector() + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + executor = _exec_with_stats(repo, [[Dead()]], collector, probe=probe) + from src.compat.openai.request import parse_chat_request + + chunks = [chunk async for chunk in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}))] + assert chunks + kinds = [event["error_type"] for event in collector.events] + assert kinds == ["rate_limit"] + assert credentials.candidates()[0].disabled is False + + +def test_stream_error_type_follows_quota_evidence_on_dead(): + """收尾行与尝试行同判据:额度耗尽的 DEAD 记 rate_limit,否则 credential_unavailable。 + + 两侧不一致会让同一次请求出现「尝试行说凭证失效、收尾行说额度耗尽」的矛盾 + (见 `_dead_as_credit`)。 + """ + from src.engine.executor import _stream_error_type + + class Dead(Exception): + def kind(self): + return ErrKind.DEAD + + assert _stream_error_type(Dead(), quota_exhausted=True) == "rate_limit" + assert _stream_error_type(Dead(), quota_exhausted=False) == "credential_unavailable" + + +async def test_soft_error_does_not_record_attribution_row(repo): + """SOFT 等其它分类不落尝试级明细:一次请求可能触发多次,落行会灌满噪声。 + + 它们的失败仍由轮换耗尽时的收尾行覆盖(见上一个用例的末尾断言)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Soft(Exception): + def kind(self): + return ErrKind.SOFT + + executor = _exec_with_stats(repo, [[Soft()]], collector) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + kinds = [event["error_type"] for event in collector.events] + assert "upstream_error" not in kinds # 尝试级不落行 + # 收尾行按最后一次错误类型落:SOFT(限流)→ rate_limit + assert kinds[-1] == "rate_limit" + + +def test_error_detail_includes_exception_class_for_empty_message(): + """日志要能分辨传输异常的种类:httpx 的读超时 / 写超时 `str()` 常为空串。 + + 实测 2026-10-23 codebuddy_intl 两次 158s/162s 的 SOFT 错误日志只剩 + 「kind=soft): 」,分不清读断、写超时还是连接重置。 + """ + from src.engine.executor import _error_detail + + class Blank(Exception): + pass + + assert _error_detail(Blank()) == "Blank" # 空消息只给类名 + assert _error_detail(ValueError("boom")) == "ValueError: boom" + assert _error_detail(None) == "-" + def test_usage_field_tolerates_missing_usage(): from src.engine.executor import _usage_field @@ -325,9 +897,164 @@ def kind(self): with pytest.raises(NoHealthyCredential): await executor.complete(parse_chat_request( {"messages": [{"role": "user", "content": "hi"}]})) - assert collector.events[-1]["error_type"] == "no_healthy_credential" + assert collector.events[-1]["error_type"] == "rate_limit" assert credentials.candidates()[0].cooling_until is not None + +async def test_blocked_error_records_model_not_found(repo): + """上游说「没这个模型」→ 统计记 `model_not_found`(不是「请求无效」)。 + + 实测 2026-10-26 officeAce `deepseek-v4.1-flash-1017` 404 + `ModelArts.81009`:归到 `invalid_request` 时归因面板显示「请求无效」, + 读起来像客户端参数写错了,实际是上游把该模型下架了。 + + 走流式路径且轮换预算用尽(`max_rotate=1`):那一行是**尝试级**归因, + 类型取自最后一次上游错误的分类(`_stream_error_type`)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Blocked(Exception): + def __init__(self) -> None: + super().__init__("model not registered") + + def kind(self): + return ErrKind.BLOCKED + + executor = _exec_with_stats(repo, [[Blocked()]], collector, max_rotate=1) + from src.compat.openai.request import parse_chat_request + + async for _ in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True})): + pass + kinds = [event["error_type"] for event in collector.events] + assert "model_not_found" in kinds + assert "invalid_request" not in kinds + # 模型级负缓存照写(只锁这一个模型,不牵连整条凭证) + assert _db.connect().execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + + +async def test_blocked_exhaustion_still_records_model_unavailable(repo): + """轮换耗尽时的收尾行仍是 `model_unavailable`(口径不变)。 + + 「上游没这个模型」与「这个模型现在全都不可用」是两件事:前者是尝试级 + 归因(`model_not_found`),后者是收尾行(`model_unavailable`)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Blocked(Exception): + def kind(self): + return ErrKind.BLOCKED + + executor = _exec_with_stats(repo, [[Blocked()]], collector) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + assert collector.events[-1]["error_type"] == "model_unavailable" + +async def test_context_overflow_exhaustion_records_context_overflow(repo): + """上下文超限(11115 / ErrKind.CONTEXT)轮换耗尽 → 归因记 context_overflow。 + + 处置同请求级错误(不冷却、只换号),但统计不能混进「请求无效」——否则 + 运维会去查参数写法,实际动作是「精简上下文 / 换长窗口模型」。收尾行取 + 最后一次错误的真实归因(`_exhaust_error_type`)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Context(Exception): + def kind(self): + return ErrKind.CONTEXT + + executor = _exec_with_stats(repo, [[Context()]], collector, max_rotate=1) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + assert collector.events[-1]["error_type"] == "context_overflow" + # 零动作:不冷却凭证 + assert credentials.candidates()[0].cooling_until is None + + +async def test_capability_rejection_skips_provider_and_records_model_capability(repo): + """模型能力不匹配(001001020 / ErrKind.CAPABILITY)→ 跳过整个上游 + 400。 + + 换号无用(同渠道每个号都回同一条),所以走 REJECT_KINDS 分支:记一条 + `model_capability` 尝试行后 `_skip_provider`,全部拒绝时抛 400。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + class Capability(Exception): + def kind(self): + return ErrKind.CAPABILITY + + executor = _exec_with_stats(repo, [[Capability()]], collector) + from src.compat.openai.request import InvalidRequest as OpenAIInvalidRequest + from src.compat.openai.request import parse_chat_request + + with pytest.raises(OpenAIInvalidRequest): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + assert [event["error_type"] for event in collector.events] == ["model_capability"] + assert credentials.candidates()[0].cooling_until is None + + +async def test_transport_timeout_exhaustion_records_upstream_timeout(repo): + """链路层瞬态(httpx.TransportError)耗尽 → upstream_timeout,不是 rate_limit。 + + SOFT 同时承载「上游限流」与「链路超时」,两者动作相反(等额度 vs 查代理): + `_failure_type` 先按异常类型兜住传输异常,不能落成「额度耗尽」。 + """ + import httpx + + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + executor = _exec_with_stats(repo, [[httpx.ConnectTimeout("boom")]], collector, + max_rotate=1) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + assert collector.events[-1]["error_type"] == "upstream_timeout" + + +async def test_dns_connect_error_records_network_error_and_does_not_cool(repo): + """DNS 解析失败(ConnectError)→ network_error,且不冷却凭证。 + + 这是本机代理/DNS 的问题,不是账号额度或上游限流。若按 SOFT 冷却,一次 + 睡眠唤醒后的解析失败会把同渠道所有健康号短冷却成「无可用凭证」。 + """ + import httpx + + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + collector = RecordingCollector() + + executor = _exec_with_stats( + repo, [[httpx.ConnectError("[Errno 8] nodename nor servname provided")]], + collector, max_rotate=1) + from src.compat.openai.request import parse_chat_request + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + assert collector.events[-1]["error_type"] == "network_error" + assert credentials.candidates()[0].cooling_until is None + + async def test_stream_plan_error_records_failure(repo): """流式路径的权益耗尽同样要落统计。""" credentials, _db = repo @@ -394,3 +1121,320 @@ async def test_complete_inline_request_error_records_real_type(repo): {"messages": [{"role": "user", "content": "hi"}]}), username="erin") assert collector.events[-1]["error_type"] == "invalid_request" assert credentials.candidates()[0].cooling_until is None + +async def test_restricted_11140_cools_account_and_skips_channel(repo): + """403/11140(账号被上游限制):2h 账号级冷却 + 跳过该渠道,不换号去撞。 + + 与 DEAD 的关键差别:**不写 disabled**——禁用是不可逆的人工状态,而这类限制 + 由上游自行解除(maiphucgiang/codebuddy2api、magpie、10router 的处理一致: + 本地无解,等恢复或换号);且同渠道其余凭证一次都不打,逐个号换着撞只会 + 在上游多留风控记录(实测一次请求连打 3 个号全 403,新号 3 分钟就被封)。 + """ + from src.compat.openai.request import parse_chat_request + from src.engine.executor import Executor, ExecutorDeps + from src.engine.scheduler import RESTRICTED_COOLDOWN_SECONDS, Scheduler + from src.provider.codebuddy import events as cb_events + from src.provider.codebuddy.client import UpstreamHTTPError + + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.add(provider="codebuddy", credential_data={"bearer_token": "u"}) + collector = RecordingCollector() + body = (b'{"code":11140,"msg":"request illegal",' + b'"displayMsg":{"en":"Your account has been restricted due to unusual activity"}}') + + class Restricted: + id = "codebuddy" + calls = 0 + + async def stream_chat(self, _data, _payload, _model): + Restricted.calls += 1 + raise UpstreamHTTPError(403, body) + yield # pragma: no cover - 抛错后的不可达分支,只为保持异步生成器形态 + + def disabled_reason(self, raw: bytes) -> str: + return cb_events.disabled_reason(raw) + + executor = Executor(ExecutorDeps( + providers={"codebuddy": Restricted()}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", stats=collector)) + + started = int(time.time()) + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + assert Restricted.calls == 1 # 同渠道其余号没有被拿去撞 + assert [event["error_type"] for event in collector.events] == ["account_restricted"] + rows = db.connect().execute( + "SELECT disabled, cooling_until FROM credentials").fetchall() + assert all(row["disabled"] == 0 for row in rows) # 不禁用,等上游解除 + cooled = [row["cooling_until"] for row in rows if row["cooling_until"] is not None] + # 第一次命中只冷却触发的那个号(渠道级暂停要窗口内第二次命中才升级) + assert len(cooled) == 1 + assert started + RESTRICTED_COOLDOWN_SECONDS - 60 <= cooled[0] \ + <= started + RESTRICTED_COOLDOWN_SECONDS + 60 + + +async def test_restricted_burst_pauses_whole_provider(repo): + """窗口内第二次命中受限 → 整渠道暂停 24h,剩余号不再被逐个拿去撞。""" + from src.compat.openai.request import parse_chat_request + from src.engine.executor import ( + RESTRICTED_PROVIDER_PAUSE_SECONDS, + Executor, + ExecutorDeps, + ) + from src.engine.scheduler import Scheduler + from src.provider.codebuddy.client import UpstreamHTTPError + + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.add(provider="codebuddy", credential_data={"bearer_token": "u"}) + + class Restricted: + id = "codebuddy" + calls = 0 + + async def stream_chat(self, _data, _payload, _model): + Restricted.calls += 1 + raise UpstreamHTTPError(403, b'{"code":11140}') + yield # pragma: no cover - 抛错后的不可达分支,只为保持异步生成器形态 + + executor = Executor(ExecutorDeps( + providers={"codebuddy": Restricted()}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", stats=RecordingCollector())) + request = {"messages": [{"role": "user", "content": "hi"}]} + + started = int(time.time()) + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request(request)) + assert Restricted.calls == 1 # 第一次:只冷却触发的号 + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request(request)) + assert Restricted.calls == 2 # 第二次命中另一个号 → 触发升级 + + rows = db.connect().execute( + "SELECT cooling_until FROM credentials").fetchall() + assert all(row["cooling_until"] is not None for row in rows) + for row in rows: + assert started + RESTRICTED_PROVIDER_PAUSE_SECONDS - 60 \ + <= row["cooling_until"] <= started + RESTRICTED_PROVIDER_PAUSE_SECONDS + 60 + + +async def test_dead_unrecognised_403_keeps_session_dead(repo): + """provider 认不出的 403(没有可操作话术)回落 "session dead",不写空串。""" + from src.compat.openai.request import parse_chat_request + from src.engine.executor import Executor, ExecutorDeps + from src.engine.scheduler import Scheduler + from src.provider.codebuddy import events as cb_events + from src.provider.codebuddy.client import UpstreamHTTPError + + credentials, db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + + class PlainDenied: + id = "codebuddy" + + async def stream_chat(self, _data, _payload, _model): + raise UpstreamHTTPError(403, b'{"code":403,"msg":"forbidden"}') + yield # pragma: no cover - 抛错后的不可达分支,只为保持异步生成器形态 + + def disabled_reason(self, raw: bytes) -> str: + return cb_events.disabled_reason(raw) + + executor = Executor(ExecutorDeps( + providers={"codebuddy": PlainDenied()}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", stats=RecordingCollector())) + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + row = db.connect().execute( + "SELECT disabled_reason FROM credentials WHERE id = ?", (cid,)).fetchone() + assert row["disabled_reason"] == "session dead" + +async def test_dead_http_error_without_provider_hint_keeps_session_dead(repo): + """provider 没实现 disabled_reason 时:即使 HTTP 有 body 也回落默认文案。""" + from src.compat.openai.request import parse_chat_request + from src.provider.codebuddy.client import UpstreamHTTPError + + credentials, db = repo + cid = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + executor = _exec_with_stats( + repo, [[UpstreamHTTPError(403, b'{"code":403,"msg":"forbidden"}')]], + RecordingCollector()) + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + row = db.connect().execute( + "SELECT disabled_reason FROM credentials WHERE id = ?", (cid,)).fetchone() + assert row["disabled_reason"] == "session dead" + +async def test_stream_inband_restricted_skips_channel(repo): + """流内 error 事件标了 RESTRICTED:同样跳过该渠道,不逐个号撞。""" + from src.compat.openai.request import parse_chat_request + from src.engine.executor import Executor, ExecutorDeps + from src.engine.scheduler import Scheduler + + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.add(provider="codebuddy", credential_data={"bearer_token": "u"}) + collector = RecordingCollector() + restricted = Event(kind=EventKind.ERROR, error_kind=ErrKind.RESTRICTED, + error_code=11140, error_message="request illegal") + + class Inband: + id = "codebuddy" + calls = 0 + + async def stream_chat(self, _data, _payload, _model): + Inband.calls += 1 + yield restricted + + executor = Executor(ExecutorDeps( + providers={"codebuddy": Inband()}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", stats=collector)) + chunks = [chunk async for chunk in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}))] + + assert chunks # 以错误帧收尾,不是静默断开 + assert Inband.calls == 1 # 同渠道第二个号没有被拿去撞 + assert [event["error_type"] for event in collector.events] == ["account_restricted"] + + +async def test_stream_http_restricted_skips_channel(repo): + """流式 HTTP 403/11140:与流内事件同一处置(跳过渠道 + account_restricted)。""" + from src.compat.openai.request import parse_chat_request + from src.engine.executor import Executor, ExecutorDeps + from src.engine.scheduler import Scheduler + from src.provider.codebuddy.client import UpstreamHTTPError + + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.add(provider="codebuddy", credential_data={"bearer_token": "u"}) + collector = RecordingCollector() + + class HttpRestricted: + id = "codebuddy" + calls = 0 + + async def stream_chat(self, _data, _payload, _model): + HttpRestricted.calls += 1 + raise UpstreamHTTPError(403, b'{"code":11140}') + yield # pragma: no cover - 抛错后的不可达分支,只为保持异步生成器形态 + + executor = Executor(ExecutorDeps( + providers={"codebuddy": HttpRestricted()}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", stats=collector)) + chunks = [chunk async for chunk in executor.stream(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}], "stream": True}))] + + assert chunks + assert HttpRestricted.calls == 1 + assert [event["error_type"] for event in collector.events] == ["account_restricted"] + + +async def test_complete_inband_restricted_skips_channel(repo): + """非流式聚合遇到流内 RESTRICTED:同样跳过渠道,不重复落收尾行。""" + from src.compat.openai.request import parse_chat_request + from src.engine.executor import Executor, ExecutorDeps + from src.engine.scheduler import Scheduler + + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.add(provider="codebuddy", credential_data={"bearer_token": "u"}) + collector = RecordingCollector() + restricted = Event(kind=EventKind.ERROR, error_kind=ErrKind.RESTRICTED, + error_code=11140, error_message="request illegal") + + class Inband: + id = "codebuddy" + calls = 0 + + async def stream_chat(self, _data, _payload, _model): + Inband.calls += 1 + yield restricted + + executor = Executor(ExecutorDeps( + providers={"codebuddy": Inband()}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", stats=collector)) + + with pytest.raises(NoHealthyCredential): + await executor.complete(parse_chat_request( + {"messages": [{"role": "user", "content": "hi"}]})) + + assert Inband.calls == 1 + # 尝试行已归因,收尾行不再重复落(否则 requests 与 Key 日额度双计) + assert [event["error_type"] for event in collector.events] == ["account_restricted"] + + +def test_restricted_summary_never_echoes_upstream_body(): + """受限日志只打受控标识:业务码 → 状态码 → 异常类名,绝不含 body。 + + 上游 body 可能回显用户内容(`body_hint` 还会截取正文),打进日志等于把 + 用户数据落到服务端日志里。 + """ + from src.engine.executor import _restricted_summary + from src.provider.codebuddy.client import UpstreamHTTPError + + class NoCode(Exception): + pass + + assert _restricted_summary(Event(kind=EventKind.ERROR, error_code=11140, + error_message="secret user text")) == "code=11140" + assert _restricted_summary(UpstreamHTTPError(403, b'{"code":11140}')) == "status=403" + assert _restricted_summary(NoCode("secret")) == "kind=NoCode" + # UpstreamStreamError 经 `.event` 取码(不是把异常描述打进日志) + from src.compat.openai.errors import UpstreamStreamError + wrapped = UpstreamStreamError(Event(kind=EventKind.ERROR, error_code=11140, + error_message="secret user text")) + assert _restricted_summary(wrapped) == "code=11140" + + +def test_restricted_burst_needs_two_distinct_credentials(repo): + """同一个凭证重复中招不升级整渠道暂停:判据是「窗口内不同凭证数」。 + + 单号 2h 冷却已经挡住重复撞,按命中次数升级等于让单一租户/单一凭证的两个 + 连续请求把整渠道停掉 24h(跨用户停服)。窗口过期后重新开窗,不累计上一轮。 + """ + from src.engine.executor import ( + RESTRICTED_BURST_WINDOW_SECONDS, + Executor, + ExecutorDeps, + ) + from src.engine.scheduler import Scheduler + + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + credentials.add(provider="codebuddy", credential_data={"bearer_token": "u"}) + + executor = Executor(ExecutorDeps( + providers={}, credentials=credentials, scheduler=Scheduler(), + default_model="glm-5.2", stats=RecordingCollector())) + + def cooled(): + rows = db.connect().execute( + "SELECT cooling_until FROM credentials").fetchall() + return any(row["cooling_until"] is not None for row in rows) + + now = int(time.time()) + executor._pause_provider_on_burst("codebuddy", "c1") # noqa: SLF001 + executor._pause_provider_on_burst("codebuddy", "c1") # noqa: SLF001 + assert not cooled() # 同号两次不升级 + + # 窗口过期:上一轮的凭证集合清零,同号再来一次也不升级 + executor._restricted_hits["codebuddy"] = ( # noqa: SLF001 + now - RESTRICTED_BURST_WINDOW_SECONDS - 1, {"c1"}) + executor._pause_provider_on_burst("codebuddy", "c1") # noqa: SLF001 + assert not cooled() + + # 第二个**不同**凭证在窗口内中招 → 整渠道暂停,但不禁用 + executor._pause_provider_on_burst("codebuddy", "c2") # noqa: SLF001 + rows = db.connect().execute( + "SELECT disabled, cooling_until FROM credentials").fetchall() + assert all(row["disabled"] == 0 for row in rows) # 只冷却,不禁用 + assert all(now + 3600 <= row["cooling_until"] <= now + 24 * 3600 + 60 + for row in rows) diff --git a/tests/engine/test_executor_transport.py b/tests/engine/test_executor_transport.py index d836d656..17efd903 100644 --- a/tests/engine/test_executor_transport.py +++ b/tests/engine/test_executor_transport.py @@ -4,10 +4,15 @@ `httpx.ConnectTimeout`——它们没有 `kind()`,此前 `_classify` 返回 None, 异常直接上抛到 `stream_guarded`,表现为回一帧 `internal_error`、既不换号 也不冷却、还不记账。本文件锁住修复后的口径:传输层失败 = SOFT。 + +`ssl.SSLError` 同属这一类但**不是** `httpx.TransportError`(anyio 只转 +`SSLEOFError`),2026-10-25 15:50 经代理的 `record layer failure` 就是这样 +逃出去的,故单列成组用例。 """ from __future__ import annotations +import ssl import time import httpx @@ -86,8 +91,32 @@ def test_upstream_transport_error_is_soft(): assert UpstreamTransportError("x").kind() is ErrKind.SOFT -async def test_stream_rotates_and_cools_on_connect_error(repo): - """首个凭证 ConnectError → 换号成功,且只有它进入 SOFT 冷却。""" +def test_is_network_error_includes_proxy_error(): + """本机代理挂掉走 `httpx.ProxyError`,不是 `ConnectError`。 + + 漏掉它时这类失败落 `upstream_timeout` 并 SOFT 冷却健康凭证——与 + 「本机网络故障不冷却」的口径直接矛盾(2026-11 实测代理进程退出)。 + `ConnectTimeout` 仍不算(可能是上游慢),保持原有边界。 + """ + from src.engine.executor import _is_network_error + + assert _is_network_error(httpx.ProxyError("proxy connect failed")) is True + assert _is_network_error(httpx.ConnectError("connect failed")) is True + assert _is_network_error(httpx.ConnectTimeout("timed out")) is False + assert _is_network_error(httpx.ReadTimeout("read timed out")) is False + assert _is_network_error(None) is False + # 包装形态:provider 把根因挂在 __cause__ 上 + wrapped = UpstreamTransportError("wrapped") + wrapped.__cause__ = httpx.ProxyError("proxy down") + assert _is_network_error(wrapped) is True + + +async def test_stream_rotates_on_connect_error_without_cooling(repo): + """首个凭证 ConnectError(本机 DNS/建连失败)→ 换号成功,但不冷却凭证。 + + 网络故障不是账号的问题;旧行为按 SOFT 冷却,会让一次 DNS 抖动冷却整池 + 健康号(2026-11-05 睡眠唤醒后实测)。仍然换号重试,所以第二个号能成功。 + """ credentials, _db = repo # 选号在健康度/余额都相同时按 credential_id 兜底排序,先选哪张不固定: # 断言按「第一次实际用的 token」反查凭证,不能假定 first 一定先被选中 @@ -100,11 +129,12 @@ async def test_stream_rotates_and_cools_on_connect_error(repo): assert provider.calls == 2 assert frames[-1] == SSE_DONE assert b'"hi"' in b"".join(frames) - # 被冷却的只有**实际失败的那张**(第一次调用用的 token 对应的凭证) + # 实际失败的那张不冷却、不累计(NETWORK 属于零动作分类) failed_id = ids[provider.tokens[0]] - assert cooled_ids(credentials) == {failed_id} - cooled = next(c for c in credentials.candidates() if c.credential_id == failed_id) - assert cooled.err_count == 0 # SOFT 不累计错误数 + assert cooled_ids(credentials) == set() + failed = next(c for c in credentials.candidates() if c.credential_id == failed_id) + assert failed.err_count == 0 + assert failed.cooling_until is None assert provider.tokens[1] != provider.tokens[0] @@ -151,3 +181,66 @@ async def test_complete_all_transport_errors_raise_no_healthy_credential(repo): await executor.complete(complete_request()) assert provider.calls == 2 assert len(cooled_ids(credentials)) == 2 + + +def _ssl_record_layer_failure() -> ssl.SSLError: + """复刻实测异常:TLS record layer failure(anyio 原样上抛,不转 BrokenResource)。 + + 2026-10-25 15:50 两次 codebuddy_intl 经 7897 代理时就是这个值;它是 + `OSError` 子类但**不是** `httpx.TransportError`,此前 `_classify` 返回 + None,异常逃到 `stream_guarded` 变成一帧 `internal_error`。 + """ + return ssl.SSLError(1, "[SSL] record layer failure (_ssl.c:2580)") + + +def test_ssl_error_is_not_httpx_transport_error(): + """前提校验:这条异常必须**不是** TransportError,否则下面的用例测不到修复。""" + assert not isinstance(_ssl_record_layer_failure(), httpx.TransportError) + + +async def test_stream_rotates_and_cools_on_ssl_error(repo): + """TLS 中断 → 换号重试(不是 internal_error),失败的那张进 SOFT 冷却。""" + credentials, _db = repo + ids = {token: add_credential(credentials, token) for token in ("first", "second")} + executor, provider = build_executor( + credentials, [[_ssl_record_layer_failure()], TRAE_GOOD]) + + frames = [frame async for frame in executor.stream(stream_request())] + joined = b"".join(frames) + + assert provider.calls == 2 + assert frames[-1] == SSE_DONE + assert b'"hi"' in joined + assert b"internal_error" not in joined + failed_id = ids[provider.tokens[0]] + assert cooled_ids(credentials) == {failed_id} + + +async def test_stream_all_ssl_errors_end_with_no_healthy_credential(repo): + """全部凭证都 TLS 中断 → no_healthy_credential 终帧,而不是 internal_error。""" + credentials, _db = repo + add_credential(credentials, "a") + add_credential(credentials, "b") + executor, provider = build_executor( + credentials, [[_ssl_record_layer_failure()]]) + + frames = [frame async for frame in executor.stream(stream_request())] + joined = b"".join(frames) + + assert provider.calls == 2 + assert b"no_healthy_credential" in joined + assert b"internal_error" not in joined + + +async def test_complete_rotates_on_ssl_error(repo): + """非流式:TLS 中断同样走换号重试。""" + credentials, _db = repo + add_credential(credentials, "first") + add_credential(credentials, "second") + executor, provider = build_executor( + credentials, [[_ssl_record_layer_failure()], TRAE_GOOD]) + + result = await executor.complete(complete_request()) + + assert provider.calls == 2 + assert result["choices"][0]["message"]["content"] == "hi" diff --git a/tests/engine/test_fallback.py b/tests/engine/test_fallback.py index 064ff04e..45a98791 100644 --- a/tests/engine/test_fallback.py +++ b/tests/engine/test_fallback.py @@ -7,7 +7,14 @@ from src.compat.openai.request import InvalidRequest from src.engine.model_resolver import ModelTarget from src.provider.base import ErrKind, Event, EventKind -from tests.engine._fallback_support import GOOD, INVALID, ModelAwareProvider, ScriptedProvider +from tests.engine._fallback_support import ( + GOOD, + INVALID, + HttpInvalid, + ModelAwareProvider, + RaisingProvider, + ScriptedProvider, +) from tests.engine._fallback_support import add_credential as _add from tests.engine._fallback_support import aliases as _aliases from tests.engine._fallback_support import make_executor as _executor @@ -33,6 +40,23 @@ async def test_complete_falls_back_when_primary_all_invalid(repo): assert tr.models_seen == ["glm-4.6"] db.close() +@pytest.mark.asyncio +async def test_complete_http_invalid_exception_skips_provider(repo): + """HTTP 级 INVALID(异常)与非流内 INVALID 同语义:跳过该上游,不冷却凭证。""" + credentials, db = repo + _add(credentials, "codebuddy", "cb") + _add(credentials, "trae", "tr") + cb = RaisingProvider("codebuddy", HttpInvalid("400 model not found")) + tr = ScriptedProvider("trae", [GOOD]) + executor = _executor({"codebuddy": cb, "trae": tr}, credentials, + {"chat": ("glm-5.2", "glm-4.6")}, + _aliases("glm-5.2", "glm-4.6")) + result = await executor.complete(_request("glm-5.2")) + assert result["choices"][0]["message"]["content"] == "ok" + assert cb.calls == 1 and tr.calls == 1 + assert tr.models_seen == ["glm-4.6"] + db.close() + @pytest.mark.asyncio async def test_complete_group_name_entry(repo): """请求兼容组名:以组内首成员发起。""" diff --git a/tests/engine/test_fallback_stream.py b/tests/engine/test_fallback_stream.py index 342a4fbd..b3a59fe6 100644 --- a/tests/engine/test_fallback_stream.py +++ b/tests/engine/test_fallback_stream.py @@ -6,7 +6,14 @@ from src.engine.executor import NoProviderForModel from src.provider.base import ErrKind, Event, EventKind -from tests.engine._fallback_support import GOOD, INVALID, ModelAwareProvider, ScriptedProvider +from tests.engine._fallback_support import ( + GOOD, + INVALID, + HttpInvalid, + ModelAwareProvider, + RaisingProvider, + ScriptedProvider, +) from tests.engine._fallback_support import add_credential as _add from tests.engine._fallback_support import aliases as _aliases from tests.engine._fallback_support import make_executor as _executor @@ -32,6 +39,24 @@ async def test_stream_falls_back_before_first_frame(repo): assert tr.models_seen == ["glm-4.6"] db.close() +@pytest.mark.asyncio +async def test_stream_http_invalid_exception_falls_back(repo): + """HTTP 级 INVALID(异常,不是流内事件)同样在首帧前回退。""" + credentials, db = repo + _add(credentials, "codebuddy", "cb") + _add(credentials, "trae", "tr") + cb = RaisingProvider("codebuddy", HttpInvalid("400 model not found")) + tr = ScriptedProvider("trae", [GOOD]) + executor = _executor({"codebuddy": cb, "trae": tr}, credentials, + {"chat": ("glm-5.2", "glm-4.6")}, + _aliases("glm-5.2", "glm-4.6")) + frames = [f async for f in executor.stream(_request("glm-5.2"), username="u")] + joined = b"".join(frames).decode() + assert "ok" in joined and "error" not in joined + assert cb.calls == 1 and tr.calls == 1 + assert tr.models_seen == ["glm-4.6"] + db.close() + @pytest.mark.asyncio async def test_stream_exhausted_chain_emits_single_error_frame(repo): """整条链都失败:只发一帧错误,且不再继续尝试。""" @@ -231,6 +256,53 @@ async def test_invalid_provider_with_many_creds_keeps_rotation_budget(repo): assert cb.calls == 1 and tr.calls == 1 db.close() +@pytest.mark.asyncio +async def test_multimodal_rejection_skips_whole_channel_instead_of_rotating(repo): + """带图请求打到非多模态渠道:跳过整个上游,轮换预算留给别的渠道。 + + 回归 2026-11-01 22:18–22:20:四个带图请求全部 400 收尾。真实原因是 + `InferHub.001001020.406 not multimodal` 被归成 REQUEST(换号重试), + codearts 的 3 条凭证各撞一次就把 `MAX_ROTATE = 3` 吃满,请求永远轮不到 + `supports_images=True` 的 codebuddy。归类改为 INVALID 后一次尝试即跳过 + 整个渠道,剩下的预算留给真正能处理图片的上游。 + """ + from src.provider.codearts import events as codearts_events + + credentials, db = repo + for token in ("a", "b", "c"): + _add(credentials, "codearts", token) + _add(credentials, "codebuddy", "cb") + # 三条 codearts 全 pin:pin 集合优先于常规排序,保证它们排在 codebuddy + # 前面被逐个选中(否则按 credential_id 字典序选号,测试结果不确定) + db.connect().execute("UPDATE credentials SET pinned = 1 WHERE provider = 'codearts'") + + rejected = [Event(kind=EventKind.ERROR, error_code=None, + error_message="InferHub.001001020.406 " + "The request model is not multimodal", + error_kind=codearts_events.classify_error_code( + "InferHub.001001020.406 The request model is not multimodal"))] + ca = ScriptedProvider("codearts", [rejected]) + cb = ScriptedProvider("codebuddy", [GOOD]) + executor = _executor( + {"codearts": ca, "codebuddy": cb}, credentials, {}, + {"codearts": {"glm-5.3-flash": "glm-5.3-flash"}, + "codebuddy": {"glm-5.3-flash": "glm-5.3-flash"}}) + + frames = [f async for f in executor.stream( + _request("glm-5.3-flash"), username="u")] + joined = b"".join(frames).decode() + assert "ok" in joined and "error" not in joined + # 只打一次 codearts 就跳过整条渠道。归 REQUEST 时这里会是 3(三条凭证 + # 各撞一次,正好吃满 max_rotate=3),然后以 invalid_request 收尾。 + assert ca.calls == 1 + assert cb.calls == 1 + # 请求侧错误不惩罚任何凭证 + rows = db.connect().execute( + "SELECT cooling_until, err_count, disabled FROM credentials").fetchall() + assert [tuple(row) for row in rows] == [(None, 0, 0)] * 4 + db.close() + + @pytest.mark.asyncio async def test_invalid_provider_many_creds_stream_keeps_rotation_budget(repo): """H2 流式路径同非流式:跳过的不认模型上游不占满轮换预算。""" diff --git a/tests/engine/test_half_open.py b/tests/engine/test_half_open.py new file mode 100644 index 00000000..ec1c4ebc --- /dev/null +++ b/tests/engine/test_half_open.py @@ -0,0 +1,331 @@ +"""冷却到期半开探测。 + +- 冷却**已过期**(库里那行还没被留存任务清掉)≠ 立刻全量回池:先放 1 个在途; +- 探测位被占时换别的号;没有别的号 → 无可用(调用方 503/重试); +- 探通(`confirm`)后在 `RECOVERED_TTL_SECONDS` 内正常放行; +- 失败/中断只归还探测位,不标记恢复;再记一次错误则清掉恢复结论; +- 模型级冷却按 `(凭证, 上游模型名)` 独立探测,与账号级互不阻挡; +- 探测位有 TTL 兜底,漏释放也不会把号永久卡住。 +""" + +from __future__ import annotations + +import time + +import pytest + +from src.engine.executor import Executor, ExecutorDeps +from src.engine.half_open import HalfOpenGuard +from src.engine.model_resolver import resolve +from src.engine.scheduler import ErrorOutcome, Scheduler +from tests.helpers.events import GOOD + +_TARGET = resolve("glm-5.2@trae", "glm-5.2") + + +class _Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + for event in GOOD: + yield event + + +def _executor(credentials, guard=None, **deps) -> Executor: + """默认装配 `guard`;`deps` 里显式给 `half_open` 时以它为准。""" + deps.setdefault("half_open", guard) + return Executor(ExecutorDeps(providers={"trae": _Provider()}, + credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", + **deps)) + + +def _expired_account_cooling(credentials, credential_id: str) -> None: + """把账号级冷却写在**过去**(模拟「刚到期、留存任务还没清」)。""" + credentials.save_error( + credential_id, ErrorOutcome(cooling_until=int(time.time()) - 1)) + + +def _expired_model_cooling(credentials, credential_id: str, model: str) -> None: + from src.engine.scheduler import ModelCooldown + + credentials.save_error( + credential_id, + ErrorOutcome(model_cooldowns={ + model: ModelCooldown(cooling_until=int(time.time()) - 1, hits=1)})) + + +# ------------------------------------------------------------------ guard 单元 + + +class _FakeClock: + def __init__(self) -> None: + self.now = 1000.0 + + def __call__(self) -> float: + return self.now + + +def test_guard_single_probe_and_release(): + clock = _FakeClock() + guard = HalfOpenGuard(now=clock) + key = ("c1", "") + assert guard.acquire(key) is True + assert guard.acquired(key) is True + assert guard.acquire(key) is False + guard.release(key) + assert guard.acquired(key) is False + assert guard.recovered(key) is False + assert guard.acquire(key) is True + + +def test_guard_confirm_marks_recovered_with_ttl(): + clock = _FakeClock() + guard = HalfOpenGuard(now=clock, recovered_ttl=30) + key = ("c1", "glm-5.2") + assert guard.acquire(key) is True + guard.confirm(key) + assert guard.recovered(key) is True + assert guard.acquired(key) is False + clock.now += 29 + assert guard.recovered(key) is True + clock.now += 1 + assert guard.recovered(key) is False + + +def test_guard_reset_clears_recovered(): + guard = HalfOpenGuard(now=_FakeClock()) + key = ("c1", "") + guard.acquire(key) + guard.confirm(key) + guard.reset("c1", "") + assert guard.recovered(key) is False + + +def test_guard_probe_ttl_recovers_leaked_slot(): + clock = _FakeClock() + guard = HalfOpenGuard(now=clock, ttl=10) + key = ("c1", "") + assert guard.acquire(key) is True + clock.now += 10 + assert guard.acquired(key) is False + assert guard.acquire(key) is True + + +def test_guard_recovered_missing_and_other_scope(): + guard = HalfOpenGuard(now=_FakeClock()) + assert guard.recovered(("nope", "")) is False + guard.acquire(("c1", "")) + guard.confirm(("c1", "")) + # 另一个作用域(同凭证的另一个模型)不受影响 + assert guard.recovered(("c1", "other")) is False + assert guard.acquire(("c1", "other")) is True + + +# --------------------------------------------------------------- executor 集成 + + +def test_expired_cooling_is_probed_one_at_a_time(repo): + """两个号都刚解冻:第一个请求占探测位,第二个请求换到另一个号。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + b = credentials.add(provider="trae", credential_data={"accessToken": "b"}) + _expired_account_cooling(credentials, a) + _expired_account_cooling(credentials, b) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + first = executor._select(_TARGET, set()) + assert first in (a, b) + second = executor._select(_TARGET, set()) + assert second in (a, b) and second != first + # 两个号的探测位都占满 → 无可用 + assert executor._select(_TARGET, set()) is None + db.close() + + +def test_only_candidate_in_probe_yields_none(repo): + """唯一候选在探测中:直接判定无可用,不放弃半开去莽撞。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _expired_account_cooling(credentials, a) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + assert executor._select(_TARGET, set()) == a + assert executor._select(_TARGET, set()) is None + db.close() + + +def test_probe_success_allows_full_pool(repo): + """探通后(`_confirm_probe`)恢复期间直接放行,不再逐个排队。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _expired_account_cooling(credentials, a) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + assert executor._select(_TARGET, set()) == a + executor._lease(a) + executor._confirm_probe(a) + executor._release(a) + # 已探通:没有探测位也能再选 + assert executor._select(_TARGET, set()) == a + assert executor._select(_TARGET, set()) == a + db.close() + + +def test_probe_failure_releases_slot_for_next_round(repo): + """探测失败(只 `_release` 不 confirm):探测位归还,下次仍能再探。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _expired_account_cooling(credentials, a) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + assert executor._select(_TARGET, set()) == a + executor._lease(a) + executor._release(a) + assert executor._select(_TARGET, set()) == a + db.close() + + +def test_model_level_probe_uses_upstream_model_scope(repo): + """模型级冷却同样半开,作用域是上游原始模型名。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _expired_model_cooling(credentials, a, "glm-5.2") + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + assert executor._select(_TARGET, set()) == a + assert guard.acquired((a, "glm-5.2")) is True + executor._lease(a) + executor._release(a) + assert guard.acquired((a, "glm-5.2")) is False + db.close() + + +def test_account_cooldown_takes_precedence_over_model(repo): + """账号级与模型级都过期:按账号级探测键占位。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _expired_account_cooling(credentials, a) + _expired_model_cooling(credentials, a, "glm-5.2") + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + assert executor._select(_TARGET, set()) == a + assert guard.acquired((a, "")) is True + db.close() + + +def test_no_probe_when_cooling_is_in_the_future(repo): + """仍在冷却中的号照旧被滤掉,与历史行为一致。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + credentials.save_error( + a, ErrorOutcome(cooling_until=int(time.time()) + 60)) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + assert executor._select(_TARGET, set()) is None + db.close() + + +def test_drop_probe_returns_slot(repo): + """选中的号在使用前被淘汰:探测位立即归还,不卡住后续请求。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _expired_account_cooling(credentials, a) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + assert executor._select(_TARGET, set()) == a + executor._drop_probe(a) + assert executor._select(_TARGET, set()) == a + db.close() + + +def test_drop_probe_is_noop_without_pending(repo): + """没有待登记的探测位时调用是幂等的(并发删除路径可能重复触发)。""" + credentials, db = repo + executor = _executor(credentials, half_open=None) + executor._drop_probe("missing") + executor._confirm_probe(None) + executor._release("missing") + db.close() + + +def test_new_error_resets_recovered_conclusion(repo): + """探通后又报错:清掉「已恢复」,下次到期重新半开(不永久免检)。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _expired_account_cooling(credentials, a) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + + assert executor._select(_TARGET, set()) == a + executor._lease(a) + executor._confirm_probe(a) + executor._release(a) + assert guard.recovered((a, "")) is True + + from src.provider.base import ErrKind + + executor._note_upstream_error(a, ErrKind.SOFT, "trae", "glm-5.2", + username="u", started=time.time()) + assert guard.recovered((a, "")) is False + db.close() + + +def test_queue_error_keeps_recovered_conclusion(repo): + """排队不是凭证故障:不重置「已恢复」结论。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + guard.acquire((a, "")) + guard.confirm((a, "")) + + from src.provider.base import ErrKind + + executor._note_upstream_error(a, ErrKind.QUEUE, "trae", "glm-5.2", + username="u", started=time.time()) + assert guard.recovered((a, "")) is True + db.close() + + +async def test_request_error_skips_reset(repo): + """请求级错误(REQUEST)零动作:不冷却、也不重置半开结论。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + guard = HalfOpenGuard() + executor = _executor(credentials, guard) + guard.acquire((a, "")) + guard.confirm((a, "")) + + from src.provider.base import ErrKind + + assert executor._note_upstream_error( + a, ErrKind.REQUEST, "trae", "glm-5.2", username="u", + started=time.time()) is False + assert guard.recovered((a, "")) is True + db.close() + + +@pytest.mark.parametrize("half_open", [None]) +def test_guard_disabled_keeps_old_behavior(repo, half_open): + """未装配 guard:冷却一到期就照旧全量回池(向后兼容)。""" + credentials, db = repo + a = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + _expired_account_cooling(credentials, a) + executor = _executor(credentials, half_open=half_open) + + assert executor._select(_TARGET, set()) == a + assert executor._select(_TARGET, set()) == a + db.close() diff --git a/tests/engine/test_model_observation.py b/tests/engine/test_model_observation.py new file mode 100644 index 00000000..f3e31a39 --- /dev/null +++ b/tests/engine/test_model_observation.py @@ -0,0 +1,120 @@ +"""上游自报模型观测:与请求模型不一致只告警(不改响应、不冷却、不写失败归因)。""" + +from __future__ import annotations + +import logging + +from src.compat.openai.request import parse_chat_request +from src.engine.executor import Executor, ExecutorDeps, _model_mismatch +from src.engine.scheduler import Scheduler +from src.provider.base import Event, EventKind, Usage + + +class _Provider: + id = "codebuddy" + + def __init__(self, events) -> None: + self.events = events + + async def stream_chat(self, _data, _payload, _model): + for event in self.events: + yield event + + def list_models(self, _data): # pragma: no cover - 本用例不调用 + return [] + + +class _Collector: + def __init__(self) -> None: + self.events: list[dict] = [] + + def record(self, **fields) -> None: + self.events.append(fields) + + +def _executor(repo, provider: _Provider) -> Executor: + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + return Executor(ExecutorDeps( + providers={"codebuddy": provider}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.2", stats=_Collector())) + + +def _request(stream: bool = True): + return parse_chat_request({"messages": [{"role": "user", "content": "hi"}], + "stream": stream}) + + +def test_model_mismatch_tolerates_version_suffixes(): + assert _model_mismatch("glm-5.2", "glm-5.2") is False + assert _model_mismatch("GLM-5.2", "glm-5.2-20261001") is False + assert _model_mismatch("glm-5.2-20261001", "glm-5.2") is False + # 真的换成了另一个模型才命中 + assert _model_mismatch("glm-5.2", "glm-4.6") is True + assert _model_mismatch("", "glm-5.2") is False + assert _model_mismatch("glm-5.2", "") is False + # 占位名(Qoder 每帧回 auto)不算替换证据 + assert _model_mismatch("glm-5.2", "auto") is False + assert _model_mismatch("glm-5.2", "DEFAULT") is False + + +async def test_stream_warns_when_upstream_reports_other_model(repo, caplog): + provider = _Provider([ + Event(kind=EventKind.CONTENT, content="ok", model="glm-4.6"), + Event(kind=EventKind.USAGE, usage=Usage(1, 2, 0), model="glm-4.6"), + Event(kind=EventKind.FINISH, finish_reason="stop", model="glm-4.6"), + ]) + executor = _executor(repo, provider) + + with caplog.at_level(logging.WARNING, logger="src.engine.executor"): + async for _ in executor.stream(_request(), username="alice"): + pass + + warnings = [r.getMessage() for r in caplog.records if "不一致" in r.getMessage()] + assert len(warnings) == 1 + assert "glm-4.6" in warnings[0] and "glm-5.2" in warnings[0] + # 只告警:响应照常成功 + assert "成功" not in warnings[0] + + +async def test_stream_matching_model_does_not_warn(repo, caplog): + provider = _Provider([ + Event(kind=EventKind.CONTENT, content="ok", model="glm-5.2-20261001"), + Event(kind=EventKind.FINISH, finish_reason="stop", model="glm-5.2-20261001"), + ]) + executor = _executor(repo, provider) + + with caplog.at_level(logging.WARNING, logger="src.engine.executor"): + async for _ in executor.stream(_request(), username="alice"): + pass + + assert not [r for r in caplog.records if "不一致" in r.getMessage()] + + +async def test_stream_without_model_field_skips_check(repo, caplog): + provider = _Provider([ + Event(kind=EventKind.CONTENT, content="ok"), + Event(kind=EventKind.FINISH, finish_reason="stop"), + ]) + executor = _executor(repo, provider) + + with caplog.at_level(logging.WARNING, logger="src.engine.executor"): + async for _ in executor.stream(_request(), username="alice"): + pass + + assert not [r for r in caplog.records if "不一致" in r.getMessage()] + + +async def test_complete_warns_when_upstream_reports_other_model(repo, caplog): + provider = _Provider([ + Event(kind=EventKind.CONTENT, content="ok", model="glm-4.6"), + Event(kind=EventKind.USAGE, usage=Usage(1, 2, 0)), + Event(kind=EventKind.FINISH, finish_reason="stop"), + ]) + executor = _executor(repo, provider) + + with caplog.at_level(logging.WARNING, logger="src.engine.executor"): + result = await executor.complete(_request(stream=False), username="alice") + + assert result["choices"][0]["message"]["content"] == "ok" + assert [r for r in caplog.records if "不一致" in r.getMessage()] diff --git a/tests/engine/test_paid_rate_override.py b/tests/engine/test_paid_rate_override.py new file mode 100644 index 00000000..328c7b37 --- /dev/null +++ b/tests/engine/test_paid_rate_override.py @@ -0,0 +1,80 @@ +"""目录倍率的实测校准(`Executor._model_rates` / `_observed_paid_rate`)。 + +校准只纠正**目录说 x0、但上游明确扣过费**的那一格;其余情况一律保持目录 +原值——目录给的正数已经是上游自报倍率,未知(None)也不该被观测值改写。 +""" + +from __future__ import annotations + +from src.engine.executor import Executor, ExecutorDeps +from src.engine.model_resolver import ModelTarget +from src.engine.scheduler import Scheduler +from src.provider.base import Model + + +class _Repo: + """`_model_rates` 只用到 credentials 之外的注入,仓储不参与。""" + + +def _executor(*, paid, declared: float | None, + model_id: str = "deepseek-v4.1-flash") -> Executor: + return Executor(ExecutorDeps( + providers={}, credentials=_Repo(), scheduler=Scheduler(), + model_aliases={"codebuddy_intl": {model_id: model_id}}, + model_list_cache={"codebuddy_intl": { + model_id: Model(id=model_id, credit_rate=declared)}}, + paid_models=paid)) + + +def _target(model: str = "deepseek-v4.1-flash") -> ModelTarget: + return ModelTarget(model=model, providers=("codebuddy_intl",)) + + +def test_zero_rate_replaced_by_observed_average(): + """x0 + 有实扣 → 用实测均值(非 0,免费档失效)。""" + executor = _executor(paid=lambda: { + ("codebuddy_intl", "deepseek-v4.1-flash"): 0.1372}, declared=0.0) + assert executor._model_rates(_target()) == {"codebuddy_intl": 0.1372} + + +def test_zero_rate_kept_when_no_observation(): + """x0 + 窗口内没观测到扣费 → 仍信目录(夜里没人用不是免费的证据)。""" + executor = _executor(paid=lambda: {}, declared=0.0) + assert executor._model_rates(_target()) == {"codebuddy_intl": 0.0} + + +def test_positive_rate_not_overridden(): + """目录给了正数倍率 → 不被实测均值覆盖(上游自报更贴渠道语义)。""" + calls: list[int] = [] + + def paid(): + calls.append(1) + return {("codebuddy_intl", "deepseek-v4.1-flash"): 0.9} + + executor = _executor(paid=paid, declared=0.11) + assert executor._model_rates(_target()) == {"codebuddy_intl": 0.11} + # 未调用观测源:非 x0 走不到校准 + assert calls == [] + + +def test_unknown_rate_stays_unknown(): + """目录没给倍率(None)→ 保持 None,不因实扣记录变成「付费倍率」。""" + executor = _executor(paid=lambda: { + ("codebuddy_intl", "deepseek-v4.1-flash"): 0.5}, declared=None) + assert executor._model_rates(_target()) == {"codebuddy_intl": None} + + +def test_calibration_absent_keeps_catalog_rate(): + """未装配观测源(旧装配 / 测试)→ 与加校准前逐字节一致。""" + executor = _executor(paid=None, declared=0.0) + assert executor._model_rates(_target()) == {"codebuddy_intl": 0.0} + + +def test_model_missing_from_cache_is_skipped(): + """目录里没有这条(该渠道未登记该模型)→ 不产生键,调度按未知处理。""" + executor = Executor(ExecutorDeps( + providers={}, credentials=_Repo(), scheduler=Scheduler(), + model_aliases={"codebuddy_intl": {"other": "other"}}, + model_list_cache={"codebuddy_intl": {}}, + paid_models=lambda: {})) + assert executor._model_rates(_target()) == {} diff --git a/tests/engine/test_prefix_debug.py b/tests/engine/test_prefix_debug.py new file mode 100644 index 00000000..10292826 --- /dev/null +++ b/tests/engine/test_prefix_debug.py @@ -0,0 +1,186 @@ +"""前缀缓存诊断(`src/engine/prefix_debug.py` + `build_prefix_observer` 接线)。 + +只验摘要与比较逻辑:同内容同摘要、分叉点定位、tools 变化塌到 system、 +按凭证隔离、LRU 有界。日志文本只断言关键字与字段值,不锁整行格式。 +""" + +from __future__ import annotations + +import pytest + +from src.api.context import build_prefix_observer +from src.engine.prefix_debug import ( + PrefixDebugTracker, + longest_common_prefix, + message_digests, + tools_digest, +) + + +def _msg(role: str, content: str) -> dict: + return {"role": role, "content": content} + + +class _Settings: + def __init__(self, *, enabled=True): + self.prefix_debug_enabled = enabled + + +# ------------------------------------------------------------------ 摘要 + +def test_message_digests_are_stable_and_shape_safe(): + messages = [_msg("system", "s"), _msg("user", "u")] + assert message_digests(messages) == message_digests(list(messages)) + assert len(message_digests(messages)) == 2 + # 同内容不同对象 ⇒ 同摘要(序列化键排序) + assert message_digests([{"content": "x", "role": "user"}]) == \ + message_digests([{"role": "user", "content": "x"}]) + # 内容变了 ⇒ 摘要变 + assert message_digests([_msg("user", "a")]) != message_digests([_msg("user", "b")]) + # 非 list 形状(畸形请求)视为无消息 + assert message_digests(None) == () + assert message_digests({"role": "user"}) == () + + +def test_tools_digest_treats_missing_and_empty_alike(): + # 哨兵值与「无 system」的 `"-"` 不同:日志里两者必须可区分 + assert tools_digest({}) == "no-tools" + assert tools_digest({"tools": []}) == "no-tools" + assert tools_digest({"tools": None}) == "no-tools" + defined = tools_digest({"tools": [{"type": "function", "function": {"name": "f"}}]}) + assert defined != "no-tools" + assert defined == tools_digest( + {"tools": [{"function": {"name": "f"}, "type": "function"}]}) + + +def test_longest_common_prefix_counts_leading_identical_entries(): + assert longest_common_prefix((), ()) == 0 + assert longest_common_prefix(("a",), ()) == 0 + assert longest_common_prefix(("a", "b"), ("a", "c")) == 1 + assert longest_common_prefix(("a", "b"), ("a", "b", "c")) == 2 + assert longest_common_prefix(("a", "b", "c"), ("a", "b", "c")) == 3 + + +# ------------------------------------------------------------------ 追踪器 + +def test_first_request_has_no_baseline(): + tracker = PrefixDebugTracker() + report = tracker.observe("c1", {"messages": [_msg("system", "s"), _msg("user", "u")]}) + assert report["messages"] == 2 + assert report["system"] == message_digests([_msg("system", "s")])[0] + assert report["tools"] == "no-tools" + # 没有上一条就没有基准:一律 None,不把「第一条」报成「前缀全变」 + assert report["lcp"] is None + assert report["reusable"] is None + assert report["tools_changed"] is None + assert report["split"] is None + # 空消息列表也不炸,system 记哨兵值 + assert tracker.observe("c2", {})["system"] == "-" + assert tracker.observe("c2", {"messages": []})["lcp"] == 0 + + +def test_identical_prefix_reports_full_reuse(): + tracker = PrefixDebugTracker() + payload = {"messages": [_msg("system", "s"), _msg("user", "u")], + "tools": [{"type": "function"}]} + tracker.observe("c1", payload) + report = tracker.observe("c1", {"messages": payload["messages"], + "tools": payload["tools"]}) + assert report["lcp"] == 2 + assert report["reusable"] == 2 + assert report["tools_changed"] is False + # 没有分叉点(全部复用)也给哨兵值 + assert report["split"] == "-" + + +def test_divergent_message_is_located(): + tracker = PrefixDebugTracker() + tracker.observe("c1", {"messages": [_msg("system", "s"), _msg("user", "u1")]}) + report = tracker.observe("c1", {"messages": [_msg("system", "s"), + _msg("user", "u2"), + _msg("assistant", "a")]}) + assert report["lcp"] == 1 + assert report["reusable"] == 1 + assert report["tools_changed"] is False + assert report["split"] == message_digests([_msg("user", "u2")])[0] + + +def test_tools_change_collapses_reuse_to_system(): + """tools 定义排在 system 之后:它变了 ⇒ 正文一条都复用不了。""" + tracker = PrefixDebugTracker() + messages = [_msg("system", "s"), _msg("user", "u")] + tracker.observe("c1", {"messages": messages, "tools": [{"n": 1}]}) + changed = tracker.observe("c1", {"messages": messages, "tools": [{"n": 2}]}) + assert changed["tools_changed"] is True + assert changed["lcp"] == 2 # 消息本身没变,但 tools 变了 + assert changed["reusable"] == 1 # 可复用塌到 system + # system 也变了(lcp 0)⇒ 一条都不剩 + tracker.observe("c1", {"messages": [{"role": "system", "content": "s2"}], + "tools": [{"n": 3}]}) + zero = tracker.observe("c1", {"messages": [_msg("user", "u")]}) + assert zero["lcp"] == 0 + assert zero["tools_changed"] is True + assert zero["reusable"] == 0 + + +def test_baselines_are_per_key(): + tracker = PrefixDebugTracker() + tracker.observe("a", {"messages": [_msg("user", "x")]}) + # 另一个 key 首次见面:没有基准 + assert tracker.observe("b", {"messages": [_msg("user", "x")]})["lcp"] is None + assert tracker.observe("a", {"messages": [_msg("user", "x")]})["lcp"] == 1 + + +def test_tracker_is_bounded_and_evicts_oldest(): + tracker = PrefixDebugTracker(capacity=1) + tracker.observe("a", {"messages": [_msg("user", "x")]}) + tracker.observe("b", {"messages": [_msg("user", "x")]}) + # a 已被 LRU 淘汰 ⇒ 重新变成「首次见到」 + assert tracker.observe("a", {"messages": [_msg("user", "x")]})["lcp"] is None + + +def test_tracker_rejects_non_positive_capacity(): + with pytest.raises(ValueError): + PrefixDebugTracker(capacity=0) + + +# ------------------------------------------------------------------ observer + +def test_observer_is_noop_when_disabled(caplog): + settings = _Settings(enabled=False) + observer = build_prefix_observer(settings) + with caplog.at_level("INFO"): + observer("c1", "qoder", "m", {"messages": [_msg("user", "u")]}) + assert "前缀诊断" not in caplog.text + + +def test_observer_logs_report_when_enabled(caplog): + settings = _Settings(enabled=True) + observer = build_prefix_observer(settings) + payload = {"messages": [_msg("system", "s"), _msg("user", "u")]} + with caplog.at_level("INFO"): + observer("cred_1", "qoder", "deepseek-v4.1-flash", payload) + observer("cred_1", "qoder", "deepseek-v4.1-flash", + {"messages": [*payload["messages"], _msg("assistant", "a")]}) + lines = [line for line in caplog.text.splitlines() if "前缀诊断 " in line] + assert len(lines) == 2 + assert "provider=qoder" in lines[0] + assert "model=deepseek-v4.1-flash" in lines[0] + assert "cred=cred_1" in lines[0] + assert "msgs=2" in lines[0] + assert "lcp=None" in lines[0] # 第一条:没有基准 + assert "reusable=2" in lines[1] # 第二条:前两条消息可复用 + assert "tools_changed=False" in lines[1] + + +def test_observer_key_isolated_by_model(caplog): + """同一凭证换模型:模板/参数不同,不复用基准。""" + settings = _Settings(enabled=True) + observer = build_prefix_observer(settings) + payload = {"messages": [_msg("user", "u")]} + with caplog.at_level("INFO"): + observer("cred_1", "qoder", "model-a", payload) + observer("cred_1", "qoder", "model-b", payload) + lines = [line for line in caplog.text.splitlines() if "前缀诊断 " in line] + assert len(lines) == 2 + assert "lcp=None" in lines[1] diff --git a/tests/engine/test_queue_retry.py b/tests/engine/test_queue_retry.py new file mode 100644 index 00000000..481c4a90 --- /dev/null +++ b/tests/engine/test_queue_retry.py @@ -0,0 +1,597 @@ +"""F3:排队语义(上游回 `isQueued` / `RESOURCE_LIMIT` + `retryAfterSeconds`)。 + +口径: + +- **不冷却**:排队不是故障,`scheduler.note_error` 对 `ErrKind.QUEUE` 零动作; +- **不换号**:等 `retryAfterSeconds` 后在**原号**上重开; +- **不占轮换预算**:排队轮次不计入 `attempts`,否则三次排队就把请求打成 503; +- **有界**:轮次上限 `QUEUE_MAX_ROUNDS`、单轮封顶 `QUEUE_MAX_WAIT_SECONDS`; +- **已出帧不重开**:半截输出不可回滚,降级为 SOFT 收尾。 + +全部用假时钟(`asyncio.sleep` 换掉),文件毫秒级跑完。 +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +from src.compat.openai.request import parse_chat_request +from src.engine.executor import ( + QUEUE_MAX_ROUNDS, + QUEUE_MAX_WAIT_SECONDS, + Executor, + ExecutorDeps, + NoHealthyCredential, +) +from src.engine.model_resolver import resolve +from src.engine.scheduler import ErrorOutcome, Scheduler +from src.provider.base import ( + QUEUE_DEFAULT_WAIT_SECONDS, + ErrKind, + Event, + EventKind, + parse_queue_hint, +) +from tests.helpers.events import GOOD + +_TARGET = resolve("glm-5.2@trae", "glm-5.2") + +_REAL_SLEEP = asyncio.sleep + + +@pytest.fixture() +def sleeps(monkeypatch): + """把 `asyncio.sleep` 换成记账版本(不真等)。""" + recorded: list[float] = [] + + async def fake(delay, result=None): + recorded.append(delay) + await _REAL_SLEEP(0) + return result + + monkeypatch.setattr(asyncio, "sleep", fake) + return recorded + + +def _request(stream: bool = True): + return parse_chat_request({"messages": [{"role": "user", "content": "hi"}], + "stream": stream}) + + +def _executor(credentials, provider, **deps) -> Executor: + return Executor(ExecutorDeps(providers={"trae": provider}, credentials=credentials, + scheduler=deps.pop("scheduler", Scheduler()), + default_model="glm-5.2", **deps)) + + +class _QueuedProvider: + """前 N 次上游回排队事件,之后正常吐 GOOD。""" + + id = "trae" + + def __init__(self, queue_times: int, retry_after: float | None = 0) -> None: + self.queue_times = queue_times + self.retry_after = retry_after + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls <= self.queue_times: + yield Event(kind=EventKind.ERROR, error_code=403, + error_message='{"isQueued":true}', + error_kind=ErrKind.QUEUE, + retry_after_seconds=self.retry_after) + return + for event in GOOD: + yield event + + +# --------------------------------------------------------------- 协议解析 + + +def test_parse_queue_hint_is_queued_flag(): + assert parse_queue_hint(json.dumps({"isQueued": True, + "retryAfterSeconds": 7})) == 7.0 + + +def test_parse_queue_hint_sub_code(): + assert parse_queue_hint(json.dumps({"subCode": "RESOURCE_LIMIT", + "retryAfterSeconds": 3})) == 3.0 + + +def test_parse_queue_hint_nested_envelope(): + body = json.dumps({"data": {"error": {"isQueued": True, + "retryAfterSeconds": 11}}}) + assert parse_queue_hint(body) == 11.0 + + +def test_parse_queue_hint_list_node(): + body = json.dumps([{"ok": 1}, {"isQueued": True, "retryAfterSeconds": 2}]) + assert parse_queue_hint(body) == 2.0 + + +def test_parse_queue_hint_list_without_queue(): + assert parse_queue_hint(json.dumps([1, "x", {"ok": 1}])) is None + + +def test_parse_queue_hint_list_without_flag(): + """列表节点整体没有排队标记:遍历完返回 None(而不是把列表本身当队列)。""" + assert parse_queue_hint(json.dumps([{"ok": 1}, {"code": 0}])) is None + + +def test_parse_queue_hint_depth_limit(): + """超过 3 层就不再下钻:再深是业务正文,继续递归只白烧 CPU。""" + node: dict = {"isQueued": True} + for _ in range(6): + node = {"wrap": node} + assert parse_queue_hint(json.dumps(node)) is None + + +def test_parse_queue_hint_defaults_when_no_seconds(): + assert parse_queue_hint('{"isQueued": true}') == QUEUE_DEFAULT_WAIT_SECONDS + + +def test_parse_queue_hint_ignores_bool_wait(): + """`retryAfterSeconds: true` 不是秒数,回落到兜底值。""" + assert parse_queue_hint('{"isQueued": true, "retryAfterSeconds": true}') == ( + QUEUE_DEFAULT_WAIT_SECONDS) + + +def test_parse_queue_hint_descends_into_serialized_string(): + """上游常把内层报文**再序列化成字符串**:排队标记在字符串里也必须命中。 + + 实测(2026-10-26):Qoder 的排队报文串了两层 + (`{"code":"403","message":"{\"code\":\"10605\",\"message\":\"{…isQueued…}\"}"}`)。 + 早先只认 dict/list → 漏判排队 → 403 被当硬失败 → 好凭证被硬禁用。 + """ + inner = json.dumps({"isQueued": True, "retryAfterSeconds": 30, + "subCode": "RESOURCE_LIMIT"}) + body = json.dumps({"code": "403", + "message": json.dumps({"code": "10605", "message": inner})}) + assert parse_queue_hint(body) == 30.0 + + +def test_parse_queue_hint_serialized_string_wait_is_not_budgeted_out(): + """串里套串**不额外消耗深度预算**:秒数在最内层也必须取到。 + + 深度只按 dict/list 的协议层级计——串里套串是同一层级的再序列化。 + """ + inner = json.dumps({"isQueued": True, "retryAfterSeconds": 12}) + body = json.dumps({"message": json.dumps({"message": inner})}) + assert parse_queue_hint(body) == 12.0 + + +def test_parse_queue_hint_flag_without_seconds_still_defaults(): + """标记节点自身没有秒数:先往深处找,实在没有才用兜底值。""" + body = json.dumps({"isQueued": True, "detail": json.dumps({"x": 1})}) + assert parse_queue_hint(body) == QUEUE_DEFAULT_WAIT_SECONDS + + +def test_parse_queue_hint_ignores_non_json_string_value(): + """普通文本(不是 JSON)不该被当成载荷解析。""" + assert parse_queue_hint(json.dumps({"message": "plain text"})) is None + + +@pytest.mark.parametrize("body", [ + b"", + b" ", + b"not json", + b'"just a string"', + b'{"isQueued": false}', + b'{"code": 500}', +]) +def test_parse_queue_hint_not_queued(body): + assert parse_queue_hint(body) is None + + +def test_parse_queue_hint_accepts_bytes(): + assert parse_queue_hint(b'{"isQueued": true, "retryAfterSeconds": 4}') == 4.0 + + +# ------------------------------------------------------- 调度器零动作 + + +def test_scheduler_queue_writes_nothing(repo): + """排队绝不惩罚凭证:err_count 不变、不写冷却。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + scheduler = Scheduler() + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + outcome = scheduler.note_error(candidate, ErrKind.QUEUE, 1_700_000_000) + assert outcome == ErrorOutcome(err_count=candidate.err_count) + assert outcome.cooling_until is None + db.close() + + +# ------------------------------------------------------------ 流式排队重开 + + +async def test_stream_requeues_on_same_credential(repo, sleeps): + """排队 1 次后成功:等 retryAfter 秒、用同一个号、只打 2 次上游。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=1, retry_after=2) + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"ok" in b"".join(frames) + assert provider.calls == 2 + assert sleeps == [2.0] + # 不写冷却:凭证仍然可立即选用 + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + assert candidate.cooling_until is None + db.close() + + +async def test_stream_queue_rounds_are_bounded(repo, sleeps): + """上游一直排队:最多重开 `QUEUE_MAX_ROUNDS` 轮,然后 503。""" + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=99, retry_after=0) + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"no_healthy_credential" in b"".join(frames) + # 首次 + 3 轮重开 = 4 次上游调用 + assert provider.calls == QUEUE_MAX_ROUNDS + 1 + assert len(sleeps) == QUEUE_MAX_ROUNDS + db.close() + + +async def test_stream_queue_wait_is_capped(repo, sleeps): + """上游点名 2 小时:单轮等待封顶到 `QUEUE_MAX_WAIT_SECONDS`。""" + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=1, retry_after=7200) + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"ok" in b"".join(frames) + assert sleeps == [QUEUE_MAX_WAIT_SECONDS] + db.close() + + +async def test_stream_queue_without_seconds_uses_default(repo, sleeps): + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=1, retry_after=None) + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"ok" in b"".join(frames) + assert sleeps == [QUEUE_DEFAULT_WAIT_SECONDS] + db.close() + + +async def test_stream_queue_after_emit_degrades_to_soft(repo, sleeps): + """已出帧后再排队:不能重开(半截输出),按 SOFT 收尾。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + class Provider: + id = "trae" + + async def stream_chat(self, _credential_data, _payload, _model): + yield Event(kind=EventKind.CONTENT, content="half") + yield Event(kind=EventKind.ERROR, error_code=403, + error_kind=ErrKind.QUEUE, retry_after_seconds=1) + + executor = _executor(credentials, Provider()) + frames = [f async for f in executor.stream(_request())] + body = b"".join(frames) + assert b"half" in body + assert b"no_healthy_credential" in body + assert sleeps == [] + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + assert candidate.cooling_until is not None # SOFT 冷却已写 + db.close() + + +async def test_stream_queue_pinned_credential_deleted(repo, sleeps): + """排队等待期间凭证被并发删除:退回正常选号,不崩。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + class Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls == 1: + yield Event(kind=EventKind.ERROR, error_code=403, + error_kind=ErrKind.QUEUE, retry_after_seconds=0) + return + for event in GOOD: + yield event + + provider = Provider() + executor = _executor(credentials, provider) + + async def _drop_then_run(): + frames = [] + async for frame in executor.stream(_request()): + frames.append(frame) + return frames + + # 在第一次 sleep 里删掉凭证(假 sleep 会走到这里) + original = asyncio.sleep + fired = False + + async def deleting_sleep(delay, result=None): + nonlocal fired + if not fired: + fired = True + credentials.delete(cid) + await original(0) + + import src.engine.executor as executor_module + + real = executor_module.asyncio.sleep + executor_module.asyncio.sleep = deleting_sleep + try: + frames = await _drop_then_run() + finally: + executor_module.asyncio.sleep = real + assert b"no_healthy_credential" in b"".join(frames) + db.close() + + +async def test_stream_http_queue_error_requeues(repo, sleeps): + """HTTP 级排队(403 + 排队 body 抛出的 UpstreamHTTPError)同样原号重开。""" + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + from src.provider.base import UpstreamHTTPError + + class QueuedHTTPError(UpstreamHTTPError): + classify_status = staticmethod(lambda status, body: ErrKind.QUEUE) + + class Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls == 1: + raise QueuedHTTPError(403, b'{"isQueued": true}', + retry_after_seconds=4.0) + for event in GOOD: + yield event + + provider = Provider() + executor = _executor(credentials, provider) + frames = [f async for f in executor.stream(_request())] + assert b"ok" in b"".join(frames) + assert provider.calls == 2 + assert sleeps == [4.0] + db.close() + + +async def test_stream_http_queue_exhausted_degrades_to_soft(repo, sleeps): + """HTTP 级排队但轮次已用尽:按 SOFT 冷却换号,不无限等。""" + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + from src.provider.base import UpstreamHTTPError + + class QueuedHTTPError(UpstreamHTTPError): + classify_status = staticmethod(lambda status, body: ErrKind.QUEUE) + + class Provider: + id = "trae" + + async def stream_chat(self, _credential_data, _payload, _model): + raise QueuedHTTPError(403, b'{"isQueued": true}') + yield # pragma: no cover - 让它成为 async generator + + executor = _executor(credentials, Provider()) + frames = [f async for f in executor.stream(_request())] + assert b"no_healthy_credential" in b"".join(frames) + assert len(sleeps) == QUEUE_MAX_ROUNDS + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + assert candidate.cooling_until is not None + db.close() + + +# --------------------------------------------------------- 非流式排队重开 + + +async def test_complete_requeues_on_same_credential(repo, sleeps): + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=1, retry_after=1) + executor = _executor(credentials, provider) + result = await executor.complete(_request(stream=False)) + assert result["choices"][0]["message"]["content"] == "ok" + assert provider.calls == 2 + assert sleeps == [1.0] + db.close() + + +async def test_complete_queue_rounds_bounded(repo, sleeps): + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + provider = _QueuedProvider(queue_times=99, retry_after=0) + executor = _executor(credentials, provider) + with pytest.raises(NoHealthyCredential): + await executor.complete(_request(stream=False)) + assert provider.calls == QUEUE_MAX_ROUNDS + 1 + db.close() + + +async def test_complete_queue_pinned_credential_deleted(repo, sleeps): + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + class Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls == 1: + yield Event(kind=EventKind.ERROR, error_code=403, + error_kind=ErrKind.QUEUE, retry_after_seconds=0) + return + for event in GOOD: + yield event + + provider = Provider() + executor = _executor(credentials, provider) + + import src.engine.executor as executor_module + + original = executor_module.asyncio.sleep + fired = False + + async def deleting_sleep(delay, result=None): + nonlocal fired + if not fired: + fired = True + credentials.delete(cid) + await _REAL_SLEEP(0) + + executor_module.asyncio.sleep = deleting_sleep + try: + with pytest.raises(NoHealthyCredential): + await executor.complete(_request(stream=False)) + finally: + executor_module.asyncio.sleep = original + db.close() + + +async def test_complete_http_queue_requeues(repo, sleeps): + credentials, db = repo + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + from src.provider.base import UpstreamHTTPError + + class QueuedHTTPError(UpstreamHTTPError): + classify_status = staticmethod(lambda status, body: ErrKind.QUEUE) + + class Provider: + id = "trae" + + def __init__(self) -> None: + self.calls = 0 + + async def stream_chat(self, _credential_data, _payload, _model): + self.calls += 1 + if self.calls == 1: + raise QueuedHTTPError(403, b'{"isQueued": true}', + retry_after_seconds=2.0) + for event in GOOD: + yield event + + provider = Provider() + executor = _executor(credentials, provider) + result = await executor.complete(_request(stream=False)) + assert result["choices"][0]["message"]["content"] == "ok" + assert sleeps == [2.0] + db.close() + + +async def test_complete_http_queue_exhausted_degrades_to_soft(repo, sleeps): + credentials, db = repo + cid = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + from src.provider.base import UpstreamHTTPError + + class QueuedHTTPError(UpstreamHTTPError): + classify_status = staticmethod(lambda status, body: ErrKind.QUEUE) + + class Provider: + id = "trae" + + async def stream_chat(self, _credential_data, _payload, _model): + raise QueuedHTTPError(403, b'{"isQueued": true}') + yield # pragma: no cover - 让它成为 async generator + + executor = _executor(credentials, Provider()) + with pytest.raises(NoHealthyCredential): + await executor.complete(_request(stream=False)) + assert len(sleeps) == QUEUE_MAX_ROUNDS + candidate = next(c for c in credentials.candidates(["trae"]) + if c.credential_id == cid) + assert candidate.cooling_until is not None + db.close() + + +# ------------------------------------------------------------- _queue_wait + + +def test_queue_wait_disabled_when_emitted(repo): + credentials, db = repo + executor = _executor(credentials, object()) + assert executor._queue_wait(1.0, True, 0) is None + db.close() + + +def test_queue_wait_disabled_when_rounds_exhausted(repo): + credentials, db = repo + executor = _executor(credentials, object()) + assert executor._queue_wait(1.0, False, QUEUE_MAX_ROUNDS) is None + db.close() + + +def test_queue_wait_clamps_negative(repo): + credentials, db = repo + executor = _executor(credentials, object()) + assert executor._queue_wait(-5.0, False, 0) == 0.0 + db.close() + + +def test_queue_wait_uses_value(repo): + credentials, db = repo + executor = _executor(credentials, object()) + assert executor._queue_wait(12.5, False, 0) == 12.5 + db.close() + + +def test_plan_queue_restart_returns_budget_and_pin(repo): + """四个入口共用同一份预算口径:轮次 +1、attempts -1、pin 原号。""" + credentials, db = repo + executor = _executor(credentials, object()) + data = {"access_token": "t"} + + plan = executor._plan_queue_restart( + 7.0, emitted=False, rounds=0, attempts=3, + provider_id="trae", credential_id="cid-1", credential_data=data) + + assert plan is not None + assert plan.wait == 7.0 + assert plan.rounds == 1 + # 排队不是换号尝试:不归还预算的话三次排队就把 max_rotate 吃满 + assert plan.attempts == 2 + # pin 原号:下一轮跳过 _pick,排队语义才不会丢 + assert plan.pinned == ("cid-1", data) + db.close() + + +def test_plan_queue_restart_none_when_emitted_or_exhausted(repo): + """不该重开的两种情形都返回 None,由调用方降级为 SOFT。""" + credentials, db = repo + executor = _executor(credentials, object()) + kwargs = dict(attempts=3, provider_id="trae", credential_id="cid-1", + credential_data={}) + + assert executor._plan_queue_restart(1.0, emitted=True, rounds=0, **kwargs) is None + assert executor._plan_queue_restart( + 1.0, emitted=False, rounds=QUEUE_MAX_ROUNDS, **kwargs) is None + db.close() + + +def test_error_type_for_queue_is_rate_limit(): + from src.engine.executor import _error_type_for + + assert _error_type_for(ErrKind.QUEUE) == "rate_limit" diff --git a/tests/engine/test_reasoning.py b/tests/engine/test_reasoning.py index c354cf8f..a1e9ac2a 100644 --- a/tests/engine/test_reasoning.py +++ b/tests/engine/test_reasoning.py @@ -128,6 +128,58 @@ def test_thinking_effort_blank_table_never_maps(): assert thinking_effort("", {"type": "enabled", "budget_tokens": 32768}) is None +# ---------------------------------------------------- output_config.effort + + +def test_output_config_effort_passes_through(): + """Claude Code 2.1.318 的 `/effort` 落在 `output_config.effort`。 + + 此前整块被丢弃 → 用户选档位静默失效且不报错(比 400 更难排查)。 + """ + parsed = parse_messages_request({ + "model": "m", "max_tokens": 8, + "messages": [{"role": "user", "content": "hi"}], + "output_config": {"effort": "low"}, + "thinking": {"type": "adaptive"}, + }) + assert parsed.raw["reasoning_effort"] == "low" + + +def test_output_config_effort_wins_over_thinking_table(): + """两个来源同时存在时以 `output_config` 为准(那是用户显式选的档位)。""" + parsed = parse_messages_request({ + "model": "m", "max_tokens": 8, + "messages": [{"role": "user", "content": "hi"}], + "output_config": {"effort": "HIGH"}, + "thinking": {"type": "enabled", "budget_tokens": 4096}, + }, thinking_efforts="4096:minimal") + assert parsed.raw["reasoning_effort"] == "high" + + +@pytest.mark.parametrize("output_config", [ + None, "effort", [], {"effort": ""}, {"effort": " "}, {"effort": 5}, {}, + {"format": {"type": "json"}}, +]) +def test_output_config_effort_absent_or_malformed_never_maps(output_config): + """缺 `effort` / 非字符串一律不映射:`output_config` 其余成员仍忽略。""" + body = {"model": "m", "max_tokens": 8, + "messages": [{"role": "user", "content": "hi"}]} + if output_config is not None: + body["output_config"] = output_config + assert "reasoning_effort" not in parse_messages_request(body).raw + + +def test_output_config_effort_falls_back_to_thinking_table(): + """`output_config` 没给 effort 时,`thinking.budget_tokens` 映射照旧生效。""" + parsed = parse_messages_request({ + "model": "m", "max_tokens": 8, + "messages": [{"role": "user", "content": "hi"}], + "output_config": {"format": {"type": "json"}}, + "thinking": {"type": "enabled", "budget_tokens": 4096}, + }, thinking_efforts="4096:low") + assert parsed.raw["reasoning_effort"] == "low" + + def test_parse_messages_request_maps_thinking_budget(): parsed = parse_messages_request( {"model": "m", "messages": [{"role": "user", "content": "hi"}], diff --git a/tests/engine/test_scheduler.py b/tests/engine/test_scheduler.py index df306652..c5582b94 100644 --- a/tests/engine/test_scheduler.py +++ b/tests/engine/test_scheduler.py @@ -3,6 +3,8 @@ from __future__ import annotations import time +from datetime import datetime +from zoneinfo import ZoneInfo import pytest @@ -278,6 +280,38 @@ def test_note_error_dead_disables(): out = Scheduler().note_error(cand(), ErrKind.DEAD, NOW) assert out.disabled and out.cooling_until is None and out.err_count == 0 +def test_note_error_restricted_cools_without_disabling(): + """账号被上游风控限制(11140):指数冷却 2h 起、绝不 disabled。 + + 禁用是不可逆的人工状态,而这类限制由上游自行解除;写成 disabled 就变成 + 「必须有人去点恢复」,还会诱使运维反复 revive 去撞(每次都可能再留记录)。 + 冷却时长按命中次数退避:固定 2h 会让整批同源账号每 2h 被重试一遍,而 + 重复撞击已被封的账号正是上游判「自动化」的证据。 + """ + from src.engine.scheduler import RESTRICTED_COOLDOWN_MAX_SECONDS, RESTRICTED_COOLDOWN_SECONDS + + out = Scheduler().note_error(cand(), ErrKind.RESTRICTED, NOW) + assert not out.disabled + assert out.cooling_until == NOW + RESTRICTED_COOLDOWN_SECONDS + assert out.err_count == 1 + assert out.model_cooldowns is None # 账号级作用域,不写模型级条目 + + # 第二次命中翻倍,逐次翻倍直到 7 天上限 + second = Scheduler().note_error(cand(err_count=1), ErrKind.RESTRICTED, NOW) + assert second.cooling_until == NOW + 2 * RESTRICTED_COOLDOWN_SECONDS + assert second.err_count == 2 + long_hit = Scheduler().note_error(cand(err_count=30), ErrKind.RESTRICTED, NOW) + assert long_hit.cooling_until == NOW + RESTRICTED_COOLDOWN_MAX_SECONDS + +def test_note_error_restricted_honours_upstream_retry_at_within_cap(): + """上游给了更晚的恢复时刻就用它,但仍夹在 24h 上界内。""" + out = Scheduler().note_error( + cand(), ErrKind.RESTRICTED, NOW, retry_at=NOW + 3 * 3600) + assert out.cooling_until == NOW + 3 * 3600 + capped = Scheduler().note_error( + cand(), ErrKind.RESTRICTED, NOW, retry_at=NOW + 7 * 24 * 3600) + assert capped.cooling_until == NOW + 24 * 3600 + def test_note_error_plan_long_cooldown(): out = Scheduler().note_error(cand(), ErrKind.PLAN, NOW) assert out.cooling_until == NOW + PLAN_COOLDOWN_SECONDS and not out.disabled @@ -431,6 +465,12 @@ def test_note_error_request_kind_is_zero_action(): assert (out.cooling_until, out.err_count, out.disabled) == (None, 2, False) assert out.model_cooldowns is None +def test_note_error_network_kind_is_zero_action(): + """本机 DNS/连接失败不是凭证的问题:不能冷却,否则网络抖动会清空整池。""" + out = Scheduler().note_error(cand(err_count=2), ErrKind.NETWORK, NOW) + assert (out.cooling_until, out.err_count, out.disabled) == (None, 2, False) + assert out.model_cooldowns is None + def test_note_error_credit_cools_until_next_4am(): """余额不足:冷却到次日签到时刻,而不是固定 12h。""" out = Scheduler().note_error(cand(), ErrKind.CREDIT, NOW) @@ -444,13 +484,29 @@ def test_note_error_credit_cools_until_next_4am(): (23, 1), ]) def test_next_credit_reset_boundaries(hour, expected_offset_days): - local = time.localtime(NOW) - today_4am = int(time.mktime((local.tm_year, local.tm_mon, local.tm_mday, - 4, 0, 0, 0, 0, -1))) + # 04:00 是**北京时间**(与签到日界同源),断言也必须按北京时区算: + # 用 time.localtime/mktime 在 TZ≠CN 的机器上会整体偏 8 小时。 + local = datetime.fromtimestamp(NOW, ZoneInfo("Asia/Shanghai")) + today_4am = int(local.replace(hour=4, minute=0, second=0, + microsecond=0).timestamp()) now_at_hour = today_4am + (hour - 4) * 3600 expected = today_4am + expected_offset_days * 86400 assert next_credit_reset(now_at_hour) == expected + +def test_next_credit_reset_ignores_process_timezone(monkeypatch): + """进程 TZ 改成 UTC 也不偏移:上游按北京时间重置。""" + monkeypatch.setenv("TZ", "UTC") + time.tzset() + try: + # 北京时间 2026-09-21 03:00 = UTC 2026-09-20 19:00 + moment = int(datetime(2026, 9, 21, 3, 0, tzinfo=ZoneInfo("Asia/Shanghai")).timestamp()) + assert next_credit_reset(moment) == int( + datetime(2026, 9, 21, 4, 0, tzinfo=ZoneInfo("Asia/Shanghai")).timestamp()) + finally: + monkeypatch.undo() + time.tzset() + def test_select_skips_model_cooled_credential_when_filtered(): """端到端:调用方按模型过滤后,被冷却的凭证不再被选中,换模型仍最优。""" s = Scheduler() @@ -462,3 +518,50 @@ def test_select_skips_model_cooled_credential_when_filtered(): # 同一份候选换模型:a 的模型级冷却不生效,健康度更高者胜出 usable = [c for c in (cooled_a, b) if c.is_selectable(NOW, "kimi-k2")] assert s.select(usable, set(), NOW) == "a" + + +# ---------------------------------------------------------------- 管理员权重 + + +def test_select_weight_reorders_within_health_tier(): + """同健康档内高权重优先:压过到期积分与在途分摊。""" + pool = [ + cand("hot", health=90, routing_weight=50), + cand("expiring", health=90, expiry_ladder=[(NOW + 600, 500.0)]), + cand("busy", health=90, routing_weight=50, inflight=3), + ] + # hot 与 busy 同权重,退到原链按在途少者(hot 0 < busy 3) + assert Scheduler().select(pool, set(), NOW) == "hot" + # 高权重仍然压过「到期积分多」:busy(50) > expiring(0) + assert Scheduler().select([c for c in pool if c.credential_id != "hot"], + set(), NOW) == "busy" + # 权重打平才退回原链:到期积分多者优先 + tied = [cand("a", health=90, routing_weight=5), + cand("b", health=90, routing_weight=5, + expiry_ladder=[(NOW + 600, 500.0)])] + assert Scheduler().select(tied, set(), NOW) == "b" + + +def test_select_negative_weight_ranks_last_in_tier(): + """负权重 = 备用号:同档内排最后,哪怕它到期积分最多。""" + pool = [cand("normal", health=90), + cand("standby", health=90, routing_weight=-10, + expiry_ladder=[(NOW + 600, 999.0)])] + assert Scheduler().select(pool, set(), NOW) == "normal" + + +def test_select_weight_never_crosses_health_tier(): + """权重不是准入条件:exhausted / unknown 号即使权重拉满也不能越过 known。""" + pool = [cand("known", health=60), + cand("dead", health=EXHAUSTED, routing_weight=1000), + cand("unknown", health=None, routing_weight=1000)] + assert Scheduler().select(pool, set(), NOW) == "known" + assert Scheduler().select([pool[1], pool[2]], set(), NOW) == "unknown" + + +def test_select_without_weight_keeps_existing_order(): + """全默认(None/0)时排序与补该列前一致:到期积分仍压过健康度。""" + pool = [cand("steady", health=95), + cand("burn", health=10, expiry_ladder=[(NOW + 600, 100.0)]), + cand("zero", health=99, routing_weight=0)] + assert Scheduler().select(pool, set(), NOW) == "burn" diff --git a/tests/engine/test_scheduler_retry_at.py b/tests/engine/test_scheduler_retry_at.py index 8d2fb7d3..d6cae4c9 100644 --- a/tests/engine/test_scheduler_retry_at.py +++ b/tests/engine/test_scheduler_retry_at.py @@ -181,6 +181,38 @@ def test_adopted_moment_is_logged(caplog): assert any("采用上游时刻" in record.getMessage() for record in caplog.records) +def test_adopted_moment_log_says_account_scope_for_soft(caplog): + """账号级分类即使传了模型名,日志也必须写「账号」。 + + 实测 zen 免费层 429(SOFT)落的是账号级 `cooling_until`,旧日志却打 + 「模型 glm-5.3-flash-free」,读起来像模型级限流,排查时误判。 + """ + with caplog.at_level(logging.INFO, logger="src.engine.scheduler"): + Scheduler().note_error(cand(), ErrKind.SOFT, NOW, model="glm-5.3-flash-free", + retry_at=NOW + 7200) + message = next(r.getMessage() for r in caplog.records if "采用上游时刻" in r.getMessage()) + assert "作用域 账号" in message + assert "glm-5.3-flash-free" not in message + + +def test_adopted_moment_log_says_model_scope_for_model_kind(caplog): + """模型级分类写模型名,与 `credential_model_cooldowns` 的写入位置一致。""" + with caplog.at_level(logging.INFO, logger="src.engine.scheduler"): + Scheduler().note_error(cand(), ErrKind.MODEL, NOW, model="deepseek-v4.1-flash", + retry_at=NOW + 7200) + message = next(r.getMessage() for r in caplog.records if "采用上游时刻" in r.getMessage()) + assert "模型 deepseek-v4.1-flash" in message + assert "作用域 账号" not in message + + +def test_adopted_moment_log_says_account_when_model_scoped_kind_lacks_model(caplog): + """MODEL 无模型名时已退化为账号级软冷却,日志不能还写模型级。""" + with caplog.at_level(logging.INFO, logger="src.engine.scheduler"): + Scheduler().note_error(cand(), ErrKind.MODEL, NOW, retry_at=NOW + 7200) + message = next(r.getMessage() for r in caplog.records if "采用上游时刻" in r.getMessage()) + assert "作用域 账号" in message + + def test_fallback_is_not_logged(caplog): with caplog.at_level(logging.INFO, logger="src.engine.scheduler"): Scheduler().note_error(cand(), ErrKind.SOFT, NOW) diff --git a/tests/engine/test_sticky_trim.py b/tests/engine/test_sticky_trim.py new file mode 100644 index 00000000..9ef9521c --- /dev/null +++ b/tests/engine/test_sticky_trim.py @@ -0,0 +1,207 @@ +"""粘性裁剪(`src/engine/sticky_trim.py`)。 + +重点是「跨轮前缀不变」:同一会话追加消息后再压,前缀必须逐字相同(否则上游 +前缀缓存还是只能命中 system 段)。其余覆盖失效分支:起点消息被改写、起点不再 +是消息组边界、新增消息把总量顶回预算、未超预算时清记忆、LRU 淘汰。 +""" + +from __future__ import annotations + +import pytest + +from src.engine.compress import estimate_messages, trim_to_budget +from src.engine.sticky_trim import ( + StickyTrimmer, + _Boundary, + _digest, + conversation_key, +) + + +def _sys(text: str = "sys") -> dict: + return {"role": "system", "content": text} + + +def _user(text: str) -> dict: + return {"role": "user", "content": text} + + +def _assistant(text: str) -> dict: + return {"role": "assistant", "content": text} + + +def _tool(call_id: str = "t1") -> dict: + return {"role": "tool", "tool_call_id": call_id, "content": "result"} + + +def _settings(**overrides): + """压缩参数(与 RuntimeSettings 同名的关键字),默认预算很小便于触发裁剪。""" + options = {"max_input_tokens": 400, "reserve_for_output": 0, + "min_keep_messages": 1, "safety_ratio": 1.0} + options.update(overrides) + return options + + +def _history(count: int, *, size: int = 400) -> list[dict]: + """system + count 条等长 user 消息(每条 0.25*size token)。""" + return [_sys(), *(_user(f"{index}-" + "x" * size) for index in range(count))] + + +# ------------------------------------------------------------------ 键与摘要 + +def test_conversation_key_uses_explicit_id_and_head_digest(): + raw = {"conversation_id": "abc", "messages": [_sys(), _user("u")]} + key = conversation_key("qoder", "m", raw) + assert key.startswith("qoder|m|conversation_id:abc|") + # 同一个会话同一份头:键稳定 + assert key == conversation_key("qoder", "m", dict(raw)) + # 头几条消息不同 ⇒ 不同键(显式 id 相同也不共用边界记忆) + other = {"conversation_id": "abc", "messages": [_sys("other"), _user("u")]} + assert conversation_key("qoder", "m", other) != key + # 没有显式 id:仍靠头摘要区分;没有 messages 也不炸 + assert "|m||" in conversation_key("qoder", "m", {"messages": [_sys()]}) + assert conversation_key("qoder", "m", {}) == conversation_key( + "qoder", "m", {"messages": "not-a-list"}) + + +# ------------------------------------------------------------------ 基本契约 + +def test_under_budget_is_noop_and_forgets_boundary(): + trimmer = StickyTrimmer() + messages = _history(2) + outcome = trimmer.compress("k", messages, **_settings(max_input_tokens=100000)) + assert outcome.messages is messages + assert outcome.trimmed is False + assert len(trimmer) == 0 + + +def test_non_positive_limit_or_budget_is_noop(): + trimmer = StickyTrimmer() + messages = _history(3) + assert trimmer.compress("k", messages, **_settings(max_input_tokens=0)).messages is messages + # safety_ratio 让预算 ≤ 0:不裁 + zero = trimmer.compress("k", messages, **_settings(max_input_tokens=10, + reserve_for_output=100)) + assert zero.messages is messages + assert zero.trimmed is False + + +def test_invalid_construction_is_rejected(): + with pytest.raises(ValueError): + StickyTrimmer(capacity=0) + with pytest.raises(ValueError): + StickyTrimmer(low_watermark=0.0) + with pytest.raises(ValueError): + StickyTrimmer(low_watermark=1.5) + + +# ------------------------------------------------------------------ 粘性核心 + +def test_trim_then_keep_prefix_stable_across_turns(): + """裁一次之后,后续追加消息不再改前缀——这是命中率回来的前提。""" + trimmer = StickyTrimmer() + options = _settings(max_input_tokens=4000) + first = trimmer.compress("k", _history(60), **options) + assert first.trimmed is True + assert first.messages[0] == _sys() + kept = len(first.messages) + + second = trimmer.compress("k", _history(61), **options) + assert second.trimmed is False # 复用旧边界,不重裁 + assert second.messages[:kept] == first.messages # 前缀逐字相同 + assert len(second.messages) == kept + 1 + + third = trimmer.compress("k", _history(62), **options) + assert third.trimmed is False + assert third.messages[:kept] == first.messages + + +def test_retrim_only_when_appended_messages_exceed_budget(): + trimmer = StickyTrimmer() + options = _settings(max_input_tokens=4000) + before = trimmer.compress("k", _history(60), **options) + kept_before = len(before.messages) + # 一次追加足够多:候选超预算 ⇒ 重裁,窗口起点下移 + after = trimmer.compress("k", _history(120), **options) + assert after.trimmed is True + assert len(after.messages) < kept_before + 60 + assert after.messages[0] == _sys() + + +def test_rewritten_history_invalidates_boundary(): + trimmer = StickyTrimmer() + options = _settings(max_input_tokens=4000) + first = trimmer.compress("k", _history(60), **options) + boundary_start = trimmer._seen["k"].start + rewritten = _history(60) + # 改掉保留窗口起点那条消息(客户端压缩/摘要改写):摘要对不上 ⇒ 重裁 + assert rewritten[boundary_start + 1] == first.messages[1] + rewritten[boundary_start + 1] = _user("changed-" + "x" * 400) + outcome = trimmer.compress("k", rewritten, **options) + assert outcome.trimmed is True + + +def test_boundary_that_is_no_longer_a_group_start_is_not_reused(): + """起点落在 tool 组中间(历史被改写)时不能复用,否则会切断 tool 配对。""" + trimmer = StickyTrimmer() + messages = [_sys(), _user("a" * 400), _assistant("saw"), _tool(), _user("z" * 400)] + body = messages[1:] + # 起点 = 2(tool 消息)在「assistant 无 tool_calls」时是组起点 + boundary = _Boundary(start=2, digest=_digest(body[2])) + reused, reason = trimmer._reuse(messages, body, boundary, budget=10000) + assert reused is not None and reason == "reused" + # assistant 加上 tool_calls 后,tool 与它同组 ⇒ 序号 2 不再是组起点 + messages[2] = {"role": "assistant", "content": "saw", + "tool_calls": [{"id": "t1", "function": {"name": "f", + "arguments": ""}}]} + body = messages[1:] + assert trimmer._reuse(messages, body, boundary, budget=10000)[1] == "not_group_start" + # 起点越界(历史变短)同样不复用 + assert trimmer._reuse(messages, body[:1], boundary, budget=10000)[1] == \ + "start_out_of_range" + + +def test_reuse_rejected_when_candidate_exceeds_budget(): + trimmer = StickyTrimmer() + trimmer.compress("k", _history(20), **_settings()) + messages = _history(20) + boundary = _Boundary(start=0, digest=_digest(messages[1])) + # 预算小到连「从起点起全部保留」都装不下 ⇒ 必须重裁 + assert trimmer._reuse(messages, messages[1:], boundary, budget=10)[1] == "over_budget" + + +def test_no_body_means_no_memory(): + trimmer = StickyTrimmer() + outcome = trimmer.compress("k", [_sys("s" * 4000)], **_settings()) + assert outcome.trimmed is True + assert len(trimmer) == 0 + # 没有记忆 ⇒ 下一轮仍然重裁(不误用起点) + assert trimmer.compress("k", [_sys("s" * 4000)], **_settings()).trimmed is True + + +def test_memory_is_bounded_lru(): + trimmer = StickyTrimmer(capacity=1) + trimmer.compress("a", _history(20), **_settings()) + trimmer.compress("b", _history(20), **_settings()) + assert len(trimmer) == 1 + # a 的记忆被淘汰 ⇒ 重新裁一次 + assert trimmer.compress("a", _history(20), **_settings()).trimmed is True + + +# ------------------------------------------------------- trim_to_budget 起点 + +def test_trim_to_budget_reports_first_kept_body_index(): + messages = [_sys(), _user("a" * 400), _user("b" * 400), _user("c" * 400)] + trimmed, start = trim_to_budget(messages, budget=1, min_keep_messages=1) + assert trimmed == [_sys(), messages[-1]] + assert start == 2 # body 中第 2 条(0 基)是保留窗口起点 + # 工具组整体保留,起点落在组首而不是组内 + grouped = [_sys(), _user("x" * 400), + {"role": "assistant", "content": "", + "tool_calls": [{"id": "t1", "function": {"name": "f", + "arguments": ""}}]}, + _tool()] + trimmed2, start2 = trim_to_budget(grouped, budget=3, min_keep_messages=2) + assert start2 == 1 # assistant(tool_calls)+tool 同组,起点为 1 + assert trimmed2[1].get("tool_calls") is not None + assert estimate_messages(trimmed2) <= estimate_messages(grouped) diff --git a/tests/engine/test_tool_output.py b/tests/engine/test_tool_output.py new file mode 100644 index 00000000..bcd32465 --- /dev/null +++ b/tests/engine/test_tool_output.py @@ -0,0 +1,84 @@ +"""tool 输出确定性压缩(`src/engine/tool_output.py`)。""" + +from __future__ import annotations + +from src.engine.tool_output import ( + compress_tool_outputs, + compress_tool_text, +) + + +def test_short_text_is_returned_untouched(): + text = "\x1b[32mok\x1b[0m" + assert compress_tool_text(text, max_chars=100) is text + # max_chars ≤ 0 视为关闭 + assert compress_tool_text("x" * 100, max_chars=0) == "x" * 100 + + +def test_ansi_sequences_are_stripped_only_when_compressing(): + text = ("\x1b[1;31mERROR boom\x1b[0m\n" * 20) + out = compress_tool_text(text, max_chars=10) + assert "\x1b[" not in out + assert "ERROR boom" in out + + +def test_consecutive_duplicate_lines_are_collapsed(): + text = "\n".join(["same line"] * 50) + "\n" + "tail" + out = compress_tool_text(text, max_chars=60, head_lines=5, tail_lines=5) + assert "same line (×50)" in out + assert out.count("same line") == 1 + + +def test_error_lines_survive_omission_window(): + noise = [f"step {index} ok" for index in range(500)] + noise.insert(250, "Traceback (most recent call last):") + noise.insert(251, "ValueError: boom") + out = compress_tool_text("\n".join(noise), max_chars=300, + head_lines=2, tail_lines=2) + assert "省略" in out + assert "Traceback (most recent call last):" in out + assert "ValueError: boom" in out + # 头尾保留 + assert "step 0 ok" in out and "step 499 ok" in out + + +def test_hard_trim_keeps_within_budget(): + text = "a" * 5000 + out = compress_tool_text(text, max_chars=200) + assert len(out) <= 200 + assert "省略" in out + # 阈值小到装不下标记时退化为纯截断 + tiny = compress_tool_text(text, max_chars=5) + assert tiny == "aaaaa" + + +def test_tail_zero_keeps_no_tail_window(): + text = "\n".join(f"line {index}" for index in range(100)) + out = compress_tool_text(text, max_chars=10, head_lines=3, tail_lines=0) + assert "line 0" in out + assert "line 99" not in out + + +def test_compress_tool_outputs_only_touches_long_tool_text(): + short = {"role": "tool", "content": "ok"} + long_tool = {"role": "tool", "content": "\x1b[31mfail\x1b[0m\n" * 100} + user = {"role": "user", "content": "fail" * 100} + list_content = {"role": "tool", + "content": [{"type": "text", "text": "fail" * 100}]} + messages = [short, long_tool, user, list_content] + + out = compress_tool_outputs(messages, max_chars=50) + + assert out is not messages + assert out[0] is short + assert out[1] is not long_tool and "\x1b[" not in out[1]["content"] + assert out[2] is user + assert out[3] is list_content # part 数组不动 + # 入参不被修改 + assert "\x1b[" in long_tool["content"] + + +def test_compress_tool_outputs_returns_same_list_when_nothing_changes(): + messages = [{"role": "tool", "content": "ok"}, + {"role": "user", "content": "hi"}] + assert compress_tool_outputs(messages, max_chars=100) is messages diff --git a/tests/engine/test_usage_consistency.py b/tests/engine/test_usage_consistency.py new file mode 100644 index 00000000..16324627 --- /dev/null +++ b/tests/engine/test_usage_consistency.py @@ -0,0 +1,95 @@ +"""成功请求的用量 ↔ 正文体量对账(上游静默替换/截断的观测信号)。""" + +from __future__ import annotations + +import logging + +import pytest + +from src.engine.executor import USAGE_MIN_CHARS, USAGE_UNDERREPORT_RATIO, Executor, ExecutorDeps +from src.engine.scheduler import Scheduler + + +def _executor() -> Executor: + return Executor(ExecutorDeps( + providers={}, credentials=object(), scheduler=Scheduler(), + default_model="m")) + + +def _call(executor: Executor, chars: int, tokens: int | None): + executor._check_usage_consistency( + provider_id="trae", credential_id="c1", model="glm-5.2", + content_chars=chars, output_tokens=tokens) + + +def test_short_content_is_ignored(caplog): + """正文太短不判定:短回复的 token/字符比噪声极大。""" + with caplog.at_level(logging.WARNING): + _call(_executor(), USAGE_MIN_CHARS - 1, None) + assert caplog.records == [] + + +def test_missing_output_tokens_warns(caplog): + with caplog.at_level(logging.WARNING): + _call(_executor(), 500, None) + assert any("未上报输出 token" in r.getMessage() for r in caplog.records) + + +def test_zero_output_tokens_warns(caplog): + with caplog.at_level(logging.WARNING): + _call(_executor(), 500, 0) + assert any("未上报输出 token" in r.getMessage() for r in caplog.records) + + +def test_underreported_tokens_warn(caplog): + """远超正常字符/token 比 → 疑似 usage 低估或模型被替换。""" + with caplog.at_level(logging.WARNING): + _call(_executor(), 5000, 100) + assert any("字符/token" in r.getMessage() for r in caplog.records) + + +def test_ratio_at_threshold_is_silent(caplog): + """恰好等于阈值不告警(判据是严格大于)。""" + tokens = int(USAGE_MIN_CHARS * USAGE_UNDERREPORT_RATIO) + with caplog.at_level(logging.WARNING): + _call(_executor(), USAGE_MIN_CHARS, tokens) + assert caplog.records == [] + + +def test_normal_ratio_is_silent(caplog): + with caplog.at_level(logging.WARNING): + _call(_executor(), 800, 400) + assert caplog.records == [] + + +@pytest.mark.parametrize("tokens", [1, 100, 999]) +def test_ratio_boundary_uses_content_chars(tokens, caplog): + chars = 4000 + ratio = chars / tokens + with caplog.at_level(logging.WARNING): + _call(_executor(), chars, tokens) + warned = any("字符/token" in r.getMessage() for r in caplog.records) + assert warned is (ratio > USAGE_UNDERREPORT_RATIO) + + +def test_release_keeps_remaining_probe_leases(): + """同一凭证有多个探测位时,释放一个不删整组(剩余留给后续释放)。""" + + class _HalfOpen: + def __init__(self): + self.released: list[str] = [] + + def release(self, key: str) -> None: + self.released.append(key) + + guard = _HalfOpen() + executor = Executor(ExecutorDeps(providers={}, credentials=object(), + scheduler=Scheduler(), default_model="m", + half_open=guard)) + executor._probe_leases["c1"] = ["k1", "k2"] + executor._release("c1") + assert guard.released == ["k2"] + assert executor._probe_leases["c1"] == ["k1"] + executor._release("c1") + assert guard.released == ["k2", "k1"] + assert "c1" not in executor._probe_leases diff --git a/tests/provider/codearts/test_checkin.py b/tests/provider/codearts/test_checkin.py index bb21ba22..667911a0 100644 --- a/tests/provider/codearts/test_checkin.py +++ b/tests/provider/codearts/test_checkin.py @@ -20,20 +20,20 @@ def _delivery(campaign: dict | None = None, *, extra_items: list | None = None) def _campaign(**over) -> dict: base = { - "campaignId": 1, "title": "每日签到领1000 积分", "type": "USER_LOGIN", - "benefitAmount": 1000, "benefitUnit": "CREDIT", - "claimable": True, "status": "ELIGIBLE", "pendingTotalAmount": 1000.0, + "campaignId": 34, "title": "每日签到领100积分", "type": "USER_LOGIN", + "benefitAmount": 100, "benefitUnit": "CREDIT", + "claimable": True, "status": "ELIGIBLE", "pendingTotalAmount": 100.0, "extra": {"endTime": "2026-12-30T16:00:00Z", "triggerMode": "MANUAL_CLAIM", "consumePriority": 100}, } base.update(over) return base -def test_events_daily_credit_campaign_picks_id_and_tolerates_shape(): +def test_events_daily_credit_campaign_picks_by_type_not_id_and_tolerates_shape(): other = {"campaignId": 3, "title": "推荐码上有礼", "status": "ELIGIBLE"} data = _delivery(_campaign(), extra_items=[other]) - assert codearts_events.daily_credit_campaign(data)["title"] == "每日签到领1000 积分" - # 只有别的活动 → None(不靠标题匹配,id 精确挑) + assert codearts_events.daily_credit_campaign(data)["title"] == "每日签到领100积分" + # 只有别的活动 → None(不靠标题匹配,靠 type=USER_LOGIN) assert codearts_events.daily_credit_campaign(_delivery(None, extra_items=[other])) is None # 形状退化一律 None,不抛(上游改版不该让签到任务炸) for bad in (None, {}, {"data": None}, {"data": []}, {"data": {"items": None}}, @@ -43,6 +43,33 @@ def test_events_daily_credit_campaign_picks_id_and_tolerates_shape(): assert codearts_events.daily_credit_campaign( _delivery(None, extra_items=[{"campaignId": True}])) is None +def test_events_daily_credit_campaign_survives_id_and_amount_changes(): + """上游换 ID / 改面额时签到不能失效(2026-11:ID 1→34、1000→100 积分)。""" + # 新 ID + 新面额,靠 type 命中 + new_id = _campaign(campaignId=34, benefitAmount=100) + assert codearts_events.daily_credit_campaign( + _delivery(new_id))["campaignId"] == 34 + # 再来一次换 ID:仍然命中,不写死任何一个 ID + assert codearts_events.daily_credit_campaign( + _delivery(_campaign(campaignId=99)))["campaignId"] == 99 + # 极老响应没有 type:仍按历史 ID 1 兜底 + legacy = {"campaignId": 1, "status": "ELIGIBLE", "claimable": True} + assert codearts_events.daily_credit_campaign(_delivery(legacy)) is not None + # 有 type 但不是 USER_LOGIN(新用户/邀请/学生认证)→ 不当签到 + for other_type in ("NEW_USER_REGISTER", "INVITE_USER", "STUDENT_CERTIFIED"): + assert codearts_events.daily_credit_campaign( + _delivery(_campaign(type=other_type))) is None + # 同类型多条:优先今天还没领的那条 + claimed = _campaign(campaignId=7, status="CONFIRMED", claimable=False) + eligible = _campaign(campaignId=34) + picked = codearts_events.daily_credit_campaign( + _delivery(eligible, extra_items=[claimed])) + assert picked["campaignId"] == 34 + # 全部已领 → 取第一条(当「已签到」处理,不当作活动不存在) + only_claimed = _campaign(campaignId=7, status="CONFIRMED", claimable=False) + assert codearts_events.daily_credit_campaign( + _delivery(only_claimed))["campaignId"] == 7 + def test_events_campaign_field_accessors_tolerate_missing(): good = _campaign() assert codearts_events.campaign_status(good) == "ELIGIBLE" @@ -52,14 +79,55 @@ def test_events_campaign_field_accessors_tolerate_missing(): assert codearts_events.campaign_claimable(good) is True assert codearts_events.campaign_claimable({"claimable": "true"}) is False assert codearts_events.campaign_claimable(None) is False - assert codearts_events.campaign_credit(good) == 1000.0 + assert codearts_events.campaign_credit(good) == 100.0 assert codearts_events.campaign_credit({"benefitAmount": True}) is None assert codearts_events.campaign_credit({"benefitAmount": "1000"}) is None assert codearts_events.campaign_credit(None) is None - assert codearts_events.campaign_name(good) == "每日签到领1000 积分" + assert codearts_events.campaign_name(good) == "每日签到领100积分" assert codearts_events.campaign_name({"title": " "}) == "每日签到领积分" assert codearts_events.campaign_name(None) == "每日签到领积分" +def test_events_campaign_type_and_id_accessors_tolerate_missing(): + """claim/confirm 必须带当轮真实 ID;缺失时给 0(该轮失败好过打错活动)。""" + good = _campaign() + assert codearts_events.campaign_type(good) == "USER_LOGIN" + assert codearts_events.campaign_type({"type": " USER_LOGIN "}) == "USER_LOGIN" + assert codearts_events.campaign_type({"type": 5}) == "" + assert codearts_events.campaign_type(None) == "" + assert codearts_events.campaign_id(good) == 34 + assert codearts_events.campaign_id({"campaignId": 99}) == 99 + assert codearts_events.campaign_id({"campaignId": True}) == 0 + assert codearts_events.campaign_id({"campaignId": "34"}) == 0 + assert codearts_events.campaign_id({}) == 0 + assert codearts_events.campaign_id(None) == 0 + + +def test_events_campaign_type_falls_back_to_trigger_event(): + """`type` 缺失时读 `extra.triggerEvent`:两者是同一标识的两个字段。 + + 只认 `type` 时,一次只剩 `triggerEvent` 的响应会落到 campaignId==1 兼容 + 分支,报「活动未开放」并 ok=True 封账——签到从此静默失效。 + """ + assert codearts_events.campaign_type( + {"extra": {"triggerEvent": "user.login"}}) == "USER_LOGIN" + assert codearts_events.campaign_type( + {"type": "", "extra": {"triggerEvent": " user.login "}}) == "USER_LOGIN" + # type 优先,不被 triggerEvent 覆盖 + assert codearts_events.campaign_type( + {"type": "NEW_USER_REGISTER", + "extra": {"triggerEvent": "user.login"}}) == "NEW_USER_REGISTER" + # 形状不对一律空串(不抛) + for bad in ({"extra": None}, {"extra": "user.login"}, {"extra": {}}, + {"extra": {"triggerEvent": 5}}, + {"extra": {"triggerEvent": " "}}): + assert codearts_events.campaign_type(bad) == "" + + # 端到端:只有 triggerEvent 的签到活动同样被挑中 + only_trigger = {"campaignId": 34, "status": "ELIGIBLE", "claimable": True, + "extra": {"triggerEvent": "user.login"}} + picked = codearts_events.daily_credit_campaign(_delivery(only_trigger)) + assert picked is not None and picked["campaignId"] == 34 + def test_events_campaign_end_epoch_parses_utc_and_degrades(): # 2026-12-30T16:00:00Z == 2026-12-31 00:00 +08(活动截止,实测原文) assert codearts_events.campaign_end_epoch(_campaign()) == 1798646400 @@ -85,7 +153,7 @@ def handler(request: httpx.Request) -> httpx.Response: client = _client(handler) campaign = await client.fetch_checkin_status(_cred()) - assert campaign is not None and campaign["campaignId"] == 1 + assert campaign is not None and campaign["campaignId"] == 34 assert seen == ["https://snap.test/v1/ops/delivery?channel=IDE"] await client.aclose() @@ -102,15 +170,15 @@ def handler(request: httpx.Request) -> httpx.Response: "data": {"status": "CONFIRMED"}}) client = _client(handler) - code, message = await client.claim_daily_credit(_cred()) + code, message = await client.claim_daily_credit(_cred(), 34) assert (code, message) == (0, "ok") assert [path for path, _ in calls] == ["/v1/ops/claim", "/v1/ops/confirm"] # idempotentKey 格式由前端 JS 定死:claim__<毫秒时间戳> claim_body = calls[0][1] - assert claim_body["campaignId"] == 1 and claim_body["channel"] == "IDE" - assert claim_body["idempotentKey"].startswith("claim_1_") + assert claim_body["campaignId"] == 34 and claim_body["channel"] == "IDE" + assert claim_body["idempotentKey"].startswith("claim_34_") assert claim_body["idempotentKey"].rsplit("_", 1)[1].isdigit() - assert calls[1][1] == {"campaignId": 1} + assert calls[1][1] == {"campaignId": 34} await client.aclose() async def test_client_claim_daily_credit_business_error_skips_confirm(): @@ -123,7 +191,7 @@ def handler(request: httpx.Request) -> httpx.Response: "message": "尚未到达权益刷新时间,暂时无法领取"}) client = _client(handler) - code, message = await client.claim_daily_credit(_cred()) + code, message = await client.claim_daily_credit(_cred(), 34) assert code == 40001 and "刷新时间" in message assert calls == ["/v1/ops/claim"] # 失败不 confirm await client.aclose() @@ -134,7 +202,7 @@ def handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"message": ""}) client = _client(handler) - assert await client.claim_daily_credit(_cred()) == (-1, "领取失败") + assert await client.claim_daily_credit(_cred(), 34) == (-1, "领取失败") await client.aclose() async def test_client_confirm_daily_credit_swallows_upstream_failure(): @@ -143,12 +211,50 @@ def handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500, json={"error_msg": "boom"}) client = _client(handler) - assert await client._confirm_daily_credit(_cred()) is False + assert await client._confirm_daily_credit(_cred(), 34) is False + await client.aclose() + + +async def test_client_claim_daily_credit_confirm_failure_is_surfaced(): + """claim 成功但 confirm 失败 → 回 CONFIRM_FAILED_CODE,不吞成「签到成功」。 + + 实测用户反馈:界面显示签到成功、官网积分未到账。confirm 是领取的第二步, + 失败必须让 checkin() 归失败,CheckinTask 才会当轮不封账、下轮补确认。 + """ + from src.provider.codearts.client import CONFIRM_FAILED_CODE + + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + if request.url.path == "/v1/ops/claim": + return httpx.Response(200, json={"code": 0, "message": "ok"}) + return httpx.Response(500, json={"error_msg": "boom"}) + + client = _client(handler) + code, message = await client.claim_daily_credit(_cred(), 34) + assert code == CONFIRM_FAILED_CODE and "确认失败" in message + assert calls == ["/v1/ops/claim", "/v1/ops/confirm"] + await client.aclose() + + +async def test_client_checkin_claim_confirm_failure_is_failure(): + """ELIGIBLE → claim 成功但 confirm 失败:整轮判失败,留给下一轮补确认。""" + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/ops/delivery": + return httpx.Response(200, json=_delivery(_campaign())) + if request.url.path == "/v1/ops/claim": + return httpx.Response(200, json={"code": 0, "message": "ok"}) + return httpx.Response(500, json={"error_msg": "boom"}) + + client = _client(handler) + result = await client.checkin(_cred()) + assert result.ok is False and "确认失败" in result.message await client.aclose() async def test_client_checkin_state_machine_branches(): """四种 status + 活动过期 + 不存在,各自归一(CheckinTask 直接消费)。""" - # ELIGIBLE 且 claimable → claim + confirm,带回 1000 积分 + # ELIGIBLE 且 claimable → claim + confirm,带回 100 积分 def eligible(request: httpx.Request) -> httpx.Response: if request.url.path == "/v1/ops/delivery": return httpx.Response(200, json=_delivery(_campaign())) @@ -156,7 +262,7 @@ def eligible(request: httpx.Request) -> httpx.Response: client = _client(eligible) result = await client.checkin(_cred()) - assert result.ok and not result.already_checked_in and result.credit == 1000.0 + assert result.ok and not result.already_checked_in and result.credit == 100.0 await client.aclose() # CLAIMED(已领未确认)→ 只补 confirm,算成功 @@ -169,6 +275,8 @@ def claimed(request: httpx.Request) -> httpx.Response: client = _client(claimed) result = await client.checkin(_cred()) assert result.ok and not result.already_checked_in and result.message == "已补确认签到" + # 补确认也要回填本次面额,管理台才不会显示「签到成功、积分 -」 + assert result.credit == 100.0 await client.aclose() # CLAIMED 但 confirm 失败 → ok=False,否则 CheckinTask 当日封账不再补 @@ -215,7 +323,7 @@ def done(request: httpx.Request, _s=status) -> httpx.Response: assert result.ok is False and result.message == "当前不可领取" await client.aclose() - # 未知 status → 失败重试(漏签 1000 积分代价高于多刷日志) + # 未知 status → 失败重试(漏签 100 积分代价高于多刷日志) weird = _campaign(status="SOMETHING_NEW") client = _client(lambda _r: httpx.Response(200, json=_delivery(weird))) result = await client.checkin(_cred()) @@ -233,12 +341,51 @@ def handler(request: httpx.Request) -> httpx.Response: assert result.ok is False and result.code == 40001 await client.aclose() + +async def test_client_checkin_uses_campaign_id_from_delivery_response(): + """claim/confirm 的 campaignId 必须取当轮响应,不能写死旧 ID 1。 + + 2026-11 上游把签到活动从 ID 1 换成 ID 34(面额 1000 → 100)。写死 ID 会 + 让 status 查询照常、claim 打到不存在的活动,最终表现为「任务在、积分不涨」。 + """ + claimed: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/ops/delivery": + return httpx.Response(200, json=_delivery(_campaign(campaignId=34))) + claimed.append(json.loads(request.content)["campaignId"]) + return httpx.Response(200, json={"code": 0, "message": "ok"}) + + client = _client(handler) + result = await client.checkin(_cred()) + assert result.ok and result.credit == 100.0 + assert claimed == [34, 34] # claim + confirm 都用当轮 ID + await client.aclose() + +async def test_client_checkin_missing_campaign_id_still_fails_cleanly(): + """响应缺 `campaignId` → 判失败重试,绝不带 0 去打写接口。""" + sent: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/ops/delivery": + campaign = _campaign() + campaign.pop("campaignId") + return httpx.Response(200, json=_delivery(campaign)) + sent.append(json.loads(request.content)["campaignId"]) + return httpx.Response(200, json={"code": 0, "message": "ok"}) + + client = _client(handler) + result = await client.checkin(_cred()) + assert result.ok is False and "campaignId" in result.message + assert sent == [] # 没有写调用 + await client.aclose() + async def test_provider_checkin_status_and_scope(): provider = CodeArtsProvider(client=_client( lambda _r: httpx.Response(200, json=_delivery(_campaign())))) status = await provider.checkin_status(_cred().to_dict()) assert status == {"active": True, "today_checked_in": False, - "today_credit": 1000.0, "activity_name": "每日签到领1000 积分"} + "today_credit": 100.0, "activity_name": "每日签到领100积分"} # uid 隔离;身份未知回落凭证 ID(空串,绝不让两个未知账号共享 scope) assert provider.checkin_scope(_cred(uid="u9").to_dict()) == "codearts|u9" assert provider.checkin_scope(_cred(uid="").to_dict()) == "" @@ -260,5 +407,5 @@ def handler(request: httpx.Request) -> httpx.Response: provider = CodeArtsProvider(client=_client(handler)) result = await provider.checkin(_cred().to_dict()) - assert result.ok and result.credit == 1000.0 + assert result.ok and result.credit == 100.0 await provider.aclose() diff --git a/tests/provider/codearts/test_client.py b/tests/provider/codearts/test_client.py index d44ef689..791fcffc 100644 --- a/tests/provider/codearts/test_client.py +++ b/tests/provider/codearts/test_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime import json import time @@ -12,10 +13,12 @@ from src.provider.codearts import auth as codearts_auth from src.provider.codearts import events as codearts_events from src.provider.codearts.client import ( + CREDIT_EXPIRING_WINDOW_SECONDS, CodeArtsClient, UpstreamHTTPError, _credit_rate, _flatten_content, + _iso_epoch, _legacy_blocks, _models_from_items, _parse_ratio, @@ -23,6 +26,7 @@ derive_chat_id, new_chat_id, parse_credits, + parse_web_credits, prepare_body, request_path, sdk_date, @@ -64,7 +68,9 @@ def test_client_prepare_body_v2_and_legacy(): assert body["stream"] is True and body["model"] == "m" assert body["chat_id"] == "c" and body["prompt_cache_key"] == "c" assert body["tool_stream"] is True and body["temperature"] == 0.5 - assert "unknown" not in body + # 透传为默认:只有必须改写的键被拦,未知键也照发(上游是 OpenAI 形状, + # 白名单会把上游支持的字段静默丢掉) + assert body["unknown"] == 1 body["messages"][0]["content"] = "changed" # 深拷贝 assert messages[0]["content"] == "hi" @@ -75,6 +81,57 @@ def test_client_prepare_body_v2_and_legacy(): assert "user_id" not in prepare_body({"messages": []}, "m", chat_id="c", legacy=True) + +def test_client_prepare_body_forwards_sampling_and_usage_fields(): + """v2 不再用白名单:采样 / 工具 / 用量相关字段必须原样到达上游。""" + payload = { + "messages": [{"role": "user", "content": "hi"}], + "max_completion_tokens": 64, + "top_k": 20, + "seed": 7, + "parallel_tool_calls": False, + "stream_options": {"include_usage": True}, + "model": "被解析后的模型名", + "chat_id": "不应透传", + } + body = prepare_body(payload, "resolved", chat_id="derived") + assert body["max_completion_tokens"] == 64 + assert body["top_k"] == 20 and body["seed"] == 7 + assert body["parallel_tool_calls"] is False + assert body["stream_options"] == {"include_usage": True} + # 必须改写的键由本渠道决定,下游同名字段不得覆盖 + assert body["model"] == "resolved" + assert body["chat_id"] == "derived" and body["prompt_cache_key"] == "derived" + +def test_client_prepare_body_clamps_output_tokens(): + """输出上限按渠道夹取到 65536(目录里 11 个模型全是这个上限)。 + + 下游只校验全局 `MAX_OUTPUT_TOKENS = 4_000_000`,原样透传会让客户端给 + 384000 的请求被上游确定性拒收,进而被判「参数无效」跳过整条渠道。 + """ + from src.provider.codearts.client import MAX_OUTPUT_TOKENS + + assert MAX_OUTPUT_TOKENS == 65536 + body = prepare_body({"messages": [], "max_tokens": 384_000, + "max_completion_tokens": 1_000_000}, "m", chat_id="c") + assert body["max_tokens"] == 65536 + assert body["max_completion_tokens"] == 65536 + # 上限以下原样保留 + body = prepare_body({"messages": [], "max_tokens": 4096, + "max_completion_tokens": 512}, "m", chat_id="c") + assert body["max_tokens"] == 4096 + assert body["max_completion_tokens"] == 512 + # 非 int(bool / str / None)不动:交给上游裁决,本层只挡已知上限 + body = prepare_body({"messages": [], "max_tokens": True, + "max_completion_tokens": "x"}, "m", chat_id="c") + assert body["max_tokens"] is True + assert body["max_completion_tokens"] == "x" + # legacy 形状不夹取(不是 OpenAI 载荷,没有这两个键的语义) + legacy = prepare_body({"messages": [], "max_tokens": 384_000}, "m", + chat_id="c", legacy=True) + assert "max_tokens" not in legacy + + def test_client_legacy_blocks_and_flatten(): assert _legacy_blocks("nope") == [] blocks = _legacy_blocks([ @@ -117,6 +174,21 @@ def test_client_credit_rate_and_ratio_parsing(): assert _credit_rate([1]) is None # 非 dict 档位跳过 assert _credit_rate("nope") is None assert _credit_rate([]) is None + # 时段档(off_peak)是折扣价,不是模型常规价:即使排在前面也必须跳过。 + # 实测形状来自 2026-11-04 codearts 模型目录(0.18x 常规 / 0.09x 错峰)。 + assert _credit_rate([ + {"time_slot_id": "off_peak", "ratio_display": "0.09x", + "before_display_ratio": "0.18x"}, + {"time_slot_id": "default", "ratio_display": "0.18x"}, + ]) == 0.18 + # 只有时段档 → 不猜(错峰若出现 x0,绝不能当成永久免费) + assert _credit_rate([{"time_slot_id": "off_peak", "ratio_display": "0x"}]) is None + # 常规档的三种写法(缺字段 / 空串 / "default")都认 + assert _credit_rate([{"time_slot_id": "", "ratio_display": "0.7x"}]) == 0.7 + assert _credit_rate([{"time_slot_id": None, "ratio_display": "0.7x"}]) == 0.7 + # 畸形 time_slot_id(非字符串)同样按「非默认档」丢弃,不落回猜值 + assert _credit_rate([{"time_slot_id": {"x": 1}, "ratio_display": "0.7x"}, + {"time_slot_id": "default", "ratio_display": "0.5x"}]) == 0.5 # 纯数字 / 无 x 后缀 / 布尔 / 非数字串 / 非法小数 assert _parse_ratio("0.7") == 0.7 assert _parse_ratio(0.5) == 0.5 @@ -149,7 +221,8 @@ def test_client_parse_credits_real_shape(): "package_credit_remain": 0.0, "show": True}, {"name": "usageBonusPackageCredit", "value": 0, "package_credit_amount": 13000.0, "package_credit_used": 1.28, - "package_credit_remain": 12998.72, "show": True}, + "package_credit_remain": 12998.72, + "package_credit_expiring_amount": 496.9, "show": True}, ], "start_date": "2026-10-01", "end_date": "2026-11-02", "package": {"package_name_cn": "体验版", "is_credit_package": True}, @@ -159,14 +232,19 @@ def test_client_parse_credits_real_shape(): assert quota.remaining == pytest.approx(13498.72) # 积分原值,不折 token assert quota.total == pytest.approx(13500.0) assert quota.probed_at == noon - # 到期日:本地 0 点,且进到期阶梯(积分用不完同样作废) + # 到期日只作套餐展示(cycle_end);**不进阶梯**——上游自己给的「7 天内 + # 到期量」远小于余额,证明 end_date 不是逐笔积分的作废时刻。 end = time.localtime(quota.cycle_end) assert (end.tm_year, end.tm_mon, end.tm_mday, end.tm_hour) == (2026, 11, 2, 0) - assert quota.expiry_ladder == [(quota.cycle_end, pytest.approx(13498.72))] + assert quota.expiry_ladder == [ + (noon + CREDIT_EXPIRING_WINDOW_SECONDS, pytest.approx(496.9))] # 分项包带中文名与共用到期日;合计项本身不进 packages(否则重复计数) assert [p["name"] for p in quota.packages] == ["基础积分", "按需积分", "福利积分"] assert quota.packages[0] == {"name": "基础积分", "total": 500.0, "used": 0.0, - "end": quota.cycle_end} + "end": quota.cycle_end, "expiring": None} + # 福利积分的「7 天内到期量」原样透传:它证明 end_date 不是每一笔的到期日 + assert quota.packages[2]["expiring"] == pytest.approx(496.9) + assert quota.packages[2]["end"] == quota.cycle_end # token 量级的 metric(usageTokenChatMessages)没有 credit 字段 → 不进 packages assert all(p["name"] != "usageTokenChatMessages" for p in quota.packages) @@ -190,6 +268,30 @@ def test_client_parse_credits_unknown_and_edge_shapes(): {"name": "usageTotalPackageCredit", "package_credit_amount": 10.0, "package_credit_remain": 3.0}]}) assert no_date.cycle_end is None and no_date.expiry_ladder is None + # 有到期日、有余额,但上游没给「7 天内到期量」→ 仍不登记阶梯:end_date 不是 + # 逐笔积分的作废时刻,拿它记阶梯会虚构「36 小时内全部作废」的告警 + no_expiring = parse_credits({"metrics": [ + {"name": "usageTotalPackageCredit", "package_credit_amount": 10.0, + "package_credit_remain": 3.0}], "end_date": "2026-11-02"}) + assert no_expiring.cycle_end is not None and no_expiring.expiry_ladder is None + # 到期量按分项求和(布尔/负数不参与),挂到「探测时刻 + 7 天」这一个桶 + summed = parse_credits({"metrics": [ + {"name": "usageBonusPackageCredit", "package_credit_amount": 9.0, + "package_credit_used": 1.0, "package_credit_expiring_amount": 4.0}, + {"name": "usageBasicPackageCredit", "package_credit_amount": 5.0, + "package_credit_used": 0.0, "package_credit_expiring_amount": 2.0}, + {"name": "usageOnDemandPackageCredit", "package_credit_amount": 1.0, + "package_credit_used": 0.0, "package_credit_expiring_amount": -3.0}, + {"name": "usageTotalPackageCredit", "package_credit_amount": 15.0, + "package_credit_remain": 14.0}]}, now=1000) + assert summed.expiry_ladder == [ + (1000 + CREDIT_EXPIRING_WINDOW_SECONDS, pytest.approx(6.0))] + # 只有到期量、剩余未知(合计项缺 remain)→ 无可消耗额度,不登记阶梯 + no_remain = parse_credits({"metrics": [ + {"name": "usageBonusPackageCredit", "package_credit_amount": 9.0, + "package_credit_used": 0.0, "package_credit_expiring_amount": 4.0}, + {"name": "usageTotalPackageCredit", "package_credit_amount": 9.0}]}, now=1000) + assert no_remain.remaining is None and no_remain.expiry_ladder is None # 剩余为 0:不携带可消耗额度 → 不进到期排序(与每日池同口径) drained = parse_credits({"metrics": [ {"name": "usageTotalPackageCredit", "package_credit_amount": 10.0, @@ -209,6 +311,14 @@ def test_client_parse_credits_unknown_and_edge_shapes(): {"name": "usageTotalPackageCredit", "package_credit_amount": 5.0, "package_credit_remain": 4.0}], "end_date": "2026-13-45"}) assert bad_date.cycle_end is None + # `expiring` 畸形(布尔/字符串)按「未提供」处理,不混进展示数字 + bad_expiring = parse_credits({"metrics": [ + {"name": "usageBonusPackageCredit", "package_credit_amount": 9.0, + "package_credit_used": 1.0, "package_credit_expiring_amount": True}, + {"name": "usageTotalPackageCredit", "package_credit_amount": 9.0, + "package_credit_remain": 8.0}]}) + assert bad_expiring.packages == [ + {"name": "福利积分", "total": 9.0, "used": 1.0, "end": None, "expiring": None}] assert [p["name"] for p in bad_date.packages] == ["usageNewPackageCredit"] # end_date 非字符串(上游换形状)同样当无到期信息 assert parse_credits({"metrics": [ @@ -236,8 +346,8 @@ def test_client_defaults_and_chat_url(): async def test_client_lazy_pools_and_aclose(): client = CodeArtsClient(endpoint="https://snap.test") - assert client._stream() is client._stream() # noqa: SLF001 - assert client._short() is client._short() # noqa: SLF001 + assert client._stream is client._stream # noqa: SLF001 + assert client._short is client._short # noqa: SLF001 await client.aclose() await CodeArtsClient().aclose() # 两侧都没创建 → 不报错 @@ -483,8 +593,329 @@ def non_json(_request: httpx.Request) -> httpx.Response: with pytest.raises(codearts_events.UpstreamProtocolViolation, match="non-JSON"): await _client(non_json).caller_identity(_cred()) + # 对外文案不得带上游端点 URL(PROPOSAL §6「错误响应不回流上游正文」): + # 异常文本只进日志,public_message 才是回客户端的受控标识。 + def non_json_capture(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="nope") + + with pytest.raises(codearts_events.UpstreamProtocolViolation) as caught: + await _client(non_json_capture).caller_identity(_cred()) + assert caught.value.public_message == "upstream returned a non-JSON response" + assert "http" not in caught.value.public_message + def non_object(_request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[1, 2]) with pytest.raises(codearts_events.UpstreamProtocolViolation, match="shape"): await _client(non_object).caller_identity(_cred()) + + +# ---------------------------------------------------------------- 网页额度明细 + +def _web_stats(*, expiring: float | None = None) -> dict: + bonus: dict = {"name": "usageBonusPackageCredit", + "package_credit_amount": 15000.0, + "package_credit_used": 100.0} + if expiring is not None: + bonus["package_credit_expiring_amount"] = expiring + return { + "metrics": [ + {"name": "usageTotalPackageCredit", + "package_credit_amount": 15500.0, + "package_credit_remain": 15400.0}, + {"name": "usageBasicPackageCredit", + "package_credit_amount": 500.0, + "package_credit_used": 0.0}, + bonus, + ], + "end_date": "2030-12-01", + } + + +def _iso(epoch: int) -> str: + return datetime.datetime.fromtimestamp( + epoch, datetime.UTC).isoformat().replace("+00:00", "Z") + + +def _web_client(*, overview: dict, campaigns: dict, stats: dict | None = None, + on_overview=None): + """portal 明细路由;campaign 值可以是单页 dict 或按页排列的 list。""" + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == codearts_events.EP_STATISTICS_PLUGIN: + return httpx.Response(200, json=stats or _web_stats()) + assert request.headers["Cookie"] == "DEVCLOUD=abc" + assert request.headers["X-Requested-With"] == "XMLHttpRequest" + assert request.headers["User-Agent"] == codearts_events.WEB_PORTAL_UA + # 网页控制台只认会话 Cookie:绝不能带 AK/SK 签名头 + assert "Authorization" not in request.headers + assert "X-Security-Token" not in request.headers + if path.endswith(codearts_events.EP_CREDIT_OVERVIEW): + if on_overview is not None: + return on_overview(request) + return httpx.Response(200, json={"code": 0, "data": overview}) + for campaign_id, pages in campaigns.items(): + if path.endswith(f"{codearts_events.EP_CREDIT_CAMPAIGN}/{campaign_id}"): + page = int(request.url.params["pageNum"]) + payload = pages[page - 1] if isinstance(pages, list) else pages + return httpx.Response(200, json={"code": 0, "data": payload}) + return httpx.Response(404) + return _client(handler) + + +def _web_cred(): + return _cred(web_cookie="DEVCLOUD=abc") + + +async def test_probe_quota_uses_web_credits_when_cookie_present(): + """有 Cookie:福利包换成逐笔明细,阶梯用真实 expireAt(余额仍走汇总)。""" + active = _iso(int(time.time()) + 30 * 86400) + older = _iso(int(time.time()) + 60 * 86400) + expired = _iso(int(time.time()) - 86400) + overview = {"items": [ + {"campaignId": 34}, {"campaignId": 1}, + {"campaignId": "bad"}, "junk", {"campaignId": True}, {"campaignId": 1}, + ]} + campaigns = { + 34: {"campaignTitle": "每日签到领100积分", "total": 2, "benefits": [ + {"benefitId": 1, "claimedAmount": 100.0, "consumedAmount": 40.0, + "expiredAmount": 0.0, "expireAt": active, + "benefitText": "每日签到领100积分"}, + {"benefitId": 2, "claimedAmount": 100.0, "consumedAmount": 100.0, + "expiredAmount": 0.0, "expireAt": expired, + "benefitText": "已用尽"}, + ]}, + # 没有 benefitText:回落 campaignTitle + 1: {"campaignTitle": "每日签到领积分", "total": 1, "benefits": [ + {"benefitId": 3, "claimedAmount": 1000.0, "consumedAmount": 0.0, + "expiredAmount": 0.0, "expireAt": older}, + ]}, + } + quota = await _web_client(overview=overview, campaigns=campaigns + ).probe_quota(_web_cred()) + + assert quota.remaining == pytest.approx(15400.0) + assert quota.total == pytest.approx(15500.0) + names = [item["name"] for item in quota.packages] + # 已过期(benefitId=2,expireAt 在昨天)整条丢弃: + assert names == ["基础积分", "每日签到领100积分", "每日签到领积分"] + assert "福利积分" not in names + assert quota.expiry_ladder == [(_iso_epoch(active), 60.0), + (_iso_epoch(older), 1000.0)] + assert quota.web_cookie_state == "ok" + + +async def test_probe_quota_web_credits_paginates_until_total(): + """按 total 翻页;total 非 int 时靠「不满一页」收尾。""" + requested_pages: list[int] = [] + first = [{"claimedAmount": 1.0, "expireAt": "2030-01-01T00:00:00Z"} + for _ in range(100)] + second = [{"claimedAmount": 1.0, "expireAt": "2030-01-02T00:00:00Z"} + for _ in range(50)] + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == codearts_events.EP_STATISTICS_PLUGIN: + return httpx.Response(200, json=_web_stats()) + if path.endswith(codearts_events.EP_CREDIT_OVERVIEW): + return httpx.Response(200, json={"code": 0, + "data": {"items": [{"campaignId": 7}]}}) + page = int(request.url.params["pageNum"]) + assert request.url.params["pageSize"] == str( + codearts_events.WEB_CREDIT_PAGE_SIZE) + requested_pages.append(page) + if page == 1: + return httpx.Response(200, json={"code": 0, "data": { + "campaignTitle": "活动", "total": 150, "benefits": first}}) + return httpx.Response(200, json={"code": 0, "data": { + "campaignTitle": "活动", "total": 150, "benefits": second}}) + + quota = await _client(handler).probe_quota(_web_cred()) + assert requested_pages == [1, 2] + assert len(quota.packages) == 1 + 150 # 基础积分 + 两页逐笔 + assert quota.expiry_ladder and len(quota.expiry_ladder) == 150 # 逐笔各一档 + + +async def test_web_campaign_rows_pagination_edges(monkeypatch): + """空页、非数值 total 的短页、翻页上限都要能收尾(不死循环)。""" + def handler_factory(pages): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == codearts_events.EP_STATISTICS_PLUGIN: + return httpx.Response(200, json=_web_stats(expiring=500.0)) + if request.url.path.endswith(codearts_events.EP_CREDIT_OVERVIEW): + return httpx.Response(200, json={"code": 0, + "data": {"items": [{"campaignId": 7}]}}) + page = int(request.url.params["pageNum"]) + return httpx.Response(200, json={"code": 0, "data": pages[page - 1]}) + return handler + + # 空页:立即收尾,整体回退(没有可用明细) + empty = await _client(handler_factory([ + {"campaignTitle": "空", "total": 0, "benefits": []}, + ])).probe_quota(_web_cred()) + assert "福利积分" in [item["name"] for item in empty.packages] + + # total 不是整数:满页继续,靠「不满一页」收尾 + full = [{"claimedAmount": 1.0, "expireAt": "2030-01-01T00:00:00Z"} + for _ in range(codearts_events.WEB_CREDIT_PAGE_SIZE)] + short = await _client(handler_factory([ + {"campaignTitle": "短页", "total": "bad", "benefits": full}, + {"campaignTitle": "短页", "total": "bad", + "benefits": [{"claimedAmount": 1.0, "expireAt": "2030-01-01T00:00:00Z"}]}, + ])).probe_quota(_web_cred()) + assert len(short.packages) == 1 + codearts_events.WEB_CREDIT_PAGE_SIZE + 1 + + # 翻页上限:total 远大于实际,仍会在 MAX 页后停 + monkeypatch.setattr("src.provider.codearts.client.MAX_WEB_CREDIT_PAGES", 1) + capped = await _client(handler_factory([ + {"campaignTitle": "长", "total": 999, + "benefits": [{"claimedAmount": 1.0, "expireAt": "2030-01-01T00:00:00Z"} + for _ in range(codearts_events.WEB_CREDIT_PAGE_SIZE)]}, + {"campaignTitle": "长", "total": 999, "benefits": []}, + ])).probe_quota(_web_cred()) + assert len(capped.packages) == 1 + codearts_events.WEB_CREDIT_PAGE_SIZE + + +async def test_probe_quota_web_credits_fall_back_on_any_failure(): + """Cookie 失效 / 形状变化 / 网络错误都只丢明细,额度仍走汇总口径。""" + def status_401(_request: httpx.Request) -> httpx.Response: + return httpx.Response(401, content=b'{"code":401}') + + def non_json(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="nope") + + def bad_code(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": 1, "message": "denied"}) + + def missing_data(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": 0}) + + def network_boom(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom", request=request) + + def non_object(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[1, 2]) + + def server_error(_request: httpx.Request) -> httpx.Response: + return httpx.Response(500, content=b"down") + + for overview_handler, state in ( + (status_401, "expired"), + (non_json, None), + (non_object, None), + (server_error, None), + (bad_code, None), + (missing_data, None), + (network_boom, None)): + before = int(time.time()) + quota = await _web_client(overview={}, campaigns={}, + stats=_web_stats(expiring=500.0), + on_overview=overview_handler + ).probe_quota(_web_cred()) + assert quota.remaining == pytest.approx(15400.0) + assert [item["name"] for item in quota.packages] == ["基础积分", "福利积分"] + assert len(quota.expiry_ladder) == 1 + assert quota.expiry_ladder[0][1] == pytest.approx(500.0) + assert before + CREDIT_EXPIRING_WINDOW_SECONDS <= quota.expiry_ladder[0][0] + assert quota.web_cookie_state == state + + # 活动页形状坏了也整体回退(不能在缺数据时拼出半份明细) + def no_benefits(request: httpx.Request) -> httpx.Response: + if request.url.path == codearts_events.EP_STATISTICS_PLUGIN: + return httpx.Response(200, json=_web_stats(expiring=500.0)) + if request.url.path.endswith(codearts_events.EP_CREDIT_OVERVIEW): + return httpx.Response(200, json={"code": 0, + "data": {"items": [{"campaignId": 34}]}}) + return httpx.Response(200, json={"code": 0, "data": {"benefits": "bad"}}) + + quota = await _client(no_benefits).probe_quota(_web_cred()) + assert quota.expiry_ladder and quota.expiry_ladder[0][1] == pytest.approx(500.0) + + +async def test_probe_quota_web_credits_empty_overview_falls_back(): + """overview 没有任何 campaign(或 items 形状不对)→ 汇总口径。""" + for overview in ({"items": []}, {"items": "bad"}, {}): + quota = await _web_client(overview=overview, campaigns={} + ).probe_quota(_web_cred()) + assert quota.remaining == pytest.approx(15400.0) + assert "福利积分" in [item["name"] for item in quota.packages] + # 会话本身可用(只是没有活动)→ 不应误报失效 + assert quota.web_cookie_state == "ok" + + +def test_parse_web_credits_edges_and_clamps(): + """逐笔解析:过滤脏条目、消耗夹紧、过期剔除、命名回落。""" + now = 1_000_000 + within = _iso(now + 86400) + rows = [ + ("标题", {"claimedAmount": 10.0, "consumedAmount": 4.0, + "expiredAmount": 2.0, "expireAt": within}), + ("用完", {"claimedAmount": 5.0, "consumedAmount": 99.0}), + (None, {"claimedAmount": 7.0, "benefitText": " "}), + ("only-title", {"claimedAmount": 3.0, "benefitText": 123}), + ("零", {"claimedAmount": 0.0}), + ("布尔", {"claimedAmount": True}), + ("坏条目", "bad"), + ("坏日期", {"claimedAmount": 1.0, "expireAt": "not-a-date"}), + ("已过期", {"claimedAmount": 9.0, "consumedAmount": 1.0, + "expireAt": _iso(now - 1)}), + ("负消耗", {"claimedAmount": 1.0, "consumedAmount": -5.0}), + ("远期", {"claimedAmount": 2.0, "benefitText": "远期", + "expireAt": "2030-01-01T00:00:00"}), + ] + packages, ladder = parse_web_credits(rows, now=now) + assert [item["name"] for item in packages] == [ + "标题", "用完", "", "only-title", "坏日期", "负消耗", "远期"] + assert "已过期" not in [item["name"] for item in packages] + assert packages[0]["used"] == pytest.approx(6.0) + assert packages[0]["expiring"] == pytest.approx(4.0) + assert packages[1]["used"] == pytest.approx(5.0) + assert packages[2]["end"] is None and packages[2]["expiring"] is None + assert packages[6]["end"] == _iso_epoch("2030-01-01T00:00:00Z") + assert ladder == [(now + 86400, 4.0), + (_iso_epoch("2030-01-01T00:00:00Z"), 2.0)] + # 空输入 / 全是无效条目 → None(调用方回退) + assert parse_web_credits([], now=now) is None + assert parse_web_credits([("x", {"claimedAmount": 0})], now=now) is None + + +async def test_probe_quota_web_detail_keeps_probe_failed_flag(): + """汇总接口判「未知」时,网页明细不能把它洗成「成功但余额空」。""" + stats = {"metrics": [{"name": "usageUnknown", "x": 1}]} + quota = await _web_client( + overview={"items": [{"campaignId": 34}]}, + campaigns={34: {"campaignTitle": "x", "total": 1, "benefits": [ + {"claimedAmount": 10.0, "expireAt": "2030-01-01T00:00:00Z"}]}}, + stats=stats, + ).probe_quota(_web_cred()) + assert quota.probe_failed is True and quota.remaining is None + assert quota.web_cookie_state == "ok" + assert quota.packages and quota.packages[-1]["total"] == pytest.approx(10.0) + + +async def test_probe_quota_marks_web_cookie_expired_on_login_shapes(): + """portal 未登录的四种形态都打「已失效」,其余失败保持未知。""" + def empty(_request): + return httpx.Response(200, content=b"") + + def html(_request): + return httpx.Response(200, text="") + + def redirect(_request): + return httpx.Response(302, headers={"Location": "https://auth.huaweicloud.com/authui/login"}) + + def business_401(_request): + return httpx.Response(200, json={"code": 401, "message": "login"}) + + def business_403(_request): + return httpx.Response(200, json={"code": 403, "message": "forbidden"}) + + def forbidden(_request): + return httpx.Response(403, content=b"no") + + for handler in (empty, html, redirect, business_401, business_403, forbidden): + quota = await _web_client(overview={}, campaigns={}, + on_overview=handler).probe_quota(_web_cred()) + assert quota.web_cookie_state == "expired" + assert "福利积分" in [item["name"] for item in quota.packages] + assert quota.remaining == pytest.approx(15400.0) diff --git a/tests/provider/codearts/test_credential.py b/tests/provider/codearts/test_credential.py index 59afe590..c6e759b3 100644 --- a/tests/provider/codearts/test_credential.py +++ b/tests/provider/codearts/test_credential.py @@ -121,3 +121,15 @@ def test_credential_merge_refreshed_rewrites_rotating_fields(): assert merged.code_verifier == "cv" and merged.domain == "d" # 刷新回包无 expiration → 沿用旧的 assert merged.expiration == old.expiration + + +def test_credential_web_cookie_roundtrip_and_aliases(): + assert _cred(web_cookie="sid=1").to_dict()["web_cookie"] == "sid=1" + assert CodeArtsCredential.from_dict({"webCookie": "a=1"}).web_cookie == "a=1" + assert CodeArtsCredential.from_dict({"cookie": "b=2"}).web_cookie == "b=2" + assert CodeArtsCredential.from_dict({}).web_cookie == "" + + +def test_merge_refreshed_preserves_web_cookie(): + old = _cred(web_cookie="sid=1", refresh_token="rt", dpop_private_jwk=_jwk()) + assert merge_refreshed(old, _token_response()).web_cookie == "sid=1" diff --git a/tests/provider/codearts/test_events.py b/tests/provider/codearts/test_events.py index f051d56c..3dfd5ab6 100644 --- a/tests/provider/codearts/test_events.py +++ b/tests/provider/codearts/test_events.py @@ -194,17 +194,63 @@ def test_events_classify_error_code_branches(): assert classify("ChatAgent.99999999") is ErrKind.OTHER def test_events_classify_request_marker_is_not_credential_fault(): - """`InferHub.001001020.406 not multimodal` 是请求侧错误(实测原文)。 + """`InferHub.001001020.406 not multimodal` 是模型能力拒绝 → CAPABILITY。 - 判成 OTHER 会让正常用户带图请求 3 次后把整条渠道拉进 10min 冷却; - REQUEST 不冷却、不累计、不落库,仍换号重试。 + 判成 OTHER 会让正常用户带图请求 3 次后把整条渠道拉进 10min 冷却;判成 + REQUEST 则会**逐个号去撞**(2026-11-01 22:18–22:20 实测:4 个带图请求在 + codearts 的 3 条凭证上各失败一次,`MAX_ROTATE = 3` 被同一渠道吃满,请求 + 永远轮不到支持图片的 codebuddy)。正确语义是「这个模型收不了图片」—— + 同渠道换号无用,跳过整个上游继续试其它渠道。 + + 处置与 INVALID 相同(`REJECT_KINDS` 一起进「跳过上游」分支);统计口径 + 独立成 `model_capability`,归因面板才不会把「模型能力不支持」写成 + 「请求写错了」(2026-11-03 从 INVALID 里独立出来)。 """ classify = codearts_events.classify_error_code - assert classify("InferHub.001001020.406") is ErrKind.REQUEST + assert classify("InferHub.001001020.406") is ErrKind.CAPABILITY assert classify( "InferHub.001001020.406 The request model is not multimodal" - ) is ErrKind.REQUEST - assert classify("The request model is not multimodal") is ErrKind.REQUEST + ) is ErrKind.CAPABILITY + assert classify("The request model is not multimodal") is ErrKind.CAPABILITY + + +def test_events_param_invalid_is_not_plan(): + """`InferHub.001001005.400 param is invalid` 是请求侧错误 → INVALID,不是 PLAN。 + + `001001005` 是点分错误码前缀,子串里含 `1005`;此前靠 + `"1005" in text` 判据直接落到「权益耗尽」→ **12h 账号级冷却**。实测 + 2026-11-03 14:41–14:55 三条 codearts 凭证相继命中,整条渠道静默下线 12h + (见 docs/oss-feature-migration-scan-2026-11-03.md §0.1)。 + + 判 INVALID 的处置是「换号无用、跳过整个上游、以 400 收尾」,与「等额度」 + 完全相反;同时真正的业务码 1005(权益耗尽)必须仍然判 PLAN。 + """ + classify = codearts_events.classify_error_code + assert classify( + "InferHub.001001005.400 The request param is invalid, Please check it" + ) is ErrKind.INVALID + assert classify("001001005") is ErrKind.INVALID + assert classify("The request param is invalid") is ErrKind.INVALID + # 防回归:真 PLAN 不能被放宽的 marker 抢走(`00001005` 只含 1005 子串) + assert classify("1005") is ErrKind.PLAN + assert classify("1005 quota exceeded") is ErrKind.PLAN + assert classify("insufficient balance") is ErrKind.PLAN + + +def test_events_param_invalid_status_path_is_invalid(): + """HTTP 400 形态的同一错误也判 INVALID(`classify_status` 与流内同口径)。""" + body = (b'{"error_code":"InferHub.001001005.400",' + b'"error_msg":"The request param is invalid, Please check it"}') + assert codearts_events.classify_status(400, body) is ErrKind.INVALID + + +def test_events_multimodal_rejection_skips_provider_instead_of_rotating(): + """HTTP 400 与流内两条路径对同一错误给出同一分类(都跳过该上游)。""" + body = (b'{"error_code":"InferHub.001001020.406",' + b'"error_msg":"The request model is not multimodal"}') + assert codearts_events.classify_status(400, body) is ErrKind.CAPABILITY + assert codearts_events.classify_error_code( + "InferHub.001001020.406 The request model is not multimodal") is ErrKind.CAPABILITY def test_events_relogin_required_markers(): """令牌端点终态判定:只有点名的一次性票据失效类错误才算不可自愈。""" @@ -268,3 +314,17 @@ async def gen(): yield b"event: ping\n" yield b": comment\n" assert [line async for line in codearts_events.iter_data_lines(gen())] == [] + + +def test_parse_line_attaches_upstream_reported_model(): + """CodeArts 载荷带 model 时挂到事件上(观测用,不改语义)。""" + snapshot = codearts_events.TextSnapshot() + events = codearts_events.parse_line(json.dumps({ + "model": "DeepSeek-V4.1", + "choices": [{"delta": {"content": "hi"}}], + }), snapshot) + assert events[0].content == "hi" + assert events[0].model == "DeepSeek-V4.1" + # 不报模型时保持 None + plain = codearts_events.parse_line('{"text": "hi"}', snapshot) + assert plain[0].model is None diff --git a/tests/provider/codearts/test_provider.py b/tests/provider/codearts/test_provider.py index 42ca431e..81962cff 100644 --- a/tests/provider/codearts/test_provider.py +++ b/tests/provider/codearts/test_provider.py @@ -16,6 +16,15 @@ from tests.provider.codearts._support import token_response as _token_response +def test_login_identity_prefers_uid_then_domain_and_name(): + """重新登录合并键:uid 稳定;老凭证缺 uid 时用 domain_id|user_name。""" + provider = CodeArtsProvider() + assert provider.login_identity({"uid": "u1", "domain_id": "d", "user_name": "n"}) == "u1" + assert provider.login_identity({"uid": "", "domain_id": "d", "user_name": "n"}) == "d|n" + assert provider.login_identity({"uid": "", "domain_id": "d", "user_name": ""}) == "" + assert provider.login_identity({}) == "" + + def test_provider_id_classify_credential_from_and_key(): provider = CodeArtsProvider() assert provider.id == "codearts" @@ -140,3 +149,13 @@ def boom(_request: httpx.Request) -> httpx.Response: _ = [e async for e in plain.stream_chat( {"access_key_id": "AK", "secret_access_key": "SK"}, {}, "m")] await plain.aclose() + + +def test_provider_set_web_cookie_only_touches_that_field(): + """与 Qoder 同纪律:只改 web_cookie,不重走归一(老字段原样保留)。""" + provider = CodeArtsProvider(client=_client(lambda _request: httpx.Response(200))) + data = {"access_key_id": "AK", "unknown_future": 1} + assert provider.set_web_cookie(data, " sid=1 ") == { + "access_key_id": "AK", "unknown_future": 1, "web_cookie": " sid=1 "} + assert "web_cookie" not in data + assert provider.set_web_cookie({"web_cookie": "x"}, "")["web_cookie"] == "" diff --git a/tests/provider/codebuddy/test_client.py b/tests/provider/codebuddy/test_client.py index b20beb42..ef5603d7 100644 --- a/tests/provider/codebuddy/test_client.py +++ b/tests/provider/codebuddy/test_client.py @@ -44,10 +44,15 @@ def test_classify_body_markers(): # 「该后端无此模型」:(账号, 模型) 负缓存 (400, b'{"code": 11102}', ErrKind.BLOCKED), (404, b'{"code": 11102}', ErrKind.BLOCKED), - # 请求级错误:不罚号,仍换号(请求体坏 / 上下文超限 / 渠道风控 / 图片无效) + # 「套餐不允许」:对**当前凭证**与 11102 等价(重试无意义、换号才有用) + (400, b'{"code": 11158, "msg": "model [gpt-6-luna] is not allowed for user ' + b'type [personal_free_user]"}', ErrKind.BLOCKED), + (404, b'{"code": 11158}', ErrKind.BLOCKED), + # 请求级错误:不罚号,仍换号(请求体坏 / 渠道风控 / 图片无效) (400, b'{"code": 11101}', ErrKind.REQUEST), (400, b'{"code": 500, "msg": "Unmarshal chat params failed"}', ErrKind.REQUEST), - (400, b'{"code": 11115, "msg": "prompt is too long"}', ErrKind.REQUEST), + # 上下文超限(11115):处置同请求级错误,统计独立成 CONTEXT + (400, b'{"code": 11115, "msg": "prompt is too long"}', ErrKind.CONTEXT), # 渠道风控(瞬时,窗口内自愈):曾误落 INVALID → 零重试直接 400,实测误伤 (400, b'{"code": 11128, "msg": "Illegal API invocation from an unapproved channel"}', ErrKind.REQUEST), @@ -55,8 +60,21 @@ def test_classify_body_markers(): # 裸数字不误伤:111020 不是 11102(要求带 "code": 键值形态) (400, b'{"trace": 1110201}', ErrKind.INVALID), (400, b'{"code": 111020}', ErrKind.INVALID), + (400, b'{"code": 111580}', ErrKind.INVALID), # 嵌套信封:业务码在内层,按集合成员判断不漏 (400, b'{"code": 0, "data": {"code": 11102}}', ErrKind.BLOCKED), + # 字符串形态的业务码(2026-11-02 上游已这么发) + (400, b'{"code": "11128"}', ErrKind.REQUEST), + (400, b'{"code": "14018"}', ErrKind.CREDIT), + # 403 上的渠道风控:必须拦在 DEAD 之前,否则一次命中硬禁用整池好号 + (403, b'{"code":"11-128","msg":"request illegal"}', ErrKind.REQUEST), + (403, b'{"code": 11128}', ErrKind.REQUEST), + # 403 的其他形态仍是凭据失效;401 不受上面那条影响 + (403, b'{"code": 1001, "msg": "session expired"}', ErrKind.DEAD), + (403, b"forbidden", ErrKind.DEAD), + (401, b'{"code":"11-128"}', ErrKind.DEAD), + # 11140 的风控优先级仍高于 403 的请求级分支 + (403, b'{"code": 11140, "msg": "request illegal"}', ErrKind.RESTRICTED), (404, b"not json at all", ErrKind.SOFT), (500, b'{"code": 9999}', ErrKind.OTHER), ]) @@ -159,3 +177,75 @@ def test_error_type_for_invalid(): from src.engine.executor import _error_type_for assert _error_type_for(ErrKind.INVALID) == "invalid_request" + + +# ------------------------------------------- 国际站指纹(2026-10-29) + +def test_conversation_seed_prefers_explicit_then_first_message(): + from src.provider.codebuddy.client import conversation_seed + + explicit = conversation_seed({ + "prompt_cache_key": "p1", + "messages": [{"role": "user", "content": "hi"}]}) + assert explicit == "prompt_cache_key:p1" + + seeded = conversation_seed({"messages": [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "同一个问题"}]}) + assert seeded is not None and seeded.startswith("first:") + # 多轮追加后首条用户消息不变 → 仍是同一个会话种子(换号/重试不换 id) + grown = conversation_seed({"messages": [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "同一个问题"}, + {"role": "assistant", "content": "答"}, + {"role": "user", "content": "追问"}]}) + assert grown == seeded + + assert conversation_seed({}) is None + assert conversation_seed({"messages": "not a list"}) is None + # 脏消息(非 dict)跳过,继续找第一条可用的 + assert conversation_seed({"messages": ["junk", {"role": "user", "content": "x"}]} + ).startswith("first:") + assert conversation_seed({"messages": [{"role": "system", "content": "s"}]}) is None + + +async def test_stream_chat_intl_gzips_body_and_keeps_conversation_id(): + """官方 CLI 形态:gzip 体 + 同一会话复用 conversation id、每轮换 request id。""" + import gzip + + from tests.helpers.http import provider_client + + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, text=fixture("chat-basic.sse")) + + client = provider_client(CodeBuddyClient, handler, intl=True, gzip_body=True) + payload = {"messages": [{"role": "user", "content": "hi"}]} + for _ in range(2): + [event async for event in client.stream_chat( + CodeBuddyCredential(bearer_token="t"), payload, "glm-5.2")] + + first, second = seen + assert first.headers["content-encoding"] == "gzip" + assert json.loads(gzip.decompress(first.content))["stream"] is True + assert first.headers["x-conversation-id"] == second.headers["x-conversation-id"] + assert first.headers["x-request-id"] != second.headers["x-request-id"] + assert first.headers["x-model-id"] == "glm-5.2" + assert first.headers["user-agent"].startswith("CLI/2.178.0") + + +async def test_stream_chat_cn_keeps_plain_json_body(): + """国内站不受国际指纹改动影响:明文 JSON、无 Content-Encoding。""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["encoding"] = request.headers.get("content-encoding") + seen["body"] = json.loads(request.content) + return httpx.Response(200, text=fixture("chat-basic.sse")) + + [event async for event in _client(handler).stream_chat( + CodeBuddyCredential(bearer_token="t"), {"messages": []}, "glm-5.2")] + assert seen["encoding"] is None + assert seen["body"]["model"] == "glm-5.2" diff --git a/tests/provider/codebuddy/test_client_model_access.py b/tests/provider/codebuddy/test_client_model_access.py index 6a7e76e0..720ef64a 100644 --- a/tests/provider/codebuddy/test_client_model_access.py +++ b/tests/provider/codebuddy/test_client_model_access.py @@ -237,3 +237,36 @@ def handler(request: httpx.Request) -> httpx.Response: models = await provider.list_models({"bearer_token": "cn-token"}) assert [model.id for model in models] == ["glm-5.2", "glm-5.3"] assert paths == ["/v3/config"] + + +# --------------------------------------------- 探活结果落盘(2026-10-29) + +async def test_probe_result_persists_and_survives_restart(tmp_path): + """探活写盘;新进程(新 client)直接读盘,不再重打 20 连发。""" + from src.provider.codebuddy.client import CodeBuddyClient + from tests.helpers.http import provider_client + + path = str(tmp_path / "access.json") + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(_body(request)["model"]) + if _body(request)["model"] == "denied": + return httpx.Response(400, json={"code": 11158}) + return httpx.Response(200, content=b"data: {}\n\n") + + credential = CodeBuddyCredential(bearer_token="t", nickname="acct") + first = provider_client(CodeBuddyClient, handler, + access_cache_path=path) + kept = await first.filter_available_models( + credential, _models("ok", "denied")) + assert [model.id for model in kept] == ["ok"] + assert sorted(calls) == ["denied", "ok"] + + # 「重启」:新的 provider/client 实例 + 空的进程内缓存 + _MODEL_ACCESS_CACHE.clear() + second = provider_client(CodeBuddyClient, lambda _r: pytest.fail("不应重新探活"), + access_cache_path=path) + kept_again = await second.filter_available_models( + credential, _models("ok", "denied")) + assert [model.id for model in kept_again] == ["ok"] diff --git a/tests/provider/codebuddy/test_client_models.py b/tests/provider/codebuddy/test_client_models.py index 9bbbdc11..aa5e8f67 100644 --- a/tests/provider/codebuddy/test_client_models.py +++ b/tests/provider/codebuddy/test_client_models.py @@ -146,3 +146,16 @@ def handler(_request: httpx.Request) -> httpx.Response: with pytest.raises(UpstreamProtocolViolation): await _client(handler).fetch_models(CodeBuddyCredential(bearer_token="t")) + + +async def test_provider_list_models_log_includes_type_when_message_empty(caplog): + """裸 `TimeoutError` 消息为空 → 回退日志仍要带类名,不能只剩「: 」。""" + class _Client: + async def fetch_models(self, _credential): + raise TimeoutError() + + provider = CodeBuddyProvider(client=_Client()) + with caplog.at_level("WARNING", logger="src.provider.codebuddy.client"): + models = await provider.list_models({"bearer_token": "t"}) + assert [m.id for m in models] == list(DEFAULT_MODELS) + assert any("TimeoutError" in record.getMessage() for record in caplog.records) diff --git a/tests/provider/codebuddy/test_client_sanitize.py b/tests/provider/codebuddy/test_client_sanitize.py index 402bc239..1f3758cf 100644 --- a/tests/provider/codebuddy/test_client_sanitize.py +++ b/tests/provider/codebuddy/test_client_sanitize.py @@ -77,3 +77,44 @@ def test_sanitize_channel_markers_counts_repeats_and_skips_noise(): assert sanitize_channel_markers({}) == 0 assert sanitize_channel_markers({"messages": "nope"}) == 0 + + +def test_sanitize_channel_markers_covers_tool_descriptions(): + """`tools[].function.description` 是同一类指纹的高发区(客户端写进去的 + 「你是 Claude Code…」),不洗会被上游整单 11128 拒。 + + `parameters` 属模型调用契约,逐串替换会改坏枚举/示例值,故不动。 + """ + from src.provider.codebuddy.client import CHANNEL_MARKERS, sanitize_channel_markers + + marker = CHANNEL_MARKERS[0] + body = {"messages": [], "tools": [ + {"type": "function", "function": { + "name": "f", "description": f"说明 {marker} 结束", + "parameters": {"type": "object", "description": marker}}}, + {"type": "function", "function": {"name": "bare"}}, # 无 description + {"type": "function", "function": {"name": "g", "description": 5}}, + {"type": "function", "function": {"name": "clean", + "description": "无指纹"}}, + {"type": "function", "function": "not-a-dict"}, + "junk", + {"function": {"description": marker}}, # 无 type 也洗 + ]} + assert sanitize_channel_markers(body) == 2 + tools = body["tools"] + assert tools[0]["function"]["description"] == "说明 [external-client-identity] 结束" + assert tools[0]["function"]["parameters"]["description"] == marker + assert tools[1]["function"] == {"name": "bare"} + assert tools[2]["function"]["description"] == 5 + assert tools[3]["function"]["description"] == "无指纹" + assert tools[4]["function"] == "not-a-dict" + assert tools[5] == "junk" + assert tools[6]["function"]["description"] == "[external-client-identity]" + + +def test_sanitize_channel_markers_tolerates_bad_tools_shape(): + """`tools` 非数组 / 元素非 dict 时不炸(出站载荷可能来自宽松客户端)。""" + from src.provider.codebuddy.client import sanitize_channel_markers + + assert sanitize_channel_markers({"messages": [], "tools": "nope"}) == 0 + assert sanitize_channel_markers({"messages": [], "tools": [5, None]}) == 0 diff --git a/tests/provider/codebuddy/test_events.py b/tests/provider/codebuddy/test_events.py index f97351e5..9224da7b 100644 --- a/tests/provider/codebuddy/test_events.py +++ b/tests/provider/codebuddy/test_events.py @@ -296,3 +296,60 @@ def test_provider_classify_routes_14003_to_model(): assert CodeBuddyProvider().classify(429, body) is ErrKind.MODEL assert CodeBuddyIntlProvider().classify(429, body) is ErrKind.MODEL assert CodeBuddyIntlProvider().classify(429, b'{"code":9999}') is ErrKind.SOFT + + +def test_disabled_reason_flags_account_restriction(): + """403/11140「账号异常行为被限制」→ 可操作原因,不能写成 session dead。 + + 实测 2026-10-27 08:21 codebuddy_intl 六个号同时回 + `{"code":11140,...,"displayMsg":{"en":"Your account has been restricted + due to unusual activity","zh":"账号存在异常行为,已被限制使用"}}`:重登无效, + 只能申诉或换号,管理台必须显示真实动作。 + """ + body = (b'{"code":11140,"msg":"request illegal",' + b'"displayMsg":{"en":"Your account has been restricted due to unusual activity",' + b'"zh":"\xe8\xb4\xa6\xe5\x8f\xb7\xe5\xad\x98\xe5\x9c\xa8\xe5\xbc\x82\xe5\xb8\xb8' + b'\xe8\xa1\x8c\xe4\xb8\xba\xef\xbc\x8c\xe5\xb7\xb2\xe8\xa2\xab\xe9\x99\x90' + b'\xe5\x88\xb6\xe4\xbd\xbf\xe7\x94\xa8"}}') + reason = cb_events.disabled_reason(body) + assert "账号被上游限制" in reason and "申诉" in reason + # 国际版子类走同一实现 + assert CodeBuddyIntlProvider().disabled_reason(body) == reason + + +def test_disabled_reason_empty_for_plain_403_and_garbage(): + """认不出原因就返回空串(落库回落 session dead),并且不因坏字节崩。""" + assert cb_events.disabled_reason(b'{"code":11128}') == "" + assert cb_events.disabled_reason(b"") == "" + assert cb_events.disabled_reason(b"\xff\xfe") == "" + + +def test_classify_11140_is_account_restricted_not_dead(): + """403 + 11140 → RESTRICTED(账号级风控),不能判 DEAD 永久硬禁用。 + + DEAD 的处置是 disabled=1 + 人工 revive,而 11140 由上游自行解除;判错会 + 把「等它自己好」变成「必须有人去点恢复」。普通 403 仍是 DEAD。 + """ + body = (b'{"code":11140,"msg":"request illegal",' + b'"displayMsg":{"en":"Your account has been restricted due to unusual activity"}}') + assert cb_events.classify_status(403, body) is ErrKind.RESTRICTED + assert cb_events.classify_status(400, body) is ErrKind.RESTRICTED + # 三语文案任一条命中即算(上游不同入口的措辞不一致) + assert cb_events.classify_status( + 403, '{"displayMsg":{"zh":"账号存在异常行为,已被限制使用"}}'.encode() + ) is ErrKind.RESTRICTED + assert cb_events.classify_status(403, b'{"code":403}') is ErrKind.DEAD + + +def test_bare_11140_substring_is_not_account_restricted(): + """正文里恰好出现 `11140`(非业务码字段)不能判 RESTRICTED。 + + 整段子串匹配时,request id / 时间戳 / 用户回显内容里带这五个数字就会把这 + 个凭证按账号级风控处理:2h 冷却、窗口内第二次即整渠道 24h 暂停,且换号 + 无效。只有 `"code":11140` 形态或点名话术才算。 + """ + body = b'{"code":400,"msg":"bad params, request id 11140"}' + assert cb_events.classify_status(400, body) is not ErrKind.RESTRICTED + assert cb_events.disabled_reason(body) == "" + # 数字 token 必须整个相等(`"code":111401` 不是 11140) + assert cb_events.classify_status(403, b'{"code":111401}') is ErrKind.DEAD diff --git a/tests/provider/codebuddy/test_headers.py b/tests/provider/codebuddy/test_headers.py index f1604e77..f0a62142 100644 --- a/tests/provider/codebuddy/test_headers.py +++ b/tests/provider/codebuddy/test_headers.py @@ -99,3 +99,118 @@ def test_stainless_fingerprint_variants(): assert h._stainless_os() == "Linux" with mock.patch.object(h.platform, "system", return_value="SunOS"): assert h._stainless_os() == "SunOS" + + +# ------------------------------------------------- 国际站指纹(2026-10-29) + +def _jwt(claims: dict) -> str: + import base64 + import json + + payload = base64.urlsafe_b64encode( + json.dumps(claims).encode()).decode().rstrip("=") + return f"header.{payload}.sig" + + +def test_intl_profile_uses_current_cli_versions_and_extra_headers(): + """国际站换官方现行版本常量,并补会话/模型/链路头(缺一即自动化信号)。""" + headers = build_headers( + CodeBuddyCredential(bearer_token="t"), "https://www.codebuddy.ai", + intl=True, conversation_seed="sess-1", model="deepseek-v4.1-flash") + assert headers["User-Agent"] == "CLI/2.178.0 CodeBuddy/2.178.0" + assert headers["X-IDE-Version"] == "2.178.0" + assert headers["X-Product-Version"] == "2.178.0" + assert headers["x-stainless-package-version"] == "7.39.0" + assert headers["x-stainless-runtime-version"] == "v26.7.0" + assert headers["X-Trace-ID"] == headers["X-Request-ID"] + assert headers["X-Session-ID"] == headers["X-Conversation-ID"] + assert headers["X-Model-ID"] == "deepseek-v4.1-flash" + + +def test_intl_conversation_id_is_stable_uuid_v1_per_session(): + """同一会话每轮同一个 id(官方 CLI 形态);不同会话互不相同。""" + first = build_headers(CodeBuddyCredential(bearer_token="t"), + "https://www.codebuddy.ai", intl=True, + conversation_seed="conv-a") + second = build_headers(CodeBuddyCredential(bearer_token="t"), + "https://www.codebuddy.ai", intl=True, + conversation_seed="conv-a") + other = build_headers(CodeBuddyCredential(bearer_token="t"), + "https://www.codebuddy.ai", intl=True, + conversation_seed="conv-b") + assert first["X-Conversation-ID"] == second["X-Conversation-ID"] + assert other["X-Conversation-ID"] != first["X-Conversation-ID"] + # v1 的版本位在第三段首位 + assert first["X-Conversation-ID"].split("-")[2][0] == "1" + assert len(first["X-Conversation-ID"]) == 36 + + +def test_intl_without_seed_falls_back_to_random_conversation_id(): + a = build_headers(CodeBuddyCredential(bearer_token="t"), + "https://www.codebuddy.ai", intl=True) + b = build_headers(CodeBuddyCredential(bearer_token="t"), + "https://www.codebuddy.ai", intl=True) + assert a["X-Conversation-ID"] != b["X-Conversation-ID"] + assert "X-Model-ID" not in a + + +def test_cn_profile_stays_byte_identical(): + """国内站正在稳定工作:版本常量与「逐请求随机」的行为都不动。""" + headers = build_headers(CodeBuddyCredential(bearer_token="t"), + "https://copilot.tencent.com") + assert headers["User-Agent"] == "CLI/2.107.0 CodeBuddy/2.107.0" + assert headers["x-stainless-package-version"] == "6.25.0" + for extra in ("X-Session-ID", "X-Trace-ID", "X-Product-Version", "X-Model-ID"): + assert extra not in headers + again = build_headers(CodeBuddyCredential(bearer_token="t"), + "https://copilot.tencent.com") + assert again["X-Conversation-ID"] != headers["X-Conversation-ID"] + + +def test_intl_user_id_falls_back_to_jwt_sub(): + """本地 intl 凭证的 user_id/account_uid 都是空串;官方 CLI 每请求都发 uid。""" + token = _jwt({"sub": "3455d334-5b72-49f9", "iss": "https://www.codebuddy.ai"}) + headers = build_headers(CodeBuddyCredential(bearer_token=token), + "https://www.codebuddy.ai", intl=True) + assert headers["X-User-Id"] == "3455d334-5b72-49f9" + # 显式 uid 优先 + explicit = build_headers( + CodeBuddyCredential(bearer_token=token, account_uid="acct"), + "https://www.codebuddy.ai", intl=True) + assert explicit["X-User-Id"] == "acct" + # 国内站不回退:历史形态逐字节不变 + assert "X-User-Id" not in build_headers(CodeBuddyCredential(bearer_token=token), + "https://copilot.tencent.com") + + +def test_intl_quota_only_headers_skip_identity(): + headers = build_headers( + CodeBuddyCredential(bearer_token=_jwt({"sub": "u"})), + "https://www.codebuddy.ai", intl=True, quota_only=True) + assert "X-User-Id" not in headers + assert headers["X-Session-ID"] # 指纹头照发,只有身份头在 quota_only 下不发 + + +def test_uid_from_token_rejects_malformed_input(): + from src.provider.codebuddy.headers import uid_from_token + + assert uid_from_token("") == "" + assert uid_from_token("only.two") == "" + assert uid_from_token("a.!!!.c") == "" + assert uid_from_token(_jwt(["not", "an", "object"])) == "" + assert uid_from_token(_jwt({"sub": 42})) == "" + assert uid_from_token(_jwt({"iss": "x"})) == "" + assert uid_from_token(_jwt({"sub": "u-1"})) == "u-1" + + +def test_conversation_uuid_cache_is_bounded(monkeypatch): + """条目上限:超量整体清空(会话 id 只是指纹,清空只影响一次)。""" + from src.provider.codebuddy import headers as hdrs + + monkeypatch.setattr(hdrs, "_CONVERSATION_CREATED_AT", {}) + monkeypatch.setattr(hdrs, "_CONVERSATION_CACHE_MAX", 2) + hdrs.conversation_uuid("a") + hdrs.conversation_uuid("b") + assert len(hdrs._CONVERSATION_CREATED_AT) == 2 + hdrs.conversation_uuid("c") + assert list(hdrs._CONVERSATION_CREATED_AT) == ["c"] diff --git a/tests/provider/codebuddy/test_intl.py b/tests/provider/codebuddy/test_intl.py index a9975f3b..e543b3b3 100644 --- a/tests/provider/codebuddy/test_intl.py +++ b/tests/provider/codebuddy/test_intl.py @@ -73,6 +73,29 @@ def test_intl_whitelist_excludes_cn_site(): assert validate_endpoint_allowed(intl, settings) +def test_intl_whitelist_rejects_cn_host_even_if_listed(): + """白名单里**混入**国内站主机 → 整个白名单作废(端点校验直接拒绝)。 + + 只校验「端点在白名单里」不够:把国内站主机加进国际站白名单后, + 端点校验会通过,国际站账号的 Token 照样发往国内站网关(跨站泄露)。 + 故白名单含国内站主机时一律拒绝。 + """ + settings = _settings( + CODEBUDDY_INTL_ALLOWED_ENDPOINTS=( + "https://www.codebuddy.ai,https://copilot.tencent.com")) + intl = "https://www.codebuddy.ai" + assert not validate_codebuddy_intl_endpoint_allowed(intl, settings) + # 带路径 / 端口 / 大小写 / 无 scheme 的国内站主机同样要识别出来 + for variant in ("https://copilot.tencent.com:443/", + "http://copilot.tencent.com", + "https://Copilot.Tencent.COM", + "copilot.tencent.com", + "copilot.tencent.com:443/path"): + poisoned = _settings( + CODEBUDDY_INTL_ALLOWED_ENDPOINTS=f"https://www.codebuddy.ai,{variant}") + assert not validate_codebuddy_intl_endpoint_allowed(intl, poisoned), variant + + # --------------------------------------------------------------- 端点解析 def test_resolve_intl_endpoint_returns_configured_host(): diff --git a/tests/provider/codebuddy/test_model_access_store.py b/tests/provider/codebuddy/test_model_access_store.py new file mode 100644 index 00000000..43e42d44 --- /dev/null +++ b/tests/provider/codebuddy/test_model_access_store.py @@ -0,0 +1,121 @@ +"""模型可用性探活结果的落盘缓存:原子写、过期剪枝、坏文件降级。""" + +from __future__ import annotations + +import json + +from src.provider.codebuddy.model_access_store import ( + MAX_ENTRIES, + VERSION, + ModelAccessStore, + cache_path, +) + + +def _store(tmp_path, *, ttl=3600, now=1000.0) -> ModelAccessStore: + return ModelAccessStore(str(tmp_path / "access.json"), ttl, + clock=lambda: now) + + +def test_cache_path_sits_in_data_dir(tmp_path): + assert cache_path(str(tmp_path)) == str(tmp_path / "codebuddy-model-access.json") + + +def test_put_then_get_roundtrips_and_survives_reload(tmp_path): + store = _store(tmp_path) + store.put(("acct", "https://www.codebuddy.ai"), 1000.0, {"ok": True, "no": False}) + assert store.get(("acct", "https://www.codebuddy.ai")) == ( + 1000.0, {"ok": True, "no": False}) + + reloaded = _store(tmp_path) + assert reloaded.get(("acct", "https://www.codebuddy.ai"))[1] == { + "ok": True, "no": False} + assert reloaded.get(("other", "https://www.codebuddy.ai")) is None + + +def test_expired_entry_is_dropped_on_get_and_load(tmp_path): + store = _store(tmp_path, ttl=10) + store.put(("acct", "ep"), 1000.0, {"ok": True}) + stale = _store(tmp_path, ttl=10, now=2000.0) + assert stale.get(("acct", "ep")) is None # 载入时按 cutoff 剪掉 + assert stale.get(("acct", "ep")) is None + + # 载入时未过期、之后过期:get 里主动删除(不再无限留在大字典里) + now = [1000.0] + live = ModelAccessStore(str(tmp_path / "live.json"), 10, clock=lambda: now[0]) + live.put(("acct", "ep"), 1000.0, {"ok": True}) + now[0] = 1030.0 + assert live.get(("acct", "ep")) is None + assert live.get(("acct", "ep")) is None + + +def test_corrupt_or_foreign_file_is_treated_as_empty(tmp_path): + path = tmp_path / "access.json" + for payload in ("{not json", json.dumps({"version": VERSION + 1, "entries": {}}), + json.dumps({"version": VERSION, "entries": []}), + json.dumps([1, 2])): + path.write_text(payload, encoding="utf-8") + assert _store(tmp_path).get(("acct", "ep")) is None + + path.unlink() + assert _store(tmp_path).get(("acct", "ep")) is None # 文件缺失 + + +def test_malformed_entries_are_skipped(tmp_path): + path = tmp_path / "access.json" + payload = { + "version": VERSION, + "entries": { + "no-separator": {"checked_at": 1000.0, "access": {}}, + "a\x1fb": "not a dict", + "a\x1fc": {"checked_at": "yesterday", "access": {}}, + "a\x1fd": {"checked_at": 1000.0, "access": ["nope"]}, + # 过期条目 + "a\x1fe": {"checked_at": 1.0, "access": {}}, + # 合法条目里夹带脏字段:脏值被丢弃,合法值保留 + "a\x1ff": {"checked_at": 1000.0, + "access": {"ok": True, "bad": "yes"}}, + }, + } + path.write_text(json.dumps(payload), encoding="utf-8") + store = _store(tmp_path, ttl=500) + assert store.get(("a", "b")) is None + assert store.get(("a", "c")) is None + assert store.get(("a", "d")) is None + assert store.get(("a", "e")) is None + assert store.get(("a", "f")) == (1000.0, {"ok": True}) + + +def test_entry_count_is_bounded(tmp_path, monkeypatch): + monkeypatch.setattr("src.provider.codebuddy.model_access_store.MAX_ENTRIES", 2) + store = _store(tmp_path) + store.put(("a", "1"), 1000.0, {"m": True}) + store.put(("a", "2"), 1001.0, {"m": True}) + store.put(("a", "3"), 1002.0, {"m": True}) + assert store.get(("a", "1")) is None # 最旧的被淘汰 + assert store.get(("a", "3")) is not None + assert len(json.loads((tmp_path / "access.json").read_text())["entries"]) == 2 + + +def test_write_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog): + store = _store(tmp_path) + + def boom(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr("os.replace", boom) + store.put(("acct", "ep"), 1000.0, {"ok": True}) + assert store.get(("acct", "ep")) == (1000.0, {"ok": True}) + assert "模型可用性缓存写盘失败" in caplog.text + + +def test_store_without_directory_writes_relative_path(tmp_path, monkeypatch): + """`path` 无目录段时跳过 makedirs(不能对空字符串 os.makedirs)。""" + monkeypatch.chdir(tmp_path) + store = ModelAccessStore("access.json", 3600, clock=lambda: 1000.0) + store.put(("acct", "ep"), 1000.0, {"ok": True}) + assert (tmp_path / "access.json").exists() + + +def test_max_entries_constant_is_sane(): + assert MAX_ENTRIES >= 32 diff --git a/tests/provider/kilo/test_client.py b/tests/provider/kilo/test_client.py index 656f3cef..2faef575 100644 --- a/tests/provider/kilo/test_client.py +++ b/tests/provider/kilo/test_client.py @@ -194,8 +194,8 @@ def no_free(_request: httpx.Request) -> httpx.Response: async def test_client_lazy_clients_and_aclose(): """未注入 client 时惰性创建;aclose 释放两侧连接池。""" client = KiloClient() - assert client._stream() is client._stream() # noqa: SLF001 - 惰性复用同一实例 - assert client._short() is client._short() # noqa: SLF001 + assert client._stream is client._stream # noqa: SLF001 - 惰性复用同一实例 + assert client._short is client._short # noqa: SLF001 await client.aclose() # 都没创建过:aclose 不报错(两个分支都为 None) diff --git a/tests/provider/lingxi/__init__.py b/tests/provider/lingxi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/provider/lingxi/_support.py b/tests/provider/lingxi/_support.py new file mode 100644 index 00000000..a784c712 --- /dev/null +++ b/tests/provider/lingxi/_support.py @@ -0,0 +1,26 @@ +"""灵犀客户端测试共享装配。""" + +from __future__ import annotations + +import json +from typing import Any + +from src.provider.lingxi.client import LingxiClient +from src.provider.lingxi.credential import LingxiCredential +from tests.helpers.http import provider_client + +CREDENTIAL = LingxiCredential("sid-abc", "42") + + +def client(handler, **kw: Any) -> LingxiClient: + kw.setdefault("host", "https://lingxi.test") + kw.setdefault("fallback_host", "https://kdocs.test") + return provider_client(LingxiClient, handler, **kw) + + +def frame(payload: dict[str, Any]) -> str: + return "data: " + json.dumps(payload) + "\n" + + +def lines(*payloads: dict[str, Any]) -> str: + return "".join(frame(payload) for payload in payloads) diff --git a/tests/provider/lingxi/test_client.py b/tests/provider/lingxi/test_client.py new file mode 100644 index 00000000..27830767 --- /dev/null +++ b/tests/provider/lingxi/test_client.py @@ -0,0 +1,847 @@ +"""灵犀上游客户端:三步对话、run 缓存/单飞、账号面接口与请求体压平。""" + +from __future__ import annotations + +import asyncio +import json +import time + +import httpx +import pytest + +from src.provider import base +from src.provider.base import ErrKind, EventKind, UpstreamProtocolViolation +from src.provider.lingxi import client as lingxi_client +from src.provider.lingxi.client import ( + EP_BALANCE, + EP_CHAT, + EP_PLANS, + EP_PROFILE, + EP_RUNS, + EP_SESSIONS, + EP_TASKS, + LingxiClient, + RunExpired, + UpstreamHTTPError, + build_body, + normalize_messages, + sanitize_schema, +) +from tests.provider.lingxi._support import CREDENTIAL, client, frame, lines + + +def _handler(mapping, *, silent=None): + """按 (method, path) 返回 JSON / 文本;未命中抛断言(测试即契约)。""" + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + key = (request.method, request.url.path) + if silent is not None: + silent(request) + value = mapping.get(key) + if value is None: + raise AssertionError(f"unexpected request {key}") + if isinstance(value, httpx.Response): + return value + if isinstance(value, str): + return httpx.Response(200, text=value) + return httpx.Response(200, json=value) + + handler.calls = calls # type: ignore[attr-defined] + return handler + + +def _ok(payload): + return {"code": "Success", "data": payload} + + +def test_bases_dedupe_when_same_host(): + instance = LingxiClient(host="https://a.test/", fallback_host="https://a.test") + assert instance.bases() == ("https://a.test",) + other = LingxiClient(host="https://a.test", fallback_host="https://b.test") + assert other.bases() == ("https://a.test", "https://b.test") + + +def test_headers_carry_cookie_and_desktop_markers(): + instance = LingxiClient(host="https://a.test") + headers = instance.headers(CREDENTIAL) + assert headers["Cookie"] == "wps_sid=sid-abc; uid=42" + assert headers["X-Client-Type"] == "lingxi_claw" + assert headers["Accept"] == "application/json" + assert headers["x-kso-app-version"] == "1.3.31" + assert headers["x-kso-device-id"] + + +async def test_fetch_profile_and_balance_tolerate_non_dict_data(): + handler = _handler({ + ("GET", EP_PROFILE): {"code": "Success", "data": "x"}, + ("GET", EP_BALANCE): _ok({"total_balance": 10}), + }) + instance = client(handler) + assert await instance.fetch_profile(CREDENTIAL) == {} + assert await instance.fetch_balance(CREDENTIAL) == {"total_balance": 10} + + +async def test_account_json_falls_back_on_5xx(): + def silent(request): + if request.url.host == "lingxi.test": + raise httpx.ConnectError("boom") + + handler = _handler({("GET", EP_PROFILE): _ok({"id": "u1", "name": "nick"})}, + silent=silent) + instance = client(handler) + assert (await instance.fetch_profile(CREDENTIAL))["id"] == "u1" + assert instance.bases() == ("https://lingxi.test", "https://kdocs.test") + + +async def test_account_json_falls_back_on_5xx_status(): + def handler(request): + if request.url.host == "lingxi.test": + return httpx.Response(500, text="down") + return httpx.Response(200, json=_ok({"id": "u2"})) + + instance = client(handler) + assert (await instance.fetch_profile(CREDENTIAL))["id"] == "u2" + + +async def test_account_json_falls_back_on_401_too(): + """kdocs 账号只在 kdocs 集群认 cookie:主域 401 不直接判死。""" + calls = [] + + def handler(request): + calls.append(request.url.host) + if request.url.host == "kdocs.test": + return httpx.Response(200, json=_ok({"id": "u1"})) + return httpx.Response(401, text="nope") + + instance = client(handler) + assert (await instance.fetch_profile(CREDENTIAL))["id"] == "u1" + assert calls == ["lingxi.test", "kdocs.test"] + + def always_401(request): + return httpx.Response(401, text="nope") + + with pytest.raises(UpstreamHTTPError) as error: + await client(always_401).fetch_profile(CREDENTIAL) + assert error.value.status == 401 + + +async def test_account_json_raises_last_error_when_all_bases_fail(): + def handler(request): + return httpx.Response(500, text="down") + + instance = client(handler) + with pytest.raises(UpstreamHTTPError): + await instance.fetch_profile(CREDENTIAL) + + +async def test_json_rejects_non_json_and_non_object(): + def non_json(request): + return httpx.Response(200, text="not json") + + instance = client(non_json) + with pytest.raises(UpstreamProtocolViolation): + await instance.fetch_balance(CREDENTIAL) + + def array_json(request): + return httpx.Response(200, json=[1, 2]) + + with pytest.raises(UpstreamProtocolViolation): + await client(array_json).fetch_balance(CREDENTIAL) + + +async def test_json_transport_error_is_translated(): + def handler(request): + raise httpx.ReadTimeout("slow") + + with pytest.raises(base.UpstreamTransportError): + await client(handler).fetch_balance(CREDENTIAL) + + +async def test_probe_quota_maps_balance_and_batches(): + now = int(time.time()) + soon = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now + 86400)) + later = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now + 60 * 86400)) + handler = _handler({("GET", EP_BALANCE): _ok({ + "total_balance": "120", + "bonus_summary": {"total_value": 300}, + "bonus_credits": [ + {"expire_time": soon, "balance": "50"}, + {"expire_time": later, "balance": 70}, + {"expire_time": "", "balance": 10}, + {"expire_time": soon, "balance": 0}, + "junk", + ], + })}) + quota = await client(handler).probe_quota(CREDENTIAL) + assert quota.remaining == 120 + assert quota.total == 300 + assert quota.probe_failed is False + # 只有 15 天内的批次进到期阶梯;全部批次进 packages(余额 > 0) + assert quota.expiry_ladder is not None and len(quota.expiry_ladder) == 1 + assert quota.packages is not None and len(quota.packages) == 2 + assert quota.probed_at is not None + + +async def test_probe_quota_falls_back_to_credits_balance_and_failed(): + handler = _handler({("GET", EP_BALANCE): _ok({"credits_balance": 5})}) + quota = await client(handler).probe_quota(CREDENTIAL) + assert quota.remaining == 5 + + handler = _handler({("GET", EP_BALANCE): _ok({})}) + quota = await client(handler).probe_quota(CREDENTIAL) + assert quota.probe_failed is True + + +async def test_fetch_models_merges_plans_and_models(): + handler = _handler({("GET", EP_PLANS): _ok({ + "plans": [{"key": "plan_pro", "name": "Pro", "reasoning_efforts": ["high"]}, + {"key": "", "name": "skip"}, + "junk"], + "models": [{"key": "GLM-5.3", "name": "GLM-5.3", "multiplier": "0.75", + "max_context_tokens": 1000, "max_new_tokens": 200, + "support_multimodal": True}, + {"key": "GLM-5.3", "name": "dup"}, + {"name": "no key"}], + })}) + models = await client(handler).fetch_models(CREDENTIAL) + ids = [model.id for model in models] + assert ids == ["plan_pro", "GLM-5.3"] + assert models[0].credit_rate is None + assert models[1].credit_rate == 0.75 + assert models[1].max_input_tokens == 1000 + assert models[1].supports_images is True + assert models[1].supports_reasoning is False + + +async def test_fetch_models_snapshot_on_failure_and_empty(): + def boom(request): + return httpx.Response(500, text="down") + + models = await client(boom).fetch_models(CREDENTIAL) + assert len(models) == 14 + assert models[1].id == "deepseek-v4.1-flash" + + empty = _handler({("GET", EP_PLANS): _ok({})}) + assert len(await client(empty).fetch_models(CREDENTIAL)) == 14 + + broken = _handler({("GET", EP_PLANS): httpx.Response(200, text="oops")}) + assert len(await client(broken).fetch_models(CREDENTIAL)) == 14 + + +async def test_fetch_models_401_propagates(): + handler = _handler({("GET", EP_PLANS): httpx.Response(401, text="no")}) + with pytest.raises(UpstreamHTTPError): + await client(handler).fetch_models(CREDENTIAL) + + +async def test_fetch_tasks_shapes(): + handler = _handler({("GET", EP_TASKS): _ok({"tasks": [{"task_key": "a"}, 1]})}) + tasks = await client(handler).fetch_tasks(CREDENTIAL) + assert tasks == [{"task_key": "a"}] + + handler = _handler({("GET", EP_TASKS): _ok({"tasks": "bad"})}) + assert await client(handler).fetch_tasks(CREDENTIAL) == [] + + +async def test_claim_task_with_and_without_date(): + seen: list[str] = [] + + def handler(request): + seen.append(request.url.path + ("?" + request.url.query.decode() + if request.url.query else "")) + return httpx.Response(200, json=_ok({"ok": 1})) + + instance = client(handler) + await instance.claim_task(CREDENTIAL, "daily_check_in", "2026-11-09") + await instance.claim_task(CREDENTIAL, "daily_check_in") + assert seen == ["/api/public/v1/tasks/daily_check_in/claim?date=2026-11-09", + "/api/public/v1/tasks/daily_check_in/claim"] + + +# ---------------------------------------------------------------- 会话/run + + +def _run_mapping(*, run_id="run-1", session_id="ses-1"): + return { + ("POST", EP_SESSIONS): _ok({"session_id": session_id}), + ("POST", EP_RUNS): _ok({"run_id": run_id, "group_id": "group-1"}), + } + + +async def test_ensure_run_caches_for_ttl(): + handler = _handler({**_run_mapping(), ("GET", EP_CHAT): "x"}) + instance = client(handler) + first = await instance.ensure_run(CREDENTIAL, "m", []) + second = await instance.ensure_run(CREDENTIAL, "m", []) + assert first["run_id"] == "run-1" and first["group_id"] == "group-1" + assert second is first + # 只建一次会话 / run + creates = [call for call in handler.calls if call.url.path == EP_SESSIONS] + assert len(creates) == 1 + + +async def test_ensure_run_rebuilds_after_ttl(monkeypatch): + handler = _handler(_run_mapping()) + instance = client(handler) + await instance.ensure_run(CREDENTIAL, "m") + monkeypatch.setattr(lingxi_client, "RUN_TTL_SECONDS", 0) + await instance.ensure_run(CREDENTIAL, "m") + assert len([c for c in handler.calls if c.url.path == EP_RUNS]) == 2 + + +async def test_ensure_run_single_flight(): + started = asyncio.Event() + release = asyncio.Event() + count = {"runs": 0} + + def handler(request): + if request.url.path == EP_SESSIONS: + return httpx.Response(200, json=_ok({"session_id": "s"})) + count["runs"] += 1 + return httpx.Response(200, json=_ok({"run_id": "r"})) + + instance = client(handler) + first = asyncio.create_task(instance.ensure_run(CREDENTIAL, "m")) + await asyncio.sleep(0) + second = asyncio.create_task(instance.ensure_run(CREDENTIAL, "m")) + started.set() + release.set() + runs = await asyncio.gather(first, second) + assert runs[0]["run_id"] == "r" + assert count["runs"] == 1 + + +async def test_ensure_run_falls_back_to_second_base(): + def handler(request): + if request.url.host == "lingxi.test": + return httpx.Response(500, text="down") + if request.url.path == EP_SESSIONS: + return httpx.Response(200, json=_ok({"session_id": "s2"})) + return httpx.Response(200, json=_ok({"run_id": "r2"})) + + instance = client(handler) + run = await instance.ensure_run(CREDENTIAL, "m") + assert run["base"] == "https://kdocs.test" + assert run["run_id"] == "r2" + + +async def test_ensure_run_falls_back_on_401(): + calls: list[str] = [] + + def handler(request): + calls.append(request.url.host) + if request.url.host == "kdocs.test": + if request.url.path == EP_SESSIONS: + return httpx.Response(200, json=_ok({"session_id": "s"})) + return httpx.Response(200, json=_ok({"run_id": "r"})) + return httpx.Response(401, text="no") + + run = await client(handler).ensure_run(CREDENTIAL, "m") + assert run["base"] == "https://kdocs.test" + assert calls[0] == "lingxi.test" + + +async def test_ensure_run_all_bases_fail(): + def handler(request): + return httpx.Response(500, text="down") + + with pytest.raises(UpstreamHTTPError): + await client(handler).ensure_run(CREDENTIAL, "m") + + +async def test_ensure_run_missing_ids_are_protocol_violations(): + handler = _handler({("POST", EP_SESSIONS): _ok({})}) + with pytest.raises(UpstreamProtocolViolation): + await client(handler).ensure_run(CREDENTIAL, "m") + + handler = _handler({("POST", EP_SESSIONS): _ok({"session_id": "s"}), + ("POST", EP_RUNS): _ok({})}) + with pytest.raises(UpstreamProtocolViolation): + await client(handler).ensure_run(CREDENTIAL, "m") + + +async def test_ensure_run_transport_error_on_both_bases(): + def handler(request): + raise httpx.ConnectError("no") + + with pytest.raises(base.UpstreamTransportError): + await client(handler).ensure_run(CREDENTIAL, "m") + + +def test_forget_run(): + instance = LingxiClient(host="https://a.test") + instance._runs["k"] = {"at": time.time()} + instance.forget_run(CREDENTIAL, "m") + assert instance._runs # key 由 identity|model 组成,这里只是覆盖调用路径 + + +# ---------------------------------------------------------------- 聊天流 + + +def _chat_mapping(stream: str, *, run=None): + mapping = dict(run or _run_mapping()) + mapping[("POST", EP_CHAT)] = stream + return mapping + + +async def test_stream_chat_yields_events_and_headers(): + stream = lines({"code": "Success", "choices": [{"text": "hi"}]}, + {"code": "Success", + "choices": [{"text": "!", "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 2}}) + handler = _handler(_chat_mapping(stream)) + events = [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": [{"role": "user", "content": "hi"}], + "max_tokens": 5}, "m")] + kinds = [event.kind for event in events] + assert kinds == [EventKind.CONTENT, EventKind.CONTENT, + EventKind.FINISH, EventKind.USAGE] + chat = [c for c in handler.calls if c.url.path == EP_CHAT][0] + assert chat.headers["x-cc-session-id"] == "ses-1" + assert chat.headers["x-cc-run-id"] == "run-1" + assert json.loads(chat.content)["base_llm_arguments"] == {"max_tokens": 5} + + +async def test_stream_chat_done_marker_ends_stream(): + handler = _handler(_chat_mapping(lines({"code": "Success", + "choices": [{"text": "a"}]}) + + "data: [DONE]\n")) + events = [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + assert [event.kind for event in events] == [EventKind.CONTENT] + + +async def test_stream_chat_session_expired_before_content(): + stream = lines({"code": "SQLNoRows", "hint": "会话不存在"}) + handler = _handler(_chat_mapping(stream)) + with pytest.raises(RunExpired): + [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + + +async def test_stream_chat_error_after_content_is_an_event(): + stream = lines({"code": "Success", "choices": [{"text": "a"}]}, + {"code": "SQLNoRows", "hint": "会话不存在"}) + handler = _handler(_chat_mapping(stream)) + events = [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + assert events[-1].kind == EventKind.ERROR + assert events[-1].error_kind is ErrKind.SOFT + + +async def test_stream_chat_skips_junk_lines_and_empty_frames(): + stream = ("\n: keepalive\nid: 1\nevent: x\nnot json\n" + + frame({"code": "Success", "choices": [{"text": "a"}]}) + + frame({"code": "Success", "choices": [{"finish_reason": "stop"}]})) + handler = _handler(_chat_mapping(stream)) + events = [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + assert [event.kind for event in events] == [EventKind.CONTENT, + EventKind.FINISH] + + +async def test_stream_chat_truncated_without_finish(): + handler = _handler(_chat_mapping(lines({"code": "Success", + "choices": [{"text": "a"}]}))) + with pytest.raises(UpstreamProtocolViolation): + [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + + +async def test_stream_chat_http_error_and_transport_error(): + handler = _handler(_chat_mapping("", run=_run_mapping())) + handler = _handler({**_run_mapping(), + ("POST", EP_CHAT): httpx.Response(429, text="slow")}) + with pytest.raises(UpstreamHTTPError) as error: + [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + assert error.value.status == 429 + + def raising(request): + raise httpx.ConnectError("down") + + with pytest.raises(base.UpstreamTransportError): + [event async for event in client(raising).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + + +# ---------------------------------------------------------------- 签到 + + +def _task_mapping(daily, balance=None, claim=None): + mapping = {("GET", EP_TASKS): _ok({"tasks": [daily, {"task_key": "x"}]}), + ("GET", EP_BALANCE): _ok(balance or {"total_balance": 10})} + if claim is not None: + mapping[("POST", "/api/public/v1/tasks/daily_check_in/claim")] = claim + return mapping + + +async def test_checkin_claimed_already(): + daily = {"task_key": "daily_check_in", "status": "claimed", + "extra": {"consecutive_days": 3}} + result = await client(_handler(_task_mapping(daily))).checkin(CREDENTIAL) + assert result.ok and result.already_checked_in + assert result.message == "今日已签到" + + +async def test_checkin_missing_task_and_auth_error_on_balance(): + result = await client(_handler(_task_mapping({"task_key": "other"}))).checkin(CREDENTIAL) + assert not result.ok and "每日签到" in result.message + + handler = _handler({("GET", EP_BALANCE): httpx.Response(401, text="no"), + ("GET", EP_TASKS): _ok({"tasks": []})}) + result = await client(handler).checkin(CREDENTIAL) + assert not result.ok and "登录态" in result.message + + +async def test_checkin_claims_and_uses_balance_delta(): + daily = {"task_key": "daily_check_in", "status": "claimable", + "extra": {"consecutive_days": 1}} + mapping = {("GET", EP_BALANCE): httpx.Response(200, json=_ok({"total_balance": 100})), + ("GET", EP_TASKS): _ok({"tasks": [daily]}), + ("POST", "/api/public/v1/tasks/daily_check_in/claim"): + _ok({"extra": {"today_reward": 5}})} + sequence = {"balance_calls": 0} + + def handler(request): + if request.url.path == EP_BALANCE: + sequence["balance_calls"] += 1 + value = 100 if sequence["balance_calls"] == 1 else 120 + return httpx.Response(200, json=_ok({"total_balance": value})) + return mapping[(request.method, request.url.path)] \ + if isinstance(mapping.get((request.method, request.url.path)), httpx.Response) \ + else httpx.Response(200, json=mapping[(request.method, request.url.path)]) + + result = await client(handler).checkin(CREDENTIAL) + assert result.ok and result.credit == 20 and not result.already_checked_in + + +async def test_checkin_claim_failure_and_zero_balance_delta(): + daily = {"task_key": "daily_check_in", "status": "claimable", + "extra": {"consecutive_days": 1}} + failure = _handler(_task_mapping(daily, claim={"result": "failed", + "hint": "领取失败"})) + result = await client(failure).checkin(CREDENTIAL) + assert not result.ok and result.message == "领取失败" + + # 余额不变 → 读 claim 里的奖励字段;再退到 already_checked_in + zero = _handler({("GET", EP_BALANCE): _ok({"total_balance": 10}), + ("GET", EP_TASKS): _ok({"tasks": [daily]}), + ("POST", "/api/public/v1/tasks/daily_check_in/claim"): + {"code": "Success", + "data": {"extra": {"today_reward": 3}}}}) + result = await client(zero).checkin(CREDENTIAL) + assert result.ok and result.credit == 3 + + zero = _handler({("GET", EP_BALANCE): _ok({"total_balance": 10}), + ("GET", EP_TASKS): _ok({"tasks": [daily]}), + ("POST", "/api/public/v1/tasks/daily_check_in/claim"): {}}) + result = await client(zero).checkin(CREDENTIAL) + assert result.ok and result.already_checked_in + + +async def test_checkin_balance_after_failure_falls_back_to_reward(): + daily = {"task_key": "daily_check_in", "status": "claimable", + "reward_amount": 7} + calls = {"n": 0} + + def handler(request): + if request.url.path == EP_BALANCE: + calls["n"] += 1 + if calls["n"] > 1: + return httpx.Response(500, text="down") + return httpx.Response(200, json=_ok({"total_balance": 10})) + if request.url.path == EP_TASKS: + return httpx.Response(200, json=_ok({"tasks": [daily]})) + return httpx.Response(200, json=_ok({})) + + result = await client(handler).checkin(CREDENTIAL) + assert result.ok and result.credit == 7 + + +async def test_checkin_status_variants(): + daily = {"task_key": "daily_check_in", "status": "claimed", + "extra": {"consecutive_days": 2, "today_reward": 5}} + status = await client(_handler(_task_mapping(daily))).checkin_status(CREDENTIAL) + assert status["today_checked_in"] is True + assert status["streak_days"] == 2 + + status = await client(_handler(_task_mapping({"task_key": "other"}))).checkin_status(CREDENTIAL) + assert status == {"active": False, "today_checked_in": False} + + +# ---------------------------------------------------------------- 请求体/工具 schema + + +def test_build_body_maps_openai_fields(): + body = build_body({ + "messages": [{"role": "user", "content": "hi"}], + "tools": [{"type": "function", + "function": {"name": "f", "parameters": { + "type": "object", "properties": {"a": {"type": ["string", "null"]}}}}}], + "tool_choice": "auto", + "temperature": 0.2, + "max_tokens": 9, + "reasoning_effort": "high", + }, "m") + assert body["stream"] is True and body["model"] == "m" + assert body["tool_choice"] == "auto" + assert body["temperature"] == 0.2 + assert body["base_llm_arguments"] == {"max_tokens": 9} + assert body["reasoning_effort"] == "high" + assert body["llm_arguments"] == {"reasoning_effort": "high"} + assert body["tools"][0]["function"]["parameters"]["properties"]["a"] == { + "type": "string"} + + +def test_build_body_drops_bad_values(): + body = build_body({"messages": "bad", "tools": "bad", "temperature": True, + "max_tokens": 0, "reasoning_effort": " "}, "m") + assert body["messages"] == [] + assert "tools" not in body and "tool_choice" not in body + assert "temperature" not in body and "base_llm_arguments" not in body + assert "reasoning_effort" not in body + + +def test_normalize_messages_drops_orphan_tool_messages(): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "tool", "tool_call_id": "c0", "content": "orphan"}, + {"role": "assistant", "tool_calls": [{"id": "c1"}, {"id": "c2"}]}, + {"role": "tool", "tool_call_id": "c1", "content": "ok"}, + {"role": "tool", "tool_call_id": "c1", "content": "dup"}, + {"role": "system", "content": "s"}, + {"role": "tool", "tool_call_id": "c2", "content": "orphan again"}, + {"role": "assistant"}, + "junk", + ] + out = normalize_messages(messages) + assert [item.get("role") for item in out] == [ + "user", "assistant", "tool", "system", "assistant"] + assert normalize_messages(None) == [] + # 深拷贝:原列表不被改写 + assert messages[0] == {"role": "user", "content": "hi"} + + +def test_sanitize_schema_resolves_refs_and_cycles(): + schema = {"type": "object", + "properties": {"a": {"$ref": "#/$defs/A"}}, + "$defs": {"A": {"type": "object", "properties": { + "self": {"$ref": "#/$defs/A"}}}, + "B": {"type": ["integer", "null"]}}} + out = sanitize_schema(schema) + assert out["properties"]["a"]["properties"]["self"] == {"type": "string"} + assert "$defs" not in out + + assert sanitize_schema({"$ref": "#/$defs/nope"}) == {"type": "string"} + assert sanitize_schema({"$ref": "#/definitions/B", + "definitions": {"B": {"type": "string"}}}) == { + "type": "string"} + + +def test_sanitize_schema_picks_branches_and_prunes_unsupported_keys(): + schema = {"anyOf": [{"type": "string"}, {"type": "object", + "properties": {"x": {"type": "string"}}}], + "description": "d"} + out = sanitize_schema(schema) + assert out["type"] == "object" + assert out["description"] == "d" + + assert sanitize_schema({"oneOf": []}) == {"type": "string"} + assert sanitize_schema({"oneOf": [1]}) == {"type": "string"} + assert sanitize_schema({"oneOf": [{"type": "array", "items": {"type": "string"}}]}) == { + "type": "array", "items": {"type": "string"}} + + pruned = sanitize_schema({"type": "object", "allOf": [{"x": 1}], + "not": {}, "if": {}, "then": {}, "else": {}, + "$id": "x", "$schema": "y"}) + assert pruned == {"type": "object"} + + +def test_sanitize_schema_primitives_and_depth_cap(): + assert sanitize_schema(None) == {"type": "string"} + assert sanitize_schema(True) == {"type": "boolean"} + assert sanitize_schema(3) == {"type": "number"} + assert sanitize_schema([]) == {"type": "array"} + nested: dict = {"type": "object", "properties": {}} + cursor = nested + for _ in range(30): + child: dict = {"type": "object", "properties": {}} + cursor["properties"]["x"] = child + cursor = child + out = sanitize_schema(nested) + walk = out + for _ in range(25): + walk = walk["properties"]["x"] + assert walk == {"type": "string"} + + +def test_last_user_text_and_helpers(): + assert lingxi_client.last_user_text([ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "a"}, + {"role": "user", "content": [{"text": "second"}, {"x": 1}]}, + {"role": "user", "content": " "}, + ]) == "second" + assert lingxi_client.last_user_text([]) == "灵犀会话" + assert lingxi_client.last_user_text([{"role": "user", "content": None}]) == "灵犀会话" + + assert lingxi_client._num("1.5") == 1.5 + assert lingxi_client._num("bad") is None + assert lingxi_client._num(True) is None + assert lingxi_client._num(object()) is None + assert lingxi_client._int("7") == 7 + assert lingxi_client._dict("x") == {} + + assert lingxi_client._parse_expire("") == 0 + assert lingxi_client._parse_expire("2026-11-09T10:00:00Z") > 0 + assert lingxi_client._parse_expire("2026-11-09 10:00:00+08:00") > 0 + assert lingxi_client._parse_expire("2026-11-09 10:00") > 0 + assert lingxi_client._parse_expire("2026-11-09") > 0 + assert lingxi_client._parse_expire("bad") == 0 + + assert lingxi_client._claim_date({"extra": {"claim_date": "2026-11-01"}}) == \ + "2026-11-01" + assert lingxi_client._claim_date({}) # 本地日期 + + +def test_frame_of_variants(): + assert lingxi_client._frame_of(None) is None + assert lingxi_client._frame_of("") is None + assert lingxi_client._frame_of(": hi") is None + assert lingxi_client._frame_of("data:") is None + assert lingxi_client._frame_of("data: [DONE]") == {} + assert lingxi_client._frame_of("data: 12") is None + assert lingxi_client._frame_of('data: {"a": 1}') == {"a": 1} + +async def test_checkin_balance_http_error_other_than_401_is_swallowed(): + daily = {"task_key": "daily_check_in", "status": "claimed"} + handler = _handler({("GET", EP_BALANCE): httpx.Response(500, text="down"), + ("GET", EP_TASKS): _ok({"tasks": [daily]})}) + result = await client(handler).checkin(CREDENTIAL) + assert result.ok and result.already_checked_in + + +async def test_ensure_run_recheck_under_lock_returns_cached(): + instance = client(_handler(_run_mapping())) + key = instance._run_key(CREDENTIAL, "m") + lock = instance._run_locks.setdefault(key, asyncio.Lock()) + async with lock: + task = asyncio.create_task(instance.ensure_run(CREDENTIAL, "m")) + await asyncio.sleep(0) + instance._runs[key] = {"session_id": "s", "run_id": "r", "at": time.time()} + run = await task + assert run["run_id"] == "r" + + +async def test_stream_chat_empty_frame_continues_then_finish(): + stream = lines({"code": "Success", "choices": [{"text": ""}]}, + {"code": "Success", "choices": [{"finish_reason": "stop"}]}) + handler = _handler(_chat_mapping(stream)) + events = [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + assert [event.kind for event in events] == [EventKind.FINISH] + + +class _BrokenStream(httpx.AsyncByteStream): + async def __aiter__(self): + yield b"data: {\"code\": \"Success\", \"choices\": [{\"text\": \"a\"}]}\n" + raise httpx.ReadTimeout("mid-stream") + + +async def test_stream_chat_transport_error_during_stream(): + handler = _handler(_run_mapping() | { + ("POST", EP_CHAT): httpx.Response(200, stream=_BrokenStream())}) + with pytest.raises(base.UpstreamTransportError): + [event async for event in client(handler).stream_chat( + CREDENTIAL, {"messages": []}, "m")] + + +async def test_client_aclose_releases_pools(): + instance = LingxiClient(host="https://a.test") + await instance.aclose() + + +def test_last_user_text_skips_non_user_items(): + assert lingxi_client.last_user_text([ + {"role": "user", "content": "first"}, {"role": "assistant", "content": "a"}, + ]) == "first" + assert lingxi_client.last_user_text(["junk"]) == "灵犀会话" + + +def test_normalize_messages_tool_calls_with_junk_entries(): + out = normalize_messages([ + {"role": "assistant", "tool_calls": ["junk", {"id": 1}, {"id": "c1"}]}, + {"role": "tool", "tool_call_id": "c1", "content": "ok"}, + ]) + assert [item["role"] for item in out] == ["assistant", "tool"] + + +def test_build_body_drops_non_function_tools_and_bad_choice(): + body = build_body({"messages": [], "tools": [ + {"type": "function"}, + {"type": "web_search", "web_search": {}}, + {"type": "function", "function": {"name": "f", "parameters": None}}, + ]}, "m") + assert "tool_choice" not in body + assert len(body["tools"]) == 1 + # parameters 缺失时按标量兜底(压平函数对 None 的既定行为) + assert body["tools"][0]["function"]["parameters"] == {"type": "string"} + # 全部工具都被剔除时不带 tools 键 + assert "tools" not in build_body({"messages": [], "tools": [ + {"type": "web_search"}]}, "m") + + +def test_tool_choice_normalization(): + """实测:字符串三种值可用,对象形态上游 400,故降级为 required。""" + assert lingxi_client._tool_choice_of("auto") == "auto" + assert lingxi_client._tool_choice_of(" none ") == "none" + assert lingxi_client._tool_choice_of({"type": "function", + "function": {"name": "f"}}) == "required" + assert lingxi_client._tool_choice_of("") is None + assert lingxi_client._tool_choice_of(1) is None + assert lingxi_client._tool_choice_of(None) is None + body = build_body({"messages": [], "tools": [ + {"type": "function", "function": {"name": "f"}}], + "tool_choice": {"type": "function", "function": {"name": "f"}}}, "m") + assert body["tool_choice"] == "required" + + +async def test_fetch_models_parses_multiplier_text(): + """上游倍率是带标记的字符串(`0.12x` / `0.75倍`),必须解析成数值。""" + handler = _handler({("GET", EP_PLANS): _ok({"models": [ + {"key": "a", "multiplier": "0.12x"}, + {"key": "b", "multiplier": "0.75倍"}, + {"key": "c", "multiplier": "×1.6"}, + {"key": "d", "multiplier": "~0.08x"}, + {"key": "e", "multiplier": 0.5}, + {"key": "f", "multiplier": "模式"}, + {"key": "g", "credit_rate": 0.2}, + ]})}) + models = {model.id: model.credit_rate + for model in await client(handler).fetch_models(CREDENTIAL)} + assert models == {"a": 0.12, "b": 0.75, "c": 1.6, "d": 0.08, + "e": 0.5, "f": None, "g": 0.2} + + +def test_rate_helper_variants(): + assert lingxi_client._rate("0.12x") == 0.12 + assert lingxi_client._rate("0.75倍") == 0.75 + assert lingxi_client._rate("×1.6") == 1.6 + assert lingxi_client._rate("约1.6x") == 1.6 + # 上游实测形态:全角波浪线(U+FF5E) + assert lingxi_client._rate("~0.12x") == 0.12 + assert lingxi_client._rate(" 2 ") == 2 + assert lingxi_client._rate(1.5) == 1.5 + assert lingxi_client._rate("模式") is None + assert lingxi_client._rate(True) is None + assert lingxi_client._rate(None) is None + + +def test_multiplier_of_nested_and_fallbacks(): + assert lingxi_client._multiplier_of({"credit": {"multiplier": "0.2x"}}) == 0.2 + assert lingxi_client._multiplier_of({"rate": 0.3}) == 0.3 + assert lingxi_client._multiplier_of({"credit": "bad"}) is None + assert lingxi_client._multiplier_of({}) is None diff --git a/tests/provider/lingxi/test_credential.py b/tests/provider/lingxi/test_credential.py new file mode 100644 index 00000000..3198eb20 --- /dev/null +++ b/tests/provider/lingxi/test_credential.py @@ -0,0 +1,96 @@ +"""灵犀凭证归一:整段 Cookie / 裸 sid / 引号 / 缺字段。""" + +from __future__ import annotations + +import hashlib + +import pytest + +from src.provider.base import UpstreamProtocolViolation +from src.provider.lingxi.credential import ( + DEVICE_ID, + LingxiCredential, + parse_cookie_input, +) + + +def test_parse_cookie_input_bare_sid(): + assert parse_cookie_input(" abc-123 ") == {"wps_sid": "abc-123"} + + +def test_parse_cookie_input_full_header_and_quotes(): + raw = "\"Cookie: wps_sid=abc; uid=42; copilot_server_branch=hb; lang=zh-cn\"" + assert parse_cookie_input(raw) == { + "wps_sid": "abc", "uid": "42", "copilot_server_branch": "hb"} + + +def test_parse_cookie_input_skips_bad_segments(): + assert parse_cookie_input("lang=zh; wps_sid=abc; foo; =bar; uid=") == { + "wps_sid": "abc"} + + +def test_parse_cookie_input_empty(): + assert parse_cookie_input(" ") == {} + assert parse_cookie_input("Cookie: ") == {} + + +def test_from_dict_accepts_cookie_field(): + credential = LingxiCredential.from_dict({"cookie": "wps_sid=abc; uid=7"}) + assert credential.wps_sid == "abc" + assert credential.uid == "7" + + +def test_from_dict_prefers_explicit_uid_and_branch(): + credential = LingxiCredential.from_dict({ + "wps_sid": "wps_sid=abc", "uid": "explicit", + "copilot_server_branch": "hb"}) + assert credential.wps_sid == "abc" + assert credential.uid == "explicit" + assert credential.branch == "hb" + + +def test_from_dict_keeps_branch_from_cookie(): + credential = LingxiCredential.from_dict({ + "raw": "wps_sid=abc; copilot_server_branch=hb"}) + assert credential.branch == "hb" + + +def test_from_dict_non_dict_is_empty(): + assert LingxiCredential.from_dict(None).wps_sid == "" + assert LingxiCredential.from_dict("x").wps_sid == "" + + +def test_from_import_requires_object_and_sid(): + with pytest.raises(UpstreamProtocolViolation): + LingxiCredential.from_import("wps_sid=abc") + with pytest.raises(UpstreamProtocolViolation): + LingxiCredential.from_import({"uid": "1"}) + credential = LingxiCredential.from_import({"wps_sid": "abc"}) + assert credential.wps_sid == "abc" + + +def test_to_dict_drops_empty_fields(): + assert LingxiCredential(wps_sid="abc").to_dict() == {"wps_sid": "abc"} + full = LingxiCredential(wps_sid="abc", uid="1", branch="hb").to_dict() + assert full == {"wps_sid": "abc", "uid": "1", + "copilot_server_branch": "hb"} + + +def test_cookie_header_order(): + assert LingxiCredential(wps_sid="abc").cookie_header() == "wps_sid=abc" + assert LingxiCredential("abc", "1", "hb").cookie_header() == ( + "wps_sid=abc; uid=1; copilot_server_branch=hb") + + +def test_identity_prefers_uid(): + assert LingxiCredential("abc", "42").identity() == "42" + + +def test_identity_falls_back_to_sid_hash(): + digest = hashlib.sha256(b"abc").hexdigest()[:16] + assert LingxiCredential("abc").identity() == f"sid-{digest}" + + +def test_device_id_is_stable_hex(): + assert len(DEVICE_ID) == 64 + assert int(DEVICE_ID, 16) >= 0 \ No newline at end of file diff --git a/tests/provider/lingxi/test_events.py b/tests/provider/lingxi/test_events.py new file mode 100644 index 00000000..6258ef5f --- /dev/null +++ b/tests/provider/lingxi/test_events.py @@ -0,0 +1,158 @@ +"""灵犀 SSE 帧 → 中立事件、错误分类与 usage 归一。""" + +from __future__ import annotations + +from src.provider.base import ErrKind, EventKind +from src.provider.lingxi import events as lingxi_events + + +def test_text_of_variants(): + assert lingxi_events.text_of("boom") == "boom" + assert lingxi_events.text_of(None) == "" + assert lingxi_events.text_of({"hint": "h"}) == "h" + assert lingxi_events.text_of({"msg": "m"}) == "m" + assert lingxi_events.text_of({"message": "x"}) == "x" + assert lingxi_events.text_of({"detail": "d"}) == "d" + assert lingxi_events.text_of({"error_message": "e"}) == "e" + assert lingxi_events.text_of({"data": {"hint": "inner"}}) == "inner" + assert lingxi_events.text_of({"data": {"other": 1}}) == "" + assert lingxi_events.text_of({"hint": " "}) == "" + + +def test_frame_error_variants(): + assert lingxi_events.frame_error("x") is None + assert lingxi_events.frame_error({"code": "Success"}) is None + assert lingxi_events.frame_error({"code": ""}) is None + code, message = lingxi_events.frame_error({"code": "Boom", "hint": "要挂"}) + assert (code, message) == ("Boom", "要挂") + code, message = lingxi_events.frame_error({"code": "Boom"}) + assert code == "Boom" and "Boom" in message + code, message = lingxi_events.frame_error({"result": "failed", "msg": "坏了"}) + assert (code, message) == ("failed", "坏了") + code, message = lingxi_events.frame_error({"result": "failed"}) + assert code == "failed" and "failed" in message + assert lingxi_events.frame_error({"result": "ok"}) is None + + +def test_classify_code_covers_documented_semantics(): + assert lingxi_events.classify_code("AuditFailed") is ErrKind.INVALID + assert lingxi_events.classify_code("x", "内容审核未通过") is ErrKind.INVALID + assert lingxi_events.classify_code("x", "wps_sid is required") is ErrKind.DEAD + assert lingxi_events.classify_code("x", "登录态已失效") is ErrKind.DEAD + assert lingxi_events.classify_code("x", "insufficient credits") is ErrKind.CREDIT + assert lingxi_events.classify_code("x", "积分不足") is ErrKind.CREDIT + assert lingxi_events.classify_code("x", "model not found") is ErrKind.BLOCKED + assert lingxi_events.classify_code("x", "模型不存在") is ErrKind.BLOCKED + assert lingxi_events.classify_code("x", "AI服务异常") is ErrKind.OTHER + assert lingxi_events.classify_code(None) is ErrKind.OTHER + + +def test_session_expired_and_audit_predicates(): + assert lingxi_events.is_session_expired("SQLNoRows: 会话不存在") + assert not lingxi_events.is_session_expired("rate limited") + assert lingxi_events.is_audit_blocked("AuditFailed") + assert lingxi_events.is_audit_blocked("暂时无法回答此问题") + assert not lingxi_events.is_audit_blocked("normal") + + +def test_classify_status_matrix(): + assert lingxi_events.classify_status(401) is ErrKind.DEAD + assert lingxi_events.classify_status(402) is ErrKind.CREDIT + assert lingxi_events.classify_status(403) is ErrKind.SOFT + assert lingxi_events.classify_status(404) is ErrKind.INVALID + assert lingxi_events.classify_status(400) is ErrKind.REQUEST + assert lingxi_events.classify_status(422) is ErrKind.REQUEST + assert lingxi_events.classify_status(429) is ErrKind.MODEL + assert lingxi_events.classify_status(500) is ErrKind.OTHER + + +def test_usage_computes_total_and_moves_flat_fields(): + events = lingxi_events.parse_frame({ + "code": "Success", + "usage": {"prompt_tokens": 10, "completion_tokens": 5, + "total_tokens": 999, "cached_tokens": 3, + "reasoning_tokens": 2}}) + usage = [event for event in events if event.kind == EventKind.USAGE][0].usage + assert usage is not None + assert usage.input_tokens == 10 + assert usage.output_tokens == 5 + assert usage.cached_tokens == 3 + assert usage.reasoning_tokens == 2 + + +def test_usage_accepts_nested_details_and_strings(): + events = lingxi_events.parse_frame({ + "usage": {"prompt_tokens": "7", "completion_tokens": 1.9, + "prompt_tokens_details": {"cached_tokens": 4}, + "completion_tokens_details": {"reasoning_tokens": 2}}}) + usage = events[0].usage + assert usage.input_tokens == 7 + assert usage.output_tokens == 1 + assert usage.cached_tokens == 4 + assert usage.reasoning_tokens == 2 + + +def test_usage_ignores_broken_values_and_empty(): + assert lingxi_events.parse_frame({"usage": {}}) == [] + assert lingxi_events.parse_frame({"usage": None}) == [] + events = lingxi_events.parse_frame({"usage": {"prompt_tokens": True, + "completion_tokens": None}}) + usage = events[0].usage + assert usage.input_tokens == 0 + assert usage.output_tokens == 0 + assert usage.cached_tokens is None + assert usage.reasoning_tokens is None + + +def test_parse_frame_emits_all_kinds_together(): + events = lingxi_events.parse_frame({ + "code": "Success", + "choices": [{"text": "hi", "reasoning_content": "think", + "tool_calls": [{"index": 0, "id": "c1", + "function": {"name": "f", + "arguments": "{}"}}], + "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 2}}) + kinds = [event.kind for event in events] + assert kinds == [EventKind.CONTENT, EventKind.REASONING, + EventKind.TOOL_CALLS, EventKind.FINISH, EventKind.USAGE] + call = events[2].tool_calls[0] + assert call["id"] == "c1" and call["type"] == "function" + assert call["function"] == {"name": "f", "arguments": "{}"} + + +def test_parse_frame_error_frame(): + events = lingxi_events.parse_frame({"code": "InsufficientCredits", + "hint": "积分不足"}) + assert len(events) == 1 + assert events[0].kind == EventKind.ERROR + assert events[0].error_kind is ErrKind.CREDIT + assert events[0].error_message == "积分不足" + + +def test_parse_frame_ignores_non_dict_and_empty_choices(): + assert lingxi_events.parse_frame(None) == [] + assert lingxi_events.parse_frame({"choices": []}) == [] + assert lingxi_events.parse_frame({"choices": [None]}) == [] + assert lingxi_events.parse_frame({"choices": [{}]}) == [] + + +def test_tool_call_shapes(): + events = lingxi_events.parse_frame({"choices": [{"tool_calls": [ + {"index": "1", "function": {"arguments": "{}"}}, + {"function": {"name": "f"}}, + {"index": None, "function": "bad"}, + "junk", + ]}]}) + calls = events[0].tool_calls + assert calls is not None + assert calls[0] == {"index": 1, "function": {"arguments": "{}"}} + assert calls[1] == {"function": {"name": "f"}} + + +def test_tool_calls_non_list_and_empty(): + assert lingxi_events.parse_frame({"choices": [{"tool_calls": "x"}]}) == [] + assert lingxi_events.parse_frame({"choices": [{"tool_calls": []}]}) == [] + +def test_int_rejects_non_numeric_string(): + assert lingxi_events.parse_frame({"usage": {"prompt_tokens": "x"}})[0].usage.input_tokens == 0 diff --git a/tests/provider/lingxi/test_provider.py b/tests/provider/lingxi/test_provider.py new file mode 100644 index 00000000..2db927ef --- /dev/null +++ b/tests/provider/lingxi/test_provider.py @@ -0,0 +1,193 @@ +"""灵犀 Provider 门面:导入、探活、签到、模型与 run 重建。""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +import pytest + +from src.provider.base import ( + CheckinResult, + ErrKind, + Event, + EventKind, + Model, + Quota, + UpstreamProtocolViolation, +) +from src.provider.lingxi import LingxiProvider +from src.provider.lingxi.client import RunExpired, UpstreamHTTPError + + +class FakeClient: + """只实现门面用到的接口;调用记录便于断言(不替实现真实 HTTP)。""" + + def __init__(self, *, quota=None, models=None, events=None, + checkin=None, status=None, errors=None) -> None: + self.quota = quota + self.models = models or [] + self._events = events or [Event(kind=EventKind.CONTENT, content="x")] + self._checkin = checkin + self._status = status or {} + self._errors = list(errors or []) + self.calls: list[Any] = [] + self.forgotten: list[Any] = [] + self.closed = False + + async def probe_quota(self, credential): + self.calls.append(("probe", credential)) + return self.quota + + async def fetch_models(self, credential): + self.calls.append(("models", credential)) + return self.models + + async def checkin(self, credential): + self.calls.append(("checkin", credential)) + if self._checkin is None: + raise AssertionError("unexpected checkin") + return self._checkin + + async def checkin_status(self, credential): + self.calls.append(("status", credential)) + return self._status + + async def stream_chat(self, credential, payload, model) -> AsyncIterator[Event]: + self.calls.append(("stream", credential, payload, model)) + if self._errors: + raise self._errors.pop(0) + for event in self._events: + yield event + + def forget_run(self, credential, model): + self.forgotten.append((credential, model)) + + async def aclose(self): + self.closed = True + + +def _provider(client) -> LingxiProvider: + return LingxiProvider(client=client) + + +def test_import_credential_normalizes_cookie(): + provider = _provider(FakeClient()) + raw = provider.import_credential({"cookie": "wps_sid=abc; uid=9"}) + assert raw == {"wps_sid": "abc", "uid": "9"} + with pytest.raises(UpstreamProtocolViolation): + provider.import_credential({"uid": "1"}) + + +def test_classify_forwards_status(): + assert _provider(FakeClient()).classify(401, b"") is ErrKind.DEAD + + +def test_login_identity_and_scope(): + provider = _provider(FakeClient()) + assert provider.login_identity({"wps_sid": "abc", "uid": "42"}) == "42" + assert provider.checkin_scope({"wps_sid": "abc", "uid": "42"}) == "lingxi|42" + assert provider.checkin_scope({}) == "" + + +async def test_probe_quota_without_sid_is_unknown(): + quota = await _provider(FakeClient()).probe_quota({}) + assert quota.probe_failed is True + + +async def test_probe_quota_delegates(): + expected = Quota(remaining=5, total=5, probed_at=1) + client = FakeClient(quota=expected) + assert await _provider(client).probe_quota({"wps_sid": "abc"}) is expected + assert client.calls[0][1].wps_sid == "abc" + + +async def test_checkin_branches(): + provider = _provider(FakeClient()) + result = await provider.checkin({}) + assert not result.ok and "wps_sid" in result.message + + ok = CheckinResult(ok=True, credit=5) + provider = _provider(FakeClient(checkin=ok)) + assert (await provider.checkin({"wps_sid": "abc"})).credit == 5 + + provider = _provider(FakeClient()) + provider.client.checkin = _raiser(UpstreamHTTPError(401, b"")) + result = await provider.checkin({"wps_sid": "abc"}) + assert not result.ok and "登录态" in result.message + + provider = _provider(FakeClient()) + provider.client.checkin = _raiser(UpstreamHTTPError(500, b"")) + result = await provider.checkin({"wps_sid": "abc"}) + assert not result.ok and "HTTP 500" in result.message + + provider = _provider(FakeClient()) + provider.client.checkin = _raiser(UpstreamProtocolViolation("bad")) + result = await provider.checkin({"wps_sid": "abc"}) + assert not result.ok and "格式异常" in result.message + + +async def test_checkin_status_branches(): + provider = _provider(FakeClient()) + assert await provider.checkin_status({}) == {"active": False, + "today_checked_in": False} + provider = _provider(FakeClient(status={"active": True})) + assert await provider.checkin_status({"wps_sid": "abc"}) == {"active": True} + + +async def test_list_models_requires_sid_and_delegates(): + provider = _provider(FakeClient()) + with pytest.raises(UpstreamProtocolViolation): + await provider.list_models({}) + expected = [Model(id="m")] + provider = _provider(FakeClient(models=expected)) + assert await provider.list_models({"wps_sid": "abc"}) == expected + + +async def test_stream_chat_requires_sid(): + provider = _provider(FakeClient()) + with pytest.raises(UpstreamProtocolViolation): + [event async for event in provider.stream_chat({}, {}, "m")] + + +async def test_stream_chat_passthrough(): + provider = _provider(FakeClient()) + events = [event async for event in provider.stream_chat( + {"wps_sid": "abc"}, {"messages": []}, "m")] + assert [event.content for event in events] == ["x"] + assert provider.client.calls[0][0] == "stream" + + +async def test_stream_chat_rebuilds_run_once(): + client = FakeClient(errors=[RunExpired("会话不存在")]) + provider = _provider(client) + events = [event async for event in provider.stream_chat( + {"wps_sid": "abc", "uid": "42"}, {}, "m")] + assert events and client.forgotten[0][1] == "m" + assert len([call for call in client.calls if call[0] == "stream"]) == 2 + + +async def test_stream_chat_gives_up_after_second_expiry(): + client = FakeClient(errors=[RunExpired("a"), RunExpired("b")]) + provider = _provider(client) + with pytest.raises(UpstreamProtocolViolation) as error: + [event async for event in provider.stream_chat({"wps_sid": "abc"}, {}, "m")] + assert "反复失效" in str(error.value) + + +async def test_aclose_closes_client(): + client = FakeClient() + await _provider(client).aclose() + assert client.closed is True + + +def _raiser(error: BaseException): + async def _call(credential): + raise error + return _call + +async def test_stream_chat_generator_closed_early(): + provider = _provider(FakeClient()) + generator = provider.stream_chat({"wps_sid": "abc"}, {}, "m") + assert (await anext(generator)).content == "x" + await generator.aclose() diff --git a/tests/provider/officeace/test_client.py b/tests/provider/officeace/test_client.py index 28bd9452..cd725897 100644 --- a/tests/provider/officeace/test_client.py +++ b/tests/provider/officeace/test_client.py @@ -105,14 +105,14 @@ def test_client_normalises_bases_and_defaults(): def test_client_lazy_clients_and_aclose(): client = OfficeAceClient(cloud_base="https://cloud.test", gateway="https://gw.test") - assert client._stream() is client._stream() - assert client._short() is client._short() - assert client._stream() is not client._short() + assert client._stream is client._stream + assert client._short is client._short + assert client._stream is not client._short async def test_client_aclose_closes_both_pools(): client = _client(lambda request: httpx.Response(200, json={})) - stream = client._stream() + stream = client._stream await client.aclose() assert stream.is_closed diff --git a/tests/provider/officeace/test_credential.py b/tests/provider/officeace/test_credential.py index 397988ae..456322ac 100644 --- a/tests/provider/officeace/test_credential.py +++ b/tests/provider/officeace/test_credential.py @@ -13,6 +13,7 @@ _jwk_from_raw, merge_refreshed, parse_tokens, + validate_gateway_url, ) from src.provider.officeace.events import CLIENT_ID from tests.provider.officeace._support import MOMENT @@ -42,6 +43,20 @@ def test_from_dict_rejects_non_object(): OfficeAceCredential.from_dict("nope") # type: ignore[arg-type] +def test_validate_gateway_url_rejects_empty(): + with pytest.raises(UpstreamProtocolViolation): + validate_gateway_url("") + with pytest.raises(UpstreamProtocolViolation): + validate_gateway_url(" / ") + + +def test_validate_gateway_url_accepts_public_host(): + """公网域名原样返回(不改写形状,`/v2` 归一在 `_gateway_of`)。""" + assert validate_gateway_url("https://gw.test/v2/") == "https://gw.test/v2" + # 公网字面量 IP 也放行(走 `else` 分支的条件为假) + assert validate_gateway_url("https://8.8.8.8/v2") == "https://8.8.8.8/v2" + + def test_from_dict_defaults_client_id(): assert OfficeAceCredential.from_dict({}).client_id == CLIENT_ID diff --git a/tests/provider/officeace/test_oauth.py b/tests/provider/officeace/test_oauth.py index 46ddcfce..8703f9cc 100644 --- a/tests/provider/officeace/test_oauth.py +++ b/tests/provider/officeace/test_oauth.py @@ -150,7 +150,8 @@ def test_store_session_entry_returns_reservation(): store = AuthStateStore() state = store.begin("alice", _session(), now=MOMENT) entry = store.session_entry(state, "alice", now=MOMENT) - assert entry is not None and entry.username == "alice" + # 归属校验:只有登记人拿得到载荷(归属人本身在基类 Reservation 上) + assert entry is not None and entry.session is not None assert store.session_entry(state, "bob", now=MOMENT) is None diff --git a/tests/provider/officeace/test_provider.py b/tests/provider/officeace/test_provider.py index 7dce7182..37ff3362 100644 --- a/tests/provider/officeace/test_provider.py +++ b/tests/provider/officeace/test_provider.py @@ -22,6 +22,16 @@ async def _empty_stream(handler, **kw): # ------------------------------------------------------------------ 身份 +def test_login_identity_prefers_account_id_then_principal(): + """重新登录合并键:account_id;缺失时用 principal_id;都没有返回空串。""" + provider = OfficeAceProvider() + assert provider.login_identity( + {"account_id": "a1", "principal_id": "p1"}) == "a1" + assert provider.login_identity( + {"account_id": "", "principal_id": "p1"}) == "p1" + assert provider.login_identity({}) == "" + + def test_credential_key_prefers_account_id(): assert credential_key(_cred(account_id="acct")) == credential_key( _cred(account_id="acct", model_app_key="other")) @@ -89,6 +99,33 @@ def test_import_credential_requires_chat_fields(over, hint): assert hint in str(caught.value) +@pytest.mark.parametrize("url", [ + "http://gw.test", # 明文传长期密钥 + "https://169.254.169.254", # 云元数据 + "https://127.0.0.1:8080", # 回环 + "https://10.0.0.9/v2", # 内网 + "https://100.100.100.200", # 共享地址段(is_global=False) + "ftp://gw.test", # 非 http(s) + "https://", # 无主机 +]) +def test_import_credential_rejects_unsafe_gateway(url): + """网关地址是长期凭据的收件方:手工导入必须 https 且不指向内网/保留地址。 + + 不校验等于给 operator 一个 SSRF 跳板,并把不过期的 `model_app_secret` + 递给任意主机(见 `validate_gateway_url`)。 + """ + with pytest.raises(UpstreamProtocolViolation): + OfficeAceProvider().import_credential(_importable(model_base_url=url)) + + +def test_import_credential_accepts_public_gateway(): + """公网域名照常放行:网关按账号/区域下发,写死主机名会在上游变更时打断功能。""" + saved = OfficeAceProvider().import_credential( + _importable(model_base_url="https://modelgw-0004.officeace.cn-southwest-2" + ".huaweicloud-agentarts.com")) + assert saved["model_base_url"].startswith("https://modelgw-0004") + + def test_import_credential_validates_dpop_key(): """坏 JWK 只有到刷新才暴露,而那时用户已以为登录成功。""" with pytest.raises(UpstreamProtocolViolation) as caught: @@ -246,6 +283,6 @@ def test_checkin_scope_unknown_identity_returns_empty(): async def test_aclose_closes_client(): client = _client(lambda request: httpx.Response(200)) - stream = client._stream() + stream = client._stream await OfficeAceProvider(client=client).aclose() assert stream.is_closed diff --git a/tests/provider/qoder/test_client.py b/tests/provider/qoder/test_client.py index 91392314..10c4689f 100644 --- a/tests/provider/qoder/test_client.py +++ b/tests/provider/qoder/test_client.py @@ -112,6 +112,58 @@ def test_build_chat_body_unions_consecutive_assistant_tool_calls(): body = build_chat_body({"messages": messages}, "m") assert body["messages"] == messages +def test_build_chat_body_normalizes_null_assistant_content(): + """带 tool_calls 的 assistant 的 `content: null` 归一为空串。 + + 上游把 null content 的 assistant 当成空消息跳过,其后的 tool 结果就成了 + 孤儿,并以 400「Messages with role 'tool' must be a response to a preceding + message with 'tool_calls'」拒绝——报文指向 tool 配对,真实原因是 null + content(2026-10-23 实测:只把 null 换成 "" 即从 400 变 200)。 + """ + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "reasoning_content": "想想", + "tool_calls": [{"id": "a", "type": "function", "function": {"name": "f"}}]}, + {"role": "tool", "tool_call_id": "a", "content": "结果"}, + ] + body = build_chat_body({"messages": messages}, "m") + assert body["messages"][1]["content"] == "" + assert body["messages"][1]["reasoning_content"] == "想想" + assert body["messages"][2] == messages[2] + # 入参不被改写(build_chat_body 深拷贝) + assert messages[1]["content"] is None + +def test_build_chat_body_normalizes_null_tool_content(caplog): + """tool 消息的 null content 同样归一,且计入日志条数。""" + messages = [ + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "a", "type": "function", "function": {"name": "f"}}]}, + {"role": "tool", "tool_call_id": "a", "content": None}, + {"role": "assistant", "content": None}, + ] + with caplog.at_level("DEBUG", logger=qoder_client.__name__): + body = build_chat_body({"messages": messages}, "m") + assert [m["content"] for m in body["messages"]] == ["", "", ""] + assert any("null content" in r.getMessage() and "3" in r.getMessage() + for r in caplog.records) + +def test_build_chat_body_leaves_other_roles_content_untouched(): + """只归一 assistant/tool:user/system 的 null 原样交给上游裁决。""" + messages = [ + {"role": "system", "content": None}, + {"role": "user", "content": None}, + {"role": "assistant", "content": "有正文"}, + {"role": "junk", "content": None}, + ] + body = build_chat_body({"messages": messages}, "m") + assert [m["content"] for m in body["messages"]] == [None, None, "有正文", None] + +def test_normalize_null_content_skips_non_dict_entries(): + """畸形历史(非 dict 消息)跳过,不改写也不计数。""" + messages = ["junk", {"role": "assistant", "content": None}] + assert qoder_client._normalize_null_content(messages) == 1 + assert messages == ["junk", {"role": "assistant", "content": ""}] + def test_build_chat_body_plain_assistant_resets_declared_ids(): """中间夹了一条不带 tool_calls 的 assistant:它打断了配对,其后的 tool 孤立。""" messages = [ @@ -209,6 +261,24 @@ def handler(_request: httpx.Request) -> httpx.Response: assert events[1].error_code == 418 assert events[1].error_kind is ErrKind.SOFT +async def test_stream_chat_queue_envelope_yields_queue_event(): + """排队信封(403 + `isQueued`)必须归 QUEUE。 + + 按状态码分类会把 403 当事务级失败硬禁用凭证,而排队恰恰说明号是好的; + 上游点名的等待秒数要原样带给 executor(原号重开)。 + """ + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=envelope( + {"isQueued": True, "retryAfterSeconds": 6}, status=403)) + + events = [e async for e in handler_for(handler).stream_chat(cred(), {}, "m")] + assert len(events) == 1 + assert events[0].kind is EventKind.ERROR + assert events[0].error_kind is ErrKind.QUEUE + assert events[0].error_code == 403 + assert events[0].retry_after_seconds == 6 + + async def test_inner_error_message_keeps_nested_details(): """错误正文放宽到 2000:200 字符会截在转义的 details 中间,根因丢失。""" detail = json.dumps({"error": {"message": "Messages with role 'tool' must be a " @@ -231,6 +301,93 @@ def handler(_request: httpx.Request) -> httpx.Response: events = [e async for e in handler_for(handler).stream_chat(cred(), {}, "m")] assert events == [] +async def test_stream_chat_skips_keepalive_null_body(): + """上游保活帧 `body:"null"`(外层 200)必须跳过,不能打断正常输出。 + + 实测(2026-10-26 16:29-16:53):上游在流中发 + `{"headers":…,"body":"null","statusCodeValue":200,"statusCode":"OK"}`。 + 早先把它当「内层不是对象」→ 抛 `UpstreamProtocolViolation` → 整条流 + 判失败(连续 13 次「流式响应失败: Qoder inner chunk is not an object」), + 而模型其实已在正常输出。这正是「静默出错」:客户端只看到回复被截断。 + """ + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=( + envelope(inner({"content": "part"})) + + envelope("null") + + envelope("null") + + envelope(inner({"content": "more"})) + + envelope("[DONE]"))) + + events = [e async for e in handler_for(handler).stream_chat(cred(), {}, "m")] + assert [e.kind for e in events] == [EventKind.CONTENT, EventKind.CONTENT] + assert "".join(e.content or "" for e in events) == "partmore" + +async def test_stream_chat_keeps_content_after_empty_object_body(): + """空对象 / 空数组内层同样是保活:跳过但不终止流。""" + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=( + envelope("{}") + + envelope("[]") + + envelope(inner({"content": "ok"})) + + envelope("[DONE]"))) + + events = [e async for e in handler_for(handler).stream_chat(cred(), {}, "m")] + assert [e.kind for e in events] == [EventKind.CONTENT] + assert events[0].content == "ok" + +def test_is_keepalive_body_shape_table(): + """保活判据用结构而非字符串前缀:非空对象 / 坏 JSON 都不是保活。""" + assert qoder_events.is_keepalive_body("null") is True + assert qoder_events.is_keepalive_body("{}") is True + assert qoder_events.is_keepalive_body("[]") is True + assert qoder_events.is_keepalive_body(" null ") is True + assert qoder_events.is_keepalive_body('{"choices":[]}') is False + assert qoder_events.is_keepalive_body('{"code":"403"}') is False + assert qoder_events.is_keepalive_body("nope") is False + assert qoder_events.is_keepalive_body('"text"') is False + assert qoder_events.is_keepalive_body("0") is False + +async def test_stream_chat_queue_envelope_from_real_upstream_frame(): + """上游真实排队帧(2026-10-26 抓取):排队标记在**两层序列化字符串**里。 + + 早先 `parse_queue_hint` 只认 dict/list,漏判 → 403 被 `classify_error_code` + 判成 DEAD → **好凭证被硬禁用**。这里守两件事:归 QUEUE、且拿到上游点名的 + 等待秒数(最内层的 `retryAfterSeconds:30`,不是兜底 5s)。 + """ + inner_payload = json.dumps({ + "isQueued": True, "modelKey": "kmodel_latest", "queueCount": 454, + "queueType": "p3", "retryAfterSeconds": 30, "serviceAvailable": True, + "subCode": "RESOURCE_LIMIT", "waitTime": 205}) + body = json.dumps({ + "code": "403", + "message": json.dumps({ + "code": "10605", + "message": inner_payload, + "subCode": "RESOURCE_LIMIT"})}) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=envelope(body, status=403)) + + events = [e async for e in handler_for(handler).stream_chat(cred(), {}, "m")] + assert len(events) == 1 + assert events[0].kind is EventKind.ERROR + assert events[0].error_kind is ErrKind.QUEUE + assert events[0].error_code == 403 + # 上游点名 30s:不是兜底值 5s(兜底只用于「上游没给秒数」) + assert events[0].retry_after_seconds == 30.0 + +def test_classify_status_recognizes_serialized_queue_envelope(): + """HTTP 层(`classify_status`)同样要认出串了两层的排队报文。""" + inner_payload = json.dumps({"isQueued": True, "retryAfterSeconds": 30, + "subCode": "RESOURCE_LIMIT"}) + body = json.dumps({"code": "403", + "message": json.dumps({"code": "10605", + "message": inner_payload})}) + # 排队优先于状态码:403 不是硬失败 + assert qoder_events.classify_status(403, body.encode()) is ErrKind.QUEUE + # 排队报文绝不能落到 DEAD(会把可自愈的凭证打进冷宫) + assert qoder_events.classify_error_code(403, body) is not ErrKind.DEAD + async def test_stream_chat_http_error_is_classified(): def handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(429, content=b"slow") @@ -466,6 +623,70 @@ def test_node_failure_400_is_model_scoped_transient(): assert qoder_events.classify_error_code(400, "bad request") is ErrKind.INVALID assert qoder_events.classify_status(400, body.encode()) is ErrKind.MODEL +def test_classify_status_queue_hint_wins_over_403(): + """排队标记优先于状态码:403 不再当硬失败(会把健康凭证打进冷宫)。""" + body = b'{"isQueued": true, "retryAfterSeconds": 9}' + assert qoder_events.classify_status(403, body) is ErrKind.QUEUE + # 没有排队标记的 403 仍是 SOFT:实测多为瞬时/风控抖动,可自愈 + assert qoder_events.classify_status(403, b"") is ErrKind.SOFT + + +def test_401_stays_dead_while_403_is_relaxed(): + """401 一律 DEAD,只有 403 放宽——401 是凭据失效的确定信号,不能 fail-open。 + + 放宽 401 会让已被吊销的 token 在 60s 轮换里反复撞上游(凭证永不退出 + 候选池),而 401 从未出现「瞬时抖动」形态。排队信封仍优先于 401。 + """ + assert qoder_events.classify_error_code(401, b"") is ErrKind.DEAD + assert qoder_events.classify_status(401, b"") is ErrKind.DEAD + assert qoder_events.classify_error_code(401, "some other failure") is ErrKind.DEAD + queued = b'{"isQueued": true, "retryAfterSeconds": 3}' + assert qoder_events.classify_error_code(401, queued) is ErrKind.QUEUE + + +def test_inner_403_without_session_marker_is_soft_not_dead(): + """流内 403 未带会话失效标记 → SOFT,和 HTTP 层同一判据。 + + 早先 `_decode_frame` 直接调 `classify_error_code`,403 不看标记就判 DEAD, + 把一个还能用的号永久禁用(2026-10-27 实测)。 + """ + body = '{"code":"403","message":"upstream risk control"}' + assert qoder_events.classify_error_code(403, body) is ErrKind.SOFT + assert qoder_events.classify_status(403, body.encode()) is ErrKind.SOFT + # 带会话失效标记才是真的 DEAD + dead = '{"code":"403","message":"Offline user session not found"}' + assert qoder_events.classify_error_code(403, dead) is ErrKind.DEAD + assert qoder_events.classify_status(403, dead.encode()) is ErrKind.DEAD + + +def test_daily_quota_exceeded_is_credit_not_dead(): + """「每日计费次数用尽」→ CREDIT(冷却到次日),不是 session 死亡。 + + 实测 2026-10-27:403 `{"code":"110","message":"Billing daily count exceeded"}` + 被流内分类判成 DEAD,qoder 号 nick5080166916 被硬禁用后需人工 revive。 + """ + body = '{"code":"110","message":"Billing daily count exceeded"}' + assert qoder_events.is_daily_quota_exceeded(body) is True + assert qoder_events.is_daily_quota_exceeded("ordinary error") is False + assert qoder_events.classify_error_code(403, body) is ErrKind.CREDIT + assert qoder_events.classify_error_code(403, body.encode()) is ErrKind.CREDIT + assert qoder_events.classify_status(403, body.encode()) is ErrKind.CREDIT + + +async def test_stream_chat_daily_quota_envelope_yields_credit(): + """流内 403 每日计费耗尽 → CREDIT 事件(不硬禁用凭证)。""" + body = json.dumps({"code": "110", "message": "Billing daily count exceeded"}) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=envelope(body, status=403)) + + events = [e async for e in handler_for(handler).stream_chat(cred(), {}, "m")] + assert len(events) == 1 + assert events[0].kind is EventKind.ERROR + assert events[0].error_kind is ErrKind.CREDIT + assert events[0].error_code == 403 + + def test_to_status_variants(): assert qoder_events._to_status(None) == 200 assert qoder_events._to_status(True) == 502 @@ -634,3 +855,104 @@ def handler(request: httpx.Request) -> httpx.Response: assert [e.kind for e in events] == [EventKind.CONTENT, EventKind.FINISH] assert seen == ["gw1.test", "gw2.test"] # 中途主机成功即止 await client.aclose() + + +def test_parse_inner_chunk_attaches_upstream_reported_model(): + events = qoder_events.parse_inner_chunk(json.dumps({ + "model": "qwen3-coder", + "choices": [{"delta": {"content": "hi"}}], + })) + assert events[0].content == "hi" + assert events[0].model == "qwen3-coder" + # 不报模型时保持 None + plain = qoder_events.parse_inner_chunk( + json.dumps({"choices": [{"delta": {"content": "hi"}}]})) + assert plain[0].model is None + + +# ------------------------------------------------- 网页额度接口(逐包到期) + +WEB_QUOTA = { + "quota_key": "big_model_credits", + "plan_quota": { + "quota_summary": {"limit_value": 0, "used_value": 0, "remaining_value": 0}, + "quota_detail": [{"limit_value": 0, "used_value": 0, "remaining_value": 0, + "expires_at": 0, "is_active": True, "source": "PLAN"}], + }, + "resource_package_quota": { + "quota_summary": {"limit_value": 200, "used_value": 0, "remaining_value": 200}, + "quota_detail": [ + {"limit_value": 100, "used_value": 0, "remaining_value": 100, + "expires_at": 1_795_000_000_000, "is_active": True, + "source": "RESOURCE_PACKAGE_SOURCE_BONUS"}, + {"limit_value": 100, "used_value": 0, "remaining_value": 100, + "expires_at": 1_796_000_000_000, "is_active": True, + "source": "RESOURCE_PACKAGE_SOURCE_BONUS"}, + ], + }, +} + +DEVICE_QUOTA = { + "userQuota": {"total": 0, "used": 0, "remaining": 0}, + "addOnQuota": {"total": 999, "used": 0, "remaining": 999}, + "expiresAt": 253_402_214_400_000, +} + + +def _web_cookie_cred(): + return cred(web_cookie="qoder_session_cookie=abc; other=1") + + +def _web_handler(web_response, device_response=DEVICE_QUOTA): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == qoder_events.EP_CREDITS_WEB: + assert request.url.host == "qoder.cn" # cn 区域网页站 + assert request.headers["Cookie"] == "qoder_session_cookie=abc; other=1" + # 网页接口按浏览器会话鉴权:不能带 CLI 的 Go UA,也不带 Bearer + assert request.headers["User-Agent"] == qoder_events.WEB_ACCOUNT_UA + assert "Authorization" not in request.headers + return web_response(request) + assert request.url.path == qoder_events.EP_QUOTA + return httpx.Response(200, json=device_response) + return handler + + +async def test_probe_quota_prefers_web_endpoint_when_cookie_present(): + client = handler_for(_web_handler(lambda _r: httpx.Response(200, json=WEB_QUOTA))) + quota = await client.probe_quota(_web_cookie_cred()) + # 逐包到期:cycle_end 是最早的包到期日,而不是 9999 哨兵 + assert quota.remaining == 200 and quota.total == 200 + assert quota.cycle_end == 1_795_000_000 + assert quota.expiry_ladder == [(1_795_000_000, 100.0), + (1_796_000_000, 100.0)] + + +async def test_probe_quota_falls_back_when_web_cookie_expired(): + """Cookie 过期(401)只丢明细,不冷却也不硬禁用:回退汇总接口继续出号。""" + client = handler_for(_web_handler( + lambda _r: httpx.Response(401, content=b'{"errorCode":"Unauthorized"}'))) + quota = await client.probe_quota(_web_cookie_cred()) + assert quota.remaining == 999 and quota.total == 999 + assert quota.cycle_end is None # 9999 哨兵归一成未知 + + +async def test_probe_quota_falls_back_when_web_endpoint_unusable(): + """形状变了 / 非 JSON / 网络错误都只回退,不把探测判失败。""" + for bad in ( + lambda _r: httpx.Response(404, content=b"gone"), + lambda _r: httpx.Response(200, text="nope"), + lambda _r: httpx.Response(200, json={"quota_key": "big_model_credits"}), + lambda request: (_ for _ in ()).throw(httpx.ConnectError("boom", + request=request)), + ): + quota = await handler_for(_web_handler(bad)).probe_quota(_web_cookie_cred()) + assert quota.remaining == 999 + + +async def test_probe_quota_skips_web_endpoint_without_cookie(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == qoder_events.EP_QUOTA # 不出网到网页接口 + return httpx.Response(200, json=DEVICE_QUOTA) + + quota = await handler_for(handler).probe_quota(cred()) + assert quota.remaining == 999 diff --git a/tests/provider/qoder/test_credential.py b/tests/provider/qoder/test_credential.py index d0161312..ad672fda 100644 --- a/tests/provider/qoder/test_credential.py +++ b/tests/provider/qoder/test_credential.py @@ -128,3 +128,20 @@ def test_credential_from_dict_rejects_non_dict(): def test_detect_realm_from_plain_qoder_com(): assert qoder_events.detect_realm_from_domain("www.qoder.com") == "intl" + + +def test_web_cookie_roundtrip_and_aliases(): + """网页 Cookie 随凭证加密落库;导出/再导入与刷新都不丢它。""" + raw = {"access_token": "t", "web_cookie": "qoder_session_cookie=abc"} + assert QoderCredential.from_dict(raw).to_dict()["web_cookie"] == ( + "qoder_session_cookie=abc") + # 兼容 cookie 别名 + assert QoderCredential.from_dict({"access_token": "t", + "cookie": "sid=1"}).web_cookie == "sid=1" + # 缺字段 = 空串(没有网页会话,额度探测走汇总接口) + assert QoderCredential.from_dict({"access_token": "t"}).web_cookie == "" + + +def test_merge_refreshed_preserves_web_cookie(): + old = cred(web_cookie="qoder_session_cookie=abc") + assert merge_refreshed(old, {"token": "dt-new"}).web_cookie == old.web_cookie diff --git a/tests/provider/qoder/test_provider.py b/tests/provider/qoder/test_provider.py index fe2c3835..8fad3681 100644 --- a/tests/provider/qoder/test_provider.py +++ b/tests/provider/qoder/test_provider.py @@ -23,7 +23,7 @@ async def test_provider_classify_import_and_credential_from(): provider = QoderProvider() assert provider.id == "qoder" assert provider.classify(402, b"") is ErrKind.CREDIT - assert provider.classify(401, b"") is ErrKind.SOFT + assert provider.classify(401, b"") is ErrKind.DEAD assert provider.classify(429, b"") is ErrKind.MODEL imported = provider.import_credential({"token": "t", "uid": "u", "realm": "intl"}) assert imported["access_token"] == "t" and imported["realm"] == "intl" @@ -168,3 +168,19 @@ def handler(_request: httpx.Request) -> httpx.Response: events = [e async for e in provider.stream_chat(cred().to_dict(), {}, "m")] assert any(e.kind is EventKind.CONTENT for e in events) await provider.aclose() + + +def test_provider_set_web_cookie_only_touches_that_field(): + """补充 Cookie 不重走归一:老凭证里的未识别字段保持原样(导出可逆)。""" + from src.provider.qoder import QoderProvider + + provider = QoderProvider() + data = {"access_token": "t", "unknown_field": "keep", "uid": "u"} + assert provider.set_web_cookie(data, " sid=1 ") == { + "access_token": "t", "unknown_field": "keep", "uid": "u", + "web_cookie": " sid=1 ", + } + # 传入的字典不被就地修改(调用方可能还持有它) + assert "web_cookie" not in data + # 空串 = 清除 + assert provider.set_web_cookie({"web_cookie": "x"}, "")["web_cookie"] == "" diff --git a/tests/provider/qoder/test_web_quota.py b/tests/provider/qoder/test_web_quota.py new file mode 100644 index 00000000..052096f3 --- /dev/null +++ b/tests/provider/qoder/test_web_quota.py @@ -0,0 +1,129 @@ +"""Qoder 网页额度接口解析:逐包到期(`parse_web_quota`)+ 哨兵到期值。""" + +from __future__ import annotations + +import time + +from src.provider.qoder.events import ( + EXPIRY_SENTINEL_MIN, + parse_quota, + parse_web_quota, +) + +NOW = 1_794_420_000 + + +def _pack(remaining: float, expires_at: int | None, *, + limit: float = 100, used: float = 0, + source: str = "RESOURCE_PACKAGE_SOURCE_BONUS", + active: bool = True) -> dict: + item: dict = { + "id": "p", + "limit_value": limit, + "used_value": used, + "remaining_value": remaining, + "is_active": active, + "source": source, + "status": "ACTIVE", + } + item["expires_at"] = expires_at + return item + + +def _container(summary: dict, detail: list[dict]) -> dict: + return {"quota_summary": summary, "quota_detail": detail} + + +def _summary(limit: float, used: float, remaining: float) -> dict: + return {"limit_value": limit, "used_value": used, + "remaining_value": remaining, "usage_percentage": 0, + "unit": "credits"} + + +REAL_SHAPE = { + "user_id": "u-1", + "quota_key": "big_model_credits", + "status": "active", + "plan_quota": _container(_summary(0, 0, 0), [ + _pack(0, 0, limit=0, source="PLAN"), + ]), + "resource_package_quota": _container(_summary(400, 45, 355), [ + _pack(100, 1_796_000_000), + _pack(100, 1_795_000_000, used=50), + _pack(0, 1_797_000_000, used=100), # 已用完:展示保留、不进阶梯 + _pack(100, None), # 无到期:不进阶梯 + _pack(100, 1_798_000_000, active=False), # 已失效:不进阶梯 + _pack(5, 253_402_214_400_000), # 哨兵到期:按无到期处理 + "junk", # 非对象条目:跳过 + ]), + "dedicated_resource_package_quota": _container(_summary(0, 0, 0), []), +} + + +def test_parse_web_quota_expands_packages_and_builds_ladder(): + quota = parse_web_quota(REAL_SHAPE, now=NOW) + + assert quota is not None + # 汇总按容器求和(不再叠加顶层 total_quota,否则翻倍) + assert quota.total == 400 + assert quota.remaining == 355 + # 哨兵到期(9999-12-31)归成 None,不参与阶梯 + assert [p["end"] for p in quota.packages].count(None) == 3 + # 逐包明细:套餐内的 0 额包也保留(排查「这个容器到底有没有」要看得到) + names = [p["name"] for p in quota.packages] + assert names[0] == "套餐内积分" + assert names.count("赠送积分") == 6 + # 到期阶梯只收「有效 + 有剩余 + 有到期」,并按到期升序 + assert quota.expiry_ladder == [(1_795_000_000, 100.0), + (1_796_000_000, 100.0)] + assert quota.cycle_end == 1_795_000_000 + assert quota.probed_at == NOW + + +def test_parse_web_quota_accepts_camel_case_aliases(): + payload = { + "planQuota": { + "quotaSummary": {"limitValue": 10, "usedValue": 2, + "remainingValue": 8}, + "quotaDetail": [{ + "limitValue": 10, "usedValue": 2, "remainingValue": 8, + "expiresAt": 1_796_500_000_000, "isActive": True, + }], + }, + } + quota = parse_web_quota(payload, now=NOW) + assert quota is not None + assert quota.total == 10 and quota.remaining == 8 + assert quota.expiry_ladder == [(1_796_500_000, 8.0)] + # 未知 source 退回容器名 + assert quota.packages == [{"name": "套餐内积分", "total": 10.0, + "used": 2.0, "end": 1_796_500_000}] + + +def test_parse_web_quota_unknown_shape_returns_none(): + """容器全缺或形状变了 → None(调用方回退汇总接口),不能当成额度 0。""" + assert parse_web_quota({}, now=NOW) is None + assert parse_web_quota({"quotaKey": "big_model_credits"}, now=NOW) is None + # detail 全不是对象也不臆造 + assert parse_web_quota( + {"plan_quota": _container(_summary(0, 0, 0), [])}, now=NOW) is None + # detail 不是数组(上游改形状)也按无明细处理,不抛错 + assert parse_web_quota( + {"plan_quota": {"quota_summary": _summary(10, 0, 10), + "quota_detail": "oops"}}, now=NOW) is None + + +def test_parse_quota_treats_far_future_expiry_as_no_expiry(): + """9999-12-31 是上游「无到期」哨兵:不能当真实重置日显示。""" + payload = { + "userQuota": {"total": 100, "used": 40, "remaining": 60}, + "addOnQuota": {"total": 50, "used": 10, "remaining": 40}, + "expiresAt": 253_402_214_400_000, + } + quota = parse_quota(payload, now=NOW) + assert quota.cycle_end is None + assert all(p["end"] is None for p in quota.packages or []) + + real = parse_quota({**payload, "expiresAt": 1_795_000_000_000}, now=NOW) + assert real.cycle_end == 1_795_000_000 + assert time.time() < EXPIRY_SENTINEL_MIN \ No newline at end of file diff --git a/tests/provider/raccoon/__init__.py b/tests/provider/raccoon/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/provider/raccoon/_support.py b/tests/provider/raccoon/_support.py new file mode 100644 index 00000000..08543aec --- /dev/null +++ b/tests/provider/raccoon/_support.py @@ -0,0 +1,13 @@ +"""小浣熊渠道测试共享装配。""" + +from __future__ import annotations + +from src.provider.raccoon.client import RaccoonClient +from tests.helpers.http import provider_client + +__all__ = ["client"] + + +def client(handler, **kw) -> RaccoonClient: + kw.setdefault("host", "https://raccoon.test") + return provider_client(RaccoonClient, handler, **kw) \ No newline at end of file diff --git a/tests/provider/raccoon/test_activity.py b/tests/provider/raccoon/test_activity.py new file mode 100644 index 00000000..22259c22 --- /dev/null +++ b/tests/provider/raccoon/test_activity.py @@ -0,0 +1,291 @@ +"""小浣熊签到 / 活跃上报 / 积分明细的纯函数。""" + +from __future__ import annotations + +from datetime import datetime + +from src.provider.raccoon import activity as raccoon_activity + +NOW = datetime.fromisoformat("2026-11-04T22:00:00+08:00") + + +def _grant(*, points=300, grant_at="2026-11-04T00:00:00+08:00", + created_at="2026-11-04T21:41:38+08:00", + expire_at="2026-11-10T23:59:59+08:00", biz_type="daily_grant"): + return {"biz_type": biz_type, "points": points, "grant_at": grant_at, + "created_at": created_at, "expire_at": expire_at} + + +def test_parse_iso_handles_offsets_naive_and_garbage(): + parsed = raccoon_activity.parse_iso("2026-11-04T21:41:38+08:00") + assert parsed is not None and parsed.year == 2026 + # 带 Z 的 UTC 也认 + assert raccoon_activity.parse_iso("2026-11-04T13:41:38Z") is not None + # 无时区按 +08:00 解释(上游全用这个时区) + naive = raccoon_activity.parse_iso("2026-11-04T21:41:38") + assert naive is not None and naive.utcoffset().total_seconds() == 8 * 3600 + for bad in (None, "", " ", 123, "not-a-date"): + assert raccoon_activity.parse_iso(bad) is None + + +def test_pick_today_grant_takes_today_only(): + items = [ + _grant(grant_at="2026-11-03T00:00:00+08:00"), + _grant(), + "junk", + {"biz_type": "daily_grant", "points": -300}, + {"biz_type": "daily_expire", "points": -300}, + {"biz_type": "daily_grant", "points": 100, + "grant_at": "2026-11-04T00:00:00+08:00"}, + ] + row = raccoon_activity.pick_today_grant(items, NOW) + # 同一天多条取点数最大的(正常发放 300 优先于补发 100) + assert row is not None and row["points"] == 300 + + +def test_pick_today_grant_returns_none_when_absent(): + assert raccoon_activity.pick_today_grant([], NOW) is None + assert raccoon_activity.pick_today_grant( + [_grant(grant_at="2026-11-03T00:00:00+08:00")], NOW) is None + # points 为 bool / 字符串 / 0 都不算 + assert raccoon_activity.pick_today_grant( + [{"biz_type": "daily_grant", "points": True, + "grant_at": "2026-11-04T00:00:00+08:00"}], NOW) is None + assert raccoon_activity.pick_today_grant( + [{"biz_type": "daily_grant", "points": "300", + "grant_at": "2026-11-04T00:00:00+08:00"}], NOW) is None + + +def test_pick_today_grant_falls_back_to_created_at(): + row = raccoon_activity.pick_today_grant( + [{"biz_type": "daily_grant", "points": 300, + "created_at": "2026-11-04T09:00:00+08:00"}], NOW) + assert row is not None + + +def test_checkin_result_three_states(): + started = NOW.timestamp() + # ① 本次触发的懒发放:created_at 在窗口内 + fresh = raccoon_activity.checkin_result( + [_grant(created_at="2026-11-04T21:59:30+08:00")], started, now=NOW) + assert fresh.ok and not fresh.already_checked_in and fresh.credit == 300 + assert "到期" in fresh.message + + # ② 今天早些时候已发:created_at 早于窗口 + early = raccoon_activity.checkin_result( + [_grant(created_at="2026-11-04T08:00:00+08:00")], started, now=NOW) + assert early.ok and early.already_checked_in + assert "不是本次触发" in early.message + + # ③ 今日未发放:按失败报(可重试) + missing = raccoon_activity.checkin_result([], started, now=NOW) + assert not missing.ok and "尚未发放" in missing.message + + +def test_checkin_result_without_expire_date(): + result = raccoon_activity.checkin_result( + [_grant(expire_at=None, created_at="2026-11-04T21:59:30+08:00")], + NOW.timestamp(), now=NOW) + assert "到期" not in result.message + + +def test_pick_active_grants_keeps_only_unexpired_and_unconsumed(): + items = [ + # 仍有效的两笔(不同到期日) + _grant(grant_at="2026-11-04T00:00:00+08:00", + expire_at="2026-11-10T23:59:59+08:00"), + _grant(grant_at="2026-11-05T00:00:00+08:00", + expire_at="2026-11-11T23:59:59+08:00"), + # 已过期 + _grant(grant_at="2026-11-01T00:00:00+08:00", + expire_at="2026-11-02T23:59:59+08:00"), + # 缺 expire_at:无法判断有效期,不算「可用」 + _grant(grant_at="2026-11-06T00:00:00+08:00", expire_at=None), + # 已被同一天同额度的 daily_expire 清零 + _grant(grant_at="2026-11-03T00:00:00+08:00", + expire_at="2026-11-09T23:59:59+08:00"), + {"biz_type": "daily_expire", "points": -300, + "grant_at": "2026-11-03T00:00:00+08:00"}, + # 非 daily_grant / 非正点数 + {"biz_type": "reward_grant", "points": 1500, + "expire_at": "2026-12-01T00:00:00+08:00"}, + {"biz_type": "daily_grant", "points": True, + "expire_at": "2026-11-12T23:59:59+08:00"}, + ] + active = raccoon_activity.pick_active_grants(items, NOW) + assert [item["points"] for item in active] == [300, 300] + # 按到期升序:11-10 在前、11-11 在后 + assert active[0]["expire_at"] < active[1]["expire_at"] + + +def test_checkin_result_uses_active_grant_when_today_missing(): + """今天没有新发放,但还有未过期积分 → 视为已签,不再报失败。""" + items = [_grant(grant_at="2026-11-02T00:00:00+08:00", + expire_at="2026-11-10T23:59:59+08:00"), + _grant(grant_at="2026-11-03T00:00:00+08:00", + expire_at="2026-11-11T23:59:59+08:00")] + result = raccoon_activity.checkin_result(items, NOW.timestamp(), now=NOW) + assert result.ok and result.already_checked_in + assert result.credit == 600 + assert "今日额度可用 600" in result.message + assert "本次未产生新发放" in result.message + # 22:00 已过宽限,直接封到当日结束(seal_until=None) + assert result.seal_until is None + + +def test_checkin_result_active_grant_before_grace_delays_seal(): + """凌晨 00:10 看到旧积分:先算已签,但封账只到 01:30,等账单同步后再核。""" + early = datetime.fromisoformat("2026-11-05T00:10:00+08:00") + items = [_grant(grant_at="2026-11-04T00:00:00+08:00", + expire_at="2026-11-10T23:59:59+08:00")] + result = raccoon_activity.checkin_result(items, early.timestamp(), now=early) + assert result.ok and result.already_checked_in + grace = raccoon_activity.grant_grace_at(early) + assert result.seal_until == int(grace.timestamp()) + + +def test_checkin_result_missing_before_grace_sets_retry_at(): + early = datetime.fromisoformat("2026-11-05T00:10:00+08:00") + result = raccoon_activity.checkin_result([], early.timestamp(), now=early) + assert not result.ok + grace = raccoon_activity.grant_grace_at(early) + assert result.retry_at == int(grace.timestamp()) + + +def test_checkin_result_missing_after_grace_has_no_retry_at(): + assert raccoon_activity.checkin_result( + [], NOW.timestamp(), now=NOW).retry_at is None + + +def test_grant_grace_at_is_cst_0130(): + early = datetime.fromisoformat("2026-11-05T00:10:00+08:00") + grace = raccoon_activity.grant_grace_at(early) + assert grace.isoformat() == "2026-11-05T01:30:00+08:00" + # UTC 输入也归一成 CST + utc = datetime.fromisoformat("2026-11-04T16:10:00+00:00") + assert raccoon_activity.grant_grace_at(utc).isoformat() == \ + "2026-11-05T01:30:00+08:00" + + +def test_quota_packages_maps_four_pools(): + packages = raccoon_activity.quota_packages({ + "daily_points": 274, "reward_points": 6000, + "monthly_points": 0, "topup_points": 100, "topup_frozen": True, + }) + assert [p["name"] for p in packages] == ["每日额度", "奖励积分", "充值积分"] + # 冻结的充值池照常列出,但标不可用 + assert packages[-1]["usable"] is False + assert packages[0]["usable"] is True + + +def test_quota_packages_expands_grants_with_expiry_and_fifo_usage(): + """按发放流水逐笔展开:每笔带真实到期;每日池的消耗从最早到期那笔扣。""" + items = [ + _grant(points=300, grant_at="2026-11-04T00:00:00+08:00", + expire_at="2026-11-10T23:59:59+08:00"), + _grant(points=300, grant_at="2026-11-03T00:00:00+08:00", + expire_at="2026-11-09T23:59:59+08:00"), + _grant(points=1500, grant_at=None, + created_at="2026-11-02T15:58:54+08:00", + expire_at=None, biz_type="reward_grant"), + # 0 点奖励(上游偶发):不进明细 + _grant(points=0, grant_at=None, + created_at="2026-11-02T15:58:55+08:00", + expire_at=None, biz_type="reward_grant"), + # 奖励也可能带到期(活动积分):照常展示到期 + _grant(points=200, grant_at=None, + created_at="2026-11-01T09:30:00+08:00", + expire_at="2026-12-01T23:59:59+08:00", + biz_type="reward_grant"), + # 已过期:不进明细 + _grant(points=300, grant_at="2026-10-01T00:00:00+08:00", + expire_at="2026-10-07T23:59:59+08:00"), + ] + packages = raccoon_activity.quota_packages( + {"daily_points": 450, "reward_points": 1500}, items, NOW) + daily = [p for p in packages if p["name"].startswith("每日积分")] + assert [p["name"] for p in daily] == ["每日积分 11-03", "每日积分 11-04"] + # 已消耗 150:最早到期那笔先扣 + assert [p["total"] - p["used"] for p in daily] == [150, 300] + assert daily[0]["end"] == 1794239999 # 2026-11-09T23:59:59+08:00 + reward = [p for p in packages if p["name"].startswith("奖励积分")] + assert reward[0]["total"] == 1500 and reward[0]["end"] is None + + +def test_quota_packages_adds_pool_rows_without_grant_coverage(): + """池里的可用量比流水多(同步延迟):补一条无到期余额,合计仍与池值一致。""" + items = [_grant(points=300, grant_at="2026-11-04T00:00:00+08:00")] + packages = raccoon_activity.quota_packages( + {"daily_points": 800, "monthly_points": 50}, items, NOW) + daily = next(p for p in packages if p["name"].startswith("每日积分 11-04")) + assert daily["total"] - daily["used"] == 300 + rest = next(p for p in packages if p["name"].startswith("每日积分(流水未覆盖)")) + assert rest["total"] == 500 and rest["end"] is None + assert any(p["name"] == "月度积分" for p in packages) + + +def test_quota_packages_treats_missing_pool_as_grant_sum(): + """余额没给池字段:按发放额合计,不把所有发放都算成已用完。""" + packages = raccoon_activity.quota_packages( + {}, [_grant(points=300)], NOW) + assert packages[0]["total"] - packages[0]["used"] == 300 + + +def test_quota_packages_lists_uncovered_pool_balances(): + """流水取不到(None)或没有任何发放 → 退回四池口径。""" + balance = {"daily_points": 300, "reward_points": 1500} + assert [p["name"] for p in raccoon_activity.quota_packages(balance, None)] == \ + ["每日额度", "奖励积分"] + assert [p["name"] for p in raccoon_activity.quota_packages( + balance, [{"biz_type": "desktop", "points": -1}], NOW)] == \ + ["每日额度", "奖励积分"] + + +def test_quota_packages_ignores_bad_values(): + assert raccoon_activity.quota_packages({}) == [] + assert raccoon_activity.quota_packages( + {"daily_points": True, "reward_points": "300", "monthly_points": -5}) == [] + + +def test_ladder_from_packages_uses_remaining_and_sorts(): + """阶梯与明细同源:取 total-used(不是发放额),按到期升序,跳过无到期/已用完。""" + ladder = raccoon_activity.ladder_from_packages([ + {"name": "每日积分 11-09", "total": 300.0, "used": 0.0, + "end": 1794326399, "usable": True}, + {"name": "每日积分 11-04", "total": 300.0, "used": 126.0, + "end": 1794239999, "usable": True}, + {"name": "奖励积分 11-05", "total": 1500.0, "used": 0.0, + "end": None, "usable": True}, + {"name": "每日积分 10-01", "total": 300.0, "used": 300.0, + "end": 1790000000, "usable": True}, + ]) + assert ladder == [(1794239999, 174.0), (1794326399, 300.0)] + + +def test_ladder_from_packages_none_when_no_expiry(): + assert raccoon_activity.ladder_from_packages([]) is None + assert raccoon_activity.ladder_from_packages([ + {"name": "奖励积分", "total": 100.0, "used": 0.0, "end": None}, + ]) is None + + +def test_pick_active_grants_ignores_malformed_expire_rows(): + """`daily_expire` 点数非数值 → 跳过,不把对应发放误判为已清零。""" + items = [ + _grant(grant_at="2026-11-03T00:00:00+08:00", + expire_at="2026-11-09T23:59:59+08:00"), + {"biz_type": "daily_expire", "points": "bad", + "grant_at": "2026-11-03T00:00:00+08:00"}, + {"biz_type": "daily_expire", "points": True, + "grant_at": "2026-11-03T00:00:00+08:00"}, + ] + active = raccoon_activity.pick_active_grants(items, NOW) + assert len(active) == 1 and active[0]["points"] == 300 + + +def test_heartbeat_headers_include_required_client_headers(): + headers = raccoon_activity.heartbeat_headers("dev-abc") + # 缺 x-client-device-id 上游回 100002 params_invalid_error + assert headers["x-client-device-id"] == "dev-abc" + assert headers["x-client-platform"] == "web" + assert headers["Content-Type"] == "application/json" \ No newline at end of file diff --git a/tests/provider/raccoon/test_client.py b/tests/provider/raccoon/test_client.py new file mode 100644 index 00000000..86e3a479 --- /dev/null +++ b/tests/provider/raccoon/test_client.py @@ -0,0 +1,408 @@ +"""小浣熊上游客户端:请求体、SSE、模型目录、积分余额。""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +import httpx +import pytest + +from src.provider.base import ErrKind, EventKind +from src.provider.raccoon.activity import CST +from src.provider.raccoon.client import ( + EP_BALANCE, + EP_BILLS, + EP_CHAT, + EP_HEARTBEAT, + EP_MODELS, + EP_USER_INFO, + RaccoonClient, + UpstreamHTTPError, + UpstreamProtocolViolation, + _model_from_item, + prepare_body, + request_headers, +) +from tests.helpers.http import chunk_payload as chunk +from tests.helpers.http import sse_text_done as _sse +from tests.provider.raccoon._support import client as _client + + +def test_prepare_body_forces_stream_and_deepcopies_messages(): + messages = [{"role": "user", "content": "hi"}] + body = prepare_body({"messages": messages, "stream": False, "temperature": 0.5}, + "sn-glm-5-3") + assert body["stream"] is True + assert body["model"] == "sn-glm-5-3" + assert body["temperature"] == 0.5 + body["messages"][0]["content"] = "changed" + assert messages[0]["content"] == "hi" + + +def test_request_headers_bearer_and_json_accept(): + headers = request_headers("tok") + assert headers["Authorization"] == "Bearer tok" + assert headers["Accept"] == "text/event-stream" + assert "AsyncOpenAI" in headers["User-Agent"] + assert request_headers("tok", accept="application/json")["Accept"] \ + == "application/json" + + +async def test_stream_chat_yields_named_events(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == EP_CHAT + assert request.headers["Authorization"] == "Bearer tok" + body = json.loads(request.content) + assert body["stream"] is True + assert body["model"] == "sn-glm-5-3" + return httpx.Response(200, text=_sse( + {"model": "sn-glm-5-3", **chunk({"reasoning_content": "r"})}, + {"model": "sn-glm-5-3", **chunk({"content": "hi"})}, + {"model": "sn-glm-5-3", **chunk({}, finish="stop")}, + )) + + events = [e async for e in _client(handler).stream_chat( + {"messages": [{"role": "user", "content": "x"}]}, "sn-glm-5-3", "tok")] + kinds = [e.kind for e in events] + assert kinds == [EventKind.REASONING, EventKind.CONTENT, EventKind.FINISH] + + +async def test_stream_chat_rejects_model_echo_mismatch(): + """未知模型会静默回落默认模型 → 首帧回显不一致必须失败,而不是照样吐字。""" + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=_sse( + {"model": "raccoon-090dd3", **chunk({"content": "wrong model"})})) + + with pytest.raises(UpstreamProtocolViolation, match="model echo mismatch"): + [e async for e in _client(handler).stream_chat( + {"messages": []}, "not-a-model", "tok")] + + +async def test_stream_chat_ignores_frames_without_model(): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=_sse(chunk({"content": "ok"}))) + + events = [e async for e in _client(handler).stream_chat( + {"messages": []}, "m", "tok")] + assert [e.kind for e in events] == [EventKind.CONTENT] + + +async def test_stream_chat_raises_classified_http_error(): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(429, content=b'{"error":"slow down"}') + + with pytest.raises(UpstreamHTTPError) as caught: + [e async for e in _client(handler).stream_chat({"messages": []}, "m", "t")] + assert caught.value.kind() is ErrKind.SOFT + + +def test_upstream_http_error_exposes_status_and_kind(): + error = UpstreamHTTPError(404, b'{"error":"model not found"}') + assert error.status == 404 + assert error.kind() is ErrKind.INVALID + + +def test_model_from_item_metadata(): + model = _model_from_item({ + "name": "sn-glm-5-3", + "billing_effective_multiplier": 0, + "tags": ["office", "reasoning", "code"], + }) + assert model is not None + assert model.id == "sn-glm-5-3" + assert model.credit_rate == 0.0 + assert model.supports_tool_call is True + assert model.supports_reasoning is True + assert model.supports_images is False + + +def test_model_from_item_vision_and_missing_tags(): + vision = _model_from_item({"name": "sn-kimi-k3", "tags": ["vision"]}) + assert vision is not None and vision.supports_images is True + plain = _model_from_item({"model_name": "raccoon-090dd3"}) + assert plain is not None and plain.id == "raccoon-090dd3" + assert plain.supports_images is None and plain.supports_reasoning is None + + +def test_model_from_item_rejects_malformed(): + assert _model_from_item("junk") is None + assert _model_from_item({}) is None + assert _model_from_item({"name": 123}) is None + assert _model_from_item({"name": " "}) is None + + +def test_model_from_item_ignores_non_numeric_multiplier(): + for value in (True, "1", None): + model = _model_from_item({"name": "m", "billing_effective_multiplier": value}) + assert model is not None and model.credit_rate is None + numeric = _model_from_item({"name": "m", "billing_effective_multiplier": 0.75}) + assert numeric is not None and numeric.credit_rate == 0.75 + + +async def test_fetch_models_flattens_categories_and_dedupes(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == EP_MODELS + return httpx.Response(200, json={"code": 0, "data": {"categories": [ + {"type": "chat", "models": [ + {"name": "raccoon-090dd3", "tags": ["code"]}, + {"name": "SN-GLM-5-3", "tags": ["reasoning"]}, + "junk", + {"no_name": True}, + ]}, + {"models": [{"name": "sn-glm-5-3", "tags": ["vision"]}]}, # 去重 + "junk-category", + {"models": "not-a-list"}, + ]}}) + + models = await _client(handler).fetch_models("tok") + # 去重按小写 id,保留先出现的条目 + assert [m.id for m in models] == ["raccoon-090dd3", "SN-GLM-5-3"] + assert models[1].supports_images is False + assert models[1].supports_reasoning is True + + +async def test_fetch_models_errors(): + def http_error(_request: httpx.Request) -> httpx.Response: + return httpx.Response(401, content=b'{"message":"unauthorized"}') + + with pytest.raises(UpstreamHTTPError): + await _client(http_error).fetch_models("tok") + + def non_json(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="not json") + + with pytest.raises(UpstreamProtocolViolation): + await _client(non_json).fetch_models("tok") + + def scalar(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[1, 2]) + + with pytest.raises(UpstreamProtocolViolation): + await _client(scalar).fetch_models("tok") + + def no_categories(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": {}}) + + with pytest.raises(UpstreamProtocolViolation): + await _client(no_categories).fetch_models("tok") + + def empty(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": {"categories": [ + {"models": ["junk"]}, {"models": [{"name": ""}]}]}}) + + with pytest.raises(UpstreamProtocolViolation, match="no models"): + await _client(empty).fetch_models("tok") + + +async def test_fetch_quota_reports_balance_and_pools(): + """余额 + 四个池的明细;流水给出逐笔到期阶梯。""" + + # 日期相对今天构造:固定日期会随时间流逝变成「已过期」,把用例变成定时 + # 炸弹(实测 2026-11-11 这条 expire_at=11-10 已过期,get_quota 正确过滤后 + # 明细退回无流水口径,断言全崩)。 + now = datetime.now(CST) + granted = (now - timedelta(days=7)).replace(hour=0, minute=0, second=0, + microsecond=0) + expires = (now + timedelta(days=3)).replace(hour=23, minute=59, second=59, + microsecond=0) + grant_at = granted.isoformat() + expire_at = expires.isoformat() + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == EP_BALANCE: + return httpx.Response(200, json={"code": 0, "data": { + "available_points": 4800, "daily_points": 300, + "reward_points": 4500, "monthly_points": 0, "topup_points": 0, + "topup_frozen": False}}) + assert request.url.path == EP_BILLS + # 到期阶梯只要发放:用 income 过滤,避免消费流水挤占窗口 + assert request.url.params["type"] == "income" + return httpx.Response(200, json={"code": 0, "data": {"items": [ + {"biz_type": "daily_grant", "points": 300, + "grant_at": grant_at, + "created_at": (granted + timedelta(hours=21, minutes=41, + seconds=38)).isoformat(), + "expire_at": expire_at}, + ]}}) + + quota = await _client(handler).fetch_quota("tok") + assert quota.remaining == 4800 + assert quota.total == 4800 + assert quota.probe_failed is False + # 明细按发放流水逐笔展开:每日积分带真实到期;奖励池没有发放流水 → + # 补一条「流水未覆盖」的余额行,避免 4500 分从明细里消失。 + assert [p["name"] for p in quota.packages] == [ + f"每日积分 {granted:%m-%d}", "奖励积分(流水未覆盖)"] + assert quota.packages[0]["total"] == 300 + assert quota.packages[0]["end"] == int(expires.timestamp()) + assert quota.packages[1]["total"] == 4500 + # 逐笔到期阶梯来自流水的 daily_grant + assert quota.expiry_ladder is not None + assert quota.expiry_ladder[0][1] == 300 + + +async def test_fetch_quota_survives_bills_failure(): + """流水失败不该拖垮余额结论(阶梯只是调度排序的加分项)。""" + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == EP_BALANCE: + return httpx.Response(200, json={"data": {"available_points": 100}}) + return httpx.Response(500, content=b"boom") + + quota = await _client(handler).fetch_quota("tok") + assert quota.remaining == 100 + assert quota.expiry_ladder is None + + +async def test_fetch_quota_zero_points_has_no_packages(): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == EP_BALANCE: + return httpx.Response(200, json={"data": {"available_points": 0}}) + return httpx.Response(200, json={"data": {"items": []}}) + + quota = await _client(handler).fetch_quota("tok") + assert (quota.remaining, quota.total, quota.packages) == (0.0, 0.0, None) + # 0/0 时 health_score 会判「已耗尽」,这正是想要的语义 + from src.provider.base import health_score + assert health_score(quota) == -1 + + +async def test_fetch_quota_errors(): + def http_error(_request: httpx.Request) -> httpx.Response: + return httpx.Response(500, content=b"boom") + + with pytest.raises(UpstreamHTTPError): + await _client(http_error).fetch_quota("tok") + + def non_json(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="nope") + + with pytest.raises(UpstreamProtocolViolation): + await _client(non_json).fetch_quota("tok") + + def no_points(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": {}}) + + with pytest.raises(UpstreamProtocolViolation): + await _client(no_points).fetch_quota("tok") + + def bool_points(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": {"available_points": True}}) + + with pytest.raises(UpstreamProtocolViolation): + await _client(bool_points).fetch_quota("tok") + + +async def test_client_lazy_pools_and_aclose(): + client = RaccoonClient() + assert client._stream is client._stream # noqa: SLF001 - 惰性复用 + assert client._short is client._short # noqa: SLF001 + await client.aclose() + +async def test_fetch_bills_returns_items(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == EP_BILLS + assert request.url.params["type"] == "all" + return httpx.Response(200, json={"data": {"items": [{"biz_type": "desktop"}]}}) + + items = await _client(handler).fetch_bills("tok") + assert items == [{"biz_type": "desktop"}] + + +async def test_fetch_bills_income_filter_passes_type(): + """签到用 `income`:上游服务端过滤只留发放,消费不会挤占条数窗口。""" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["type"] == "income" + return httpx.Response(200, json={"data": {"items": [ + {"biz_type": "daily_grant", "points": 300}]}}) + + items = await _client(handler).fetch_bills("tok", bill_type="income") + assert items == [{"biz_type": "daily_grant", "points": 300}] + + +async def test_fetch_bills_errors(): + def http_error(_request: httpx.Request) -> httpx.Response: + return httpx.Response(401, content=b'{"message":"unauthorized"}') + + with pytest.raises(UpstreamHTTPError): + await _client(http_error).fetch_bills("tok") + + def non_json(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="nope") + + with pytest.raises(UpstreamProtocolViolation): + await _client(non_json).fetch_bills("tok") + + def no_items(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": {}}) + + with pytest.raises(UpstreamProtocolViolation): + await _client(no_items).fetch_bills("tok") + + +async def test_visit_gets_user_info_and_returns_data(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == EP_USER_INFO + assert request.method == "GET" + assert request.headers["Authorization"] == "Bearer tok" + return httpx.Response(200, json={"code": 0, "data": {"id": "1"}}) + + assert await _client(handler).visit("tok") == {"id": "1"} + + +async def test_visit_errors_and_missing_data(): + def http_error(_request: httpx.Request) -> httpx.Response: + return httpx.Response(401, content=b'{"code":401}') + + with pytest.raises(UpstreamHTTPError): + await _client(http_error).visit("tok") + + def non_json(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="nope") + + with pytest.raises(UpstreamProtocolViolation): + await _client(non_json).visit("tok") + + def no_data(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": 0}) + + # 没有 data 段不算失败:200 即成立 + assert await _client(no_data).visit("tok") == {} + + +async def test_heartbeat_sends_client_headers(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == EP_HEARTBEAT + assert request.method == "POST" + # 缺 x-client-device-id 上游回 100002 + assert request.headers["x-client-device-id"] == "dev-x" + assert request.headers["x-client-platform"] == "web" + return httpx.Response(200, json={"code": 0, "data": { + "device_id": "dev-x", "trusted_device": True, + "last_seen_at": "2026-11-04T23:20:49+08:00"}}) + + payload = await _client(handler).heartbeat("tok", "dev-x") + assert payload["trusted_device"] is True + + +async def test_heartbeat_errors(): + def http_error(_request: httpx.Request) -> httpx.Response: + return httpx.Response(400, content=b'{"code":100002}') + + with pytest.raises(UpstreamHTTPError): + await _client(http_error).heartbeat("tok", "dev-x") + + def non_json(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="nope") + + with pytest.raises(UpstreamProtocolViolation): + await _client(non_json).heartbeat("tok", "dev-x") + + def no_data(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": 0}) + + # 没有 data 段不算失败:心跳只要 200 就成立 + assert await _client(no_data).heartbeat("tok", "dev-x") == {} diff --git a/tests/provider/raccoon/test_credential.py b/tests/provider/raccoon/test_credential.py new file mode 100644 index 00000000..d274e7dc --- /dev/null +++ b/tests/provider/raccoon/test_credential.py @@ -0,0 +1,128 @@ +"""小浣熊凭证归一:JWT / 裸 token / 整段 cookie。""" + +from __future__ import annotations + +import base64 +import json + +from src.provider.raccoon.credential import ( + RaccoonCredential, + jwt_claims, + token_from_cookie, +) + + +def _jwt(claims: dict) -> str: + def seg(value: dict) -> str: + raw = json.dumps(value).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + return f"{seg({'alg': 'RS256'})}.{seg(claims)}.sig" + + +BOX = _jwt({"sub": "pu-01a", "exp": 1796391693, "name": "Raccoon"}) + + +def test_jwt_claims_parses_and_rejects_non_jwt(): + assert jwt_claims(BOX) == {"sub": "pu-01a", "exp": 1796391693, + "name": "Raccoon"} + assert jwt_claims("not-a-jwt") == {} + assert jwt_claims("a..c") == {} + + +def test_jwt_claims_handles_bad_base64_and_non_object(): + assert jwt_claims("a.!!!.c") == {} + bad_json = base64.urlsafe_b64encode(b"\xff\xfe").decode("ascii").rstrip("=") + assert jwt_claims(f"a.{bad_json}.c") == {} + scalar = base64.urlsafe_b64encode(b"123").decode("ascii").rstrip("=") + assert jwt_claims(f"a.{scalar}.c") == {} + + +def test_token_from_cookie(): + assert token_from_cookie("a=1; xhx_token=abc; b=2") == "abc" + assert token_from_cookie("xhx_token=abc") == "abc" + assert token_from_cookie("a=1; b=2") == "" + # 没有 `=` 的段不应崩 + assert token_from_cookie("junk; xhx_token=v") == "v" + + +def test_from_dict_and_to_dict_round_trip(): + credential = RaccoonCredential.from_dict( + {"access_token": "t", "uid": "u", "nickname": "n", "expires_at": 5}) + assert credential.to_dict() == {"access_token": "t", "uid": "u", + "nickname": "n", "expires_at": 5} + + +def test_from_dict_treats_bad_expiry_as_zero(): + for value in (True, "5", None, 5.5): + assert RaccoonCredential.from_dict( + {"access_token": "t", "expires_at": value}).expires_at == 0 + + +def test_from_import_accepts_raw_jwt_and_extracts_claims(): + credential = RaccoonCredential.from_import(BOX) + assert credential.access_token == BOX + assert credential.uid == "pu-01a" + assert credential.nickname == "Raccoon" + assert credential.expires_at == 1796391693 + + +def test_from_import_accepts_all_token_keys(): + for key in ("access_token", "token", "jwt", "xhx_token"): + credential = RaccoonCredential.from_import({key: BOX}) + assert credential.access_token == BOX + + +def test_from_import_accepts_cookie_string(): + credential = RaccoonCredential.from_import({"cookie": f"a=1; xhx_token={BOX}"}) + assert credential.access_token == BOX + + +def test_from_import_explicit_fields_win(): + credential = RaccoonCredential.from_import( + {"access_token": BOX, "uid": "explicit", "nickname": "昵称"}) + assert credential.uid == "explicit" + assert credential.nickname == "昵称" + + +def test_from_import_user_id_and_user_name_fallbacks(): + credential = RaccoonCredential.from_import( + {"access_token": "plain", "user_id": "u2", "user_name": "名字"}) + assert (credential.uid, credential.nickname) == ("u2", "名字") + + +def test_from_import_rejects_empty_shapes(): + for raw in (None, 3, {}, {"cookie": "a=1"}, {"access_token": " "}): + assert RaccoonCredential.from_import(raw).access_token == "" + + +def test_from_token_handles_missing_claims(): + credential = RaccoonCredential.from_import({"access_token": "plain"}) + assert (credential.access_token, credential.uid, credential.nickname, + credential.expires_at) == ("plain", "", "", 0) + + +def test_from_token_falls_back_to_uid_claim_and_nickname(): + token = _jwt({"uid": "u3", "nickname": "nick", "exp": "bad"}) + credential = RaccoonCredential.from_import({}) + assert credential.to_dict() == {"access_token": "", "uid": "", "nickname": "", + "expires_at": 0} + credential = RaccoonCredential.from_import({"token": token}) + assert credential.uid == "u3" + assert credential.nickname == "nick" + assert credential.expires_at == 0 + +def test_derive_device_id_is_stable_and_shaped_like_upstream(): + from src.provider.raccoon.credential import derive_device_id + + first = derive_device_id("pu-01a") + assert first == derive_device_id("pu-01a") # 同一账号永远同一台「设备」 + assert first != derive_device_id("pu-other") # 不同账号不能撞设备 + # 上游实测形态:dev<32hex>- + assert first.startswith("dev") + body = first[3:] + hex_part, _, uuid_part = body.partition("-") + assert len(hex_part) == 32 and all(c in "0123456789abcdef" for c in hex_part) + assert len(uuid_part.split("-")) == 5 + # uid 缺失时用 anonymous 占位(可用但会被关联),不是随机值 + assert derive_device_id("") == derive_device_id("") diff --git a/tests/provider/raccoon/test_events.py b/tests/provider/raccoon/test_events.py new file mode 100644 index 00000000..898ca0a7 --- /dev/null +++ b/tests/provider/raccoon/test_events.py @@ -0,0 +1,90 @@ +"""小浣熊 SSE → 事件映射与错误分类。""" + +from __future__ import annotations + +from src.provider.base import ErrKind, EventKind +from src.provider.raccoon import events as raccoon_events +from tests.helpers.http import chunk_payload as chunk +from tests.helpers.http import sse_frame + + +def test_parse_frame_maps_content_and_finish(): + """正文与 finish 同帧:finish 不能被正文吞掉(客户端否则永远收不到结束)。""" + frame = sse_frame(chunk({"content": "hi"}, finish="stop")) + kinds = [event.kind for event in raccoon_events.parse_frame(frame)] + assert kinds == [EventKind.CONTENT, EventKind.FINISH] + + +def test_parse_frame_reasoning_content_is_separate_when_alone(): + frame = sse_frame(chunk({"reasoning_content": "why"})) + kinds = [event.kind for event in raccoon_events.parse_frame(frame)] + assert kinds == [EventKind.REASONING] + + +def test_parse_frame_accepts_reasoning_alias(): + frame = sse_frame(chunk({"reasoning": "why"})) + kinds = [event.kind for event in raccoon_events.parse_frame(frame)] + assert kinds == [EventKind.REASONING] + + +def test_parse_frame_done_and_empty_frames_are_ignored(): + assert raccoon_events.parse_frame(sse_frame("[DONE]")) == [] + assert raccoon_events.parse_frame(sse_frame("")) == [] + + +def test_parse_frame_usage_and_model_echo(): + frame = sse_frame({"model": "sn-glm-5-3", "choices": [], + "usage": {"prompt_tokens": 3, "completion_tokens": 2}}) + events = raccoon_events.parse_frame(frame) + assert events[0].kind is EventKind.USAGE + assert events[0].model == "sn-glm-5-3" + + +def test_parse_frame_error_envelope_uses_string_code(): + frame = sse_frame({"error": {"code": "user_model_rate_limited", + "message": "您的模型请求频率超限,请稍后重试"}}) + event = raccoon_events.parse_frame(frame)[0] + assert event.kind is EventKind.ERROR + assert event.error_kind is ErrKind.CONCURRENCY + + +def test_parse_frame_error_without_message(): + frame = sse_frame({"error": {"code": "some_new_error"}}) + event = raccoon_events.parse_frame(frame)[0] + assert event.error_message == "" + assert event.error_kind is ErrKind.OTHER + + +def test_classify_error_markers(): + assert raccoon_events.classify_error(b"user identity could not be verified") \ + is ErrKind.DEAD + assert raccoon_events.classify_error("unauthorized") is ErrKind.DEAD + assert raccoon_events.classify_error("user_model_rate_limited") \ + is ErrKind.CONCURRENCY + assert raccoon_events.classify_error("积分不足") is ErrKind.CREDIT + assert raccoon_events.classify_error("balance exhausted") is ErrKind.CREDIT + assert raccoon_events.classify_error("weird") is ErrKind.OTHER + + +def test_classify_status_auth_and_identity(): + assert raccoon_events.classify_status(401) is ErrKind.DEAD + assert raccoon_events.classify_status( + 403, b'{"message":"User identity could not be verified"}') is ErrKind.DEAD + # 403 但没点到身份校验 → 请求级(不硬禁用一条好凭证) + assert raccoon_events.classify_status(403, b'{"message":"forbidden"}') \ + is ErrKind.REQUEST + + +def test_classify_status_credit_and_rate_limit(): + assert raccoon_events.classify_status(402) is ErrKind.CREDIT + assert raccoon_events.classify_status( + 429, b'{"error":{"code":"user_model_rate_limited"}}') is ErrKind.CONCURRENCY + assert raccoon_events.classify_status(429, b'{"error":"slow down"}') \ + is ErrKind.SOFT + + +def test_classify_status_other_statuses(): + for status in (400, 404, 422): + assert raccoon_events.classify_status(status) is ErrKind.INVALID + for status in (502, 503, 504, 500): + assert raccoon_events.classify_status(status) is ErrKind.OTHER \ No newline at end of file diff --git a/tests/provider/raccoon/test_provider.py b/tests/provider/raccoon/test_provider.py new file mode 100644 index 00000000..197e6aa2 --- /dev/null +++ b/tests/provider/raccoon/test_provider.py @@ -0,0 +1,428 @@ +"""小浣熊 Provider:导入、额度、目录缓存、未知模型防护、节流。""" + +from __future__ import annotations + +import types +from collections.abc import AsyncIterator +from datetime import datetime +from typing import Any + +import pytest + +from src.provider.base import ErrKind, Event, EventKind, Model, Quota +from src.provider.raccoon import activity as raccoon_activity +from src.provider.raccoon import client as raccoon_client +from src.provider.raccoon.client import ( + RaccoonProvider, + UpstreamHTTPError, + UpstreamProtocolViolation, +) + + +class FakeClient: + """最小替身:只实现 provider 用到的三个方法(本用例不覆盖未调用项)。""" + + def __init__(self, *, models: list[Model] | None = None, + quota: Quota | None = None, events: list[Event] | None = None, + error: Exception | None = None) -> None: + self.models = models or [] + self.quota = quota or Quota(remaining=1, total=1) + self.events = events or [] + self.error = error + self.calls: list[tuple[Any, ...]] = [] + + async def fetch_models(self, token: str) -> list[Model]: + self.calls.append(("models", token)) + return self.models + + async def fetch_quota(self, token: str) -> Quota: + self.calls.append(("quota", token)) + if self.error is not None: + raise self.error + return self.quota + + async def visit(self, token: str) -> dict: + self.calls.append(("visit", token)) + return {} + + async def stream_chat(self, payload: dict, model: str, + token: str) -> AsyncIterator[Event]: + self.calls.append(("chat", model, token, payload)) + for event in self.events: + yield event + + async def aclose(self) -> None: + self.calls.append(("aclose",)) + + +TOKEN = "tok" +DATA = {"access_token": TOKEN, "uid": "u", "nickname": "n", "expires_at": 5} + + +def test_import_credential_normalizes_and_extracts_uid(): + provider = RaccoonProvider(client=FakeClient()) + jwt = "a.b.c" + stored = provider.import_credential({"access_token": jwt}) + assert stored["access_token"] == jwt + # `a.b.c` 不是合法 JWT claims → uid 留空(不猜) + assert stored["uid"] == "" + + +def test_import_credential_rejects_empty_token(): + provider = RaccoonProvider(client=FakeClient()) + with pytest.raises(UpstreamProtocolViolation, match="missing access token"): + provider.import_credential({"cookie": "a=1"}) + + +def test_credential_expires_at(): + provider = RaccoonProvider(client=FakeClient()) + assert provider.credential_expires_at(DATA) == 5 + assert provider.credential_expires_at({}) == 0 + + +def test_classify_delegates_to_events(): + provider = RaccoonProvider(client=FakeClient()) + assert provider.classify(401, b"") is ErrKind.DEAD + + +async def test_probe_quota_without_token_is_unknown_not_exhausted(): + provider = RaccoonProvider(client=FakeClient()) + quota = await provider.probe_quota({}) + assert quota.probe_failed is True + assert quota.total is None + + +async def test_probe_quota_reads_upstream(): + fake = FakeClient(quota=Quota(remaining=12, total=12)) + provider = RaccoonProvider(client=fake) + quota = await provider.probe_quota(DATA) + assert quota.remaining == 12 + assert fake.calls == [("quota", TOKEN)] + + +async def test_list_models_requires_token_and_updates_known_models(): + provider = RaccoonProvider(client=FakeClient()) + with pytest.raises(UpstreamProtocolViolation, match="missing token"): + await provider.list_models({}) + + fake = FakeClient(models=[Model(id="sn-GLM-5-3"), Model(id="raccoon-090dd3")]) + provider = RaccoonProvider(client=fake) + models = await provider.list_models(DATA) + assert [m.id for m in models] == ["sn-GLM-5-3", "raccoon-090dd3"] + assert provider.known_models == {"sn-glm-5-3", "raccoon-090dd3"} + + +async def test_stream_chat_requires_token(): + provider = RaccoonProvider(client=FakeClient()) + with pytest.raises(UpstreamProtocolViolation, match="missing token"): + [e async for e in provider.stream_chat({}, {"messages": []}, "m")] + + +async def test_stream_chat_rejects_model_missing_from_catalog_before_upstream(): + fake = FakeClient() + provider = RaccoonProvider(client=fake) + provider.known_models = {"sn-glm-5-3"} + with pytest.raises(UpstreamHTTPError) as caught: + [e async for e in provider.stream_chat(DATA, {"messages": []}, "raccoon-090dd3")] + assert caught.value.status == 404 + assert caught.value.kind() is ErrKind.INVALID + # 目录里没有 → 一次上游请求都不发 + assert fake.calls == [] + + +async def test_stream_chat_allows_known_model_and_passes_through(): + fake = FakeClient(events=[Event(kind=EventKind.CONTENT, content="hi")]) + provider = RaccoonProvider(client=fake) + provider.known_models = {"sn-glm-5-3"} + events = [e async for e in provider.stream_chat(DATA, {"messages": []}, + "SN-GLM-5-3")] + assert [e.kind for e in events] == [EventKind.CONTENT] + assert fake.calls[0][0] == "chat" + assert fake.calls[0][1] == "SN-GLM-5-3" + + +async def test_stream_chat_passes_through_when_catalog_not_loaded(): + """目录还没拉过(已知集为空)时放行:真实保护靠上游回显的 model。""" + fake = FakeClient(events=[Event(kind=EventKind.CONTENT, content="x")]) + provider = RaccoonProvider(client=fake) + events = [e async for e in provider.stream_chat(DATA, {"messages": []}, "any")] + assert [e.kind for e in events] == [EventKind.CONTENT] + + +async def test_provider_aclose_closes_client(): + fake = FakeClient() + provider = RaccoonProvider(client=fake) + await provider.aclose() + assert fake.calls == [("aclose",)] + + +async def test_stream_chat_uses_pacer(): + """pacer 必须被取用并释放;替身只实现 wait_turn/release(与生产同接口)。""" + + class PacerSpy: + def __init__(self) -> None: + self.keys: list[str] = [] + self.released: list[str] = [] + + async def wait_turn(self, key: Any = None) -> None: + self.keys.append(str(key)) + + def release(self, key: Any = None) -> None: + self.released.append(str(key)) + + spy = PacerSpy() + provider = RaccoonProvider(client=FakeClient( + events=[Event(kind=EventKind.CONTENT, content="x")]), pacer=spy) + events = [e async for e in provider.stream_chat(DATA, {"messages": []}, "m")] + assert [e.kind for e in events] == [EventKind.CONTENT] + assert spy.keys == ["u"] # 按凭证 uid 分桶 + assert spy.released == ["u"] + +# ------------------------------------------------------------------ 签到 / 活跃 + + +class CheckinFakeClient(FakeClient): + """签到/活跃用的替身:bills、visit 与 heartbeat 分别可控。""" + + def __init__(self, *, bills=None, heartbeat_payload=None, error=None, + visit_error=None): + super().__init__() + self.bills = bills if bills is not None else [] + # 显式区分「空字典」与「未传」:`or` 会把 {} 换成默认值 + self.heartbeat_payload = (heartbeat_payload if heartbeat_payload is not None + else {"trusted_device": True, + "last_seen_at": "2026-11-04T23:20:49+08:00"}) + self.error = error + self.visit_error = visit_error + + async def visit(self, token): + self.calls.append(("visit", token)) + if self.visit_error is not None: + raise self.visit_error + return {} + + async def fetch_bills(self, token, limit=60, bill_type="all"): + self.calls.append(("bills", token, bill_type)) + if self.error is not None: + raise self.error + return self.bills + + async def heartbeat(self, token, device_id): + self.calls.append(("heartbeat", token, device_id)) + if self.error is not None: + raise self.error + return self.heartbeat_payload + + +def _grant(points=300, created="2026-11-04T21:59:30+08:00"): + return {"biz_type": "daily_grant", "points": points, + "grant_at": "2026-11-04T00:00:00+08:00", + "created_at": created, "expire_at": "2026-11-10T23:59:59+08:00"} + + +#: 固定「现在」:签到结论依赖「今天」,真实时钟会让用例隔天失效 +NOW = datetime.fromisoformat("2026-11-04T22:00:00+08:00") + + +@pytest.fixture +def frozen_now(monkeypatch): + """把签到用到的两个时间源都钉在 NOW。 + + `now_cst` 决定「今天」,`client.time.time` 决定本次调用的 started; + 两者不一致会让「本次触发」的判定飘掉。`time` 换成替身而非打补丁到 + 真模块,避免影响同进程里的其他代码。 + """ + monkeypatch.setattr(raccoon_activity, "now_cst", lambda: NOW) + monkeypatch.setattr(raccoon_client, "time", + types.SimpleNamespace(time=lambda: NOW.timestamp())) + + +async def test_checkin_triggers_lazy_grant_then_reads_bills(frozen_now): + fake = CheckinFakeClient(bills=[_grant()]) + provider = RaccoonProvider(client=fake) + result = await provider.checkin(DATA) + assert result.ok and result.credit == 300 + # 先做页面访问(触发延期),再摸余额,最后读流水核对 + assert fake.calls[0] == ("visit", TOKEN) + assert fake.calls[1] == ("quota", TOKEN) + assert fake.calls[2] == ("bills", TOKEN, "income") + + +async def test_checkin_ignores_visit_failure(frozen_now): + """页面访问失败不阻断积分核对(活跃任务还会再试一次)。""" + fake = CheckinFakeClient(bills=[_grant()], + visit_error=UpstreamHTTPError(500, b"boom")) + result = await RaccoonProvider(client=fake).checkin(DATA) + assert result.ok and result.credit == 300 + + +async def test_checkin_accepts_unexpired_old_grant(frozen_now): + """今天没有新发放,但账户里还有未过期每日积分 → 已签。""" + old = {**_grant(created="2026-11-03T08:00:00+08:00"), + "grant_at": "2026-11-03T00:00:00+08:00"} + fake = CheckinFakeClient(bills=[old]) + result = await RaccoonProvider(client=fake).checkin(DATA) + assert result.ok and result.already_checked_in + assert "今日额度可用 300" in result.message + + +async def test_checkin_reports_already_when_grant_is_old(frozen_now): + fake = CheckinFakeClient(bills=[_grant(created="2026-11-04T08:00:00+08:00")]) + provider = RaccoonProvider(client=fake) + result = await provider.checkin(DATA) + assert result.ok and result.already_checked_in + + +async def test_checkin_fails_when_no_grant_today(frozen_now): + provider = RaccoonProvider(client=CheckinFakeClient(bills=[])) + result = await provider.checkin(DATA) + assert not result.ok and "尚未发放" in result.message + + +async def test_checkin_without_token(): + provider = RaccoonProvider(client=CheckinFakeClient()) + result = await provider.checkin({}) + assert not result.ok and "token" in result.message + + +class BillsErrorClient(FakeClient): + """只让 bills 失败(余额探测正常):验证流水的失败会变成可读的签到失败。""" + + def __init__(self, error: Exception) -> None: + super().__init__() + self.error = error + + async def fetch_quota(self, token): + # 余额探测必须成功:签到先摸余额触发懒发放,失败的是随后的流水 + self.calls.append(("quota", token)) + return self.quota + + async def fetch_bills(self, token, limit=60, bill_type="all"): + raise self.error + + +async def test_checkin_bills_http_error_and_protocol_violation(): + http_error = BillsErrorClient(UpstreamHTTPError(500, b"boom")) + result = await RaccoonProvider(client=http_error).checkin(DATA) + assert not result.ok and "HTTP 500" in result.message + + bad = BillsErrorClient(UpstreamProtocolViolation("bad")) + result = await RaccoonProvider(client=bad).checkin(DATA) + assert not result.ok and "格式异常" in result.message + + +async def test_checkin_status_reads_bills(frozen_now): + """状态查询只看「今天发过没有」,不依赖本次是否触发。""" + provider = RaccoonProvider(client=CheckinFakeClient(bills=[_grant()])) + status = await provider.checkin_status(DATA) + assert status["active"] is True + assert status["today_checked_in"] is True + assert status["today_credit"] == 300 + + +async def test_checkin_status_when_nothing_granted_today(frozen_now): + provider = RaccoonProvider(client=CheckinFakeClient(bills=[])) + status = await provider.checkin_status(DATA) + assert status["active"] is True + assert status["today_checked_in"] is False + assert status["today_credit"] is None + + +async def test_checkin_status_counts_unexpired_old_grants(frozen_now): + """今天没有新发放,但未过期积分仍算已签(余额可用的真实状态)。""" + old = {**_grant(created="2026-11-03T08:00:00+08:00"), + "grant_at": "2026-11-03T00:00:00+08:00"} + provider = RaccoonProvider(client=CheckinFakeClient(bills=[old])) + status = await provider.checkin_status(DATA) + assert status["active"] is True + assert status["today_checked_in"] is True + assert status["today_credit"] == 300 + + +async def test_checkin_status_without_token(): + provider = RaccoonProvider(client=CheckinFakeClient()) + status = await provider.checkin_status({}) + assert status == {"active": False, "today_checked_in": False} + + +def test_checkin_scope_and_activity_scope_use_uid(): + provider = RaccoonProvider(client=FakeClient()) + assert provider.checkin_scope(DATA) == "raccoon|u" + assert provider.activity_scope(DATA) == "raccoon|u" + # 身份未知返回空串(调用方回落凭证 ID),绝不让所有凭证算出同一 scope + assert provider.checkin_scope({}) == "" + assert provider.activity_scope({}) == "" + + +async def test_activity_visits_page_then_sends_stable_device_id(): + fake = CheckinFakeClient() + provider = RaccoonProvider(client=fake) + result = await provider.activity(DATA) + assert result.ok and result.user_id == "u" + # 先页面访问(触发延期),再心跳 + assert fake.calls[0] == ("visit", TOKEN) + beats = [call for call in fake.calls if call[0] == "heartbeat"] + assert beats and beats[0][2].startswith("dev") + # 设备 id 由 uid 稳定派生:两次调用必须一致(随机值会被当成新设备) + second = await provider.activity(DATA) + beats = [call for call in fake.calls if call[0] == "heartbeat"] + assert second.ok and beats[1][2] == beats[0][2] + + +async def test_activity_visit_failure_still_sends_heartbeat_but_reports_failure(): + fake = CheckinFakeClient(visit_error=UpstreamHTTPError(403, b"no")) + result = await RaccoonProvider(client=fake).activity(DATA) + assert not result.ok and "页面访问失败(HTTP 403)" in result.message + # 访问失败不影响心跳照发 + assert [call[0] for call in fake.calls] == ["visit", "heartbeat"] + + +async def test_activity_visit_protocol_failure(): + fake = CheckinFakeClient(visit_error=UpstreamProtocolViolation("bad")) + result = await RaccoonProvider(client=fake).activity(DATA) + assert not result.ok and "页面访问响应格式异常" in result.message + + +async def test_activity_without_token(): + provider = RaccoonProvider(client=CheckinFakeClient()) + result = await provider.activity({}) + assert not result.ok and "token" in result.message + + +async def test_activity_reports_untrusted_device(): + fake = CheckinFakeClient(heartbeat_payload={"trusted_device": False}) + result = await RaccoonProvider(client=fake).activity(DATA) + assert result.ok and "未被上游信任" in result.message + + +async def test_activity_errors(): + http_error = CheckinFakeClient(error=UpstreamHTTPError(400, b"boom")) + result = await RaccoonProvider(client=http_error).activity(DATA) + assert not result.ok and "HTTP 400" in result.message + + bad = CheckinFakeClient(error=UpstreamProtocolViolation("bad")) + result = await RaccoonProvider(client=bad).activity(DATA) + assert not result.ok and "格式异常" in result.message + + +async def test_activity_heartbeat_error_keeps_visit_error_in_message(): + """访问和心跳同时失败:两条原因都要进一行汇报。""" + http = CheckinFakeClient(error=UpstreamHTTPError(400, b"boom"), + visit_error=UpstreamHTTPError(403, b"no")) + result = await RaccoonProvider(client=http).activity(DATA) + assert not result.ok + assert "心跳失败(HTTP 400)" in result.message + assert "页面访问失败(HTTP 403)" in result.message + + bad = CheckinFakeClient(error=UpstreamProtocolViolation("bad"), + visit_error=UpstreamProtocolViolation("bad2")) + result = await RaccoonProvider(client=bad).activity(DATA) + assert not result.ok + assert "心跳响应格式异常" in result.message + assert "页面访问响应格式异常" in result.message + + +async def test_activity_message_without_last_seen(): + fake = CheckinFakeClient(heartbeat_payload={}) + result = await RaccoonProvider(client=fake).activity(DATA) + assert result.ok and result.message == "页面访问已上报;设备心跳已上报" diff --git a/tests/provider/test_auth_state.py b/tests/provider/test_auth_state.py new file mode 100644 index 00000000..93a82171 --- /dev/null +++ b/tests/provider/test_auth_state.py @@ -0,0 +1,101 @@ +"""共享 OAuth pending 会话登记表。 + +四个渠道原先各写一份 `AuthStateStore`(owner/consume/cancel/cleanup 逐字相同), +现由基类承担;这里直接测基类语义,各渠道的 `begin` 包装由各自测试覆盖。 +""" + +from __future__ import annotations + +import pytest + +from src.provider.auth_state import AuthStateStore, pkce_pair + +MOMENT = 1_700_000_000 + + +def test_begin_registers_and_owner_checks_username(): + store: AuthStateStore[str] = AuthStateStore(600) + state = store.begin("alice", "payload", now=MOMENT) + assert store.owner(state, "alice", now=MOMENT) is True + # 别人拿不到(归属校验是安全约束,不是风格) + assert store.owner(state, "bob", now=MOMENT) is False + assert store.payload(state, "bob", now=MOMENT) is None + + +def test_owner_is_false_for_unknown_state(): + store: AuthStateStore[str] = AuthStateStore(600) + assert store.owner("nope", "alice", now=MOMENT) is False + assert store.payload("nope", "alice", now=MOMENT) is None + assert store.reservation("nope", "alice", now=MOMENT) is None + + +def test_owner_expires_lazily_and_drops_entry(): + """过期即判否并从表里删掉(惰性清理,避免字典堆积)。""" + store: AuthStateStore[str] = AuthStateStore(10) + state = store.begin("alice", "p", now=MOMENT) + assert store.owner(state, "alice", now=MOMENT + 9) is True + assert store.owner(state, "alice", now=MOMENT + 10) is False + assert len(store) == 0 + + +def test_consume_is_single_use_and_cancel_aliases_it(): + store: AuthStateStore[str] = AuthStateStore(600) + state = store.begin("alice", "p", now=MOMENT) + assert store.consume(state, "alice", now=MOMENT) is True + assert store.consume(state, "alice", now=MOMENT) is False + + other = store.begin("alice", "p", now=MOMENT) + assert store.cancel(other, "alice", now=MOMENT) is True + assert store.cancel(other, "alice", now=MOMENT) is False + + +def test_cleanup_removes_only_expired(): + store: AuthStateStore[str] = AuthStateStore(10) + old = store.begin("alice", "p", now=MOMENT) + fresh = store.begin("alice", "p", now=MOMENT + 5) + store.cleanup(now=MOMENT + 10) + assert store.owner(old, "alice", now=MOMENT + 10) is False + assert store.owner(fresh, "alice", now=MOMENT + 10) is True + + +def test_max_pending_evicts_oldest(): + """攒太多没登完的顶掉最老的(OfficeAce 的实测需求)。""" + store: AuthStateStore[str] = AuthStateStore(600, max_pending=2) + first = store.begin("alice", "a", now=MOMENT) + second = store.begin("alice", "b", now=MOMENT + 1) + third = store.begin("alice", "c", now=MOMENT + 2) + assert store.owner(first, "alice", now=MOMENT + 2) is False + assert store.owner(second, "alice", now=MOMENT + 2) is True + assert store.owner(third, "alice", now=MOMENT + 2) is True + assert len(store) == 2 + + +def test_reservation_returns_owner_and_payload(): + store: AuthStateStore[str] = AuthStateStore(600) + state = store.begin("alice", "payload", now=MOMENT) + entry = store.reservation(state, "alice", now=MOMENT) + assert entry is not None + assert entry.username == "alice" + assert entry.created_at == MOMENT + assert entry.payload == "payload" + + +def test_pkce_pair_challenge_is_s256_of_verifier(): + import base64 + import hashlib + + verifier, challenge = pkce_pair() + digest = hashlib.sha256(verifier.encode("ascii")).digest() + assert challenge == base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def test_pkce_pair_accepts_explicit_verifier(): + """调用方自带 verifier(Qoder 的自造 64 位字母表)时不再随机。""" + assert pkce_pair("fixed-verifier")[0] == "fixed-verifier" + + +@pytest.mark.parametrize("length", [1, 32, 64]) +def test_pkce_pair_roundtrip_length(length): + verifier, challenge = pkce_pair("v" * length) + assert verifier == "v" * length + assert "=" not in challenge diff --git a/tests/provider/test_credential_fields.py b/tests/provider/test_credential_fields.py new file mode 100644 index 00000000..ebd9f424 --- /dev/null +++ b/tests/provider/test_credential_fields.py @@ -0,0 +1,80 @@ +"""华为云系渠道共用的凭证字段解析。 + +两家(CodeArts / OfficeAce)落盘形状一致,解析规则曾各写一份且字节级相同。 +这里直接测共享实现本身;各渠道仍保留同名薄包装,其行为由各自的 +`test_credential.py` 覆盖。 +""" + +from __future__ import annotations + +import json + +import pytest + +from src.provider.credential_fields import ( + epoch_from_iso, + expiration_from_raw, + jwk_from_raw, + relogin_required, + text_field, +) + + +def test_text_field_picks_first_non_empty_string(): + raw = {"a": " ", "b": " x ", "c": "y"} + assert text_field(raw, "a", "b", "c") == "x" + assert text_field(raw, "a", "missing") == "" + # 非字符串一律跳过(bool / 数字不是合法字段值) + assert text_field({"a": 5, "b": True}, "a", "b") == "" + + +def test_epoch_from_iso_rejects_non_string_and_bad_value(): + assert epoch_from_iso(None) == 0 + assert epoch_from_iso("") == 0 + assert epoch_from_iso(" ") == 0 + assert epoch_from_iso(1234) == 0 + assert epoch_from_iso("not-a-date") == 0 + assert epoch_from_iso("2026-10-21T00:00:00Z") > 0 + + +@pytest.mark.parametrize("raw,expected", [ + (True, 0), # bool 不是时间戳 + (0, 0), + (1700000000, 1700000000), # 秒 + (1700000000000, 1700000000), # 毫秒归一到秒 + ("1700000000", 1700000000), # 数字字符串 + ("1700000000000", 1700000000), + ("2026-10-21T00:00:00Z", None), # ISO8601(只断言非 0) + ("bad", 0), + (None, 0), + (1.5, 1), +]) +def test_expiration_from_raw(raw, expected): + got = expiration_from_raw(raw) + if expected is None: + assert got > 0 + else: + assert got == expected + + +def test_jwk_from_raw_accepts_string_and_dict(): + assert jwk_from_raw('{"kty": "EC", "d": "abc"}') == {"kty": "EC", "d": "abc"} + assert jwk_from_raw({"kty": "EC", "n": 5}) == {"kty": "EC"} # 非字符串值丢弃 + assert jwk_from_raw("{bad json") == {} + assert jwk_from_raw(None) == {} + assert jwk_from_raw(5) == {} + + +def test_relogin_required_matches_markers_only(): + markers = ("invalid refresh token", "sts5.1806") + assert relogin_required(b'{"error":"invalid refresh token"}', markers) != "" + assert relogin_required(b'{"code":"STS5.1806"}', markers) != "" # 大小写不敏感 + assert relogin_required(b'{"error":"internal server error"}', markers) == "" + # 可重试类失败绝不能判成终态(判错=永久废掉一条好凭证) + assert relogin_required(b"", markers) == "" + assert relogin_required(b"\xff\xfe invalid refresh token", markers) != "" + + +def test_jwk_from_raw_roundtrip_json_string(): + payload = {"kty": "EC", "crv": "P-256", "d": "s"} + assert jwk_from_raw(json.dumps(payload)) == payload diff --git a/tests/provider/test_lazy_clients.py b/tests/provider/test_lazy_clients.py new file mode 100644 index 00000000..25a7216c --- /dev/null +++ b/tests/provider/test_lazy_clients.py @@ -0,0 +1,197 @@ +"""渠道客户端共享的懒建连接池。 + +7 家原先各写一份「两个懒建 client + aclose」,差异只有 property/方法两种写法。 +这里直接测共享实现:懒建、复用、按需释放。 +""" + +from __future__ import annotations + +import httpx +import pytest + +from src.provider.lazy_clients import LazyClients + + +class _Client(LazyClients): + def __init__(self, *, stream=None, short=None, proxy=None) -> None: + self._stream_client = stream + self._short_client = short + self._proxy = proxy + + +def test_pools_are_lazy_and_reused(): + client = _Client() + assert client._stream_client is None and client._short_client is None + + stream = client._stream + short = client._short + + assert client._stream is stream # 复用同一实例,不重建 + assert client._short is short + assert stream is not short # 两条独立的池 + + +def test_injected_clients_are_used_as_is(): + """注入的 client(测试与复用场景)不能被替换掉。""" + given = httpx.AsyncClient() + client = _Client(stream=given) + assert client._stream is given + + +def test_proxy_is_forwarded_to_both_pools(monkeypatch): + calls: list[dict] = [] + + def fake(**kwargs): + calls.append(kwargs) + return httpx.AsyncClient() + + monkeypatch.setattr("src.provider.lazy_clients.build_client", fake) + client = _Client(proxy="socks5://127.0.0.1:1080") + + assert client._stream is not None + assert client._short is not None + assert calls[0]["proxy"] == "socks5://127.0.0.1:1080" + assert calls[1]["proxy"] == "socks5://127.0.0.1:1080" + # 两条池用不同的超时(流式读无上限,短请求整体 30s) + assert calls[0]["timeout"].read is None + assert calls[1]["timeout"].read == 30.0 + + +async def test_aclose_skips_pools_never_created(): + """只建了 stream 时,aclose 不能因 short 为 None 而崩(懒建的意义)。""" + client = _Client() + assert client._stream is not None + assert client._short_client is None + await client.aclose() + + +async def test_aclose_is_idempotent(): + client = _Client() + stream, short = client._stream, client._short + await client.aclose() + assert stream.is_closed and short.is_closed + # 再关一次不该抛(客户端已 closed 时 aclose 是 no-op) + await client.aclose() + + +async def test_aclose_with_no_pools_is_noop(): + client = _Client() + await client.aclose() # 一条都没建过 + + +@pytest.mark.parametrize("attr", ["_stream", "_short"]) +def test_pools_ignore_environment_proxy(attr): + """`trust_env=False` 是既有约定:不吃 HTTP_PROXY,避免劫持带 Token 的请求。""" + client = _Client() + assert getattr(client, attr).trust_env is False + + +# --------------------------------------------- 出口 IP 粘性:按凭证分池 + + +@pytest.fixture() +def pool(monkeypatch): + """装一个两成员出口池;退出时清空,避免污染其它用例。""" + from src.provider.proxy import CredentialProxyPool, set_proxy_pool + + set_proxy_pool(CredentialProxyPool(("http://a:1", "http://b:2"))) + try: + yield + finally: + set_proxy_pool(CredentialProxyPool()) + + +def test_same_credential_reuses_one_pool_per_proxy(pool, monkeypatch): + from src.request_context import credential_scope + + calls: list[str | None] = [] + + def fake(**kwargs): + calls.append(kwargs.get("proxy")) + return httpx.AsyncClient() + + monkeypatch.setattr("src.provider.lazy_clients.build_client", fake) + client = _Client() + + with credential_scope("cred_a"): + first = client._stream + again = client._stream + assert first is again # 同一凭证复用同一条池 + assert calls == [calls[0]] # 只建了一次 + assert calls[0] in ("http://a:1", "http://b:2") + + with credential_scope("cred_b"): + stream_b = client._stream + # cred_b 落在同一出口时复用池,落在另一出口时新建——两种都合法, + # 但出口必须与池分配一致 + from src.provider.proxy import credential_proxy + + assert calls[-1] == credential_proxy("cred_b") + if credential_proxy("cred_b") == calls[0]: + assert stream_b is first + else: + assert stream_b is not first + + +def test_credential_pool_overrides_channel_proxy(pool, monkeypatch): + calls: list[str | None] = [] + monkeypatch.setattr( + "src.provider.lazy_clients.build_client", + lambda **kw: calls.append(kw.get("proxy")) or httpx.AsyncClient()) + from src.request_context import credential_scope + + client = _Client(proxy="http://channel:9") + with credential_scope("cred_x"): + assert client._short is not None + assert calls[0] in ("http://a:1", "http://b:2") + + +def test_without_pool_falls_back_to_channel_proxy(monkeypatch): + calls: list[str | None] = [] + monkeypatch.setattr( + "src.provider.lazy_clients.build_client", + lambda **kw: calls.append(kw.get("proxy")) or httpx.AsyncClient()) + from src.request_context import credential_scope + + client = _Client(proxy="http://channel:9") + with credential_scope("cred_x"): + assert client._stream is not None + assert calls == ["http://channel:9"] + + +def test_no_credential_context_uses_channel_proxy(pool, monkeypatch): + calls: list[str | None] = [] + monkeypatch.setattr( + "src.provider.lazy_clients.build_client", + lambda **kw: calls.append(kw.get("proxy")) or httpx.AsyncClient()) + + client = _Client(proxy="http://channel:9") + assert client._stream is not None + assert calls == ["http://channel:9"] + + +async def test_aclose_closes_every_per_proxy_pool(pool, monkeypatch): + from src.request_context import credential_scope + + created: list[httpx.AsyncClient] = [] + + def fake(**kwargs): + client = httpx.AsyncClient() + created.append(client) + return client + + monkeypatch.setattr("src.provider.lazy_clients.build_client", fake) + client = _Client() + from src.provider.proxy import credential_proxy + + # 覆盖两个出口(找一个落在另一桶的凭证) + with credential_scope("cred_a"): + assert client._stream is not None + other = next( + f"cred_{i}" for i in range(100) + if credential_proxy(f"cred_{i}") != credential_proxy("cred_a")) + with credential_scope(other): + assert client._stream is not None + + await client.aclose() + assert created and all(item.is_closed for item in created) diff --git a/tests/provider/test_login_identity.py b/tests/provider/test_login_identity.py new file mode 100644 index 00000000..d5a19baa --- /dev/null +++ b/tests/provider/test_login_identity.py @@ -0,0 +1,115 @@ +"""登录合并键(`provider/login_identity.py`)与合并落库策略。""" + +from __future__ import annotations + +from src.provider.codearts import CodeArtsProvider +from src.provider.login_identity import ( + LOGIN_IDENTITIES, + LOGIN_IDENTITY_SETS, + codearts_login_identities, + codearts_login_identity, + login_identities_for, + login_identity_for, + officeace_login_identities, + officeace_login_identity, +) +from src.provider.officeace import OfficeAceProvider + + +def test_registry_declares_only_mergeable_channels(): + assert set(LOGIN_IDENTITIES) == {"codearts", "officeace", "lingxi"} + # 首选身份与别名集合必须覆盖同一批渠道,否则「能合并」与「找得到」会错开 + assert set(LOGIN_IDENTITY_SETS) == set(LOGIN_IDENTITIES) + + +def test_codearts_identity_prefers_uid_then_domain_and_name(): + assert codearts_login_identity({ + "uid": "u1", "domain_id": "d", "user_name": "n"}) == "u1" + # camelCase 落盘形态同样认(历史凭证) + assert codearts_login_identity({"userId": "u2"}) == "u2" + assert codearts_login_identity({ + "uid": "", "domainId": "d", "userName": "n"}) == "d|n" + assert codearts_login_identity({"uid": "", "domain_id": "d"}) == "" + assert codearts_login_identity({}) == "" + assert codearts_login_identity({"uid": " "}) == "" + + +def test_codearts_identity_set_holds_all_aliases(): + """别名集合让「身份补全失败」的登录仍能命中老行。 + + 老行只有 `uid`、新登录只有 `domain_id|user_name`(`_fill_identity` 网络 + 失败),单键比较永远不等 → 会新增重复凭证;交集判定才能合并。 + """ + # 两个别名都给 + assert codearts_login_identities( + {"uid": "u1", "domain_id": "d", "user_name": "n"}) == {"u1", "d|n"} + # 只有 uid(老行形态) + assert codearts_login_identities({"uid": "u1", "domain_id": "d"}) == {"u1"} + # 只有兜底键(身份补全失败的登录形态) + assert codearts_login_identities( + {"domain_id": "d", "user_name": "n"}) == {"d|n"} + assert codearts_login_identities({}) == frozenset() + assert codearts_login_identities({"uid": " ", "domain_id": " "}) == frozenset() + + +def test_officeace_identity_prefers_account_then_principal(): + assert officeace_login_identity({"account_id": "a"}) == "a" + assert officeace_login_identity({"accountId": "a"}) == "a" + assert officeace_login_identity({"principal_id": "p"}) == "p" + assert officeace_login_identity({"account_id": "", "principalId": "p"}) == "p" + assert officeace_login_identity({}) == "" + + +def test_officeace_identity_set_holds_all_aliases(): + assert officeace_login_identities( + {"account_id": "a", "principal_id": "p"}) == {"a", "p"} + assert officeace_login_identities({"accountId": "a"}) == {"a"} + assert officeace_login_identities({}) == frozenset() + + +def test_login_identity_for_rejects_unknown_channel_and_bad_payload(): + assert login_identity_for("trae", {"accessToken": "t"}) == "" + assert login_identity_for("codearts", {"uid": "u1"}) == "u1" + # 非 dict 的畸形凭证不该抛(登录路径不能因它整体失败) + assert login_identity_for("codearts", None) == "" # type: ignore[arg-type] + assert login_identity_for("officeace", "nope") == "" # type: ignore[arg-type] + + +def test_login_identities_for_rejects_unknown_channel_and_bad_payload(): + assert login_identities_for("trae", {"accessToken": "t"}) == frozenset() + assert login_identities_for("codearts", {"uid": "u1"}) == {"u1"} + # 非 dict 的畸形凭证不该抛(登录路径不能因它整体失败) + assert login_identities_for("codearts", None) == frozenset() # type: ignore[arg-type] + assert login_identities_for("officeace", "nope") == frozenset() # type: ignore[arg-type] + + +def test_login_identities_for_drops_non_string_aliases(): + """畸形 getter 返回值(非 iterable / 字符串 / 含非字符串项)一律安全退化。""" + from src.provider import login_identity as module + + original_sets = dict(module.LOGIN_IDENTITY_SETS) + try: + module.LOGIN_IDENTITY_SETS["weird"] = lambda _data: "u1" # 字符串不算集合 + module.LOGIN_IDENTITY_SETS["scalar"] = lambda _data: 42 # 非 iterable + module.LOGIN_IDENTITY_SETS["mixed"] = lambda _data: {"u1", "", None, 5} + module.LOGIN_IDENTITY_SETS["boom"] = _raiser + assert module.login_identities_for("weird", {}) == frozenset() + assert module.login_identities_for("scalar", {}) == frozenset() + assert module.login_identities_for("mixed", {}) == {"u1"} + # getter 抛错 → 空集(畸形凭证不影响登录) + assert module.login_identities_for("boom", {}) == frozenset() + finally: + module.LOGIN_IDENTITY_SETS.clear() + module.LOGIN_IDENTITY_SETS.update(original_sets) + + +def _raiser(_data): + raise ValueError("malformed credential") + + +def test_provider_facade_delegates_to_shared_identity(): + assert CodeArtsProvider().login_identity({"uid": "u1"}) == "u1" + assert OfficeAceProvider().login_identity({"account_id": "a"}) == "a" + # 与注册表同源:脚本与登录路径不会各判一套 + assert CodeArtsProvider().login_identity({"uid": "x"}) == login_identity_for( + "codearts", {"uid": "x"}) diff --git a/tests/provider/test_naming.py b/tests/provider/test_naming.py index 92cf67f7..05fc45b9 100644 --- a/tests/provider/test_naming.py +++ b/tests/provider/test_naming.py @@ -267,6 +267,29 @@ def test_synonym_table_collapses_vendor_prefixed_cb_name(self): # Flash 档不带前缀,不受影响 assert normalize_model_key("MiMo-V2.6-Flash") == "mimo-v2.6-flash" + def test_synonym_table_collapses_raccoon_sn_prefix(self): + """小浣熊的 `sn-*` 前缀与连字符版本号收敛到其余渠道的写法。 + + 商汤把托管模型统一加 `sn-` 前缀、版本号用连字符(`5-3`)而非点号 + (`5.3`)。不登记就是「同一模型两条对外条目」,用户得挑渠道才路由得通。 + 其中 `sn-deepseek-v4-1-flash-1017` 的 `1017` 是**快照日期**、不是模型 + 身份——它就是 DeepSeek V4.1 Flash 本身(与 CodeArts 同倍率 0.18x), + 故收敛到 `deepseek-v4.1-flash` 而不是带 `-1017` 的键。 + """ + assert normalize_model_key("sn-glm-5-3") == "glm-5.3" + assert normalize_model_key("sn-glm-5-3") == normalize_model_key("glm-5.3") + assert display_model_name("sn-glm-5-3") == "GLM 5.3" + assert normalize_model_key("sn-kimi-k3") == "kimi-k3" + assert normalize_model_key("sn-sensenova-6-8-flash") == "sensenova-6.8-flash" + # 快照日期不是身份:1017 档收敛到不带日期的规范键 + assert normalize_model_key("sn-deepseek-v4-1-flash-1017") == \ + "deepseek-v4.1-flash" + assert display_model_name("sn-deepseek-v4-1-flash-1017") == \ + "DeepSeek V4.1 Flash" + # 收敛后与 CodeArts 的同名模型是同一键(合并成一条对外条目) + assert normalize_model_key("sn-deepseek-v4-1-flash-1017") == \ + normalize_model_key("deepseek-v4.1-flash") + def test_synonym_display_is_idempotent(self): """规范展示名再洗一遍不变(幂等),否则中心层二次清洗会来回横跳。""" canonical = display_model_name("DeepSeek-Flash") diff --git a/tests/provider/test_openai_chunk.py b/tests/provider/test_openai_chunk.py new file mode 100644 index 00000000..8a510976 --- /dev/null +++ b/tests/provider/test_openai_chunk.py @@ -0,0 +1,182 @@ +"""共享 OpenAI chunk 解析原语:usage 合并后的并集语义。 + +7 个渠道原先各写一份 `_usage`,抽到 `usage_from_openai` 后必须保证 +「details 优先、顶层兜底」的并集行为不丢——尤其顶层 `reasoning_tokens` +(TRAE / Zen / Kilo / Qoder 实测会给)不能因 details 缺失而归 None。 +""" + +from __future__ import annotations + +import json + +import pytest + +from src.provider.base import Event, EventKind, UpstreamProtocolViolation +from src.provider.openai_chunk import ( + details_int, + openai_event, + openai_events, + payload_of, + usage_from_openai, +) + + +def test_reasoning_tokens_falls_back_to_top_level(): + """details 缺失时顶层 reasoning_tokens 必须保留(TRAE 等渠道的真实形状)。""" + usage = usage_from_openai({"prompt_tokens": 1, "reasoning_tokens": 148}) + assert usage.reasoning_tokens == 148 + + +def test_details_win_over_top_level(): + """details 与顶层同时存在时以 details 为准(CB / OfficeAce / CodeArts 实测)。""" + usage = usage_from_openai({ + "prompt_tokens": 1, + "reasoning_tokens": 9, + "completion_tokens_details": {"reasoning_tokens": 100}, + }) + assert usage.reasoning_tokens == 100 + + +@pytest.mark.parametrize("bad", [True, "5", None, 1.5]) +def test_details_rejects_non_int(bad): + """details 里的非法值(bool / 字符串 / 浮点)回落顶层,不当成 0。""" + usage = usage_from_openai({ + "completion_tokens_details": {"reasoning_tokens": bad}, + "reasoning_tokens": 7, + }) + assert usage.reasoning_tokens == 7 + + +def test_credit_only_when_channel_reports_it(): + """`credit=True` 的渠道才解析 credit;其余渠道忽略该字段。""" + assert usage_from_openai({"credit": 1.5}, credit=True).credit == 1.5 + assert usage_from_openai({"credit": 1.5}).credit is None + # bool 不是有效 credit(True 会被当成 1.0) + assert usage_from_openai({"credit": True}, credit=True).credit is None + + +def test_cached_fallbacks_tried_in_order(): + """details → 顶层 → 渠道私有兜底键,逐级回落;0 是有效值不算缺失。""" + assert usage_from_openai( + {"cache_read_input_tokens": 7}, + cached_fallbacks=("cache_read_input_tokens",)).cached_tokens == 7 + # 顶层 cached_tokens 优先于私有兜底键 + assert usage_from_openai( + {"cached_tokens": 2, "cache_read_input_tokens": 5}, + cached_fallbacks=("cache_read_input_tokens",)).cached_tokens == 2 + # 0 必须保留(TRAE 未命中报 0) + assert usage_from_openai( + {"cache_read_input_tokens": 0}, + cached_fallbacks=("cache_read_input_tokens",)).cached_tokens == 0 + assert usage_from_openai({}).cached_tokens is None + + +def test_details_int_handles_non_dict_details(): + """details 不是对象时直接回落顶层,不崩。""" + assert details_int({"prompt_tokens_details": "nope", "cached_tokens": 3}, + "prompt_tokens_details", "cached_tokens") == 3 + assert details_int({"prompt_tokens_details": None, "cached_tokens": 3}, + "prompt_tokens_details", "cached_tokens") == 3 + + +# ---------------------------------------------------------------- 帧解析 + + +def _frame(data: str): + from src.engine.sse import SSEFrame + + return SSEFrame(event="message", data=data) + + +def test_payload_of_skips_empty_and_done(): + assert payload_of(_frame(""), "X") is None + assert payload_of(_frame("[DONE]"), "X") is None + assert payload_of(_frame('{"a":1}'), "X") == {"a": 1} + + +def test_payload_of_rejects_bad_json_and_non_object(): + """坏帧必须显式失败,不能静默返回空回复;报错文案带渠道名便于定位。""" + with pytest.raises(UpstreamProtocolViolation, match="unparsable X SSE data"): + payload_of(_frame("{bad"), "X") + with pytest.raises(UpstreamProtocolViolation, match="X SSE data is not an object"): + payload_of(_frame("[1,2]"), "X") + + +def test_openai_event_priority_tool_calls_over_content(): + """tool_calls > 正文:同一 delta 两者都有时只产出一个代表性事件。""" + event = openai_event({"choices": [{"delta": { + "tool_calls": [{"function": {"name": "f", "arguments": ""}}], + "content": "文本"}}]}) + assert event is not None and event.kind is EventKind.TOOL_CALLS + + +def test_openai_event_drops_blank_tool_calls_and_falls_through(): + """空名空参的噪声 tool_call 整条丢弃,继续看正文。""" + event = openai_event({"choices": [{"delta": { + "tool_calls": [{"function": {"name": "", "arguments": ""}}], + "content": "正文"}}]}) + assert event is not None and event.kind is EventKind.CONTENT + + +@pytest.mark.parametrize("key", ["reasoning_content", "reasoning"]) +def test_openai_event_reasoning_keys_are_parameterised(key): + """不猜键:只认调用方点名的 reasoning 字段。""" + payload = {"choices": [{"delta": {key: "思考"}}]} + assert openai_event(payload, reasoning_keys=(key,)).kind is EventKind.REASONING + # 点错键就读不到(默认只认 reasoning_content,Kilo 必须显式传 reasoning) + assert openai_event(payload, reasoning_keys=("other",)) is None + + +def test_openai_event_error_as_string_only_when_asked(): + """OfficeAce 实测有 `{"error": "文本"}`;其他渠道不该认这个形态。""" + + def classify(error: dict) -> Event: + return Event(kind=EventKind.ERROR, error_message=str(error.get("message"))) + + payload = {"error": "炸了"} + assert openai_event(payload, error_event=classify, + error_as_string=True).kind is EventKind.ERROR + assert openai_event(payload, error_event=classify) is None + + +def test_openai_events_appends_usage_and_finish_on_merged_frame(): + """合并帧(正文 + usage + finish)三者都要出,否则客户端收不到结束信号。""" + events = openai_events(_frame(json.dumps({ + "choices": [{"delta": {"content": "尾"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3}})), "X") + kinds = [e.kind for e in events] + assert kinds == [EventKind.CONTENT, EventKind.USAGE, EventKind.FINISH] + + +def test_openai_events_does_not_duplicate_usage_or_finish(): + """代表性事件已经是 usage / finish 时不再补一条。""" + assert [e.kind for e in openai_events(_frame(json.dumps({ + "choices": [], "usage": {"prompt_tokens": 1}})), "X")] == [EventKind.USAGE] + assert [e.kind for e in openai_events(_frame(json.dumps({ + "choices": [{"delta": {}, "finish_reason": "stop"}]})), "X")] == [EventKind.FINISH] + + +def test_openai_events_empty_frame_returns_no_events(): + assert openai_events(_frame("[DONE]"), "X") == [] + + +def test_openai_event_attaches_upstream_reported_model(): + """上游自报模型挂到事件上(executor 用它做静默替换观测)。""" + event = openai_event({"model": "glm-4.6", + "choices": [{"delta": {"content": "x"}}]}) + assert event.model == "glm-4.6" + # 没报模型 / 空串:保持 None + assert openai_event({"choices": [{"delta": {"content": "x"}}]}).model is None + assert openai_event({"model": "", + "choices": [{"delta": {"content": "x"}}]}).model is None + + +def test_openai_events_attaches_model_to_appended_usage_and_finish(): + frame = _frame(json.dumps({ + "model": "m-1", + "choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 2}, + })) + events = openai_events(frame, "Test") + assert events, "finish/usage 帧必须产出事件" + assert all(event.model == "m-1" for event in events) diff --git a/tests/provider/test_probe_failure.py b/tests/provider/test_probe_failure.py new file mode 100644 index 00000000..9c979f80 --- /dev/null +++ b/tests/provider/test_probe_failure.py @@ -0,0 +1,34 @@ +"""额度探测失败 → 重新登录原因。""" + +from __future__ import annotations + +from src.provider.lingxi.client import UpstreamHTTPError as LingxiHTTPError +from src.provider.probe_failure import relogin_reason + + +def test_non_http_error_keeps_unknown(): + assert relogin_reason("lingxi", RuntimeError("boom")) == "" + + +def test_unbound_classify_status_keeps_unknown(): + """基类异常没绑定 classify_status(测试替身形态)→ 不升级处置。""" + from src.provider.base import UpstreamHTTPError + + assert relogin_reason("lingxi", UpstreamHTTPError(401, b"")) == "" + + +def test_non_dead_http_error_keeps_unknown(): + # 灵犀把 500 归为 OTHER:只是一次探测失败,不能据此停号 + assert relogin_reason("lingxi", LingxiHTTPError(500, b"")) == "" + + +def test_lingxi_dead_explains_kicked_session(): + reason = relogin_reason("lingxi", LingxiHTTPError(401, b"")) + assert "WPS" in reason + assert "登录新账号" in reason + + +def test_other_provider_dead_gets_generic_reason(): + reason = relogin_reason("codebuddy", LingxiHTTPError(401, b"")) + assert "登录态失效" in reason + assert "WPS" not in reason \ No newline at end of file diff --git a/tests/provider/test_proxy.py b/tests/provider/test_proxy.py index fadf2987..7c15ba16 100644 --- a/tests/provider/test_proxy.py +++ b/tests/provider/test_proxy.py @@ -138,24 +138,27 @@ def _codearts_client(proxy): return CodeArtsClient(proxy=proxy) -@pytest.mark.parametrize("factory,module_path,is_property", [ - (_trae_client, "src.provider.trae.client", False), - (_zen_client, "src.provider.zen.client", False), - (_kilo_client, "src.provider.kilo.client", False), - (_codebuddy_client, "src.provider.codebuddy.client", True), - (_qoder_client, "src.provider.qoder.client", True), - (_codearts_client, "src.provider.codearts.client", False), +@pytest.mark.parametrize("factory,module_path", [ + (_trae_client, "src.provider.trae.client"), + (_zen_client, "src.provider.zen.client"), + (_kilo_client, "src.provider.kilo.client"), + (_codebuddy_client, "src.provider.codebuddy.client"), + (_qoder_client, "src.provider.qoder.client"), + (_codearts_client, "src.provider.codearts.client"), ]) -async def test_provider_client_forwards_proxy(monkeypatch, factory, module_path, - is_property): +async def test_provider_client_forwards_proxy(monkeypatch, factory, module_path): + """代理按渠道转发:两条池都拿到同一个 proxy。 + + `build_client` 现在只在 `lazy_clients` 里调用(7 家共用一份懒建逻辑), + 所以 patch 点在那里,`module_path` 只用来定位被测客户端类。 + """ import importlib - module = importlib.import_module(module_path) - calls = _capture_build(monkeypatch, module) + calls = _capture_build(monkeypatch, + importlib.import_module("src.provider.lazy_clients")) client = factory("socks5://127.0.0.1:1080") - stream = client._stream if is_property else client._stream() - short = client._short if is_property else client._short() + stream, short = client._stream, client._short assert calls[0]["proxy"] == "socks5://127.0.0.1:1080" assert calls[1]["proxy"] == "socks5://127.0.0.1:1080" assert stream is not None and short is not None @@ -273,4 +276,66 @@ def test_build_app_rejects_bad_provider_proxies(tmp_path): settings = _settings(data_dir=str(tmp_path), provider_proxies="nope=http://p:1") with pytest.raises(ValueError): - build_app(settings) \ No newline at end of file + build_app(settings) +# ------------------------------------------------- 出口 IP 粘性(按凭证分池) + + +def test_parse_proxy_pool_basic_and_dedupe(): + from src.provider.proxy import parse_proxy_pool + + assert parse_proxy_pool("") == () + assert parse_proxy_pool(" ; ; ") == () + assert parse_proxy_pool("http://a:1;socks5://b:2") == ("http://a:1", "socks5://b:2") + # 重复项按首次出现去重:池子写重了不该让某出口被抽中两次 + assert parse_proxy_pool("http://a:1;http://a:1") == ("http://a:1",) + + +@pytest.mark.parametrize("bad", ["127.0.0.1:7890", "ftp://p:1", "http:/broken"]) +def test_parse_proxy_pool_rejects_bad_scheme(bad): + from src.provider.proxy import parse_proxy_pool + + with pytest.raises(ValueError): + parse_proxy_pool(bad) + + +def test_pool_assignment_is_stable_and_bounded(): + from src.provider.proxy import CredentialProxyPool + + pool = CredentialProxyPool(("http://a:1", "http://b:2", "http://c:3")) + assert len(pool) == 3 + first = pool.for_credential("cred_abc") + # 同一凭证永远同一出口(重启后仍成立,分配只依赖 sha256) + assert all(pool.for_credential("cred_abc") == first for _ in range(20)) + assert first in ("http://a:1", "http://b:2", "http://c:3") + + +def test_pool_empty_or_missing_credential_returns_none(): + from src.provider.proxy import CredentialProxyPool + + assert CredentialProxyPool().for_credential("cred_x") is None + assert not CredentialProxyPool() + assert CredentialProxyPool(("http://a:1",)).for_credential(None) is None + + +def test_pool_distributes_across_members(): + from src.provider.proxy import CredentialProxyPool + + pool = CredentialProxyPool(("http://a:1", "http://b:2")) + seen = {pool.for_credential(f"cred_{i}") for i in range(50)} + assert seen == {"http://a:1", "http://b:2"} + + +def test_set_and_get_pool(): + from src.provider.proxy import ( + CredentialProxyPool, + credential_proxy, + get_proxy_pool, + set_proxy_pool, + ) + + try: + set_proxy_pool(CredentialProxyPool(("http://a:1",))) + assert get_proxy_pool() + assert credential_proxy("cred_1") == "http://a:1" + finally: + set_proxy_pool(CredentialProxyPool()) diff --git a/tests/provider/test_retry_after.py b/tests/provider/test_retry_after.py index 5fc027d1..523efdff 100644 --- a/tests/provider/test_retry_after.py +++ b/tests/provider/test_retry_after.py @@ -9,12 +9,15 @@ import email.utils import time +from datetime import datetime, timedelta, timezone import httpx import pytest from src.provider.base import ( + ErrKind, UpstreamHTTPError, + parse_reset_at, parse_retry_after, parse_retry_after_seconds, retry_hints, @@ -196,3 +199,103 @@ def _credential_for(module_path: str): if "trae" in module_path: return {"accessToken": "t"} return {"bearer_token": "t"} + + +# ------------------------------------------------- 响应体里的重置时刻(A2b) +# +# CodeBuddy 国际站 `429 + 6004` 不回 `Retry-After` 头,恢复时刻只在正文: +# `... your usage will reset at 2026-10-25 22:34:25 UTC+8, alternatively ...`。 +# 不解析它就永远回落 600s 基数:实测同一号一天被爆 42 次,每次都「冷却到期 +# → 重试 → 又 429」。 + +# 2026-10-25 22:34:25 UTC+8 = 14:34:25Z +RESET_MOMENT = 1_792_938_865 +RESET_BODY = ( + b'{"code":6004,"msg":"usage exceeds frequency limit, but don\'t worry, your ' + b'usage will reset at 2026-10-25 22:34:25 UTC+8, alternatively, you can ' + b'switch to the other models to continue using it.","requestId":"8967ac37"}' +) + + +def test_parse_reset_at_from_real_body(): + assert parse_reset_at(RESET_BODY, NOW) == RESET_MOMENT + # 距现在多少秒 = 绝对时刻 - now(供诊断,不参与钳制) + assert retry_hints({}, NOW, body=RESET_BODY)["retry_after_seconds"] == ( + RESET_MOMENT - NOW) + + +def test_parse_reset_at_accepts_str_and_other_offsets(): + """str 入参、`UTC+08:00` 写法、负偏移都要能解析。""" + text = "usage will reset at 2026-10-25 22:34:25 UTC+08:00" + assert parse_reset_at(text, NOW) == RESET_MOMENT + # UTC-5:同一墙钟时刻在 UTC-5 是 5 小时之后 + negative = "reset at 2026-10-25 22:34:25 UTC-5" + assert parse_reset_at(negative, NOW) == RESET_MOMENT + 13 * 3600 + + +def test_parse_reset_at_rejects_past_and_garbage(): + """过去时刻 / 无偏移 / 脏日期一律 None:交给调度器回落本地常量。""" + assert parse_reset_at("reset at 2020-01-01 00:00:00 UTC+8", NOW) is None + # 无 UTC 偏移:无法判断时区,不猜 + assert parse_reset_at("reset at 2026-10-25 22:34:25", NOW) is None + # 非法日期(2 月 30 日) + assert parse_reset_at("reset at 2026-02-30 00:00:00 UTC+8", NOW) is None + assert parse_reset_at("", NOW) is None + assert parse_reset_at(b"", NOW) is None + assert parse_reset_at(b"no reset word here 2026-10-25 22:34:25 UTC+8", NOW) is None + + +def test_retry_hints_prefers_header_over_body(): + """头是协议标准位,正文只是文案:两者都有时以头为准。""" + hints = retry_hints({"Retry-After": "120"}, NOW, body=RESET_BODY) + assert hints == {"retry_at": NOW + 120, "retry_after_seconds": 120.0} + + +def test_retry_hints_falls_back_to_body_without_header(): + hints = retry_hints({}, NOW, body=RESET_BODY) + assert hints["retry_at"] == RESET_MOMENT + # body 不含重置时刻时仍是 None(行为与历史版本一致) + assert retry_hints({}, NOW, body=b'{"code":6004}') == { + "retry_at": None, "retry_after_seconds": None} + # 不传 body 与历史签名逐字节等价 + assert retry_hints({}, NOW) == {"retry_at": None, "retry_after_seconds": None} + + +def _reset_body_at(moment: int) -> bytes: + """按 `moment` 生成一份真实形状的正文(重置时刻写成 UTC+8 墙钟)。 + + 端到端用例必须用**相对当前时刻**的正文。写死某个日期的话,跑到那天之后 + `parse_reset_at` 会把「过去的时刻」判成 None(这是它的正确行为,见 + `test_parse_reset_at_rejects_past_and_garbage`),用例就变成隔天必红—— + 实测 2026-10-25 22:34:25 那条写死正文在 22:35 之后即失败。 + """ + stamp = datetime.fromtimestamp(moment, timezone(timedelta(hours=8))) + return ( + b'{"code":6004,"msg":"usage exceeds frequency limit, but don\'t worry, your ' + b'usage will reset at ' + + stamp.strftime("%Y-%m-%d %H:%M:%S").encode() + + b' UTC+8, alternatively, you can switch to the other models to ' + b'continue using it.","requestId":"8967ac37"}' + ) + + +async def test_codebuddy_stream_fills_retry_at_from_body(): + """CodeBuddy 的 raise 点把 body 传给了 retry_hints:429+6004 冷却到重置时刻。""" + from src.provider.codebuddy.client import CodeBuddyClient, CodeBuddyCredential + + reset_at = int(time.time()) + 3600 + body = _reset_body_at(reset_at) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(429, content=body) + + transport = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=None) + client = CodeBuddyClient(stream_client=transport, short_client=transport) + before = int(time.time()) + with pytest.raises(UpstreamHTTPError) as caught: + [event async for event in client.stream_chat( + CodeBuddyCredential(bearer_token="t"), {"messages": []}, + "deepseek-v4.1-flash")] + assert caught.value.retry_at == reset_at + assert caught.value.kind() is ErrKind.MODEL + assert caught.value.retry_at > before diff --git a/tests/provider/test_stream_write_timeout.py b/tests/provider/test_stream_write_timeout.py index f357c404..f56009dc 100644 --- a/tests/provider/test_stream_write_timeout.py +++ b/tests/provider/test_stream_write_timeout.py @@ -9,22 +9,13 @@ import importlib import httpx -import pytest - -# 6 个渠道客户端模块,均导出 STREAM_TIMEOUT / SHORT_TIMEOUT -CLIENT_MODULES = [ - "src.provider.codebuddy.client", - "src.provider.codearts.client", - "src.provider.kilo.client", - "src.provider.qoder.client", - "src.provider.trae.client", - "src.provider.zen.client", -] - - -@pytest.mark.parametrize("module_name", CLIENT_MODULES) -def test_stream_timeout_allows_slow_body_upload(module_name: str) -> None: - module = importlib.import_module(module_name) + +# 超时常量只有一份(proxy.py),所有渠道经 LazyClients 共用 +MODULE = "src.provider.proxy" + + +def test_stream_timeout_allows_slow_body_upload() -> None: + module = importlib.import_module(MODULE) timeout = module.STREAM_TIMEOUT # 上传数 MB 请求体在代理链路上需要足够余量 @@ -34,9 +25,8 @@ def test_stream_timeout_allows_slow_body_upload(module_name: str) -> None: assert timeout.read is None -@pytest.mark.parametrize("module_name", CLIENT_MODULES) -def test_short_timeout_unchanged(module_name: str) -> None: - module = importlib.import_module(module_name) +def test_short_timeout_unchanged() -> None: + module = importlib.import_module(MODULE) # 非流式短请求保持 30s,防止顺手改坏 expected = httpx.Timeout(30.0) diff --git a/tests/provider/trae/test_client.py b/tests/provider/trae/test_client.py index e6b0532a..fa71517d 100644 --- a/tests/provider/trae/test_client.py +++ b/tests/provider/trae/test_client.py @@ -32,7 +32,7 @@ def _client(handler, **kw) -> TraeClient: def test_resolve_flat_auto_and_forced(): assert resolve("glm-5.2", "d").providers == ( "codebuddy", "codebuddy_intl", "trae", "zen", "kilo", "qoder", - "codearts", "officeace") + "codearts", "officeace", "raccoon", "lingxi") assert resolve("", "glm-5.2").model == "glm-5.2" assert resolve("auto", "glm-5.2").model == "glm-5.2" assert resolve(None, "glm-5.2").model == "glm-5.2" @@ -91,6 +91,39 @@ def handler(request: httpx.Request) -> httpx.Response: assert models == [Model(id="glm-5.2", name="GLM")] assert quota.remaining == 70 and quota.total == 100 +async def test_client_fetch_models_context_window_prefers_max(): + """上下文窗口取 `max`(真实上限),缺失/非法/不大于 dev 时回落 `dev`。 + + 只读 `dev` 会让 1M 上下文的模型报 200k,客户端据此提前压缩上下文。 + """ + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"config_info_list": [ + {"config_name": "glm-5.3", + "context_window_tokens": {"dev": 200000, "max": 1000000}}, + {"config_name": "mimo", + "context_window_tokens": {"dev": 200000, "max": 981504}}, + # max 不大于 dev → 没有信息量,按 dev + {"config_name": "flat", + "context_window_tokens": {"dev": 200000, "max": 200000}}, + # max 非法 → 回落 dev + {"config_name": "bad-max", + "context_window_tokens": {"dev": 128000, "max": "1M"}}, + # 只有 max + {"config_name": "max-only", + "context_window_tokens": {"max": 400000}}, + # max 为 bool(int 子类,必须排除) + {"config_name": "bool-max", + "context_window_tokens": {"dev": 64000, "max": True}}, + # max 非正 + {"config_name": "zero-max", + "context_window_tokens": {"dev": 64000, "max": 0}}, + ]}) + + models = await _client(handler).fetch_models(TraeCredential(access_token="a")) + assert [m.max_input_tokens for m in models] == [ + 1000000, 981504, 200000, 128000, 400000, 64000, 64000] + + async def test_client_fetch_models_metadata(): """倍率 / 上下文窗口 / 推理能力透传。""" def handler(_request: httpx.Request) -> httpx.Response: @@ -310,8 +343,8 @@ def not_object(_request: httpx.Request) -> httpx.Response: async def test_client_lazy_and_close_paths(): client = TraeClient() - assert client._stream() is client._stream() - assert client._short() is client._short() + assert client._stream is client._stream + assert client._short is client._short await client.aclose() async def test_provider_import_classify_and_models(): @@ -492,3 +525,41 @@ def handler(_request: httpx.Request) -> httpx.Response: usage = next(e.usage for e in events if e.kind is EventKind.USAGE) assert usage.credit is not None assert usage.credit_estimated is True + + +async def test_client_stamps_metadata_model_hint_on_later_events(): + """extra_info 回显的真实模型名贴到后续 usage / finish 事件(观测用)。""" + sse = ( + 'event: metadata\ndata: {"model":""}\n\n' + 'event: output\ndata: {"response":"hi","reasoning_content":"","tool_calls":null}\n\n' + 'event: extra_info\ndata: {"model":"glm-5.2-real","input_token":1}\n\n' + 'event: token_usage\ndata: {"prompt_tokens":1,"completion_tokens":1}\n\n' + 'event: done\ndata: {"finish_reason":"stop"}\n\n' + ) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=sse) + + events = [e async for e in _client(handler).stream_chat( + TraeCredential(access_token="a"), {"messages": []}, "glm-5.2")] + content = next(e for e in events if e.kind is EventKind.CONTENT) + usage = next(e for e in events if e.kind is EventKind.USAGE) + finish = next(e for e in events if e.kind is EventKind.FINISH) + # 内容帧在 extra_info 之前,拿不到提示 + assert content.model is None + # extra_info 之后的事件都带上真实模型名 + assert usage.model == "glm-5.2-real" + assert finish.model == "glm-5.2-real" + + +async def test_provider_model_failure_log_includes_type_when_message_empty(caplog): + """裸 `TimeoutError` 消息为空 → 回退日志仍要带类名,不能只剩「: 」。""" + class _Client: + async def fetch_models(self, _credential): + raise TimeoutError() + + provider = TraeProvider(client=_Client()) + with caplog.at_level("WARNING", logger="src.provider.trae.client"): + models = await provider.list_models({"accessToken": "a", "uid": "u"}) + assert [m.id for m in models] == list(STATIC_MODELS) + assert any("TimeoutError" in record.getMessage() for record in caplog.records) diff --git a/tests/provider/trae/test_events.py b/tests/provider/trae/test_events.py index 150733ee..30186540 100644 --- a/tests/provider/trae/test_events.py +++ b/tests/provider/trae/test_events.py @@ -93,6 +93,10 @@ def test_classify_status_detects_1005_in_body(): (429, b'{"code": 6004}', ErrKind.MODEL), (429, b'{"code": 500}', ErrKind.SOFT), (500, b'{"code": 14018}', ErrKind.CREDIT), # 业务码优先于状态码 + # 字符串 / 连字符形态的业务码同样认(与 CodeBuddy 共用的 `business_codes`) + (400, b'{"code": "11102"}', ErrKind.BLOCKED), + (400, b'{"code": "11-102"}', ErrKind.BLOCKED), + (402, b'{"code": "14018"}', ErrKind.CREDIT), (403, b'{"code": 4008, "message": "Your requests have exceeded the quota."}', ErrKind.CREDIT), # 配额耗尽:HTTP 形态同样判 CREDIT ]) @@ -152,3 +156,34 @@ def test_blank_nameless_tool_call_is_dropped(arguments): events = trae_events.parse_all_events( trae_events.SSEFrame(event="output", data=payload)) assert EventKind.TOOL_CALLS not in [e.kind for e in events] + + +def test_output_frame_attaches_upstream_reported_model(): + """TRAE 帧里带 model 时挂到事件上(观测用)。""" + frame = trae_events.SSEFrame( + event="output", + data='{"model":"trae-model","response":"hi","reasoning_content":"",' + '"tool_calls":null}') + assert trae_events.parse_frame(frame).model == "trae-model" + plain = trae_events.SSEFrame(event="output", data='{"response":"hi"}') + assert trae_events.parse_frame(plain).model is None + + +def test_frame_model_reads_metadata_frames_only(): + """元数据帧回显的真实模型名:extra_info / timing_cost 可读,其余帧不看。""" + extra = trae_events.SSEFrame(event="extra_info", + data='{"model":"glm-5.2","version":"x"}') + assert trae_events.frame_model(extra) == "glm-5.2" + timing = trae_events.SSEFrame(event="timing_cost", + data='{"provider_model_name":"glm-5.2"}') + assert trae_events.frame_model(timing) == "glm-5.2" + # 空串 / 缺键 / 坏 JSON / 非对象 / 非元数据帧 / 无 data 一律 None + for frame in ( + trae_events.SSEFrame(event="metadata", data='{"model":""}'), + trae_events.SSEFrame(event="extra_info", data='{}'), + trae_events.SSEFrame(event="extra_info", data="{broken"), + trae_events.SSEFrame(event="extra_info", data="[1]"), + trae_events.SSEFrame(event="output", data='{"model":"glm-5.2"}'), + trae_events.SSEFrame(event="extra_info", data=""), + ): + assert trae_events.frame_model(frame) is None diff --git a/tests/provider/zen/test_client.py b/tests/provider/zen/test_client.py index e4873f32..300295d7 100644 --- a/tests/provider/zen/test_client.py +++ b/tests/provider/zen/test_client.py @@ -217,19 +217,67 @@ def test_pretty_model_name_plain_numeric_segment(): assert _pretty_segment("2.5") == "2.5" assert _pretty_segment("3") == "3" -async def test_fetch_models_custom_suffix_and_probe_network_error(): - """后缀可覆盖;探活连接异常按不可用处理。""" +async def test_fetch_models_probe_network_error_keeps_candidates(): + """后缀可覆盖;探活连接异常(暂不可判定)→ 保留候选,不清空渠道。""" def handler(request: httpx.Request) -> httpx.Response: if request.url.path == EP_MODELS: return httpx.Response(200, json={"data": [ {"id": "m1-free"}, {"id": "m2-lite"}]}) raise httpx.ConnectError("boom") - with pytest.raises(zen_events.UpstreamProtocolViolation, match="no free model"): - await _client(handler, free_suffix="-lite").fetch_models() + models = await _client(handler, free_suffix="-lite").fetch_models() + assert [m.id for m in models] == ["m2-lite"] -async def test_fetch_models_probe_timeout_is_unavailable(monkeypatch): - """探活首字超时按不可用:整体套 `asyncio.timeout`,不拖垮模型列表。""" +async def test_fetch_models_probe_429_keeps_candidates(): + """429(匿名免费额度耗尽)属暂不可判定 → 保留候选。 + + 实测 2026-11-03 匿名探测全线 429:旧口径要求 2xx 会让 Zen 整渠道显示 + 「无模型」,而模型本身并未下架。 + """ + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == EP_MODELS: + return httpx.Response(200, json={"data": [{"id": "a-free"}]}) + return httpx.Response(429, json={"error": {"type": "FreeUsageLimitError"}}) + + models = await _client(handler).fetch_models() + assert [m.id for m in models] == ["a-free"] + +async def test_fetch_models_probe_429_does_not_log_warning(caplog): + """429 是已知稳态,只记 DEBUG:否则每轮 8 条 WARNING 淹没真问题。 + + 实测 2026-11-08 一天 224 条 `zen 探活 … 暂不可判定:HTTP 429`,占全部 + WARNING 的 73%;而 zen 渠道自 10-23 后无流量,纯噪声。 + """ + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == EP_MODELS: + return httpx.Response(200, json={"data": [{"id": "a-free"}]}) + return httpx.Response(429, json={"error": {"type": "FreeUsageLimitError"}}) + + with caplog.at_level("DEBUG", logger="src.provider.zen.client"): + await _client(handler).fetch_models() + + levels = {record.levelname for record in caplog.records + if "zen 探活" in record.getMessage()} + assert "WARNING" not in levels + assert "DEBUG" in levels + +async def test_fetch_models_probe_5xx_logs_warning(caplog): + """非 429 的暂不可判定(5xx)仍打 WARNING:那才是要人看的信号。""" + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == EP_MODELS: + return httpx.Response(200, json={"data": [{"id": "a-free"}]}) + return httpx.Response(503, json={"error": {"type": "ServerError"}}) + + with caplog.at_level("DEBUG", logger="src.provider.zen.client"): + models = await _client(handler).fetch_models() + + assert [m.id for m in models] == ["a-free"] + warnings = [record for record in caplog.records + if record.levelname == "WARNING" and "zen 探活" in record.getMessage()] + assert warnings and "HTTP 503" in warnings[0].getMessage() + +async def test_fetch_models_probe_timeout_keeps_candidates(monkeypatch): + """探活首字超时按「暂不可判定」保留:不拖垮启动,也不清空渠道。""" from src.provider.zen import client as zen_client monkeypatch.setattr(zen_client, "PROBE_TIMEOUT", 0.01) @@ -239,8 +287,8 @@ async def handler(request: httpx.Request) -> httpx.Response: await asyncio.sleep(1) # 首字过慢,超过 PROBE_TIMEOUT return httpx.Response(200, json={"data": [{"id": "slow-free"}]}) - with pytest.raises(zen_events.UpstreamProtocolViolation, match="no free model"): - await _client(handler).fetch_models() + models = await _client(handler).fetch_models() + assert [m.id for m in models] == ["slow-free"] async def test_fetch_models_reuses_probe_result_within_ttl(): """判活结果在 models_cache_ttl 内复用:服务层每 300s 重拉列表不会重探上游。""" @@ -313,12 +361,12 @@ def no_candidates(_request: httpx.Request) -> httpx.Response: with pytest.raises(zen_events.UpstreamProtocolViolation, match="no free candidates"): await _client(no_candidates).fetch_models() - # 候选都有,但探活全军覆没(上游整体故障)→ 交给上层缓存兜底 + # 候选都有,但全部被**确定**判死(付费 401 / 下线 400 / 区域 403)→ 抛出让上层缓存兜底 def all_dead(request: httpx.Request) -> httpx.Response: if request.url.path == EP_MODELS: return httpx.Response(200, json={"data": [{"id": "a-free"}, {"id": "b-free"}]}) - return httpx.Response(500, text="down") + return httpx.Response(403, text="not available in your country") with pytest.raises(zen_events.UpstreamProtocolViolation, match="liveness probe"): await _client(all_dead).fetch_models() @@ -326,8 +374,8 @@ def all_dead(request: httpx.Request) -> httpx.Response: async def test_client_lazy_clients_and_aclose(): """未注入 client 时惰性创建;aclose 释放两侧连接池。""" client = ZenClient() - assert client._stream() is client._stream() # noqa: SLF001 - 惰性复用同一实例 - assert client._short() is client._short() # noqa: SLF001 + assert client._stream is client._stream # noqa: SLF001 - 惰性复用同一实例 + assert client._short is client._short # noqa: SLF001 await client.aclose() # 都没创建过:aclose 不报错(两个分支都为 None) diff --git a/tests/provider/zen/test_events.py b/tests/provider/zen/test_events.py index e8fe9ca7..85304f46 100644 --- a/tests/provider/zen/test_events.py +++ b/tests/provider/zen/test_events.py @@ -116,6 +116,13 @@ def test_usage_tolerates_bad_types(): assert event.usage.cached_tokens is None assert event.usage.credit is None +def test_usage_parses_credit_when_present(): + """Zen 是上游真给 credit 的渠道之一:数值必须解析出来(不是默认值吞掉)。""" + event = zen_events.parse_frame(frame( + {"choices": [], "usage": {"prompt_tokens": 5, "credit": 0.25}})) + assert event is not None and event.usage is not None + assert event.usage.credit == 0.25 + def test_classify_status_maps_each_branch(): # 401 只代表「该模型需要付费 key」,不是虚拟凭证失效;归 DEAD 会因一次 # 强制 @zen 的付费模型请求把整条渠道硬禁用。 diff --git a/tests/provider/zen/test_gate_config.py b/tests/provider/zen/test_gate_config.py new file mode 100644 index 00000000..3855e38b --- /dev/null +++ b/tests/provider/zen/test_gate_config.py @@ -0,0 +1,156 @@ +"""Zen 门禁的**可配置部分**:UA 形态、API 根、门禁工具集合。 + +三件套都是移动靶(上游随时收紧),因此全部走配置/构造参数,默认值保持 +仓库实测通过的行为: + +- UA:`opencode/`;模板可用 `{version}` 占位; +- 根:`/zen/v1`(默认)或 `/inference/v1`; +- 门禁工具:默认 `bash,read`,可切成第三方实测的四件套。 + +逐字节 hash 未实现:上游当前是否校验 tools 定义 hash **未核实**(本仓 +2026-10-28 复验受免费额度 429 阻断),凭空猜 hash 只会把可用状态换成 403。 +""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from src.provider.zen.client import ( + DEFAULT_API_ROOT, + EP_CHAT, + MIN_OPENCODE_VERSION, + REQUIRED_TOOLS, + ZenClient, + _normalize_api_root, + ensure_gate_tools, + gate_headers, + prepare_body, +) +from tests.helpers.http import chunk_payload as chunk +from tests.helpers.http import sse_text_done as _sse + + +def _handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=_sse(chunk({"content": "ok"}, finish="stop"))) + + +def _client(handler, **kwargs) -> ZenClient: + transport = httpx.MockTransport(handler) + return ZenClient(stream_client=httpx.AsyncClient(transport=transport), + short_client=httpx.AsyncClient(transport=transport), + **kwargs) + + +# ------------------------------------------------------------------ UA 形态 + + +def test_default_ua_shape(): + assert gate_headers(version="2.3.4")["User-Agent"] == "opencode/2.3.4" + + +def test_ua_template_renders_version(): + headers = gate_headers(version="2.0.29", user_agent="opencode/latest/{version}/cli") + assert headers["User-Agent"] == "opencode/latest/2.0.29/cli" + + +def test_ua_without_placeholder_is_verbatim(): + headers = gate_headers(version="9.9.9", user_agent="opencode/edge") + assert headers["User-Agent"] == "opencode/edge" + + +async def test_client_sends_configured_ua(): + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers["User-Agent"]) + return _handler(request) + + client = _client(handler, version="2.0.29", + user_agent="opencode/latest/{version}/cli") + [e async for e in client.stream_chat({"messages": []}, "m")] + assert seen == ["opencode/latest/2.0.29/cli"] + + +# ------------------------------------------------------------------ API 根 + + +def test_normalize_api_root_strips_trailing_slash(): + assert _normalize_api_root("/inference/v1/") == "/inference/v1" + + +def test_normalize_api_root_defaults_for_empty(): + assert _normalize_api_root(" ") == DEFAULT_API_ROOT + + +@pytest.mark.parametrize("bad", ["zen/v1", "/zen/v1?x=1", "/zen/v1#frag", + "/zen v1"]) +def test_normalize_api_root_rejects_malformed(bad): + with pytest.raises(ValueError): + _normalize_api_root(bad) + + +async def test_client_uses_configured_root(): + paths: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + return _handler(request) + + client = _client(handler, api_root="/inference/v1") + [e async for e in client.stream_chat({"messages": []}, "m")] + assert paths == ["/inference/v1/chat/completions"] + assert EP_CHAT == "/zen/v1/chat/completions" # 默认常量不变 + + +# -------------------------------------------------------------- 门禁工具集合 + + +def test_default_gate_tools_unchanged(): + body: dict = {} + injected = ensure_gate_tools(body) + assert injected == frozenset(REQUIRED_TOOLS) + assert [t["function"]["name"] for t in body["tools"]] == list(REQUIRED_TOOLS) + + +def test_custom_gate_tools_four_piece_set(): + body: dict = {"tools": [{"type": "function", "function": {"name": "bash"}}]} + injected = ensure_gate_tools(body, ("bash", "glob", "grep", "read")) + assert injected == frozenset({"glob", "grep", "read"}) + names = [t["function"]["name"] for t in body["tools"]] + assert names == ["bash", "glob", "grep", "read"] + + +def test_prepare_body_forwards_required_tools(): + payload = {"messages": [{"role": "user", "content": "x"}]} + body, injected = prepare_body(payload, "m", ("bash", "read", "grep")) + assert injected == frozenset({"bash", "read", "grep"}) + assert body["stream"] is True + # 原 payload 不被原地改写(消息深拷、tools 只在 body 上补) + assert "tools" not in payload + + +async def test_client_sends_configured_tool_set(): + seen: list[list[str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + seen.append([t["function"]["name"] for t in payload["tools"]]) + return _handler(request) + + client = _client(handler, gate_tools=("bash", "glob", "grep", "read")) + [e async for e in client.stream_chat({"messages": []}, "m")] + assert seen == [["bash", "glob", "grep", "read"]] + + +def test_empty_gate_tools_falls_back_to_default(): + assert _client(_handler, gate_tools=()).gate_tools == REQUIRED_TOOLS + + +def test_client_defaults_match_upstream_known_state(): + client = _client(_handler) + assert client.api_root == DEFAULT_API_ROOT + assert client.version == MIN_OPENCODE_VERSION + assert client.user_agent == "" diff --git a/tests/regression/test_coverage_core.py b/tests/regression/test_coverage_core.py index 47b511b5..ee459fa9 100644 --- a/tests/regression/test_coverage_core.py +++ b/tests/regression/test_coverage_core.py @@ -50,7 +50,9 @@ def test_session_rejects_bad_claims(payload): def test_session_uses_wall_clock_when_now_omitted(): token = session_mod.create_session_token("a", "sec") - assert session_mod.verify_session_token(token, "sec") == ("a", 0) + username, epoch, issued_at = session_mod.verify_session_token(token, "sec") + assert (username, epoch) == ("a", 0) + assert isinstance(issued_at, int) # -------------------------------------------------------------------- users diff --git a/tests/regression/test_coverage_edge_response.py b/tests/regression/test_coverage_edge_response.py index 14d2cc41..ea92ca32 100644 --- a/tests/regression/test_coverage_edge_response.py +++ b/tests/regression/test_coverage_edge_response.py @@ -53,10 +53,11 @@ def test_aggregate_tool_call_fragment_when_function_missing(): result = aggregate([ Event(kind=EventKind.TOOL_CALLS, tool_calls=[{"id": "a", "index": 0}]), Event(kind=EventKind.TOOL_CALLS, - tool_calls=[{"id": "a", "index": 0, "function": {"arguments": "1"}}]), + tool_calls=[{"id": "a", "index": 0, + "function": {"arguments": '{"a": 1}'}}]), ], "m") message = result["choices"][0]["message"] - assert message["tool_calls"][0]["function"]["arguments"] == "1" + assert message["tool_calls"][0]["function"]["arguments"] == '{"a": 1}' def test_aggregate_reasoning_after_tool_calls(): """tool_calls 之后再有 reasoning(response 146 分支顺序)。""" diff --git a/tests/regression/test_coverage_edge_trae.py b/tests/regression/test_coverage_edge_trae.py index a90a27a5..32d2cb87 100644 --- a/tests/regression/test_coverage_edge_trae.py +++ b/tests/regression/test_coverage_edge_trae.py @@ -62,8 +62,8 @@ def test_ug_headers_carry_region_and_auth(): async def test_client_aclose_is_idempotent(): client = TraeClient() - client._stream() - client._short() + assert client._stream is not None # 两条池都建起来 + assert client._short is not None await client.aclose() await client.aclose() @@ -158,7 +158,7 @@ def test_close_when_only_one_client_created(): from src.provider.trae.client import TraeClient client = TraeClient() - client._stream() # 只实例化 stream 客户端 + assert client._stream is not None # 只实例化 stream 客户端 asyncio.run(client.aclose()) def test_build_login_url_missing_keys_still_builds(): diff --git a/tests/regression/test_coverage_trae.py b/tests/regression/test_coverage_trae.py index b1ed2bd4..930962b8 100644 --- a/tests/regression/test_coverage_trae.py +++ b/tests/regression/test_coverage_trae.py @@ -19,7 +19,7 @@ from src.engine.scheduler import Scheduler from src.engine.sse import iter_frames from src.main import build_app -from src.provider.base import Event, EventKind, Quota, Usage +from src.provider.base import ErrKind, Event, EventKind, Quota, Usage from src.provider.trae import events as trae_events from src.provider.trae.callback import parse_callback_url from src.provider.trae.client import TraeClient, TraeCredential, TraeProvider @@ -221,10 +221,38 @@ def _request(stream=False): "stream": stream}) -def test_executor_candidate_lookup_missing_raises(repo): +def test_candidate_or_none_falls_back_to_placeholder(repo): + """记错误路径用占位候选:凭证已被并发删除时不得抛 503 语义的异常。""" executor, _provider, _credentials = _executor(repo, [GOOD]) - with pytest.raises(NoHealthyCredential): - executor._candidate("ghost") + ghost = executor._candidate_or_none("ghost", "trae") + assert ghost.credential_id == "ghost" and ghost.provider == "trae" + + +def test_note_upstream_error_tolerates_concurrently_deleted_credential(repo): + """凭证在请求过程中被删 → 记错误只告警不冒泡(此前抛成 503 / 断流)。""" + credentials, _db = repo + credential_id = credentials.add(provider="trae", credential_data={"accessToken": "a"}) + + class _RecordingStats: + def __init__(self) -> None: + self.events: list[dict] = [] + + def record(self, **fields) -> None: + self.events.append(fields) + + stats = _RecordingStats() + provider = _ScriptedProvider([GOOD]) + executor = Executor(ExecutorDeps(providers={"trae": provider}, credentials=credentials, + scheduler=Scheduler(), default_model="glm-5.3", + stats=stats)) + credentials.delete(credential_id) # 请求进行中被管理员删掉 + + attributed = executor._note_upstream_error( + credential_id, ErrKind.DEAD, "trae", "m", + username="u", started=0.0) + # DEAD 仍按「尝试级明细」返回 True,让调用方跳过收尾行(不重复计数) + assert attributed is True + assert stats.events and stats.events[0]["error_type"] == "credential_unavailable" async def test_executor_skips_concurrently_deleted_credential(repo): @@ -250,11 +278,12 @@ def deleting(credential_id): def test_aggregate_tool_call_without_index_merges_by_generated_position(): result = aggregate([ Event(kind=EventKind.TOOL_CALLS, - tool_calls=[{"id": "x", "function": {"name": "f", "arguments": "1"}}]), + tool_calls=[{"id": "x", "function": {"name": "f", "arguments": '{"a": '}}]), Event(kind=EventKind.TOOL_CALLS, - tool_calls=[{"id": "x", "function": {"arguments": "2"}}]), + tool_calls=[{"id": "x", "function": {"arguments": "1}"}}]), ], "m") - assert result["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] == "12" + assert (result["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] + == '{"a": 1}') def test_stream_translator_tool_call_without_id_uses_next_slot(): diff --git a/tests/regression/test_hardening_admin.py b/tests/regression/test_hardening_admin.py index 44ff7420..72f0f606 100644 --- a/tests/regression/test_hardening_admin.py +++ b/tests/regression/test_hardening_admin.py @@ -4,7 +4,7 @@ import asyncio import contextlib -from pathlib import Path +import time import httpx import pytest @@ -218,6 +218,28 @@ def test_revive_credential_endpoint(client): def test_revive_unknown_credential_is_400(client): assert client.post("/api/credentials/cred_missing/revive").status_code == 400 + +def test_revive_keeps_upstream_cooldown_unless_forced(client): + """默认「恢复」保留上游点名的冷却;`force=true` 才清除(见 repo.revive)。""" + created = client.post("/api/credentials", json={ + "provider": "codebuddy", "credential": {"token": "t"}}).json()["id"] + db = client.app.state.credentials._db + future = int(time.time()) + 3600 + db.connect().execute( + "UPDATE credentials SET disabled = 1, cooling_until = ?, cooling_kind = 'soft' " + "WHERE id = ?", (future, created)) + db.connect().commit() + + client.post(f"/api/credentials/{created}/revive") + row = db.connect().execute("SELECT disabled, cooling_until FROM credentials " + "WHERE id = ?", (created,)).fetchone() + assert row["disabled"] == 0 and row["cooling_until"] == future + + client.post(f"/api/credentials/{created}/revive", json={"force": True}) + row = db.connect().execute("SELECT cooling_until FROM credentials " + "WHERE id = ?", (created,)).fetchone() + assert row["cooling_until"] is None + # ------------------------------------------------------------- #24 429 @@ -419,15 +441,48 @@ def test_dump_request_body_trims_to_keep_limit(tmp_path): remaining = list((tmp_path / "dumps").glob("*.json")) assert 0 < len(remaining) <= chat.DUMP_KEEP_FILES -def test_dump_request_body_survives_unwritable_dir(tmp_path, monkeypatch): - """诊断落盘失败不影响聊天请求。""" - from src.api import chat +def test_dump_request_body_survives_unwritable_dir(tmp_path): + """诊断落盘失败不影响聊天请求(路径不可用 → OSError 被吞掉)。 - def boom(*args, **kwargs): - raise OSError("read-only file system") + `DATA_DIR/dumps` 已被占成普通文件时 `mkdir` 抛 `FileExistsError`:诊断失败 + 绝不能把请求带崩,也不能向外抛任何异常。 + """ + from src.api import chat - monkeypatch.setattr(Path, "write_text", boom) + (tmp_path / "dumps").write_text("not a directory", encoding="utf-8") chat.dump_request_body(tmp_path, {"a": 1}) # 不应抛异常 + assert (tmp_path / "dumps").is_file() + + +def test_dump_files_are_owner_only_and_expire_after_a_day(tmp_path): + """请求体含完整对话:目录 0700、文件 0600;条数没超也要按 24h 过期清理。""" + import os + import time as _time + + from src.api import chat + + chat.dump_request_body(tmp_path, {"messages": [{"role": "user", "content": "hi"}]}) + dump_dir = tmp_path / "dumps" + written = list(dump_dir.glob("*.json")) + assert len(written) == 1 + assert dump_dir.stat().st_mode & 0o777 == 0o700 + assert written[0].stat().st_mode & 0o777 == 0o600 + + # 老版本建过 0755 的目录:下一次落盘必须把它收紧(mkdir 的 mode 只影响新建) + dump_dir.chmod(0o755) + chat.dump_request_body(tmp_path, {"a": 1}) + assert dump_dir.stat().st_mode & 0o777 == 0o700 + + # 条数没超,但超过 24h 的文件也要被清掉(按 mtime 判) + fresh = sorted(dump_dir.glob("*.json"))[-1] + old = dump_dir / "0000000000001.json" + old.write_text("old", encoding="utf-8") + _time.sleep(0) + os.utime(old, (0, 0)) # mtime 归零 = 远超 24h + chat.dump_request_body(tmp_path, {"b": 2}) + remaining = {item.name for item in dump_dir.glob("*.json")} + assert old.name not in remaining + assert fresh.name in remaining def test_dump_request_body_trims_ignoring_unlink_errors(tmp_path, monkeypatch): """淘汰旧文件时遇到错误不抛出(suppress 分支)。""" diff --git a/tests/regression/test_hardening_stream.py b/tests/regression/test_hardening_stream.py index 81185920..6970536c 100644 --- a/tests/regression/test_hardening_stream.py +++ b/tests/regression/test_hardening_stream.py @@ -84,6 +84,21 @@ async def fast(): assert [f async for f in with_keepalive(fast(), interval=5)] == [b"a", b"b"] + +@pytest.mark.asyncio +async def test_with_keepalive_uses_protocol_specific_frame(): + """心跳帧随出口协议走:Anthropic 的 ping 事件替换默认注释帧。""" + ping = b'event: ping\ndata: {"type": "ping"}\n\n' + + async def slow(): + await asyncio.sleep(0.05) + yield b"data: x\n\n" + + frames = [f async for f in with_keepalive(slow(), interval=0.01, + keepalive_frame=ping)] + assert ping in frames + assert SSE_COMMENT not in frames + @pytest.mark.asyncio async def test_stream_disconnect_records_usage(): """客户端断开时把已知用量记账(client_disconnect),不再静默丢弃。 @@ -167,8 +182,8 @@ def save_success(self, credential_id, *, model=None): async def test_stream_success_then_tail_disconnect_not_double_recorded(): """成功路径已记账后,收尾阶段的断开不再重复记账。 - 上游未发 FINISH 时 executor 补发 [DONE](finish()),正常成功记录 - 先行产生;若客户端此时断开,断连分支不得再记第二条。 + 上游发 FINISH 后 executor 收尾成帧;若客户端在 [DONE] 之后断开, + 断连分支不得再记第二条。 """ from src.engine.executor import Executor, ExecutorDeps @@ -181,6 +196,7 @@ class Provider: async def stream_chat(self, credential_data, payload, model): yield Event(kind=EventKind.CONTENT, content="a") yield Event(kind=EventKind.CONTENT, content="b") + yield Event(kind=EventKind.FINISH, finish_reason="stop") executor = Executor(ExecutorDeps( providers={"trae": Provider()}, credentials=_FakeCredentials(), diff --git a/tests/regression/test_review_fixes_backend.py b/tests/regression/test_review_fixes_backend.py new file mode 100644 index 00000000..3de3782f --- /dev/null +++ b/tests/regression/test_review_fixes_backend.py @@ -0,0 +1,359 @@ +"""全仓静态审查修掉的后端缺陷的回归测试。 + +这一组守着六条「静默出错」的语义(每条都曾真实发生): + +1. DEAD 尝试已落明细 → 轮换耗尽不再落收尾行(一次请求一行,Key 配额不双扣) +2. 管理台降级不被重启撤销(`ADMIN_USERNAMES` 只在引导期提权) +3. JSON 溢出成 inf 的 `1e999` → 400 而不是 500 +4. pin 端点:非字符串 id → 400;不存在的 id → 400 且不清掉别人的 pin +5. 国际站白名单含国内站主机 → 启动报错(跨站发送即凭证泄露) +6. 统计 `metric` 未知取值 → 400 而不是静默回落 +""" + +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from src.auth.bootstrap import bootstrap_users +from src.auth.rbac import ROLE_ADMIN, ROLE_VIEWER +from src.auth.users import create_password_hash +from src.config import Settings, validate_codebuddy_intl_endpoint_allowed +from src.db.conn import Database +from src.db.crypto import CredentialCipher +from src.db.migrate import apply_schema +from src.db.repo import CredentialRepository, UserRepository +from src.engine.executor import Executor, ExecutorDeps, NoHealthyCredential +from src.engine.scheduler import Scheduler +from src.main import build_app +from src.provider.base import ErrKind +from tests.helpers.constants import SECRET +from tests.helpers.providers import ScriptedProvider + +# ---------------------------------------------------------------- 1. DEAD 双落 + + +class _Dead(Exception): + """上游报「session 失效」的替身异常(kind = DEAD)。""" + + def kind(self) -> ErrKind: + return ErrKind.DEAD + + +class _Collector: + """记录每次 `record` 调用,便于断言「落了几行明细」。""" + + def __init__(self) -> None: + self.events: list[dict] = [] + + def record(self, **fields) -> None: + self.events.append(fields) + + +def _dead_executor(tmp_path, *, max_rotate: int = 1): + db = Database(tmp_path / "dead.sqlite3") + apply_schema(db.connect()) + credentials = CredentialRepository(db, CredentialCipher(SECRET)) + for index in range(3): + credentials.add(provider="trae", credential_data={"accessToken": f"a{index}"}) + collector = _Collector() + executor = Executor(ExecutorDeps( + providers={"trae": ScriptedProvider([[_Dead()]])}, credentials=credentials, + scheduler=Scheduler(max_rotate=max_rotate), default_model="glm-5.2", + stats=collector)) + return executor, collector, db + + +async def test_dead_exhaustion_records_single_row(tmp_path): + """DEAD 尝试行已落 → 轮换耗尽不再落收尾行(一次请求一行明细)。""" + from tests.helpers.chat import chat_request + + executor, collector, db = _dead_executor(tmp_path) + try: + with pytest.raises(NoHealthyCredential): + await executor.complete(chat_request()) + finally: + db.close() + assert [event["error_type"] for event in collector.events] == [ + "credential_unavailable"] + + +async def test_dead_exhaustion_does_not_double_count_requests(tmp_path): + """Key 配额按明细行数计:双落会被扣两次日请求额度。""" + from tests.helpers.chat import chat_request + + executor, collector, db = _dead_executor(tmp_path) + try: + with pytest.raises(NoHealthyCredential): + await executor.complete(chat_request()) + finally: + db.close() + assert len(collector.events) == 1 + + +async def test_stream_dead_exhaustion_records_single_row(tmp_path): + """流式路径同口径:DEAD 已落尝试行 → 选号耗尽不再落收尾行。""" + from tests.helpers.chat import chat_request + + db = Database(tmp_path / "dead-stream.sqlite3") + apply_schema(db.connect()) + credentials = CredentialRepository(db, CredentialCipher(SECRET)) + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + collector = _Collector() + executor = Executor(ExecutorDeps( + providers={"trae": ScriptedProvider([[_Dead()]])}, credentials=credentials, + scheduler=Scheduler(max_rotate=1), default_model="glm-5.2", stats=collector)) + try: + chunks = [chunk async for chunk in executor.stream(chat_request())] + finally: + db.close() + assert chunks # 以错误帧收尾,不是静默断开 + assert [event["error_type"] for event in collector.events] == [ + "credential_unavailable"] + + +# ------------------------------------------------------- 2. 重启撤销管理台降级 + + +class _Config: + """bootstrap 只用到 `admin_set` 一个属性的最小桩。""" + + def __init__(self, admins=()) -> None: + self.admin_set = frozenset(admins) + + +def test_bootstrap_does_not_repromote_when_active_admin_exists(tmp_path): + """库里有活跃 admin → env 不再改写任何人的角色(降级不被重启撤销)。""" + db = Database(tmp_path / "boot.sqlite3") + apply_schema(db.connect()) + repo = UserRepository(db) + repo.create("root", create_password_hash("a"), role=ROLE_ADMIN, now=1) + repo.create("boss", create_password_hash("b"), role=ROLE_ADMIN, now=1) + repo.update_role("root", ROLE_VIEWER, bump_epoch=True) + + bootstrap_users(_Config(["root"]), repo) + + assert repo.get("root")["role"] == ROLE_VIEWER + db.close() + + +def test_bootstrap_promotes_during_bootstrap_period(tmp_path): + """引导期(库里没有活跃 admin)仍按 env 提权:老部署升级的出路。""" + db = Database(tmp_path / "boot2.sqlite3") + apply_schema(db.connect()) + repo = UserRepository(db) + repo.create("root", create_password_hash("a"), role=ROLE_VIEWER, now=1) + + bootstrap_users(_Config(["root"]), repo) + + assert repo.get("root")["role"] == ROLE_ADMIN + db.close() + + +def test_restart_keeps_console_downgrade(admin_client, tmp_path): + """端到端:管理台降级 → 重启 → 角色仍是降级后的值。""" + app, _client = admin_client + app.state.user_repo.create("boss", create_password_hash("bosspw"), + role=ROLE_ADMIN, now=1) + app.state.user_repo.update_role("root", ROLE_VIEWER, bump_epoch=True) + + restarted = build_app(app.state.settings) + + assert restarted.state.user_repo.get("root")["role"] == ROLE_VIEWER + + +# ------------------------------------------------------------- 3. 1e999 → 400 + + +def _app_with_key(admin_client): + app, _client = admin_client + app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + return app, app.state.api_keys.create("root")["api_key"] + + +def _chat_app(tmp_path): + """装配一个带 trae provider 的 app + API Key(聊天请求要有真上游才 200)。""" + from tests.helpers.events import TRAE_GOOD + + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root") + app = build_app(settings, providers={"trae": ScriptedProvider([TRAE_GOOD])}) + app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + return app, app.state.api_keys.create("root")["api_key"] + + +def _chat_with_key(app, key, body: str): + """发一次聊天请求(用装配好的 trae provider,返回响应)。""" + with TestClient(app, raise_server_exceptions=False) as client: + return client.post("/v1/chat/completions", + headers={"Authorization": f"Bearer {key}"}, + content=body) + + +def test_json_overflow_number_is_400_not_500(tmp_path, monkeypatch): + """`1e999` 解析成 inf:应 400,不能走到 httpx 序列化炸成 500。""" + app, key = _chat_app(tmp_path) + response = _chat_with_key(app, key, + ('{"messages":[{"role":"user","content":"hi"}],' + '"model":"glm-5.2","extra_num":1e999}')) + assert response.status_code == 400 + assert response.json()["error"]["code"] == "invalid_request" + + +def test_json_overflow_in_nested_array_is_400(tmp_path, monkeypatch): + """嵌套数组里的 inf 同样拦(逐字段校验覆盖不到的位置)。""" + app, key = _chat_app(tmp_path) + response = _chat_with_key(app, key, + ('{"messages":[{"role":"user","content":"hi"}],' + '"model":"glm-5.2","extra":[1,2e999]}')) + assert response.status_code == 400 + + +def test_finite_exponent_number_still_accepted(tmp_path, monkeypatch): + """正常的浮点指数(1e3)不受影响:拦的是溢出,不是小数。""" + app, key = _chat_app(tmp_path) + response = _chat_with_key(app, key, json.dumps( + {"messages": [{"role": "user", "content": "hi"}], + "model": "glm-5.2", "extra": 1e3})) + assert response.status_code == 200 + + +# --------------------------------------------------------------- 4. pin 端点 + + +def test_pin_rejects_non_string_credential_id(admin_client): + """dict / list 形态的 id → 400(此前走到 sqlite 参数绑定 → 500)。""" + app, client = admin_client + for bad in ({"a": 1}, ["a"], 1, True): + response = client.post("/api/credentials/pin", json={"credential_id": bad}) + assert response.status_code == 400, bad + assert response.json()["error"]["code"] == "invalid_request" + + +def test_pin_unknown_credential_is_400_and_keeps_existing_pin(admin_client): + """不存在的 id → 400,且**不清掉**别的凭证已有的 pin。""" + app, client = admin_client + app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + real = app.state.credentials.candidates()[0].credential_id + assert client.post("/api/credentials/pin", + json={"credential_id": real}).status_code == 200 + + response = client.post("/api/credentials/pin", + json={"credential_id": "no-such-id"}) + + assert response.status_code == 400 + pinned = [c.credential_id for c in app.state.credentials.candidates() if c.pinned] + assert pinned == [real] + + +def test_pin_none_clears_pin(admin_client): + """`credential_id: null` = 取消指定,仍然合法。""" + app, client = admin_client + app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"}) + real = app.state.credentials.candidates()[0].credential_id + client.post("/api/credentials/pin", json={"credential_id": real}) + assert client.post("/api/credentials/pin", + json={"credential_id": None}).status_code == 200 + assert [c for c in app.state.credentials.candidates() if c.pinned] == [] + + +def test_set_pinned_returns_false_for_unknown_id(tmp_path): + """仓储层:目标不存在时返回 False 且不动既有 pin。""" + db = Database(tmp_path / "pin.sqlite3") + apply_schema(db.connect()) + credentials = CredentialRepository(db, CredentialCipher(SECRET)) + credentials.add(provider="trae", credential_data={"accessToken": "a"}) + real = credentials.candidates()[0].credential_id + assert credentials.set_pinned(real) is True + assert credentials.set_pinned("nope") is False + assert [c.credential_id for c in credentials.candidates() if c.pinned] == [real] + db.close() + + +# ------------------------------------------------------ 5. 国际站白名单跨站校验 + + +def test_intl_whitelist_rejects_cn_host(): + """国际站白名单里含国内站主机 → 一律拒绝(跨站发送即凭证泄露)。""" + settings = Settings( + _env_file=None, APP_SECRET=SECRET, + CODEBUDDY_INTL_ALLOWED_ENDPOINTS="https://copilot.tencent.com") + assert validate_codebuddy_intl_endpoint_allowed( + "https://copilot.tencent.com", settings) is False + + +def test_intl_whitelist_rejects_cn_host_with_port_and_path(): + """带端口/路径的国内站地址同样要拦住(比主机名而非整串)。""" + settings = Settings( + _env_file=None, APP_SECRET=SECRET, + CODEBUDDY_INTL_ALLOWED_ENDPOINTS="https://copilot.tencent.com:443/api") + assert validate_codebuddy_intl_endpoint_allowed( + "https://copilot.tencent.com:443/api", settings) is False + + +def test_intl_whitelist_allows_intl_host(): + """正常配置不受影响。""" + settings = Settings( + _env_file=None, APP_SECRET=SECRET, + CODEBUDDY_INTL_ALLOWED_ENDPOINTS="https://www.codebuddy.ai") + assert validate_codebuddy_intl_endpoint_allowed( + "https://www.codebuddy.ai", settings) is True + + +def test_intl_whitelist_with_cn_host_fails_startup(tmp_path, monkeypatch): + """白名单填国内站 → 启动报错(不静默放行)。 + + 先建好用户再触发启动:端点解析发生在用户引导之前,报的是配置错 + (`ValueError`)而不是「没有可用用户」(`UsersFileError`)。 + """ + monkeypatch.setenv("USERS_FILE", str(tmp_path / "none.txt")) + settings = Settings( + _env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path), + ADMIN_USERNAMES="root", + CODEBUDDY_INTL_ALLOWED_ENDPOINTS="https://copilot.tencent.com") + db = Database(settings.db_path) + apply_schema(db.connect()) + UserRepository(db).create("root", create_password_hash("pw"), + role=ROLE_ADMIN, now=1) + db.close() + + with pytest.raises(ValueError, match="CODEBUDDY_INTL_API_ENDPOINT"): + build_app(settings) + + +# ------------------------------------------------------------ 6. metric 取值域 + + +def test_stats_timeline_rejects_unknown_metric(admin_client): + """未知 metric → 400(此前静默回落 requests,错误被吞掉)。""" + _app, client = admin_client + response = client.get("/api/stats/timeline", params={"metric": "bogus"}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == "invalid_request" + + +def test_stats_model_timeline_rejects_unknown_metric(admin_client): + _app, client = admin_client + response = client.get("/api/stats/model-timeline", params={"metric": "bogus"}) + assert response.status_code == 400 + + +def test_stats_percentile_timeline_rejects_non_latency_metric(admin_client): + """分位时序只认 latency / ttfb:其它 metric → 400。""" + _app, client = admin_client + response = client.get("/api/stats/percentile-timeline", + params={"metric": "requests"}) + assert response.status_code == 400 + ok = client.get("/api/stats/percentile-timeline", params={"metric": "ttfb"}) + assert ok.status_code == 200 + + +def test_stats_timeline_accepts_all_known_metrics(admin_client): + """取值域内的 metric 全部放行。""" + _app, client = admin_client + for metric in ("requests", "tokens", "cost", "latency", "ttfb", + "success_rate", "cache_rate", "cost_per_request"): + response = client.get("/api/stats/timeline", params={"metric": metric}) + assert response.status_code == 200, metric diff --git a/tests/scripts/test_cleanup_invalid_stats.py b/tests/scripts/test_cleanup_invalid_stats.py index 10e56b76..b4be671f 100644 --- a/tests/scripts/test_cleanup_invalid_stats.py +++ b/tests/scripts/test_cleanup_invalid_stats.py @@ -48,7 +48,7 @@ def test_stale_only_when_other_provider_succeeded(db): collector.record(username="u", provider="trae", model="solo", ok=False, error_type="upstream_error") # 非 invalid → 不删 stale = CLEANUP.stale_groups(collector._db.connect()) - assert [(p, m, c) for p, m, c in stale] == [("trae", "solo", 1)] + assert [(p, m, c) for p, m, c, _low, _high in stale] == [("trae", "solo", 1)] def test_delete_and_sync_hourly_aligns_aggregate(db): @@ -58,9 +58,10 @@ def test_delete_and_sync_hourly_aligns_aggregate(db): error_type="invalid_request") collector.record(username="u", provider="codebuddy", model="solo", ok=True) conn = database.connect() - assert CLEANUP.delete_stale(conn, CLEANUP.stale_groups(conn)) == 1 + stale = CLEANUP.stale_groups(conn) + assert CLEANUP.delete_stale(conn, stale) == 1 conn.commit() - CLEANUP.sync_hourly(conn) + CLEANUP.sync_hourly(conn, CLEANUP._deleted_hours(stale)) rows = [tuple(r) for r in conn.execute( "SELECT provider, model, requests FROM usage_hourly ORDER BY provider")] assert rows == [("codebuddy", "solo", 1)] diff --git a/tests/scripts/test_cleanup_stale_blocked.py b/tests/scripts/test_cleanup_stale_blocked.py new file mode 100644 index 00000000..4ec9cb61 --- /dev/null +++ b/tests/scripts/test_cleanup_stale_blocked.py @@ -0,0 +1,193 @@ +"""scripts/cleanup_stale_blocked.py 的判定与删除测试。 + +背景(2026-10-26 实测):`deepseek-v4.1-flash-1017` 只有 officeAce 的 +`/v2/models` 挂着,探活判死后请求扇出到 codebuddy / codearts / +codebuddy_intl,三家各回「无此模型」→ 8 条负缓存写在了**从没有该模型**的 +渠道上。负缓存的语义是「该后端无此模型」,前提是该后端曾被问过这个模型; +目录里没有 → 这条缓存拦不到任何东西 → 是噪声。 +""" + +from __future__ import annotations + +import json +import sqlite3 +import time +from pathlib import Path + +import pytest + +from src.db.conn import Database +from src.db.migrate import apply_schema +from tests.helpers.paths import load_script + +CLEAN = load_script("cleanup_stale_blocked") + + +@pytest.fixture() +def db(tmp_path): + database = Database(tmp_path / "t.sqlite3") + apply_schema(database.connect()) + yield database + database.close() + + +def _credential(conn, provider: str) -> str: + conn.execute( + "INSERT INTO credentials (id, provider, data_enc, enabled, disabled, created_at) " + "VALUES (?, ?, ?, 1, 0, ?)", (f"cred_{provider}", provider, b"{}", int(time.time()))) + return f"cred_{provider}" + + +def _blocked(conn, credential_id: str, model: str, *, + until: int | None = None, reason: str = "blocked") -> None: + conn.execute( + "INSERT INTO credential_model_cooldowns " + "(credential_id, model, cooling_until, hits, reason) VALUES (?,?,?,?,?)", + (credential_id, model, until if until is not None else int(time.time()) + 3600, + 1, reason)) + + +def _catalog(data_dir: Path, tables: dict[str, list[str]]) -> None: + payload = {"version": 1, "providers": { + provider_id: {"saved_at": time.time(), + "models": [{"id": model_id} for model_id in models]} + for provider_id, models in tables.items()}} + (data_dir / "model_catalog.json").write_text( + json.dumps(payload), encoding="utf-8") + + +def test_stale_rows_only_when_channel_never_had_the_model(db, tmp_path): + """目录里没有该模型的渠道 → 负缓存是扇出噪声;目录里有的 → 保留。""" + conn = db.connect() + _credential(conn, "officeace") + _credential(conn, "codebuddy") + _blocked(conn, "cred_officeace", "deepseek-v4.1-flash-1017") + _blocked(conn, "cred_codebuddy", "deepseek-v4.1-flash-1017") + conn.commit() + _catalog(tmp_path, {"officeace": ["deepseek-v4.1-flash-1017"], + "codebuddy": ["deepseek-v4.1-flash"]}) + + tables = CLEAN._catalog_models(tmp_path) + stale = CLEAN.stale_rows(conn, tables) + assert [(row[0], row[1]) for row in stale] == [ + ("codebuddy", "deepseek-v4.1-flash-1017")] + + +def test_expired_and_non_blocked_rows_are_ignored(db, tmp_path): + """已过期的负缓存与其它 reason(模型限流)都不是本脚本的目标。""" + conn = db.connect() + _credential(conn, "codebuddy") + _blocked(conn, "cred_codebuddy", "gone", until=int(time.time()) - 1) + _blocked(conn, "cred_codebuddy", "limited", reason="model") + conn.commit() + _catalog(tmp_path, {"codebuddy": ["other"]}) + + stale = CLEAN.stale_rows(conn, CLEAN._catalog_models(tmp_path)) + assert stale == [] + + +def test_channels_absent_from_catalog_are_kept(db, tmp_path): + """目录快照里没有该渠道 → 无从判断,保守保留(不删)。""" + conn = db.connect() + _credential(conn, "kilo") + _blocked(conn, "cred_kilo", "m") + conn.commit() + _catalog(tmp_path, {"officeace": ["m"]}) + + stale = CLEAN.stale_rows(conn, CLEAN._catalog_models(tmp_path)) + assert stale == [] + + +def test_include_known_also_returns_catalogued_models(db, tmp_path): + """`--all` 连「目录里登记过」的也返回(上游已恢复时强制清理)。""" + conn = db.connect() + _credential(conn, "officeace") + _blocked(conn, "cred_officeace", "m") + conn.commit() + _catalog(tmp_path, {"officeace": ["m"]}) + + tables = CLEAN._catalog_models(tmp_path) + assert CLEAN.stale_rows(conn, tables) == [] + known = CLEAN.stale_rows(conn, tables, include_known=True) + assert [(row[0], row[1]) for row in known] == [("officeace", "m")] + + +def test_delete_rows_removes_only_targeted_entries(db, tmp_path): + """删除只命中待清理的行,其余负缓存原样保留。""" + conn = db.connect() + _credential(conn, "officeace") + _credential(conn, "codebuddy") + _blocked(conn, "cred_officeace", "m-ok") + _blocked(conn, "cred_codebuddy", "m-stale") + conn.commit() + _catalog(tmp_path, {"officeace": ["m-ok"], "codebuddy": ["other"]}) + + stale = CLEAN.stale_rows(conn, CLEAN._catalog_models(tmp_path)) + assert CLEAN.delete_rows(conn, stale) == 1 + conn.commit() + remaining = [row[0] for row in conn.execute( + "SELECT model FROM credential_model_cooldowns")] + assert remaining == ["m-ok"] + + +def test_missing_or_broken_catalog_deletes_nothing(db, tmp_path, capsys): + """没有可读目录快照 → 无判据,一律不删(绝不能把负缓存清空)。""" + conn = db.connect() + _credential(conn, "codebuddy") + _blocked(conn, "cred_codebuddy", "m") + conn.commit() + db_path = Path(db.path) + db.close() + + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path)]) == 0 + assert "无判据,不删任何行" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + + # 快照损坏:同样不删 + (tmp_path / "model_catalog.json").write_text("{broken", encoding="utf-8") + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path)]) == 0 + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + + +def test_main_preview_and_apply(db, tmp_path, capsys): + """预览不动库;`--apply` 删除并留备份。""" + conn = db.connect() + _credential(conn, "codebuddy") + _blocked(conn, "cred_codebuddy", "m-stale") + conn.commit() + _catalog(tmp_path, {"codebuddy": ["other"]}) + db_path = Path(db.path) + db.close() + + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path)]) == 0 + assert "共 1 条负缓存" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 1 + + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path), + "--apply"]) == 0 + assert "已删除 1 条负缓存" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM credential_model_cooldowns").fetchone()[0] == 0 + assert list(tmp_path.glob("*.bak-*")), "apply 前必须留下备份" + + +def test_main_reports_nothing_to_clean(db, tmp_path, capsys): + conn = db.connect() + _credential(conn, "codebuddy") + _blocked(conn, "cred_codebuddy", "m") + conn.commit() + _catalog(tmp_path, {"codebuddy": ["m"]}) + db_path = Path(db.path) + db.close() + + assert CLEAN.main(["--db", str(db_path), "--data-dir", str(tmp_path)]) == 0 + assert "没有需要清理的负缓存" in capsys.readouterr().out + + +def test_main_rejects_missing_db(db, tmp_path): + db.close() + with pytest.raises(SystemExit): + CLEAN.main(["--db", str(tmp_path / "nope.sqlite3")]) diff --git a/tests/scripts/test_common_backup.py b/tests/scripts/test_common_backup.py new file mode 100644 index 00000000..479dafb3 --- /dev/null +++ b/tests/scripts/test_common_backup.py @@ -0,0 +1,96 @@ +"""`scripts/_common.py`:运维脚本共享的写库前备份与保留策略。 + +这些脚本不在 `--cov=src` 范围内,但备份保留策略曾让本机 `data/` 涨到 +870MB(28 份 `.bak-*` 从不清),所以行为必须有测试兜住。 +""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + +from tests.helpers.paths import load_script + +COMMON = load_script("_common") + + +def _make_db(path: Path, value: str = "v") -> Path: + conn = sqlite3.connect(str(path)) + conn.execute("CREATE TABLE t (x TEXT)") + conn.execute("INSERT INTO t VALUES (?)", (value,)) + conn.commit() + conn.close() + return path + + +def test_backup_creates_snapshot_with_rows(tmp_path): + """在线备份 API 落出的快照可独立打开且含数据。""" + db_path = _make_db(tmp_path / "coding2api.sqlite3", "hello") + target = COMMON.backup(db_path) + assert target.exists() + assert target.name.startswith("coding2api.sqlite3.bak-") + conn = sqlite3.connect(str(target)) + try: + assert conn.execute("SELECT x FROM t").fetchone() == ("hello",) + finally: + conn.close() + + +def test_backup_prunes_to_keep(tmp_path): + """默认只留最近 5 份:一次性脚本每跑一次落 40MB+,不清会撑爆 data/。""" + db_path = _make_db(tmp_path / "coding2api.sqlite3") + for index in range(8): + stale = tmp_path / f"coding2api.sqlite3.bak-2026010{index + 1}-000000" + stale.write_text("old", encoding="utf-8") + # mtime 递增,保证「最近 5 份」是确定的 + time.sleep(0.01) + COMMON.backup(db_path, keep=5) + remaining = sorted(tmp_path.glob("coding2api.sqlite3.bak-*")) + assert len(remaining) == 5 + + +def test_backup_keep_zero_disables_pruning(tmp_path): + """`keep <= 0` = 不清理(保留全部,给需要人工比对的场景)。""" + db_path = _make_db(tmp_path / "coding2api.sqlite3") + for index in range(3): + (tmp_path / f"coding2api.sqlite3.bak-2026010{index + 1}-000000").write_text( + "old", encoding="utf-8") + COMMON.backup(db_path, keep=0) + assert len(list(tmp_path.glob("coding2api.sqlite3.bak-*"))) == 4 + + +def test_prune_ignores_unlink_failure(tmp_path, monkeypatch): + """清理失败不阻断备份:权限/竞态导致的 unlink 失败只跳过。""" + db_path = _make_db(tmp_path / "coding2api.sqlite3") + for index in range(3): + (tmp_path / f"coding2api.sqlite3.bak-2026010{index + 1}-000000").write_text( + "old", encoding="utf-8") + + real_unlink = Path.unlink + + def boom(self, *args, **kwargs): + raise OSError("denied") + + monkeypatch.setattr(Path, "unlink", boom) + try: + COMMON._prune_backups(db_path, keep=1) + finally: + monkeypatch.setattr(Path, "unlink", real_unlink) + # 没删掉也不抛;备份本身已经完成 + assert len(list(tmp_path.glob("coding2api.sqlite3.bak-*"))) == 3 + + +def test_prune_survives_stat_failure(tmp_path, monkeypatch): + """glob 后 stat 失败(文件被并发删除)时静默返回,不冒泡。""" + db_path = _make_db(tmp_path / "coding2api.sqlite3") + real_stat = Path.stat + + def boom(self, *args, **kwargs): + raise OSError("gone") + + monkeypatch.setattr(Path, "stat", boom) + try: + COMMON._prune_backups(db_path, keep=1) + finally: + monkeypatch.setattr(Path, "stat", real_stat) diff --git a/tests/scripts/test_create_user.py b/tests/scripts/test_create_user.py index ad3f842e..2b2e6f4e 100644 --- a/tests/scripts/test_create_user.py +++ b/tests/scripts/test_create_user.py @@ -94,11 +94,21 @@ def test_password_stdin_empty_rejected(db_path, monkeypatch): def test_username_with_control_chars_rejected(db_path): - """换行/回车会污染审计与日志,create_user 拒绝(冒号在 DB 用户名里合法)。""" + """换行/回车会污染审计与日志,create_user 拒绝。""" with pytest.raises(SystemExit): SCRIPT_MODULE.main(["a\nb", "--password", "pw", "--db", str(db_path)]) +def test_username_with_colon_rejected(db_path): + """冒号也拒绝:用户名会写进 users.txt 的 `用户名:hash` 行,冒号会截断字段。 + + 规则本体在 `src/auth/users.py`(与写 users.txt 的 `hash_password.py` 同源), + PROPOSAL §6「拒绝含 `:` / 换行的用户名」。 + """ + with pytest.raises(SystemExit): + SCRIPT_MODULE.main(["a:b", "--password", "pw", "--db", str(db_path)]) + + def test_delete_requires_force(db_path): SCRIPT_MODULE.main(["alice", "--password", "pw", "--db", str(db_path)]) with pytest.raises(SystemExit): diff --git a/tests/scripts/test_purge_failure_stats.py b/tests/scripts/test_purge_failure_stats.py new file mode 100644 index 00000000..82a695f1 --- /dev/null +++ b/tests/scripts/test_purge_failure_stats.py @@ -0,0 +1,176 @@ +"""scripts/purge_failure_stats.py 的过滤、删除与汇总同步测试。""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + +import pytest + +from src.db.conn import Database +from src.db.migrate import apply_schema +from src.stats.collector import StatsCollector +from tests.helpers.paths import load_script + +PURGE = load_script("purge_failure_stats") + + +@pytest.fixture() +def db(tmp_path): + database = Database(tmp_path / "t.sqlite3") + apply_schema(database.connect()) + yield database, StatsCollector(database) + database.close() + + +def _seed(collector: StatsCollector) -> None: + """两条 qoder 失败 + 一条 zen 失败 + 一条成功(成功行永不被删)。""" + collector.record(username="u", provider="qoder", model="m", ok=False, + error_type="invalid_request") + collector.record(username="u", provider="qoder", model="m", ok=False, + error_type="invalid_request") + collector.record(username="u", provider="zen", model="m", ok=False, + error_type="upstream_error") + collector.record(username="u", provider="qoder", model="m", ok=True) + + +def test_failure_groups_group_and_order(db): + """分组口径:provider+model+error_type,按条数降序。""" + _database, collector = db + _seed(collector) + groups = PURGE.failure_groups(collector._db.connect()) + assert [(g[0], g[2], g[3]) for g in groups] == [ + ("qoder", "invalid_request", 2), ("zen", "upstream_error", 1)] + + +def test_filters_narrow_by_provider_and_error_type(db): + """过滤条件只能收窄,成功行永不进入待删集合。""" + _database, collector = db + _seed(collector) + conn = collector._db.connect() + assert [g[3] for g in PURGE.failure_groups(conn, provider="qoder")] == [2] + assert [g[3] for g in PURGE.failure_groups(conn, error_type="upstream_error")] == [1] + assert PURGE.failure_groups(conn, provider="trae") == [] + + +def test_days_filter_uses_window(db): + """`--days` 只覆盖窗口内的失败明细。""" + _database, collector = db + conn = collector._db.connect() + old = int(time.time()) - 3 * 86400 + conn.execute( + "INSERT INTO usage_events (id, ts, username, provider, model, ok, error_type) " + "VALUES ('old', ?, 'u', 'qoder', 'm', 0, 'invalid_request')", (old,)) + conn.commit() + assert PURGE.failure_groups(conn, days=1) == [] + assert [g[3] for g in PURGE.failure_groups(conn, days=7)] == [1] + + +def test_delete_groups_keeps_success_rows(db): + """删除只命中 ok=0,成功明细与其它渠道保持原样。""" + database, collector = db + _seed(collector) + conn = database.connect() + assert PURGE.delete_groups(conn, provider="qoder") == 2 + conn.commit() + remaining = [tuple(r) for r in conn.execute( + "SELECT provider, ok FROM usage_events ORDER BY provider")] + assert remaining == [("qoder", 1), ("zen", 0)] + + +def test_sync_hourly_aligns_aggregate(db): + """删完重算:该小时 requests 下降,孤儿小时行被清掉。""" + database, collector = db + _seed(collector) + conn = database.connect() + hours = PURGE.hour_range(conn, "ok = 0", []) + PURGE.delete_groups(conn, provider="qoder", error_type="invalid_request") + conn.commit() + PURGE.sync_hourly(conn, hours) + rows = [tuple(r) for r in conn.execute( + "SELECT provider, requests, ok_count FROM usage_hourly ORDER BY provider")] + assert rows == [("qoder", 1, 1), ("zen", 1, 0)] + + +def test_sync_hourly_keeps_permanent_hours_outside_deleted_range(db): + """删除区间的孤儿清理不能碰历史小时:90 天前明细已被 retention 删掉, + 那些小时行是永久汇总(PROPOSAL §6),无条件清孤儿会整批删掉。""" + database, collector = db + _seed(collector) + old = int(time.time()) - 200 * 86400 + collector.record(username="u", provider="qoder", model="m", ok=True, now=old) + collector.rollup_hourly() + conn = database.connect() + collector.purge_expired(90) # 模拟保留期任务:明细没了,汇总还在 + old_hour = (old // 3600) * 3600 + assert conn.execute("SELECT COUNT(*) FROM usage_hourly WHERE hour_utc = ?", + (old_hour,)).fetchone()[0] == 1 + + hours = PURGE.hour_range(conn, "ok = 0", []) + PURGE.delete_groups(conn, provider="qoder") + conn.commit() + PURGE.sync_hourly(conn, hours) + assert conn.execute("SELECT COUNT(*) FROM usage_hourly WHERE hour_utc = ?", + (old_hour,)).fetchone()[0] == 1 + + +def test_main_preview_and_apply(db, tmp_path, capsys): + database, collector = db + _seed(collector) + db_path = Path(database.path) + database.close() + + assert PURGE.main(["--db", str(db_path), "--provider", "qoder"]) == 0 + assert "共 2 条失败明细" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM usage_events").fetchone()[0] == 4 + + assert PURGE.main(["--db", str(db_path), "--provider", "qoder", "--apply"]) == 0 + assert "已删除 2 条明细" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM usage_events").fetchone()[0] == 2 + assert list(tmp_path.glob("*.bak-*")), "apply 前必须留下备份" + + +def test_main_all_flag_purges_every_failure(db, capsys): + database, collector = db + _seed(collector) + db_path = Path(database.path) + database.close() + assert PURGE.main(["--db", str(db_path), "--all", "--apply", "--yes"]) == 0 + assert "已删除 3 条明细" in capsys.readouterr().out + assert sqlite3.connect(str(db_path)).execute( + "SELECT COUNT(*) FROM usage_events WHERE ok = 0").fetchone()[0] == 0 + + +def test_main_reports_nothing_to_clean(db, capsys): + database, _collector = db + db_path = Path(database.path) + database.close() + assert PURGE.main(["--db", str(db_path), "--all"]) == 0 + assert "没有需要清理的记录" in capsys.readouterr().out + + +def test_main_requires_filter_and_rejects_missing_db(db, tmp_path): + database, _collector = db + db_path = Path(database.path) + database.close() + with pytest.raises(SystemExit): + PURGE.main(["--db", str(db_path)]) # 没给条件 → 拒绝 + with pytest.raises(SystemExit): + PURGE.main(["--db", str(db_path), "--days", "0"]) # 非正窗口 → 拒绝 + with pytest.raises(SystemExit): + PURGE.main(["--db", str(tmp_path / "nope.sqlite3"), "--all"]) + + +def test_main_all_requires_yes_and_rejects_narrowing_flags(db): + """`--all` 是「无条件删全部」:必须 --yes 二次确认,且不能叠加收窄条件。""" + database, collector = db + _seed(collector) + db_path = Path(database.path) + database.close() + with pytest.raises(SystemExit): + PURGE.main(["--db", str(db_path), "--all", "--apply"]) # 缺 --yes + with pytest.raises(SystemExit): + PURGE.main(["--db", str(db_path), "--all", "--days", "7"]) # 静默收窄 diff --git a/tests/scripts/test_rotate_app_secret.py b/tests/scripts/test_rotate_app_secret.py new file mode 100644 index 00000000..9e4353ec --- /dev/null +++ b/tests/scripts/test_rotate_app_secret.py @@ -0,0 +1,157 @@ +"""scripts/rotate_app_secret.py:APP_SECRET 轮换与凭证重加密。 + +`CredentialCipher` 从 APP_SECRET 派生 Fernet 密钥(无盐 SHA-256),换密钥 +等于换掉全部凭证的加密键——不重加密就只能重录。本脚本是那条一次性运维路径: +全解 → 全校验 → 单事务重写,任何一条失败整体回滚。 +""" + +from __future__ import annotations + +import json +import sqlite3 + +import pytest + +from src.db.crypto import CredentialCipher +from tests.helpers.paths import load_script + +SCRIPT = load_script("rotate_app_secret") + +OLD = "old-secret-0123456789abc" +NEW = "new-secret-0123456789xyz" + + +def _db_with_credentials(tmp_path, count: int = 2): + db_path = tmp_path / "coding2api.sqlite3" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE credentials (id TEXT PRIMARY KEY, data_enc BLOB NOT NULL)") + cipher = CredentialCipher(OLD) + for index in range(count): + payload = json.dumps({"accessToken": f"tok{index}"}).encode() + conn.execute("INSERT INTO credentials (id, data_enc) VALUES (?, ?)", + (f"c{index}", cipher.encrypt(payload))) + conn.commit() + conn.close() + return db_path + + +def _rows(db_path): + conn = sqlite3.connect(db_path) + try: + return dict(conn.execute("SELECT id, data_enc FROM credentials").fetchall()) + finally: + conn.close() + + +def test_rotate_rewrites_all_rows_with_new_secret(tmp_path): + db_path = _db_with_credentials(tmp_path, count=3) + assert SCRIPT.rotate(db_path, OLD, NEW) == 3 + new_cipher = CredentialCipher(NEW) + for key, ciphertext in _rows(db_path).items(): + expected = json.dumps({"accessToken": f"tok{key[1:]}"}).encode() + assert new_cipher.decrypt(ciphertext) == expected + # 旧密钥再也解不开 + from src.db.crypto import CredentialDecryptError + + for ciphertext in _rows(db_path).values(): + with pytest.raises(CredentialDecryptError): + CredentialCipher(OLD).decrypt(ciphertext) + + +def test_rotate_is_idempotent_in_shape_but_uses_fresh_tokens(tmp_path): + """重加密会生成新密文(Fernet 带随机 IV),但明文不变。""" + db_path = _db_with_credentials(tmp_path, count=1) + before = _rows(db_path)["c0"] + SCRIPT.rotate(db_path, OLD, NEW) + after = _rows(db_path)["c0"] + assert before != after + assert CredentialCipher(NEW).decrypt(after) == CredentialCipher(OLD).decrypt(before) + + +def test_rotate_aborts_without_writing_when_any_row_fails(tmp_path): + """任一行解不开就整体中止,库保持原样(不能留下新旧混杂的库)。""" + db_path = _db_with_credentials(tmp_path, count=2) + # 混入一条用第三方密钥加密的行 + conn = sqlite3.connect(db_path) + conn.execute("INSERT INTO credentials (id, data_enc) VALUES (?, ?)", + ("bad", CredentialCipher("other-secret-0123456789").encrypt(b'{"x": 1}'))) + conn.commit() + conn.close() + before = _rows(db_path) + with pytest.raises(SystemExit) as caught: + SCRIPT.rotate(db_path, OLD, NEW) + assert "bad" in str(caught.value) + assert _rows(db_path) == before # 一行都没改 + + +def test_rotate_rejects_ciphertext_that_is_not_json(tmp_path): + """解密成功但不是 JSON:同样视为坏行,整体中止。""" + db_path = tmp_path / "db.sqlite3" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE credentials (id TEXT PRIMARY KEY, data_enc BLOB NOT NULL)") + conn.execute("INSERT INTO credentials (id, data_enc) VALUES (?, ?)", + ("c0", CredentialCipher(OLD).encrypt(b"not-json"))) + conn.commit() + conn.close() + before = _rows(db_path) + with pytest.raises(SystemExit) as caught: + SCRIPT.rotate(db_path, OLD, NEW) + assert "合法 JSON" in str(caught.value) + assert _rows(db_path) == before + + +def test_rotate_rejects_same_secret(tmp_path): + db_path = _db_with_credentials(tmp_path, count=1) + with pytest.raises(SystemExit) as caught: + SCRIPT.rotate(db_path, OLD, OLD) + assert "相同" in str(caught.value) + + +def test_read_secret_handles_env_and_plain_text(tmp_path): + env_file = tmp_path / ".env" + env_file.write_text("# 注释\nAPP_SECRET='quoted-secret'\nOTHER=1\n") + assert SCRIPT._read_secret(env_file) == "quoted-secret" + + plain = tmp_path / "secret.txt" + plain.write_text("plain-secret\n") + assert SCRIPT._read_secret(plain) == "plain-secret" + + +def test_read_secret_rejects_empty_and_multiline(tmp_path): + empty_env = tmp_path / "empty.env" + empty_env.write_text("APP_SECRET=\n") + with pytest.raises(SystemExit): + SCRIPT._read_secret(empty_env) + + blank = tmp_path / "blank.txt" + blank.write_text(" \n") + with pytest.raises(SystemExit): + SCRIPT._read_secret(blank) + + multiline = tmp_path / "multi.txt" + multiline.write_text("a\nb\n") + with pytest.raises(SystemExit): + SCRIPT._read_secret(multiline) + + +def test_main_end_to_end(tmp_path): + db_path = _db_with_credentials(tmp_path, count=2) + old_file = tmp_path / "old.env" + old_file.write_text(f"APP_SECRET={OLD}\n") + new_file = tmp_path / "new.env" + new_file.write_text(f"APP_SECRET={NEW}\n") + assert SCRIPT.main(["--db", str(db_path), + "--old-secret-file", str(old_file), + "--new-secret-file", str(new_file)]) == 0 + assert CredentialCipher(NEW).decrypt(_rows(db_path)["c0"]) + + +def test_main_rejects_missing_database(tmp_path): + old_file = tmp_path / "old.env" + old_file.write_text(f"APP_SECRET={OLD}\n") + new_file = tmp_path / "new.env" + new_file.write_text(f"APP_SECRET={NEW}\n") + with pytest.raises(SystemExit): + SCRIPT.main(["--db", str(tmp_path / "nope.sqlite3"), + "--old-secret-file", str(old_file), + "--new-secret-file", str(new_file)]) diff --git a/tests/stats/test_collector.py b/tests/stats/test_collector.py index d3d29237..f5b5303c 100644 --- a/tests/stats/test_collector.py +++ b/tests/stats/test_collector.py @@ -119,12 +119,17 @@ def test_stats_retention_and_rollup(stats): collector.record(username="u", provider="trae", model="m", ok=True, input_tokens=5, latency_ms=20, now=2000) task = RetentionTask(collector, retention_days=90) - first = task.run_once() + # 周期 rollup 只重算最近窗口(见 ROLLUP_OVERLAP_SECONDS):本用例的时间戳 + # 是 1970 年,必须显式给 now,否则窗口外什么都扫不到。 + first = task.run_once(now=10_000) assert first["rolled_up"] >= 1 rows = query._db.connect().execute( "SELECT requests, input_tokens FROM usage_hourly").fetchall() assert sum(r["requests"] for r in rows) == 2 - # 清理:把 now 推到 91 天之后 + # 显式 now 同样传播给清理:run_once 不得用真实时间聪明地删掉 1970 年明细 + # (此前会),否则下面「再写一条再清」的计数是两段真实时刻的杂凑。 + assert collector.purge_expired(90, now=10_000 + 91 * 86400) == 2 + # 再写一条(同 ts=1000,尚未清理过的新明细) collector.record(username="u", provider="trae", model="m", ok=True, now=1000) assert collector.purge_expired(90, now=1000 + 91 * 86400) == 1 # 小时汇总永久保留 @@ -259,6 +264,40 @@ def boom(_row): assert stats[1].overview(username="u")["requests"] == 1 +def test_record_notifies_paid_snapshot_after_commit(stats): + """每写一行明细就失效实测扣费快照:否则新观测要等 600s TTL 才生效。 + + 目录 x0 数据烧额度的窗口正是这段延迟——`invalidate()` 的 docstring 承诺 + 「刚写完一条明细的调用方用」,装配点必须真的接上。 + """ + from src.stats.collector import StatsCollector + + calls: list[int] = [] + collector = StatsCollector(stats[0]._db, on_write=lambda: calls.append(1)) + collector.record(username="u", provider="trae", model="m", ok=True) + collector.record(username="u", provider="trae", model="m", ok=True) + assert calls == [1, 1] + + +def test_record_swallows_paid_snapshot_failure(stats): + """快照失效失败同样不能影响统计写入。""" + from src.stats.collector import StatsCollector + + def boom(): + raise RuntimeError("invalidate exploded") + + collector = StatsCollector(stats[0]._db, on_write=boom) + collector.record(username="u", provider="trae", model="m", ok=True) + assert stats[1].overview(username="u")["requests"] == 1 + + +def test_record_notifies_paid_snapshot_writer_is_optional(stats): + """未装配 `on_write`(脚本 / 老装配):写入照常,不报错。""" + collector, query = stats + collector.record(username="u", provider="trae", model="m", ok=True) + assert query.overview(username="u")["requests"] == 1 + + def test_record_publishes_normalized_model_and_error_type(stats): """广播的是**归一后**的值(与明细表一致),不是调用方原始入参。""" from src.stats.collector import StatsCollector @@ -269,3 +308,37 @@ def test_record_publishes_normalized_model_and_error_type(stats): error_type="not-controlled") assert published[0]["model"] == "unknown" assert published[0]["error_type"] is None + + +def test_window_key_usage_groups_by_key_and_orders_by_tokens(stats): + """按 Key 告警的数据源:只统计带 Key 的明细,token 降序。""" + from src.request_context import set_api_key_id + + collector, _query = stats + for key_id, tokens in (("k1", 10), ("k1", 30), ("k2", 999), (None, 5000)): + set_api_key_id(key_id) + try: + collector.record(username=f"u-{key_id}", provider="trae", model="m", + ok=True, input_tokens=tokens, output_tokens=0) + finally: + set_api_key_id(None) + rows = collector.window_key_usage(since=0) + assert [row["api_key_id"] for row in rows] == ["k2", "k1"] + assert rows[1]["tokens"] == 40 + assert rows[1]["requests"] == 2 + # 窗口外不返回(since 取未来) + assert collector.window_key_usage(since=int(time.time()) + 3600) == [] + + +def test_window_cost_sums_known_hours(stats): + """预算告警的数据源:窗口内成本求和 + 可定价小时数。""" + collector, _query = stats + collector.record(username="u", provider="trae", model="deepseek-v4.1-flash", + ok=True, input_tokens=100, output_tokens=10) + collector.rollup_hourly() + spent, known, hours = collector.window_cost(since=0) + assert spent >= 0 + assert hours == 1 + assert known in (0, 1) # 价表未装配时 cost 为 NULL → known=0 + # 窗口外(未来)→ 全 0,不是 None + assert collector.window_cost(since=int(time.time()) + 3600) == (0.0, 0, 0) diff --git a/tests/stats/test_pricing.py b/tests/stats/test_pricing.py index cea8fd31..1ddc911e 100644 --- a/tests/stats/test_pricing.py +++ b/tests/stats/test_pricing.py @@ -235,6 +235,29 @@ def test_query_model_timeline_cost_unpriced_is_none(collector_query): assert result["points"][0]["unpriced"] is None +def test_query_timeline_cost_per_request(collector_query): + """单请求成本 = 已定价成本 ÷ 请求数(先聚合后相除);无可定价明细给 None。""" + collector, query, _db = collector_query + hour = 1_699_999_200 # 整点,与小时桶对齐 + # 两条可定价(各 1M 输入 = 1 USD = 6.7 CNY)+ 一条不可定价 + for offset in (0, 1): + collector.record(username="u", provider="trae", model="glm-5.2", ok=True, + input_tokens=1_000_000, output_tokens=0, now=hour + offset) + collector.record(username="u", provider="trae", model="unpriced", ok=True, + input_tokens=1_000_000, output_tokens=0, now=hour + 2) + points = query.timeline(username="u", metric="cost_per_request") + # 成本只算可定价部分(13.4 CNY),请求数 3 → 4.466667 + assert points[0]["trae"] == pytest.approx(13.4 / 3, rel=1e-4) + + # 全部不可定价 → None(不是 0 元) + other_hour = hour + 3600 + collector.record(username="u", provider="trae", model="unpriced", ok=True, + input_tokens=100, output_tokens=0, now=other_hour) + points = query.timeline(username="u", metric="cost_per_request", + since=other_hour) + assert points[0]["trae"] is None + + def test_collector_rollup_preserves_cost(collector_query): collector, query, db = collector_query now = int(time.time()) diff --git a/tests/stats/test_query.py b/tests/stats/test_query.py index 932edd5d..95bb8580 100644 --- a/tests/stats/test_query.py +++ b/tests/stats/test_query.py @@ -4,13 +4,14 @@ import time +import pytest from fastapi.testclient import TestClient from src.auth.session import create_session_token from src.config import Settings from src.main import build_app from src.stats.collector import StatsCollector -from src.stats.query import StatsQuery +from src.stats.query import MAX_DENSE_HOURS, StatsQuery from src.tasks.retention import RetentionTask from tests.helpers.constants import SECRET @@ -414,7 +415,10 @@ def test_stats_until_filters_groups_and_timeline(stats): collector.rollup_hourly() # 小时汇总路径:桶 0 与桶 7200,until=7200 只留桶 0 assert query.by_provider(username="u", until=7200)[0]["requests"] == 1 - assert len(query.timeline(username="u", until=7200)) == 1 + # 时间轴按小时补齐:桶 0 有数据、桶 3600 补 0,桶 7200 被 until 排除 + points = query.timeline(username="u", until=7200) + assert [point["hour"] for point in points] == [0, 3600] + assert points[0]["trae"] == 1 and points[1]["trae"] == 0 # 明细路径(ts 列):凭证维度读 usage_events,until 也按 ts 过滤 by_cred = query.by_credential(username="u", until=7200) assert sum(row["requests"] for row in by_cred) == 0 # 明细无 credential_id 不分组 @@ -438,13 +442,15 @@ def test_stats_hourly_rolls_via_retention(stats): collector.record(username="u", provider="codebuddy", model="m", ok=True, now=1_700_003_602, input_tokens=7, output_tokens=3, credit=0.5, latency_ms=100) - # 增量累加:最新小时立即可见(不需等 5 分钟一轮的 rollup) + # 增量累加:最新小时立即可见(不需等 5 分钟一轮的 rollup)。 + # 时间轴按 since 补齐:since=1_700_000_000 落在桶 1699999200,故首桶是空的 points = query.timeline(username="u", since=1_700_000_000) - assert len(points) == 1 and points[0]["trae"] == 2 and points[0]["codebuddy"] == 1 + assert [point["hour"] for point in points] == [1699999200, 1700002800] + assert points[1]["trae"] == 2 and points[1]["codebuddy"] == 1 # 全量重算与该小时的增量值一致(幂等,不双计) RetentionTask(collector, retention_days=90).run_once() points = query.timeline(username="u", since=1_700_000_000) - assert len(points) == 1 and points[0]["trae"] == 2 and points[0]["codebuddy"] == 1 + assert points[1]["trae"] == 2 and points[1]["codebuddy"] == 1 bucket = collector._db.connect().execute( "SELECT SUM(requests), SUM(ok_count), SUM(input_tokens), SUM(output_tokens), " "SUM(credit_known), SUM(latency_sum) FROM usage_hourly").fetchone() @@ -496,10 +502,195 @@ def test_stats_timeline_metric_dimensions(stats): # 非法 metric 回退请求次数 assert query.timeline(username="u", metric="bogus") == req + +def test_stats_timeline_derived_metrics(stats): + """派生指标:成功率 / 缓存命中率 / 单请求成本,均为「先聚合后相除」。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + # codebuddy:3 次请求 2 成功,输入 100 命中 40,单次成本由 pricing 决定 + collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, + input_tokens=40, cached_tokens=40, now=base) + collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, + input_tokens=40, cached_tokens=0, now=base) + collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=False, + error_type="rate_limit", input_tokens=20, now=base) + collector.rollup_hourly() + + rate = query.timeline(username="u", metric="success_rate")[0] + # 3 请求 2 成功 → 0.6667(不是逐小时平均,而是 sum/sum) + assert rate["codebuddy"] == pytest.approx(0.6667) + cache = query.timeline(username="u", metric="cache_rate")[0] + # 命中 40 / 输入 100 → 0.4 + assert cache["codebuddy"] == pytest.approx(0.4) + + +def test_stats_timeline_derived_metrics_no_sample_is_none(stats): + """无样本的派生指标给 None(断线),不画成 0%。""" + collector, query = stats + # 未上报缓存 → cache_rate 为 None;无请求的小时在稠密轴上同样是 None + collector.record(username="u", provider="trae", model="unpriced", ok=True, + input_tokens=100, now=1_700_000_000) + collector.rollup_hourly() + assert query.timeline(username="u", metric="cache_rate")[0]["trae"] is None + # 无可定价明细 → 单请求成本 None + assert query.timeline(username="u", metric="cost_per_request")[0]["trae"] is None + # 全失败的小时:均值类为 None(不是 0 ms) + collector.record(username="u", provider="trae", model="m", ok=False, + error_type="upstream_error", now=1_700_003_600) + collector.rollup_hourly() + lat = query.timeline(username="u", metric="latency", since=1_700_000_000) + assert lat[1]["trae"] is None + + +def test_stats_timeline_dense_hours(stats): + """时间轴稠密:缺失小时补桶,可加量补 0、均值补 None。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + collector.record(username="u", provider="trae", model="m", ok=True, now=base) + collector.record(username="u", provider="trae", model="m", ok=True, + now=base + 5 * 3600) # 中间空 4 小时 + collector.rollup_hourly() + + points = query.timeline(username="u") + assert len(points) == 6 # 首末之间每小时一个点 + assert [point["hour"] for point in points] == [base + i * 3600 for i in range(6)] + # 请求数:空档补 0 + assert [point["trae"] for point in points] == [1, 0, 0, 0, 0, 1] + # 均值:空档补 None(不是 0 ms) + lat = query.timeline(username="u", metric="latency") + assert lat[1]["trae"] is None + + +def test_stats_timeline_total_recomputes_ratio(stats): + """`__total` 按同一小时的 sum/count 重新相除,不是各渠道值相加。""" + collector, query = stats + base = 1_699_999_200 + # codebuddy:2 请求全成功、各 100ms;trae:2 请求 1 成功、各 900ms + for _ in range(2): + collector.record(username="u", provider="codebuddy", model="m", ok=True, + latency_ms=100, now=base) + collector.record(username="u", provider="trae", model="m", ok=True, + latency_ms=900, now=base) + collector.record(username="u", provider="trae", model="m", ok=False, + error_type="rate_limit", latency_ms=900, now=base) + collector.rollup_hourly() + + # 请求数:可加 → 4 + req = query.timeline(username="u")[0] + assert req["codebuddy"] == 2 and req["trae"] == 2 and req["__total"] == 4 + # 耗时:全池加权均值 (100+100+900)/3 = 366.67 → 367;相加会得到 1000 + lat = query.timeline(username="u", metric="latency")[0] + assert lat["__total"] == 367 + # 成功率:3 成功 / 4 请求 = 0.75;相加会得到 1.5 + rate = query.timeline(username="u", metric="success_rate")[0] + assert rate["__total"] == pytest.approx(0.75) + + +def test_stats_timeline_dense_hours_respects_window(stats): + """稠密轴按 since/until 补齐窗口两端;跨度超上限时退回已观测小时。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + collector.record(username="u", provider="trae", model="m", ok=True, now=base) + collector.rollup_hourly() + + # 窗口从更早开始 → 前面补空桶 + points = query.timeline(username="u", since=base - 2 * 3600) + assert [point["hour"] for point in points] == [base - 7200, base - 3600, base] + assert points[0]["trae"] == 0 + # 跨度超 MAX_DENSE_HOURS(92 天)→ 不补,只回已观测小时 + wide = query.timeline(username="u", since=base - MAX_DENSE_HOURS * 3600) + assert [point["hour"] for point in wide] == [base] + + +def test_stats_model_timeline_dense_and_derived(stats): + """model_timeline 同样稠密补桶,派生指标语义与 timeline 一致。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + collector.record(username="u", provider="trae", model="a", ok=True, + input_tokens=50, cached_tokens=25, now=base) + collector.record(username="u", provider="trae", model="a", ok=False, + error_type="rate_limit", now=base + 2 * 3600) + collector.rollup_hourly() + + result = query.model_timeline(username="u", metric="success_rate") + assert [point["hour"] for point in result["points"]] == [ + base, base + 3600, base + 7200] + assert result["points"][0]["a"] == 1.0 + assert result["points"][1]["a"] is None # 空档:比率断线 + assert result["points"][2]["a"] == 0.0 + cache = query.model_timeline(username="u", metric="cache_rate") + assert cache["points"][0]["a"] == pytest.approx(0.5) + + +def test_stats_percentile_timeline(stats): + """分位时序:P50/P95 按小时算,只统计成功请求,缺失小时补 None。""" + collector, query = stats + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 + for latency in (100, 200, 300, 400, 1000): + collector.record(username="u", provider="trae", model="m", ok=True, + latency_ms=latency, now=base) + # 失败请求的延迟不计入(ok=0) + collector.record(username="u", provider="trae", model="m", ok=False, + error_type="rate_limit", latency_ms=99999, now=base) + collector.record(username="u", provider="trae", model="m", ok=True, + latency_ms=700, now=base + 2 * 3600) + + result = query.percentile_timeline(username="u", since=base) + assert result["metric"] == "latency" and result["window_days"] == 90 + points = result["points"] + assert [point["hour"] for point in points] == [base, base + 3600, base + 7200] + # 5 个样本:P50 = ⌈0.5·5⌉ = 第 3 小 = 300;P95 = ⌈0.95·5⌉ = 第 5 小 = 1000 + assert points[0]["p50"] == 300 and points[0]["p95"] == 1000 + assert points[0]["samples"] == 5 + assert points[1]["p50"] is None and points[1]["samples"] == 0 + assert points[2]["p50"] == 700 + # ttfb 口径:无 ttfb 数据 → 全 None + assert all(point["p50"] is None + for point in query.percentile_timeline(username="u", + since=base, + metric="ttfb")["points"]) + + +def test_percentile_timeline_defaults_to_recent_window(stats): + """`since` 缺省必须收敛到最近 24h:否则会对 90 天明细全表跑窗口函数。 + + 这条 SQL 带 `ROW_NUMBER() OVER (PARTITION BY 小时)`,无窗口时是全表排序; + 任意登录用户(含 viewer)都能反复触发,是现成的 DB CPU DoS。 + """ + collector, query = stats + now = 1_700_000_000 + collector.record(username="u", provider="trae", model="m", ok=True, + latency_ms=100, now=now - 1000) # 窗口内 + collector.record(username="u", provider="trae", model="m", ok=True, + latency_ms=900, now=now - 3 * 86400) # 3 天前:默认窗口外 + result = query.percentile_timeline(username="u", now=now) + samples = sum(point["samples"] for point in result["points"]) + assert samples == 1, "默认窗口外(3 天前)的行不应参与统计" + + +def test_stats_percentile_timeline_endpoint(admin_client): + """percentile-timeline 端点:作用域与 metric 透传。""" + _app, client = admin_client + collector = client.app.state.stats_collector + # 默认窗口是最近 24h,写入时要给当前时刻,否则会被窗口过滤掉 + collector.record(username="root", provider="trae", model="m", ok=True, + latency_ms=500, ttfb_ms=120) + + def _observed(body): + """取有样本的那个小时:默认窗口会补齐 24 个空小时(前端断线用)。""" + return next(point for point in body["points"] if point["samples"]) + + body = client.get("/api/stats/percentile-timeline").json() + assert body["metric"] == "latency" and _observed(body)["p50"] == 500 + ttfb = client.get("/api/stats/percentile-timeline?metric=ttfb").json() + assert ttfb["metric"] == "ttfb" and _observed(ttfb)["p95"] == 120 + # 默认窗口必须收敛(不再是对全表跑窗口函数):24h 窗口按整点对齐最多 25 桶 + assert len(body["points"]) <= 25 + def test_stats_model_timeline_metric_and_ttfb_rollup(stats): """model_timeline 指标切换 + ttfb 聚合进小时表(老库补列后可用)。""" collector, query = stats - base = 1_700_000_000 + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, input_tokens=10, output_tokens=5, latency_ms=100, ttfb_ms=40, now=base) collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, @@ -520,7 +711,7 @@ def test_stats_model_timeline_metric_and_ttfb_rollup(stats): def test_stats_model_timeline_top_models_and_points(stats): """按模型趋势:Top N 宽表点列,按总量降序;其余模型不出线。""" collector, query = stats - base = 1_700_000_000 + base = 1_699_999_200 # 整点:与 usage_hourly 的 hour 桶对齐 # glm-5.2 共 4 次(最热)、kimi-k3 共 2 次、legacy-old 共 1 次(超出 Top 2) for _ in range(4): collector.record(username="u", provider="codebuddy", model="glm-5.2", ok=True, now=base) @@ -589,8 +780,10 @@ def test_stats_model_timeline_endpoint(admin_client): # metric 参数切换取值语义(默认请求次数 vs tokens 合计) tokens = client.get("/api/stats/model-timeline?metric=tokens").json() assert tokens["points"][0]["m"] == 15 - # 非法 metric 回退请求次数 - assert client.get("/api/stats/model-timeline?metric=bogus").json()["points"] == body["points"] + # 非法 metric 显式 400(此前静默回退 requests,错误被吞掉) + bad = client.get("/api/stats/model-timeline?metric=bogus") + assert bad.status_code == 400 + assert bad.json()["error"]["code"] == "invalid_request" def test_stats_events_empty(stats): """空库:明细返回空页且无游标。""" diff --git a/tests/stats/test_query_percentiles.py b/tests/stats/test_query_percentiles.py index 067e9ebc..301ae05d 100644 --- a/tests/stats/test_query_percentiles.py +++ b/tests/stats/test_query_percentiles.py @@ -122,11 +122,11 @@ def test_errors_groups_by_provider_model_and_error_type(stats): rows = query.errors(since=now - 100, now=now) assert rows == [ {"provider": "trae", "model": "glm", "error_type": "rate_limit", - "failures": 3, "last_ts": now + 2}, + "failures": 3, "rescued": 0, "last_ts": now + 2}, {"provider": "codebuddy", "model": "kimi", "error_type": "rate_limit", - "failures": 1, "last_ts": now + 20}, + "failures": 1, "rescued": 0, "last_ts": now + 20}, {"provider": "trae", "model": "glm", "error_type": "upstream_error", - "failures": 1, "last_ts": now + 10}, + "failures": 1, "rescued": 0, "last_ts": now + 10}, ] @@ -174,3 +174,97 @@ def test_errors_limit_is_floored_at_one(stats): def test_errors_empty(stats): _collector, query = stats assert query.errors(now=int(time.time())) == [] + + +def test_errors_counts_rescued_attempts_by_request_id(stats): + """同 request_id 另有成功行 = 换号救回,单列下发(failures − rescued 才是受损数)。 + + 换号重试是常态:`invalid_request` 每次上游拒绝都落一行,随后换渠道成功 + 也留着这条失败。不区分会把「已救回的尝试」读成「用户没拿到结果」。 + """ + from src.request_context import set_request_id + + collector, query = stats + now = 1_700_000_000 + try: + # req-a:先失败后成功(救回) + set_request_id("req-a") + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now) + collector.record(username="u", provider="codebuddy", model="glm", ok=True, + now=now + 1) + # req-b:只失败(未救回) + set_request_id("req-b") + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now + 2) + # req-c:同一渠道两次失败 + 一次成功,两行都算救回 + set_request_id("req-c") + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now + 3) + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now + 4) + collector.record(username="u", provider="trae", model="glm", ok=True, now=now + 5) + finally: + set_request_id(None) + + rows = query.errors(since=now - 10, now=now) + assert len(rows) == 1 + row = rows[0] + assert row["failures"] == 4 # req-a 1 + req-b 1 + req-c 2 + assert row["rescued"] == 3 # req-a 1 + req-c 2 + assert row["failures"] - row["rescued"] == 1 # 只有 req-b 真正受损 + + +def test_errors_rescued_ignores_rows_without_request_id(stats): + """老库历史行 request_id 为 NULL:无法关联成功行,恒算未救回(不误报救回)。""" + collector, query = stats + now = 1_700_000_000 + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="rate_limit", now=now) + row = query.errors(since=now - 10, now=now)[0] + assert row["failures"] == 1 and row["rescued"] == 0 + + +def test_errors_rescued_is_scoped_to_same_user(stats): + """request_id 来自入站 `X-Request-ID`(客户端可伪造):跨用户不能互相「救回」。 + + 只按 ID 关联时,bob 的成功行会把 alice 的失败标成已救回(实测可复现), + 传了 `username=` 的查询同样会读到跨租户数据。 + """ + from src.request_context import set_request_id + + collector, query = stats + now = 1_700_000_000 + try: + set_request_id("shared-id") + collector.record(username="alice", provider="trae", model="glm", ok=False, + error_type="rate_limit", now=now) + collector.record(username="bob", provider="codebuddy", model="glm", ok=True, + now=now + 1) + finally: + set_request_id(None) + + alice = query.errors(username="alice", since=now - 10, now=now) + assert alice[0]["failures"] == 1 and alice[0]["rescued"] == 0 + everyone = query.errors(since=now - 10, now=now) + assert sum(row["rescued"] for row in everyone) == 0 + + +def test_errors_rescued_sees_success_after_window_end(stats): + """救援判定不叠加时间窗:成功行可能落在窗口右界之后(同一请求的重试)。""" + from src.request_context import set_request_id + + collector, query = stats + now = 1_700_000_000 + try: + set_request_id("req-late") + collector.record(username="u", provider="trae", model="glm", ok=False, + error_type="invalid_request", now=now) + collector.record(username="u", provider="codebuddy", model="glm", ok=True, + now=now + 500) + finally: + set_request_id(None) + + # 窗口 [now-10, now+100):成功行在窗口外,仍应算救回 + rows = query.errors(since=now - 10, until=now + 100, now=now) + assert rows[0]["failures"] == 1 and rows[0]["rescued"] == 1 diff --git a/tests/tasks/test_activity.py b/tests/tasks/test_activity.py index 7ef8d59f..4616ecc1 100644 --- a/tests/tasks/test_activity.py +++ b/tests/tasks/test_activity.py @@ -53,9 +53,11 @@ def activity_repo(tmp_path): apply_schema(db.connect()) return CredentialRepository(db, CredentialCipher(SECRET)) -def _add(repo, *, provider="codebuddy", token="t", disabled=False): +def _add(repo, *, provider="codebuddy", token="t", disabled=False, enabled=True): credential_id = repo.add(provider=provider, credential_data={"bearer_token": token}) + if not enabled: + repo.set_enabled(credential_id, False) if disabled: from src.engine.scheduler import Candidate, Scheduler from src.provider.base import ErrKind @@ -100,6 +102,21 @@ async def test_activity_task_skips_unsupported_and_disabled(activity_repo): assert report.skipped == 2 assert provider.calls == 1 +async def test_activity_task_skips_soft_disabled_credentials(activity_repo): + """`enabled=0`(用户软暂停)不参与活跃上报:暂停不能只停调度。 + + 与 checkin / refresh / quota_probe / growth 同口径。此前只判 `disabled`, + 于是「暂停」的号仍被拿去打卡。 + """ + _add(activity_repo, token="a") + _add(activity_repo, token="b", enabled=False) + provider = _FakeProvider() + task = ActivityTask(activity_repo, {"codebuddy": provider}, hour=10) + report = await task.run_once() + assert report.attempted == 1 and report.skipped == 1 + assert provider.calls == 1 + + async def test_activity_task_skips_missing_provider_and_data(): """provider 不认识该渠道、或凭证数据缺失 → 跳过(不调上游)。""" class _Repo: @@ -146,6 +163,23 @@ async def test_activity_task_failure_retries_and_does_not_abort(activity_repo): again = await task.run_once() assert again.attempted == 2 +async def test_activity_task_exception_log_keeps_class_name(activity_repo, caplog): + """抛出的异常必须记成 `类名: 消息`,不能只剩一个空尾巴。 + + 回归 2026-11-09:日志是 + `activity report failed for cred_x: `(冒号后为空)——上游 `ConnectError` + 的 `str()` 为空,运维无法判断是代理抖动还是上游拒绝。与 checkin / + quota_probe 统一走 `error_text`。 + """ + _add(activity_repo, token="b") + task = ActivityTask(activity_repo, {"codebuddy": _DispatchProvider()}, hour=10) + with caplog.at_level("WARNING", logger="src.tasks.activity"): + await task.run_once() + messages = [record.getMessage() for record in caplog.records + if "activity report failed" in record.getMessage()] + assert messages, "未记录失败日志" + assert any("RuntimeError: boom" in message for message in messages) + async def test_activity_task_records_growth_event_on_success(activity_repo): _add(activity_repo) events = GrowthRepository(activity_repo._db) # noqa: SLF001 @@ -171,6 +205,18 @@ def test_activity_task_day_key_is_cn_local(): task._now = _cn(0, 30) assert task._day_key() == "2026-09-21" +async def test_activity_task_prunes_stale_done_scopes(activity_repo): + """跨天后清掉前一天的封账记录:长驻进程否则每天每账号各攒一条(无上限)。""" + _add(activity_repo, token="a") + task = ActivityTask(activity_repo, {"codebuddy": _FakeProvider()}, + hour=10, now=_cn(10, 5)) + task._done_scopes = {"2026-09-20:s1", "2026-09-21:s1", "2026-09-19:s1"} + + await task.run_once() + + # 非当天两条被清掉;当天那条保留(本轮已成功会重新写入同键) + assert task._done_scopes == {"2026-09-21:s1"} + def test_build_runner_activity_toggle(tmp_path): """活跃上报恒建对象,开关只决定跑不跑(开关可热更)。 diff --git a/tests/tasks/test_alerting.py b/tests/tasks/test_alerting.py index a36b61b6..80ab1a22 100644 --- a/tests/tasks/test_alerting.py +++ b/tests/tasks/test_alerting.py @@ -120,3 +120,172 @@ def test_status_store_failing_threshold_and_sorting(): assert store.failing(0) == [] assert store.failing(2) == [("growth", 3)] assert store.failing(1) == [("checkin", 1), ("growth", 3)] + +# -------------------------------------- 单 Key 用量暴涨 / 非工作时段请求 + + +def test_evaluate_key_burst_fires_per_key(): + alerts = _no_alerts( + key_usage=[{"api_key_id": "k1", "username": "alice", + "requests": 12, "tokens": 900_000}, + {"api_key_id": "k2", "username": "bob", + "requests": 3, "tokens": 100}], + key_burst_tokens=500_000, + ) + assert [a.rule for a in alerts] == ["key_burst"] + alert = alerts[0] + assert alert.scope == "k1" + assert alert.severity == SEVERITY_WARNING + assert alert.detail["tokens"] == 900_000 + assert "alice" in alert.message + + +def test_evaluate_key_burst_threshold_disabled(): + assert _no_alerts(key_usage=[{"api_key_id": "k1", "username": "a", + "requests": 1, "tokens": 10**9}], + key_burst_tokens=0) == [] + + +def test_evaluate_key_burst_below_threshold_silent(): + assert _no_alerts(key_usage=[{"api_key_id": "k1", "username": "a", + "requests": 1, "tokens": 499_999}], + key_burst_tokens=500_000) == [] + + +def test_evaluate_key_burst_handles_missing_fields(): + """老行/异常行缺字段:按 0 处理,不抛异常。""" + alerts = _no_alerts(key_usage=[{"api_key_id": "k1", "tokens": 600_000}], + key_burst_tokens=500_000) + assert alerts[0].scope == "k1" + assert alerts[0].detail["username"] is None + assert alerts[0].detail["requests"] == 0 + + +def test_evaluate_off_hours_fires_above_min_requests(): + alerts = _no_alerts(error_counts=(30, 0), off_hours=True, + off_hours_min_requests=20) + assert [a.rule for a in alerts] == ["off_hours"] + assert alerts[0].detail["requests"] == 30 + + +def test_evaluate_off_hours_requires_min_requests(): + assert _no_alerts(error_counts=(19, 0), off_hours=True, + off_hours_min_requests=20) == [] + + +def test_evaluate_off_hours_disabled_by_zero_threshold_or_flag(): + assert _no_alerts(error_counts=(999, 0), off_hours=True, + off_hours_min_requests=0) == [] + assert _no_alerts(error_counts=(999, 0), off_hours=False, + off_hours_min_requests=20) == [] + + +def test_in_off_hours_window_semantics(): + from src.tasks.alerting import in_off_hours + + # 白天工作 9..19:区间半开 [9, 19) + assert in_off_hours(8, 9, 19) is True + assert in_off_hours(9, 9, 19) is False + assert in_off_hours(18, 9, 19) is False + assert in_off_hours(19, 9, 19) is True + # 跨零点工作 22..7 + assert in_off_hours(23, 22, 7) is False + assert in_off_hours(6, 22, 7) is False + assert in_off_hours(7, 22, 7) is True + assert in_off_hours(12, 22, 7) is True + # 相等 = 关闭 + assert in_off_hours(3, 0, 0) is False + +# ------------------------------------------------------------ 预算分层告警 + + +def test_parse_levels_dedupes_sorts_and_filters(): + from src.tasks.alerting import parse_levels + + assert parse_levels("50,80,95,100") == (50, 80, 95, 100) + assert parse_levels("100, 50,abc,0,101,50,") == (50, 100) + assert parse_levels("") == () + assert parse_levels(" , ") == () + + +def test_month_start_is_local_month_boundary(): + from src.tasks.alerting import month_start + + start = month_start(1_790_000_000) + import time + + tm = time.localtime(start) + assert (tm.tm_mday, tm.tm_hour, tm.tm_min) == (1, 0, 0) + + +def test_evaluate_budget_reports_highest_reached_level(): + alerts = _no_alerts(budget_cny=100.0, budget_spent=85.0, + budget_levels=(50, 80, 95, 100)) + assert [a.rule for a in alerts] == ["budget"] + assert alerts[0].scope == "budget_80" + assert alerts[0].severity == SEVERITY_WARNING + assert alerts[0].detail["percent"] == 85.0 + + +def test_evaluate_budget_overrun_is_critical(): + alerts = _no_alerts(budget_cny=100.0, budget_spent=140.0, + budget_levels=(50, 80, 95, 100)) + assert alerts[0].scope == "budget_100" + assert alerts[0].severity == SEVERITY_CRITICAL + + +def test_evaluate_budget_below_first_level_silent(): + assert _no_alerts(budget_cny=100.0, budget_spent=49.9, + budget_levels=(50, 80)) == [] + + +def test_evaluate_budget_disabled_by_zero_budget_or_empty_levels(): + assert _no_alerts(budget_cny=0.0, budget_spent=999.0, + budget_levels=(50,)) == [] + assert _no_alerts(budget_cny=100.0, budget_spent=999.0, + budget_levels=()) == [] + assert _no_alerts(budget_cny=100.0, budget_spent=0.0, + budget_levels=(50,)) == [] + + +def test_evaluate_channel_dead_fires_for_zero_success_channel(): + """渠道零成功 → critical;`last_ok` 进 detail 供「多久没成功」定位。""" + alerts = _no_alerts(dead_channels=[ + {"provider": "officeace", "requests": 551, "last_ok": 1793000000}]) + assert len(alerts) == 1 + alert = alerts[0] + assert alert.rule == "channel_dead" and alert.severity == SEVERITY_CRITICAL + assert alert.scope == "officeace" + assert alert.detail["requests"] == 551 + assert "551 次请求全部失败" in alert.message + + +def test_evaluate_channel_dead_never_succeeded_wording(): + alerts = _no_alerts(dead_channels=[ + {"provider": "zen", "requests": 40, "last_ok": None}]) + assert "从未成功过" in alerts[0].message + + +def test_evaluate_channel_dead_absent_when_no_rows(): + assert _no_alerts(dead_channels=[]) == [] + assert _no_alerts(dead_channels=None) == [] + + +def test_evaluate_checkin_fails_above_streak_threshold(): + alerts = _no_alerts( + checkin_failures=[{"credential_id": "cred_a", "streak": 18, + "message": "上游今日每日积分尚未发放(稍后重试)"}], + checkin_fail_min_streak=3) + assert len(alerts) == 1 + alert = alerts[0] + assert alert.rule == "checkin_failing" and alert.severity == SEVERITY_WARNING + assert alert.scope == "cred_a" + assert "连续 18 轮签到失败" in alert.message + assert alert.detail["streak"] == 18 + + +def test_evaluate_checkin_fails_below_threshold_or_rule_off(): + rows = [{"credential_id": "cred_a", "streak": 2, "message": "x"}] + assert _no_alerts(checkin_failures=rows, checkin_fail_min_streak=3) == [] + # 规则关闭(阈值 0)→ 即使调用方误传数据也不报 + assert _no_alerts(checkin_failures=rows, checkin_fail_min_streak=0) == [] diff --git a/tests/tasks/test_alerting_admin.py b/tests/tasks/test_alerting_admin.py index 78deb7e7..a4753aa5 100644 --- a/tests/tasks/test_alerting_admin.py +++ b/tests/tasks/test_alerting_admin.py @@ -335,6 +335,31 @@ def _boom(host, port): assert blocked_webhook_host("https://unresolvable.example/x") is True +def test_blocked_webhook_host_covers_non_global_ranges(monkeypatch): + """`is_private` 覆盖不到的段必须显式拦,否则是现成的 SSRF 口子。 + + `ipaddress.is_private` 对 CGNAT 共享段(100.64.0.0/10)与若干保留段返回 + False;而这些地址在部分云厂商内是可达的内网(实测 100.100.100.200 能通)。 + """ + from src.runtime_settings import blocked_webhook_host + + for url in ("http://100.64.0.1/hook", # RFC 6598 CGNAT + "http://100.100.100.200/hook", # 实测可达的云内地址 + "http://198.18.0.1/hook", # 基准测试段 + "http://203.0.113.5/hook"): # 文档段 + assert blocked_webhook_host(url) is True, url + + # 解析到这些段的域名同样拦 + monkeypatch.setattr("src.runtime_settings.socket.getaddrinfo", + lambda host, port: [(2, 1, 6, "", ("100.100.100.200", 0))]) + assert blocked_webhook_host("https://cgnat.example/x") is True + + # 公网地址照常放行(不误伤) + monkeypatch.setattr("src.runtime_settings.socket.getaddrinfo", + lambda host, port: [(2, 1, 6, "", ("93.184.216.34", 0))]) + assert blocked_webhook_host("https://hook.example/a") is False + + def test_env_webhook_url_is_validated_at_load(tmp_path): """env 提供的 ALERT_WEBHOOK_URL 也要过校验(不能只靠运行时失败兜底)。""" from src.db.repo import RuntimeSettingsRepository @@ -347,3 +372,48 @@ def test_env_webhook_url_is_validated_at_load(tmp_path): with pytest.raises(InvalidSetting, match="内网/回环/保留"): load_runtime_settings(base, RuntimeSettingsRepository(db)) db.close() + + +def test_feishu_and_slack_urls_share_webhook_validation(tmp_path): + """新增出口与通用 webhook 同一套 URL 校验,不能成为 SSRF 侧门。""" + from src.db.repo import RuntimeSettingsRepository + from src.runtime_settings import InvalidSetting, load_runtime_settings + + db = Database(tmp_path / "t2.sqlite3") + apply_schema(db.connect()) + base = Settings(_env_file=None, APP_SECRET=SECRET) + runtime = load_runtime_settings(base, RuntimeSettingsRepository(db)) + for key in ("alert_feishu_webhook_url", "alert_slack_webhook_url"): + with pytest.raises(InvalidSetting, match="只支持 http/https"): + runtime.set_many({key: "file:///etc/passwd"}) + with pytest.raises(InvalidSetting, match="内网/回环/保留"): + runtime.set_many({key: "http://127.0.0.1/x"}) + runtime.set_many({key: "https://hooks.example/x"}) + assert getattr(runtime, key) == "https://hooks.example/x" + db.close() + + +def test_window_dead_channels_zero_success_only(repo): + """零成功的渠道才报:有任一成功即排除;样本不足也排除。""" + _credentials, db = repo + stats = StatsCollector(db) + # officeace:3 次全失败 + for offset in (0, 10, 20): + stats.record(username="u", provider="officeace", model="m", ok=False, + now=1000 + offset) + # codebuddy:失败 2 次 + 成功 1 次 → 有成功,不算死亡 + stats.record(username="u", provider="codebuddy", model="m", ok=False, now=1000) + stats.record(username="u", provider="codebuddy", model="m", ok=False, now=1010) + stats.record(username="u", provider="codebuddy", model="m", ok=True, now=1020) + # trae:只有 1 次失败 → 样本不足 + stats.record(username="u", provider="trae", model="m", ok=False, now=1030) + + rows = stats.window_dead_channels(since=1000, min_requests=2) + assert [row["provider"] for row in rows] == ["officeace"] + assert rows[0]["requests"] == 3 + # 窗口内的成功时刻会带出来(无成功则为 None) + assert rows[0]["last_ok"] is None + assert [row["provider"] for row in stats.window_dead_channels( + since=1000, min_requests=1)] == ["officeace", "trae"] + # 窗口外不计 + assert stats.window_dead_channels(since=2000, min_requests=1) == [] diff --git a/tests/tasks/test_alerting_task.py b/tests/tasks/test_alerting_task.py index d48534ee..4ab31155 100644 --- a/tests/tasks/test_alerting_task.py +++ b/tests/tasks/test_alerting_task.py @@ -584,3 +584,253 @@ def test_alert_severity_warning_constant(): assert SEVERITY_WARNING == "warning" assert SEVERITY_CRITICAL == "critical" assert isinstance(time.time(), float) + +class _KeyStats: + """统计替身:错误率固定 0,按 Key 用量可注入并记录调用窗口。""" + + def __init__(self, key_usage=None): + self.calls: list[int] = [] + self._key_usage = key_usage or [] + + def window_error_rate(self, *, since): + return (0, 0) + + def window_key_usage(self, *, since, limit=50): + self.calls.append(since) + return self._key_usage + + +async def test_alert_task_key_burst_rule_uses_its_own_window(tmp_path): + stats = _KeyStats([{"api_key_id": "k1", "username": "alice", + "requests": 5, "tokens": 900_000}]) + task, alerts, db = _task( + tmp_path, stats=stats, now=lambda: 10_000, + key_burst_tokens=lambda: 500_000, + key_burst_window_minutes=lambda: 10, + ) + await task.run_once() + row = alerts.recent()[0] + assert row["rule"] == "key_burst" + # 窗口用 key_burst_window_minutes(10 分钟),不是错误率窗口 + assert stats.calls == [10_000 - 600] + + +async def test_alert_task_key_burst_skipped_when_window_closed(tmp_path): + stats = _KeyStats([{"api_key_id": "k1", "username": "a", + "requests": 5, "tokens": 900_000}]) + task, alerts, db = _task( + tmp_path, stats=stats, now=lambda: 10_000, + key_burst_tokens=lambda: 500_000, + key_burst_window_minutes=lambda: 0, + ) + await task.run_once() + assert stats.calls == [] + assert alerts.recent() == [] + + +async def test_alert_task_off_hours_rule_reads_local_clock(tmp_path): + """非工作时段按本地时钟判定:构造一个明确在工作时间外的时刻。""" + + def noon_like_utc() -> int: + # 取本地时区 03:00 的时刻(任何时区下 `hour == 3` 都成立) + local = time.localtime() + return int(time.mktime((local.tm_year, local.tm_mon, local.tm_mday, + 3, 0, 0, 0, 0, -1))) + + class _BusyStats: + def window_error_rate(self, *, since): + return (30, 0) + + task, alerts, db = _task( + tmp_path, stats=_BusyStats(), now=noon_like_utc, + off_hours_start=lambda: 9, off_hours_end=lambda: 19, + off_hours_min_requests=lambda: 20, + ) + await task.run_once() + assert [row["rule"] for row in alerts.recent()] == ["off_hours"] + + +class _CostStats: + """统计替身:成本按月起点聚合,记录调用窗口。""" + + def __init__(self, spent=0.0): + self.calls: list[int] = [] + self._spent = spent + + def window_error_rate(self, *, since): + return (0, 0) + + def window_cost(self, *, since): + self.calls.append(since) + return (self._spent, 1, 1) + + +async def test_alert_task_budget_uses_month_start(tmp_path): + from src.tasks.alerting import month_start + + stats = _CostStats(85.0) + task, alerts, db = _task( + tmp_path, stats=stats, now=lambda: 1_790_000_000, + budget_cny=lambda: 100.0, budget_levels=lambda: "50,80,95,100", + ) + await task.run_once() + assert stats.calls == [month_start(1_790_000_000)] + row = alerts.recent()[0] + assert row["rule"] == "budget" + assert row["scope"] == "budget_80" + + +async def test_alert_task_budget_disabled_skips_query(tmp_path): + stats = _CostStats(999.0) + task, alerts, db = _task(tmp_path, stats=stats, now=lambda: 1_790_000_000, + budget_cny=lambda: 0.0) + await task.run_once() + assert stats.calls == [] + assert alerts.recent() == [] + + +async def test_alert_task_budget_level_upgrade_resolves_old_level(tmp_path): + """50% 档告警后升到 80%:旧档不再命中,一轮迟滞后写成已恢复。""" + stats = _CostStats(60.0) + task, alerts, db = _task( + tmp_path, stats=stats, now=lambda: 1_790_000_000, + budget_cny=lambda: 100.0, budget_levels=lambda: "50,80,100", + clear_ticks=lambda: 1, silence_minutes=lambda: 0, + ) + await task.run_once() + assert [row["scope"] for row in alerts.recent()] == ["budget_50"] + + stats._spent = 85.0 + await task.run_once() + scopes = [row["scope"] for row in alerts.recent()] + assert "budget_80" in scopes + # 50% 档已被写成恢复(resolved_at 非空),不再算未恢复 + unresolved = [row["scope"] for row in alerts.unresolved()] + assert unresolved == ["budget_80"] + +async def test_alert_task_delivers_feishu_and_slack_shapes(tmp_path): + """飞书 / Slack 各按自己的消息格式发送,仍带签名头。""" + seen: list[tuple[str, dict, str | None]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.url.host, json.loads(request.content), + request.headers.get("X-Coding2API-Signature"))) + return httpx.Response(200) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + task, alerts, db = _task( + tmp_path, credentials=_StubCredentials(counts={"total": 1, "ready": 0}), + feishu_url=lambda: "https://open.feishu.cn/hook/f", + slack_url=lambda: "https://hooks.slack.example/s", + client=client, now=lambda: 5, app_secret=lambda: "secret") + report = await task.run_once() + assert report["delivered"] == 1 + assert alerts.recent()[0]["delivered"] == 1 + + by_host = {host: body for host, body, _ in seen} + feishu = by_host["open.feishu.cn"] + assert feishu["msg_type"] == "text" + assert "Coding2API" in feishu["content"]["text"] + slack = by_host["hooks.slack.example"] + assert set(slack) == {"text"} and "Coding2API" in slack["text"] + assert all(signature for _, _, signature in seen) + await client.aclose() + db.close() + +async def test_alert_task_channel_failure_isolated(tmp_path): + """一个渠道失败不影响其它渠道发送,但整体不算投递成功。""" + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.host) + return httpx.Response(500 if request.url.host.endswith("feishu.cn") else 200) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + task, alerts, db = _task( + tmp_path, credentials=_StubCredentials(counts={"total": 1, "ready": 0}), + feishu_url=lambda: "https://open.feishu.cn/hook/f", + slack_url=lambda: "https://hooks.slack.example/s", + client=client, now=lambda: 5) + report = await task.run_once() + assert sorted(calls) == ["hooks.slack.example", "open.feishu.cn"] + assert report["delivered"] == 0 + assert alerts.recent()[0]["delivery_error"] is not None + await client.aclose() + db.close() + + +class _DeadChannelStats: + """统计替身:只提供新规则需要的 `window_dead_channels`。""" + + def __init__(self, rows): + self._rows = rows + self.calls: list[dict] = [] + + def window_dead_channels(self, *, since, min_requests): + self.calls.append({"since": since, "min_requests": min_requests}) + return list(self._rows) + + def window_error_rate(self, *, since): + return (0, 0) + + +async def test_alert_task_reports_dead_channel(tmp_path): + """渠道零成功进入告警;静默窗内不重复落库。""" + stats = _DeadChannelStats([ + {"provider": "officeace", "requests": 551, "last_ok": 1793000000}]) + task, alerts, db = _task( + tmp_path, stats=stats, now=lambda: 1794000000, + channel_dead_min_requests=lambda: 20, + channel_dead_window_minutes=lambda: 60) + report = await task.run_once() + assert report["fired"] == 1 + assert alerts.recent()[0]["rule"] == "channel_dead" + assert stats.calls == [{"since": 1794000000 - 60 * 60, "min_requests": 20}] + # 第二轮仍在静默窗内 → 抑制 + assert (await task.run_once())["suppressed"] == 1 + db.close() + + +async def test_alert_task_dead_channel_rule_off_skips_query(tmp_path): + """阈值 0 = 关闭:连查询都不发(避免无谓扫表)。""" + stats = _DeadChannelStats([{"provider": "x", "requests": 99, "last_ok": None}]) + task, alerts, db = _task( + tmp_path, stats=stats, now=lambda: 1794000000, + channel_dead_min_requests=lambda: 0) + assert (await task.run_once())["evaluated"] == 0 + assert stats.calls == [] + assert alerts.recent() == [] + db.close() + + +async def test_alert_task_reports_checkin_failure_streak(tmp_path): + """签到连续失败走独立规则;仓储为 None 时安静跳过。""" + class _Checkins: + def __init__(self, rows): + self._rows = rows + self.calls: list[dict] = [] + + def consecutive_failures(self, *, min_streak, since): + self.calls.append({"min_streak": min_streak, "since": since}) + return list(self._rows) + + checkins = _Checkins([{"credential_id": "cred_a", "streak": 18, + "last_ts": 1794000000, "message": "上游未发放"}]) + task, alerts, db = _task( + tmp_path, now=lambda: 1794000000, checkins=checkins, + checkin_fail_min_streak=lambda: 3, + checkin_fail_window_hours=lambda: 24) + report = await task.run_once() + assert report["fired"] == 1 + row = alerts.recent()[0] + assert row["rule"] == "checkin_failing" and row["scope"] == "cred_a" + assert checkins.calls == [{"min_streak": 3, + "since": 1794000000 - 24 * 3600}] + db.close() + + # 未注入仓储:规则不生效,也不崩(共用同一个库,只断言本轮无新增评估) + off, off_alerts, off_db = _task( + tmp_path, now=lambda: 1794000000, checkins=None, + checkin_fail_min_streak=lambda: 3) + assert (await off.run_once())["evaluated"] == 0 + off_db.close() diff --git a/tests/tasks/test_checkin.py b/tests/tasks/test_checkin.py index bfa42582..b6305403 100644 --- a/tests/tasks/test_checkin.py +++ b/tests/tasks/test_checkin.py @@ -3,9 +3,11 @@ from __future__ import annotations import time +from datetime import datetime +from zoneinfo import ZoneInfo from src.provider.codebuddy.checkin import CheckinResult -from src.tasks.checkin import CheckinTask +from src.tasks.checkin import CheckinTask, _cn_now from tests.helpers.scheduler import disabled_outcome as _disabled_outcome from tests.tasks._support import ProbeProvider @@ -105,6 +107,57 @@ async def test_checkin_skips_disabled(repo): assert report.attempted == 0 assert report.skipped == 1 +async def test_checkin_skip_providers_stops_only_that_channel(repo): + """渠道级止损:点名渠道不调上游,其它渠道照常签,且聊天能力不受影响。 + + 回归 2026-11-09:raccoon 上游自 11-05 停止写 daily_grant,三个号每轮 + 都回「今日每日积分尚未发放」,7 天 484 次尝试仅 10 次成功。停用签到 + 但又不能暂停凭证——`enabled=0` 会连聊天一起停掉。 + """ + credentials, _db = repo + credentials.add(provider="raccoon", credential_data={"bearer_token": "r", + "uid": "u1"}) + credentials.add(provider="codebuddy", credential_data={"bearer_token": "c", + "account_uid": "u2"}) + + class TwoChannelProvider: + id = "codebuddy" + + def __init__(self) -> None: + self.calls: list[str] = [] + + async def checkin(self, data): + self.calls.append(data["bearer_token"]) + return CheckinResult(ok=True, credit=100) + + def checkin_scope(self, data): + return data.get("uid") or data.get("account_uid") or "" + + raccoon = TwoChannelProvider() + raccoon.id = "raccoon" + codebuddy = TwoChannelProvider() + task = CheckinTask(credentials, {"raccoon": raccoon, "codebuddy": codebuddy}, + skip_providers=" raccoon , ") + report = await task.run_once() + + assert raccoon.calls == [] # 点名渠道一次都没打 + assert codebuddy.calls == ["c"] # 其它渠道照常 + assert report.succeeded == 1 and report.skipped >= 1 + +async def test_checkin_skip_providers_is_hot_reloadable(repo): + """取值器每轮现读:热更关掉跳过 → 下一轮就能重新签。""" + credentials, _db = repo + credentials.add(provider="raccoon", credential_data={"bearer_token": "r", + "uid": "u1"}) + provider = ProbeProvider() + provider.id = "raccoon" + enabled = {"skip": True} + task = CheckinTask(credentials, {"raccoon": provider}, + skip_providers=lambda: "raccoon" if enabled["skip"] else "") + assert (await task.run_once()).attempted == 0 + enabled["skip"] = False + assert (await task.run_once()).attempted == 1 + async def test_checkin_supports_both_providers(repo): """TRAE 与 CodeBuddy 都实现签到后,任务要能覆盖两个上游。""" credentials, _db = repo @@ -197,11 +250,12 @@ async def checkin(self, data): now = time.struct_time((2026, 9, 11, 10, 0, 0, 3, 254, 0)) first = await task.run_once(now=now) assert first.succeeded == 1 and first.failed == 1 - # 第二轮:ok 已封账(skipped),bad 重试成功 - second = await task.run_once(now=now) + # 第二轮:ok 已封账(skipped),bad 在 10 分钟退避到期后重试成功 + retry_at = time.struct_time((2026, 9, 11, 10, 10, 0, 3, 254, 0)) + second = await task.run_once(now=retry_at) assert second.skipped == 1 and second.succeeded == 1 and second.failed == 0 # 第三轮:全部封账,不再调上游 - third = await task.run_once(now=now) + third = await task.run_once(now=retry_at) assert third.attempted == 0 and third.skipped == 2 assert provider.calls == {"ok": 1, "bad": 2} # 次日清账:所有 scope 重新可签 @@ -219,3 +273,235 @@ async def test_checkin_on_success_callback_fires(repo): task = CheckinTask(credentials, {"codebuddy": ProbeProvider()}, on_success=seen.append) report = await task.run_once() assert report.succeeded == 1 and len(seen) == 1 + + +async def test_checkin_day_boundary_is_beijing_regardless_of_local_tz(repo, monkeypatch): + """日界固定北京时间:进程 TZ 改成 UTC 也不得偏移。 + + 上游「每日一次」按北京时间切。此前用 `time.localtime()`,TZ≠CN 时 + 日界会偏几小时(UTC 下 16:00 之后的签到被算成次日 → 每天漏签一次)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "a", + "account_uid": "cb"}) + provider = ProbeProvider() + task = CheckinTask(credentials, {"codebuddy": provider}) + # 北京时间 2026-09-11 00:30 = UTC 2026-09-10 16:30 + early = int(datetime(2026, 9, 11, 0, 30, tzinfo=ZoneInfo("Asia/Shanghai")).timestamp()) + + monkeypatch.setenv("TZ", "UTC") + time.tzset() + try: + assert task._day_key(_cn_now(early)) == "2026-09-11" + assert task._day_key(_cn_now(early + 3600)) == "2026-09-11" + finally: + monkeypatch.undo() + time.tzset() + + +def test_checkin_day_end_handles_month_end(): + """月末不能构造出「32 号」:`datetime` 不做溢出归一(mktime 才会)。""" + task = CheckinTask(None, {}) # 只算日期,不用仓储 + last_day = time.struct_time((2026, 9, 30, 23, 0, 0, 2, 273, 0)) + end = task._day_end(last_day) + assert datetime.fromtimestamp(end, ZoneInfo("Asia/Shanghai")).day == 1 + # 2026-10-01 00:00 北京时间 + assert end == int(datetime(2026, 10, 1, tzinfo=ZoneInfo("Asia/Shanghai")).timestamp()) + + +async def test_checkin_records_audit_events(repo): + """每轮签到落一行审计日志:成功与失败都记,trigger=auto。 + + 「签到显示成功却没加积分」类问题要靠逐次记录定位;只记成功会让失败静默。 + """ + from src.db.repo import CheckinEventRepository + + credentials, db = repo + credentials.add(provider="codebuddy", + credential_data={"bearer_token": "a", "account_uid": "a"}) + credentials.add(provider="codebuddy", + credential_data={"bearer_token": "b", "account_uid": "b"}) + + class Provider: + id = "codebuddy" + + async def checkin(self, data): + if data["bearer_token"] == "b": + return CheckinResult(ok=False, code=7, message="上游忙") + return CheckinResult(ok=True, credit=1000.0, code=0, message="签到成功") + + def checkin_scope(self, data): + return f"cb|{data['account_uid']}" + + events = CheckinEventRepository(db) + task = CheckinTask(credentials, {"codebuddy": Provider()}, events=events) + report = await task.run_once() + assert report.succeeded == 1 and report.failed == 1 + + rows = [events.recent(candidate.credential_id)[0] + for candidate in credentials.candidates()] + assert {(row["ok"], row["trigger"]) for row in rows} == {(1, "auto"), (0, "auto")} + # 成功行的领取积分如实落库;失败行 code/文案保留 + success = next(row for row in rows if row["ok"] == 1) + failure = next(row for row in rows if row["ok"] == 0) + assert success["credit"] == 1000.0 and success["code"] == 0 + assert failure["code"] == 7 and failure["message"] == "上游忙" + + +async def test_checkin_failure_backoff_skips_then_retries(repo): + """连续失败按 10m → 20m 退避:到期前跳过且不调上游,到期后重试。 + + 回归 2026-11-05:raccoon 两个号从 00:29 到 17:54 每 10 分钟锤一次 + 「今日积分尚未发放」,一天 145 条 WARNING。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "a", + "account_uid": "x"}) + provider = ProbeProvider(checkin_ok=False) + task = CheckinTask(credentials, {"codebuddy": provider}) + t0 = time.struct_time((2026, 9, 11, 10, 0, 0, 3, 254, 0)) + t9 = time.struct_time((2026, 9, 11, 10, 9, 0, 3, 254, 0)) + t10 = time.struct_time((2026, 9, 11, 10, 10, 0, 3, 254, 0)) + t29 = time.struct_time((2026, 9, 11, 10, 29, 0, 3, 254, 0)) + t30 = time.struct_time((2026, 9, 11, 10, 30, 0, 3, 254, 0)) + + assert (await task.run_once(now=t0)).failed == 1 + assert provider.checkin_calls == 1 + + skipped = await task.run_once(now=t9) # 10 分钟退避未到期 + assert skipped.attempted == 0 and skipped.skipped == 1 + assert provider.checkin_calls == 1 + + assert (await task.run_once(now=t10)).failed == 1 # 到期 → 重试,退避翻倍 + assert provider.checkin_calls == 2 + + skipped = await task.run_once(now=t29) # 20 分钟退避未到期 + assert skipped.attempted == 0 and provider.checkin_calls == 2 + assert (await task.run_once(now=t30)).failed == 1 + assert provider.checkin_calls == 3 + + +async def test_checkin_manual_trigger_bypasses_failure_backoff(repo): + """管理台「立即运行」不受失败退避限制(管理员要能当场重试)。""" + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "a", + "account_uid": "x"}) + provider = ProbeProvider(checkin_ok=False) + task = CheckinTask(credentials, {"codebuddy": provider}) + t0 = time.struct_time((2026, 9, 11, 10, 0, 0, 3, 254, 0)) + t1 = time.struct_time((2026, 9, 11, 10, 1, 0, 3, 254, 0)) + + assert (await task.run_once(now=t0)).failed == 1 + manual = await task.run_once(now=t1, trigger="manual") + assert manual.attempted == 1 and manual.failed == 1 + assert provider.checkin_calls == 2 + + +async def test_checkin_success_clears_failure_backoff(repo): + """成功清零连续失败计数:再次失败从 10 分钟重新起算,不继承旧档位。""" + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "a", + "account_uid": "x"}) + + class Sequence(ProbeProvider): + def __init__(self) -> None: + super().__init__() + self.results: list[CheckinResult] = [] + self.seal_until: int | None = None + + async def checkin(self, _data): + self.checkin_calls += 1 + if self.results: + return self.results.pop(0) + return CheckinResult(ok=False, message="上游忙") + + provider = Sequence() + task = CheckinTask(credentials, {"codebuddy": provider}) + t0 = time.struct_time((2026, 9, 11, 10, 0, 0, 3, 254, 0)) + t10 = time.struct_time((2026, 9, 11, 10, 10, 0, 3, 254, 0)) + t11 = time.struct_time((2026, 9, 11, 10, 11, 0, 3, 254, 0)) + t20 = time.struct_time((2026, 9, 11, 10, 20, 0, 3, 254, 0)) + t21 = time.struct_time((2026, 9, 11, 10, 21, 0, 3, 254, 0)) + + assert (await task.run_once(now=t0)).failed == 1 + # 10:10 成功,但封账只到同一时刻 → 下一轮立即可再试(模拟短周期渠道) + provider.results.append(CheckinResult(ok=True, credit=10, + seal_until=int(datetime( + 2026, 9, 11, 10, 10, + tzinfo=ZoneInfo("Asia/Shanghai")).timestamp()))) + assert (await task.run_once(now=t10)).succeeded == 1 + # 10:11 再失败:退避应回到基础 10 分钟(10:21),而不是旧的 20 分钟 + assert (await task.run_once(now=t11)).failed == 1 + skipped = await task.run_once(now=t20) + assert skipped.attempted == 0 and skipped.skipped == 1 + assert (await task.run_once(now=t21)).failed == 1 + assert provider.checkin_calls == 4 + + +def test_checkin_backoff_delay_doubles_and_caps_at_one_hour(): + """退避档位 10m → 20m → 40m → 封顶 1h(不再增长,避免整天不重试)。""" + from src.tasks.checkin import _cn_epoch + + task = CheckinTask(None, {}) # 只测状态机,不用仓储 + now = time.struct_time((2026, 9, 11, 10, 0, 0, 3, 254, 0)) + delays = [] + for _ in range(6): + task._note_failure("2026-09-11:x", now) + delays.append(task._failures["2026-09-11:x"][1] - _cn_epoch(now)) + assert task._backed_off("2026-09-11:x", now, manual=False) is True + assert task._backed_off("2026-09-11:x", now, manual=True) is False + assert delays == [600, 1200, 2400, 3600, 3600, 3600] + + +def test_checkin_failure_retry_at_delays_past_local_backoff(): + """渠道点名的重试时刻(小浣熊 01:30)只能延后、不能提前本地退避。""" + from src.tasks.checkin import _cn_epoch + + task = CheckinTask(None, {}) + now = time.struct_time((2026, 9, 11, 0, 2, 0, 4, 254, 0)) + grace = int(datetime(2026, 9, 11, 1, 30, + tzinfo=ZoneInfo("Asia/Shanghai")).timestamp()) + task._note_failure("2026-09-11:x", now, retry_at=grace) + assert task._failures["2026-09-11:x"][1] == grace + before = time.struct_time((2026, 9, 11, 1, 20, 0, 4, 254, 0)) + after = time.struct_time((2026, 9, 11, 1, 30, 0, 4, 254, 0)) + assert task._backed_off("2026-09-11:x", before, manual=False) is True + assert task._backed_off("2026-09-11:x", after, manual=False) is False + + # 渠道给的时刻比本地退避更早 → 仍按 10 分钟退避,不被缩短 + task._note_failure("2026-09-11:y", now, retry_at=_cn_epoch(now)) + assert task._failures["2026-09-11:y"][1] == _cn_epoch(now) + 600 + + +async def test_checkin_failure_state_pruned_on_new_day(repo): + """跨天清退避:昨天的失败不能把今天的首次签到挡掉。""" + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "a", + "account_uid": "x"}) + provider = ProbeProvider(checkin_ok=False) + task = CheckinTask(credentials, {"codebuddy": provider}) + today = time.struct_time((2026, 9, 11, 23, 55, 0, 4, 255, 0)) + tomorrow = time.struct_time((2026, 9, 12, 0, 1, 0, 5, 256, 0)) + + assert (await task.run_once(now=today)).failed == 1 + assert any(key.startswith("2026-09-11:") for key in task._failures) + report = await task.run_once(now=tomorrow) + assert report.attempted == 1 and report.failed == 1 + assert not any(key.startswith("2026-09-11:") for key in task._failures) + + +async def test_checkin_exception_log_includes_type_when_message_empty(repo, caplog): + """异常消息为空时日志仍要带类名(原先只打空串,排障看不到原因)。""" + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "a", + "account_uid": "x"}) + + class Boom(ProbeProvider): + async def checkin(self, _data): + raise TimeoutError() + + task = CheckinTask(credentials, {"codebuddy": Boom()}) + with caplog.at_level("WARNING", logger="src.tasks.checkin"): + report = await task.run_once() + assert report.failed == 1 + assert any("TimeoutError" in record.getMessage() for record in caplog.records) diff --git a/tests/tasks/test_decrypt_failure.py b/tests/tasks/test_decrypt_failure.py new file mode 100644 index 00000000..a574248f --- /dev/null +++ b/tests/tasks/test_decrypt_failure.py @@ -0,0 +1,203 @@ +"""解不开的凭证(APP_SECRET 换过 / 密文损坏)**不得连累整轮任务**。 + +**背景(2026-10-26 14:48 实测)**:一条凭证的密文解不开时,`credential_data` +抛 `CredentialDecryptError` → 早先直接冒泡 → 整轮后台任务(额度探测、预刷新、 +签到、成长中心、活跃上报、模型探活)挂掉,其余好凭证一条都没跑。 + +各任务的口径一致:**跳过这条凭证**(记 skipped + 一条 ERROR 日志),其余 +照常。这组用例把六条路径一起钉住。 +""" + +from __future__ import annotations + +from src.db.crypto import CredentialCipher, CredentialDecryptError +from src.db.repo import GrowthRepository, ModelProbeRepository +from src.tasks.activity import ActivityTask +from src.tasks.checkin import CheckinTask +from src.tasks.growth import GrowthTask +from src.tasks.model_probe import ModelProbeTask +from src.tasks.quota_probe import QuotaProbeTask +from src.tasks.refresh import RefreshTask +from tests.tasks._support import ProbeProvider + + +def _break_ciphertext(credentials, db, credential_id: str) -> None: + """把该凭证的密文换成另一个密钥加密的(当前 APP_SECRET 解不开)。""" + other = CredentialCipher("a-completely-different-secret") + with db.transaction() as conn: + conn.execute("UPDATE credentials SET data_enc = ? WHERE id = ?", + (other.encrypt(b'{"bearer_token": "t"}'), credential_id)) + + +async def test_quota_probe_skips_undecryptable_credential(repo, caplog): + credentials, db = repo + good = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + bad = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t2"}) + _break_ciphertext(credentials, db, bad) + + task = QuotaProbeTask(credentials, {"codebuddy": ProbeProvider()}) + with caplog.at_level("ERROR", logger="src.tasks.quota_probe"): + report = await task.run_once() + # 坏的那条跳过,好的那条照常探测 + assert report.skipped == 1 + assert report.succeeded == 1 + assert credentials.candidates()[0].credential_id == good + + +async def test_refresh_skips_undecryptable_credential(repo, caplog): + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={ + "bearer_token": "old", "refresh_token": "RT", "auth_source": "oauth", + "expires_at": 1}) + bad = credentials.add(provider="codebuddy", credential_data={ + "bearer_token": "old", "refresh_token": "RT", "auth_source": "oauth", + "expires_at": 1}) + _break_ciphertext(credentials, db, bad) + + task = RefreshTask(credentials, {"codebuddy": ProbeProvider(refresh_new="NEW")}, + skew_seconds=3600, now=lambda: 1000) + with caplog.at_level("ERROR", logger="src.tasks.refresh"): + report = await task.run_once() + assert report.skipped >= 1 + + +async def test_checkin_skips_undecryptable_credential(repo, caplog): + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + bad = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t2"}) + _break_ciphertext(credentials, db, bad) + + task = CheckinTask(credentials, {"codebuddy": ProbeProvider()}) + with caplog.at_level("ERROR", logger="src.tasks.checkin"): + report = await task.run_once() + assert report.skipped >= 1 + + +async def test_growth_skips_undecryptable_credential(repo, caplog): + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + bad = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t2"}) + _break_ciphertext(credentials, db, bad) + + task = GrowthTask(credentials, {"codebuddy": ProbeProvider()}, + GrowthRepository(db)) + with caplog.at_level("ERROR", logger="src.tasks.growth"): + report = await task.run_once() + assert report.skipped >= 1 + + +async def test_activity_skips_undecryptable_credential(repo, caplog): + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + bad = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t2"}) + _break_ciphertext(credentials, db, bad) + + task = ActivityTask(credentials, {"codebuddy": ProbeProvider()}) + with caplog.at_level("ERROR", logger="src.tasks.activity"): + report = await task.run_once() + assert report.skipped >= 1 + + +async def test_model_probe_skips_undecryptable_credential(repo, caplog): + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + bad = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t2"}) + _break_ciphertext(credentials, db, bad) + + task = ModelProbeTask(credentials, {"codebuddy": ProbeProvider()}, + ModelProbeRepository(db)) + with caplog.at_level("ERROR", logger="src.tasks.model_probe"): + await task.run_once() + # 不抛异常即达标:早先直接冒泡让整个探活任务挂掉 + + +async def test_model_probe_uses_readable_credential_after_undecryptable_one(repo): + """第一个候选解不开 → 继续找下一个可用的(不是整条渠道放弃)。""" + credentials, db = repo + bad = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + good = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t2"}) + _break_ciphertext(credentials, db, bad) + + task = ModelProbeTask(credentials, {"codebuddy": ProbeProvider()}, + ModelProbeRepository(db)) + data = task._probe_data("codebuddy") + assert data is not None + assert data["bearer_token"] == "t2" + assert good != bad + + +async def test_growth_skips_undecryptable_credential_before_data_read(repo, caplog): + """成长中心:渠道无 growth 能力时先判能力、再读凭证(解不开的那条跳过)。""" + credentials, db = repo + bad = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + _break_ciphertext(credentials, db, bad) + + class NoGrowth: + id = "codebuddy" + + task = GrowthTask(credentials, {"codebuddy": NoGrowth()}, + GrowthRepository(db)) + with caplog.at_level("ERROR", logger="src.tasks.growth"): + report = await task.run_once() + # 无 growth 能力 → 跳过(读凭证前就返回,不会触发解密) + assert report.skipped == 1 + + +async def test_growth_skips_undecryptable_credential_with_growth_capable(repo, caplog): + """渠道**有**成长中心能力时,解不开的凭证被跳过(整轮不挂)。""" + from tests.provider.codebuddy._growth_support import StubGrowthProvider + + credentials, db = repo + bad = credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + _break_ciphertext(credentials, db, bad) + + task = GrowthTask(credentials, {"codebuddy": StubGrowthProvider()}, + GrowthRepository(db)) + with caplog.at_level("ERROR", logger="src.tasks.growth"): + report = await task.run_once() + assert report.skipped == 1 + assert report.attempted == 0 + + +async def test_refresh_skips_undecryptable_credential_after_lock(repo, caplog): + """预刷新:持锁后重读时解不开 → 跳过(不冒泡打断整轮)。""" + credentials, db = repo + bad = credentials.add(provider="codebuddy", credential_data={ + "bearer_token": "old", "refresh_token": "RT", "auth_source": "oauth", + "expires_at": 1}) + _break_ciphertext(credentials, db, bad) + + task = RefreshTask(credentials, {"codebuddy": ProbeProvider(refresh_new="NEW")}, + skew_seconds=3600, now=lambda: 1000) + with caplog.at_level("ERROR", logger="src.tasks.refresh"): + report = await task.run_once() + assert report.skipped >= 1 + + +async def test_refresh_reread_under_lock_survives_decrypt_failure(repo, caplog): + """**持锁后重读**那一刻解不开 → 跳过该凭证(早先直接冒泡)。 + + 锁内重读是「读 → await 上游 → 整份覆写」的第二读点:第一次读通过、 + 等锁期间凭证被换成解不开的密文,第二次读就抛。 + """ + credentials, db = repo + credentials.add(provider="codebuddy", credential_data={ + "bearer_token": "old", "refresh_token": "RT", "auth_source": "oauth", + "expires_at": 1}) + + task = RefreshTask(credentials, {"codebuddy": ProbeProvider(refresh_new="NEW")}, + skew_seconds=3600, now=lambda: 1000) + original = credentials.credential_data + calls = {"n": 0} + + def _flaky(cid): + calls["n"] += 1 + if calls["n"] == 1: + return original(cid) + raise CredentialDecryptError("credential decryption failed") + + credentials.credential_data = _flaky # type: ignore[method-assign] + with caplog.at_level("ERROR", logger="src.tasks.refresh"): + report = await task.run_once() + assert report.skipped == 1 + assert report.failed == 0 diff --git a/tests/tasks/test_growth.py b/tests/tasks/test_growth.py index a648d26c..54844a20 100644 --- a/tests/tasks/test_growth.py +++ b/tests/tasks/test_growth.py @@ -39,6 +39,35 @@ async def test_growth_task_skips_providers_without_growth_and_disabled(growth_re assert report.attempted == 0 and report.skipped == 2 assert provider.calls == 0 +async def test_growth_task_skips_soft_disabled_credentials(growth_repo): + """`enabled=0`(用户软暂停)不参与成长中心:暂停不能只停调度。 + + 与 checkin / refresh / quota_probe 同口径。此前只判 `disabled`,于是 + 「暂停」的号仍被后台任务拿去领积分。 + """ + credentials, events = growth_repo + paused_id = credentials.add(provider="codebuddy", credential_data={"bearer_token": "p"}) + assert credentials.set_enabled(paused_id, False) + provider = _StubGrowthProvider() + report = await GrowthTask(credentials, {"codebuddy": provider}, events).run_once() + assert report.attempted == 0 and report.skipped == 1 + assert provider.calls == 0 + + +async def test_growth_task_paused_does_not_claim_shared_account_scope(growth_repo): + """暂停凭证必须早于 `seen.add` 跳过,否则会白占同账号的名额。""" + credentials, events = growth_repo + paused_id = credentials.add(provider="codebuddy", + credential_data={"bearer_token": "a", "account_uid": "same"}) + credentials.set_enabled(paused_id, False) + credentials.add(provider="codebuddy", + credential_data={"bearer_token": "b", "account_uid": "same"}) + provider = _StubGrowthProvider() + report = await GrowthTask(credentials, {"codebuddy": provider}, events).run_once() + assert report.attempted == 1 and report.succeeded == 1 + assert provider.calls == 1 + + async def test_growth_task_dedupes_same_upstream_account(growth_repo): """同上游账号多凭证共享一轮:避免重复领取与多余请求。""" credentials, events = growth_repo diff --git a/tests/tasks/test_model_probe.py b/tests/tasks/test_model_probe.py index c94e526c..6404f110 100644 --- a/tests/tasks/test_model_probe.py +++ b/tests/tasks/test_model_probe.py @@ -2,12 +2,14 @@ 口径见 `src/tasks/model_probe.py`: -* 只探仍被 blocked 负缓存的 (渠道, 模型),同一结论只探一次; +* 探仍被 blocked 负缓存的 (渠道, 模型) 与**已判死**的行,同一结论只探一次; * 成功 → 清该渠道的 blocked 负缓存(恢复入口); * 上游明确「无此模型」→ failed,保留负缓存; * 额度/限流/网络/超时/静默空流 → inconclusive,**绝不禁用**; * 人工接管(manual != '')整行跳过; -* 探活不写 usage_events、不动冷却(与生产隔离)。 +* 探活不写 usage_events、不动冷却(与生产隔离); +* 判死行即使没有负缓存也必须复探:判死即从列表摘掉且没有到期时间,复探 + 是唯一的恢复入口(`model_probes.failed` 只看 `state`)。 """ from __future__ import annotations @@ -293,6 +295,73 @@ async def test_same_model_across_credentials_probed_once(repo): assert credentials.blocked_cooldowns() == [] +async def test_failed_probe_row_is_reprobed_and_recovers(repo): + """判死行即使没有负缓存也要复探:隐藏没有到期时间,复探是唯一恢复入口。 + + 真实场景(officeAce `ModelArts.81009`):上游 `/v2/models` 挂着模型但推理 + 端点 404,人工探活判死后该行没有负缓存兜底;旧口径下它永远留在 Playground。 + """ + credentials, db = repo + _add(credentials) + _probes(db).record(provider="codebuddy", model="Gone-Model", state="failed") + provider = FakeProvider() + task = ModelProbeTask(credentials, {"codebuddy": provider}, _probes(db)) + + report = await task.run_once() + + assert report.attempted == 1 + assert report.succeeded == 1 + # 复探用**上游原名**(`model_probes` 存的就是原名),不做小写化 + assert provider.calls[0][0] == "Gone-Model" + assert _probes(db).get("codebuddy", "Gone-Model")["state"] == "ok" + assert _probes(db).failed() == set() + + +async def test_failed_probe_row_still_failed_keeps_hidden(repo): + """复探仍判死 → 结论继续是 failed,展示保持隐藏(不是「探过就放出来」)。""" + credentials, db = repo + _add(credentials) + _probes(db).record(provider="codebuddy", model="m", state="failed") + provider = FakeProvider([Event(EventKind.ERROR, error_message="no such model", + error_kind=ErrKind.BLOCKED)]) + task = ModelProbeTask(credentials, {"codebuddy": provider}, _probes(db)) + + report = await task.run_once() + + assert report.failed == 1 + row = _probes(db).get("codebuddy", "m") + assert (row["state"], row["failures"]) == ("failed", 2) + assert _probes(db).failed() == {("codebuddy", "m")} + + +async def test_blocked_and_failed_row_probed_only_once(repo): + """判死行同时挂着负缓存时只探一次(两类目标去重)。""" + credentials, db = repo + cid = _add(credentials) + _seed_blocked(credentials, credential_id=cid, model="m") + _probes(db).record(provider="codebuddy", model="m", state="failed") + provider = FakeProvider() + task = ModelProbeTask(credentials, {"codebuddy": provider}, _probes(db)) + + report = await task.run_once() + + assert report.attempted == 1 + assert len(provider.calls) == 1 + assert credentials.blocked_cooldowns() == [] + + +async def test_failed_probe_row_without_credentials_is_skipped(repo): + """判死行所属渠道一个凭证都没有 → skipped(不炸,也不清任何东西)。""" + credentials, db = repo + _probes(db).record(provider="codebuddy", model="m", state="failed") + task = ModelProbeTask(credentials, {"codebuddy": FakeProvider()}, _probes(db)) + + report = await task.run_once() + + assert (report.attempted, report.skipped) == (0, 1) + assert _probes(db).failed() == {("codebuddy", "m")} + + async def test_manual_takeover_skips_probe(repo): """人工接管后自动探活整行跳过(不让后台纠正人的判断)。""" credentials, db = repo diff --git a/tests/tasks/test_pacer.py b/tests/tasks/test_pacer.py index ad653137..fdcda7d7 100644 --- a/tests/tasks/test_pacer.py +++ b/tests/tasks/test_pacer.py @@ -7,7 +7,7 @@ import pytest from src.tasks import TaskReport -from src.tasks.pacer import JITTER_RATIO, Pacer, stable_key +from src.tasks.pacer import JITTER_RATIO, Pacer, paced, stable_key async def test_pacer_disabled_is_noop(): @@ -400,3 +400,71 @@ def test_pacer_range_interval_is_not_jittered_further(): def test_pacer_disabled_next_interval_is_zero(): assert Pacer(0, 0).next_interval() == 0.0 + + +# ------------------------------------------------------------------ paced() + + +class _RecordingPacer: + """只实现 provider 真正用到的两个接口(paced 不要求更多)。""" + + def __init__(self) -> None: + self.waited: list[str | None] = [] + self.released: list[str | None] = [] + + async def wait_turn(self, key: str | None = None) -> None: + self.waited.append(key) + + def release(self, key: str | None = None) -> None: + self.released.append(key) + + +async def test_paced_takes_and_releases_turn(): + pacer = _RecordingPacer() + async with paced(pacer, "bucket"): + assert pacer.waited == ["bucket"] + assert pacer.released == [] # 还没出块 + + assert pacer.released == ["bucket"] + + +async def test_paced_releases_even_when_body_raises(): + """`finally` 归还名额:漏了会让该桶永久满载(「用了三次就限制」)。""" + pacer = _RecordingPacer() + with pytest.raises(RuntimeError): + async with paced(pacer, "b"): + raise RuntimeError("上游炸了") + + assert pacer.released == ["b"] + + +async def test_paced_releases_on_generator_early_close(): + """`async for ... break` 不耗尽生成器:出块时必须已归还。""" + pacer = _RecordingPacer() + + async def consume() -> None: + async with paced(pacer, "b"): + yield 1 # noqa: B901 - 故意只产一个 + + gen = consume() + assert await gen.__anext__() == 1 + await gen.aclose() # 模拟 break 后的显式关闭 + + assert pacer.released == ["b"] + + +async def test_paced_without_pacer_is_passthrough(): + """无凭证渠道 / 测试:pacer=None 直接放行,不报错。""" + ran = False + async with paced(None, "whatever"): + ran = True + + assert ran is True + + +async def test_paced_passes_key_through(): + pacer = _RecordingPacer() + async with paced(pacer, None): + pass + + assert pacer.waited == [None] and pacer.released == [None] diff --git a/tests/tasks/test_quota_probe.py b/tests/tasks/test_quota_probe.py index 79554179..8a18c6b3 100644 --- a/tests/tasks/test_quota_probe.py +++ b/tests/tasks/test_quota_probe.py @@ -24,6 +24,25 @@ async def test_quota_probe_failure_marks_unknown_not_zero(repo): assert report.failed == 1 assert credentials.candidates()[0].health is None +async def test_quota_probe_dead_401_marks_relogin_required(repo): + """401 = 登录态死亡:额度探测直接硬禁用并写可操作原因,不停在「未知」。""" + from src.provider.lingxi.client import UpstreamHTTPError + + credentials, _db = repo + credential_id = credentials.add( + provider="lingxi", credential_data={"wps_sid": "sid", "uid": "u1"}) + task = QuotaProbeTask( + credentials, {"lingxi": ProbeProvider(error=UpstreamHTTPError(401, b""))}) + report = await task.run_once() + + assert report.failed == 1 + row = next(r for r in credentials.list_all() if r["id"] == credential_id) + assert row["disabled"] + assert "WPS" in row["disabled_reason"] + # 硬禁用后不再进候选池,下一轮也不会再探 + assert credentials.candidates(selectable_only=True) == [] + + async def test_quota_probe_skips_unknown_provider_and_missing_data(repo): credentials, _db = repo credentials.add(provider="ghost", credential_data={"x": 1}) @@ -58,3 +77,68 @@ async def test_quota_probe_skips_when_credential_vanished(repo): report = await task.run_once() credentials.credential_data = original # type: ignore[method-assign] assert report.skipped == 1 + + +async def test_quota_probe_skips_disabled_and_paused_credentials(repo): + """硬禁用与用户软暂停的号不探测:必然失败,只会白打上游并刷 WARNING。 + + 口径与 refresh / checkin / growth / activity 一致(实测容器实例对一条 + 10-15 就过期的硬禁用号每小时探一次,7 天 160 次必然 401)。 + """ + from src.engine.scheduler import ErrorOutcome + + credentials, _db = repo + alive = credentials.add(provider="codebuddy", credential_data={"bearer_token": "a"}) + dead = credentials.add(provider="codebuddy", credential_data={"bearer_token": "d"}) + paused = credentials.add(provider="codebuddy", credential_data={"bearer_token": "p"}) + credentials.save_error(dead, ErrorOutcome(disabled=True)) + credentials.set_enabled(paused, False) + provider = ProbeProvider() + task = QuotaProbeTask(credentials, {"codebuddy": provider}) + + report = await task.run_once() + + assert report.attempted == 1 + assert report.succeeded == 1 + assert report.skipped == 2 + # 死号/暂停号的 health 不被探测改写(保持原值) + healths = {c.credential_id: c.health for c in credentials.candidates()} + assert healths[dead] is None + assert healths[paused] is None + assert healths[alive] == 80 + + +async def test_quota_probe_scopes_credential_for_proxy_stickiness(repo): + """探活必须在「该凭证」的上下文里发(出口 IP 与推理一致)。""" + from src.request_context import get_credential_id + + credentials, _db = repo + credential_id = credentials.add( + provider="codebuddy", credential_data={"bearer_token": "t"}) + seen: list[str | None] = [] + + class _Scoped: + async def probe_quota(self, data): + seen.append(get_credential_id()) + from src.provider.base import Quota + return Quota(remaining=1, total=2, probed_at=1) + + task = QuotaProbeTask(credentials, {"codebuddy": _Scoped()}) + await task.run_once() + assert seen == [credential_id] + # 退出循环后归还上下文,不把凭证 id 泄漏给后续任务 + assert get_credential_id() is None + + +async def test_quota_probe_log_includes_type_when_message_empty(repo, caplog): + """裸 `TimeoutError` 的 str 为空,日志必须补类名(否则只剩「: 」)。""" + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + task = QuotaProbeTask(credentials, + {"codebuddy": ProbeProvider(error=TimeoutError())}) + with caplog.at_level("WARNING", logger="src.tasks.quota_probe"): + report = await task.run_once() + assert report.failed == 1 + message = next(r.getMessage() for r in caplog.records + if "quota probe failed" in r.getMessage()) + assert message.endswith("TimeoutError") diff --git a/tests/tasks/test_retention.py b/tests/tasks/test_retention.py new file mode 100644 index 00000000..b929922d --- /dev/null +++ b/tests/tasks/test_retention.py @@ -0,0 +1,106 @@ +"""RetentionTask 的 rollup 窗口:只重算最近窗口,旧小时不得被改小。 + +全表 GROUP BY 每 5 分钟跑一次会长时间持有 SQLite 写锁,期间的 `record()` +撞 busy_timeout 后被上层吞掉 → 用量丢失。周期路径改成「最近 2 小时、整点 +对齐」的增量重算;历史全量重算由 `scripts/rollup_hourly.py` 显式触发。 +""" + +from __future__ import annotations + +from src.db.conn import Database +from src.db.migrate import apply_schema +from src.stats.collector import StatsCollector +from src.tasks.retention import ROLLUP_OVERLAP_SECONDS, RetentionTask + + +class _RecordingCollector: + """只记录窗口起点(本用例不落库)。""" + + def __init__(self) -> None: + self.since: int | None = -1 + self.purged_now: int | None = -1 # run_once 必须把 now 也传给清理 + + def rollup_hourly(self, since: int | None = None) -> int: + self.since = since + return 0 + + def purge_expired(self, _days: int, now: int | None = None) -> int: + self.purged_now = now + return 0 + + +def test_rollup_since_aligns_down_to_whole_hour(): + now = 1_700_000_000 + since = RetentionTask.rollup_since(now) + assert since % 3600 == 0 + # 必须覆盖完整窗口,且边界小时整小时参与重算(否则 REPLACE 会写小) + assert since <= now - ROLLUP_OVERLAP_SECONDS + assert since > now - ROLLUP_OVERLAP_SECONDS - 3600 + + +def test_rollup_since_near_epoch_falls_back_to_full_scan(): + """时间戳极早(测试/时钟异常)时退回全量,宁可慢也不漏。""" + assert RetentionTask.rollup_since(0) == 0 + assert RetentionTask.rollup_since(60) == 0 + + +def test_run_once_passes_window_to_rollup(): + collector = _RecordingCollector() + task = RetentionTask(collector) # type: ignore[arg-type] + report = task.run_once(now=1_700_000_000) + assert collector.since == RetentionTask.rollup_since(1_700_000_000) + # now 同样传播给清理:不传 on 就会用真实时间(测试输出依赖系统时钟) + assert collector.purged_now == 1_700_000_000 + assert report["rolled_up"] == 0 + + +def test_periodic_rollup_does_not_rewrite_old_hours(tmp_path): + """窗口外的小时保持原值:证明周期任务不再全表重算。""" + db = Database(tmp_path / "st.sqlite3") + apply_schema(db.connect()) + collector = StatsCollector(db) + now = 1_700_000_000 + old_hour = ((now - 10 * 86400) // 3600) * 3600 + recent_hour = ((now - 60) // 3600) * 3600 + + collector.record(username="alice", provider="trae", model="m", ok=True, + input_tokens=1, now=old_hour + 10) + collector.record(username="alice", provider="trae", model="m", ok=True, + input_tokens=5, now=recent_hour + 10) + # 人工改掉旧小时(模拟「历史上已被对账过的汇总」):窗口内重算不得碰它 + with db.transaction() as conn: + conn.execute("UPDATE usage_hourly SET requests = 999 WHERE hour_utc = ?", + (old_hour,)) + + report = RetentionTask(collector).run_once(now=now) + assert report["rolled_up"] >= 1 + + with db.transaction() as conn: + old = conn.execute("SELECT requests FROM usage_hourly WHERE hour_utc = ?", + (old_hour,)).fetchone() + recent = conn.execute( + "SELECT requests, input_tokens FROM usage_hourly WHERE hour_utc = ?", + (recent_hour,)).fetchone() + assert old["requests"] == 999 # 窗口外:完全不参与重算 + assert recent["requests"] == 1 and recent["input_tokens"] == 5 + db.close() + + +def test_retention_prunes_checkin_events(): + """签到审计日志按保留期清理;未装配时不做任何清理。""" + class _Events: + def __init__(self) -> None: + self.keep: int | None = None + + def prune(self, *, keep_days: int) -> int: + self.keep = keep_days + return 5 + + events = _Events() + report = RetentionTask(_RecordingCollector(), checkin_events=events, + retention_days=30).run_once(now=1_700_000_000) + assert events.keep == 30 and report["purged_checkin_events"] == 5 + + # 老调用方(不传 checkin_events)不清理,报告里计 0 + bare = RetentionTask(_RecordingCollector()).run_once(now=1_700_000_000) + assert bare["purged_checkin_events"] == 0 diff --git a/tests/tasks/test_runner.py b/tests/tasks/test_runner.py index bfdb59a6..a5095696 100644 --- a/tests/tasks/test_runner.py +++ b/tests/tasks/test_runner.py @@ -23,6 +23,19 @@ from tests.helpers.constants import SECRET +def _cn_epoch_of(year: int, month: int, day: int, hour: int, minute: int = 0) -> int: + """北京时间某刻的 epoch 秒(签到日界固定 Asia/Shanghai,与进程 TZ 无关)。 + + 不能用 `time.mktime`:它按本地时区解释,CI(UTC)上会差 8 小时,让 + 「封账未到期」的用例假失败。 + """ + from datetime import datetime + from zoneinfo import ZoneInfo + + return int(datetime(year, month, day, hour, minute, + tzinfo=ZoneInfo("Asia/Shanghai")).timestamp()) + + class StubProvider: id = "codebuddy" @@ -393,7 +406,7 @@ async def test_providers_release_http_pools_on_shutdown(tmp_path): trae = TraeProvider() # 触发惰性客户端创建 assert codebuddy.client._stream is not None - assert trae.client._stream() is not None + assert trae.client._stream is not None await codebuddy.aclose() await trae.aclose() @@ -402,6 +415,56 @@ async def test_providers_release_http_pools_on_shutdown(tmp_path): await trae.aclose() +def test_lifespan_starts_task_runner_in_background(monkeypatch, tmp_path): + """lifespan 必须把 runner.start() 放到后台,不能在 startup 里 await。 + + `start()` 首轮是两个真实的上游网络调用(额度探测 + token 预刷新),最慢 + 十几秒;内联会让 lifespan startup 不结束 → uvicorn 尚未 accept,容器健康 + 探针在窗口内判 unhealthy。放后台后 /health 在 start 完成前就是可达的; + 关闭期则先收尾 start 再 stop(否则 stop 先跑会产生孤儿循环任务)。 + """ + import asyncio + + from fastapi.testclient import TestClient + + from src.main import build_app + from tests.helpers.auth import isolated_settings + + # 探测预算(远大于一次 /health 往返):请求返回时首轮探测「仍在跑」 + SLOW_START_SECONDS = 0.5 + + class FakeRunner: + """只实现 lifespan 用到的面:start / stop(与 state 引用)。""" + + def __init__(self): + self.start_entered = asyncio.Event() + self.start_completed = False + self.stopped = False + self.stop_after_start = False # stop 必须晚于 start 完成 + + async def start(self): + self.start_entered.set() + await asyncio.sleep(SLOW_START_SECONDS) # 模拟首轮上游 IO + self.start_completed = True + + async def stop(self): + self.stopped = True + self.stop_after_start = self.start_completed + + fake = FakeRunner() + monkeypatch.setattr("src.main.build_runner", lambda *a, **k: fake) + + app = build_app(isolated_settings(tmp_path)) + with TestClient(app) as http: + http.get("/health") + # start 已被调度(create_task 执行过至少一次调度点),但仍在首轮探测中 + assert fake.start_entered.is_set(), "runner.start 未被调度" + assert not fake.start_completed, "startup 又变成了内联 await" + + # 关闭期:先等 start 收尾,再 stop(顺序即不产生孤儿循环) + assert fake.start_completed and fake.stopped and fake.stop_after_start + + def test_run_entry_point_exists_and_is_callable(): """pyproject 的 [project.scripts] 指向 src.main:run,它必须存在。""" import inspect @@ -455,7 +518,7 @@ async def test_runner_checkin_fires_when_due(repo, monkeypatch): provider = StubProvider() calls: list[int] = [] - async def fake_run_once(*, now=None): + async def fake_run_once(*, now=None, trigger="auto"): calls.append(1) from src.tasks import TaskReport @@ -530,6 +593,97 @@ def fake_run(app, **kwargs): assert captured["access_log"] is False # 停机排空:不给 timeout_graceful_shutdown 就是无限等(长流卡住直到 SIGKILL) assert captured["timeout_graceful_shutdown"] == main.GRACEFUL_SHUTDOWN_SECONDS + # uvicorn 自带 proxy_headers=True 会把回环对端请求的 scope["client"] 改写成 + # X-Forwarded-For 尾条目,绕过应用层 `TRUST_PROXY` 判定(来源 IP 白名单 / + # 登录限流可被回环调用方伪造)。必须显式关掉,来源解析只归 access.client_ip。 + assert captured["proxy_headers"] is False + + +def test_run_releases_instance_lock_after_uvicorn_returns(monkeypatch, tmp_path): + """uvicorn 退出后必须释放锁,否则重启永远起不来。""" + import sys + import types + + from src import main + from src.instance_lock import LOCK_NAME, InstanceLock + + fake_uvicorn = types.ModuleType("uvicorn") + fake_uvicorn.run = lambda app, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "uvicorn", fake_uvicorn) + monkeypatch.setenv("APP_SECRET", SECRET) + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + + main.run() + + # 能再次取到锁 = 上次确实释放了 + lock = InstanceLock(tmp_path) + lock.acquire() + lock.release() + assert (tmp_path / LOCK_NAME).is_file() + + +def test_run_aborts_when_another_instance_holds_lock(monkeypatch, tmp_path, capsys): + """已有实例持锁时 run() 干净返回,绝不进 build_app(避免双写库)。 + + 这是崩溃循环的止损点:若这里以非零退出,launchd(SuccessfulExit=false)/ + systemd(Restart=on-failure)会把「另一个实例在跑」当成崩溃每 10s 重拉, + 实测刷过 2377 条 `[Errno 48]`。所以必须退出码 0。 + """ + import sys + import types + + from src import main + from src.instance_lock import InstanceLock + + started: list[int] = [] + + def fake_run(app, **kwargs): + started.append(1) + + fake_uvicorn = types.ModuleType("uvicorn") + fake_uvicorn.run = fake_run # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "uvicorn", fake_uvicorn) + monkeypatch.setenv("APP_SECRET", SECRET) + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + + holder = InstanceLock(tmp_path) + holder.acquire() + try: + main.run() # 不得抛 SystemExit(非零退出会触发重拉循环) + finally: + holder.release() + + assert started == [], "取锁失败时不得启动 uvicorn" + err = capsys.readouterr().err + assert "启动中止" in err + assert "另一个实例" in err + + +def test_run_releases_lock_when_build_app_raises(monkeypatch, tmp_path): + """build_app 抛异常也要释放锁(finally 覆盖,不是只在正常路径)。""" + import sys + import types + + from src import main + from src.instance_lock import InstanceLock + + fake_uvicorn = types.ModuleType("uvicorn") + fake_uvicorn.run = lambda app, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "uvicorn", fake_uvicorn) + monkeypatch.setenv("APP_SECRET", SECRET) + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + + def boom(_config): + raise RuntimeError("组装失败") + + monkeypatch.setattr(main, "build_app", boom) + + with pytest.raises(RuntimeError): + main.run() + + lock = InstanceLock(tmp_path) + lock.acquire() + lock.release() async def test_loop_executes_runner_after_interval(repo): @@ -586,17 +740,18 @@ async def failing_checkin(_data): runner, _collector = _runner(repo, provider) task = runner._checkin task._done_scopes.clear() - now = _time.localtime() + now = _time.struct_time((2026, 9, 11, 10, 0, 0, 3, 254, 0)) report = await task.run_once(now=now) assert report.failed == 1 and report.succeeded == 0 assert task._day_key(now) not in task._done_scopes # 未封账 → 下轮重试 - # 下一轮成功 → 封账 + # 下一轮(退避 10 分钟后)成功 → 封账 async def ok_checkin(_data): return CheckinResult(ok=True, credit=200, code=0) provider.checkin = ok_checkin - report2 = await task.run_once(now=now) + retry_at = _time.struct_time((2026, 9, 11, 10, 10, 0, 3, 254, 0)) + report2 = await task.run_once(now=retry_at) assert report2.succeeded == 1 and report2.failed == 0 assert any(key.startswith(f"{task._day_key(now)}:") for key in task._done_scopes) @@ -618,7 +773,9 @@ async def sealed_checkin(_data): message="今日已签到", seal_until=window_end) midnight = time.struct_time((2026, 10, 8, 0, 5, 0, 0, 1, -1)) - window_end = int(time.mktime((2026, 10, 8, 10, 5, 0, 0, 1, -1))) + # 期望值按北京时间算:`_sealed` 用 `_cn_epoch` 解释 struct_time,CI(UTC) + # 上若用 time.mktime 会整体差 8 小时,封账立刻过期 → 用例假失败 + window_end = _cn_epoch_of(2026, 10, 8, 10, 5) provider.checkin = sealed_checkin task = CheckinTask(credentials, {"codebuddy": provider}) @@ -651,7 +808,7 @@ async def test_checkin_seal_expired_entry_is_not_treated_as_sealed(repo): async def checkin(_data): calls.append(1) return CheckinResult(ok=True, credit=1, code=0, - seal_until=int(time.mktime((2026, 10, 8, 0, 10, 0, 0, 1, -1)))) + seal_until=_cn_epoch_of(2026, 10, 8, 0, 10)) provider.checkin = checkin task = CheckinTask(credentials, {"codebuddy": provider}) @@ -662,11 +819,14 @@ async def checkin(_data): assert len(calls) == 2 -def test_checkin_day_end_is_next_local_midnight(): - """默认封账截止 = 当日 24:00(渠道不给 seal_until 时的兜底)。""" +def test_checkin_day_end_is_next_beijing_midnight(): + """默认封账截止 = 当日 24:00(渠道不给 seal_until 时的兜底)。 + + 日界是**北京时间**,与进程 TZ 无关(CI runner 是 UTC)。 + """ task = CheckinTask.__new__(CheckinTask) now = time.struct_time((2026, 10, 8, 23, 30, 0, 0, 1, -1)) - assert task._day_end(now) == int(time.mktime((2026, 10, 9, 0, 0, 0, 0, 1, -1))) + assert task._day_end(now) == _cn_epoch_of(2026, 10, 9, 0, 0) class RecordingPacer: diff --git a/tests/tasks/test_status.py b/tests/tasks/test_status.py index e0490b0e..d7b7ec4e 100644 --- a/tests/tasks/test_status.py +++ b/tests/tasks/test_status.py @@ -21,7 +21,7 @@ from src.tasks.refresh import RefreshTask from src.tasks.retention import RetentionTask from src.tasks.runner import TaskRunner, build_runner -from src.tasks.status import TASK_BY_KEY, TASK_SPECS, TaskStatusStore +from src.tasks.status import TASK_BY_KEY, TASK_GROUPS, TASK_SPECS, TaskStatusStore from tests.helpers.constants import SECRET @@ -188,6 +188,8 @@ def test_task_status_lists_assembled_tasks_with_intervals(repo): "quota_probe", "refresh", "checkin", "retention"] by_key = {item["key"]: item for item in status} assert by_key["quota_probe"]["interval_seconds"] == 3600.0 + assert by_key["quota_probe"]["group"] == "account" + assert by_key["retention"]["group"] == "ops" assert by_key["checkin"]["interval_seconds"] == 600.0 assert by_key["retention"]["interval_seconds"] == 300.0 assert by_key["quota_probe"]["enabled"] is True @@ -288,8 +290,11 @@ def test_runner_reuses_injected_status_store(repo): def test_task_specs_are_unique_and_named(): assert len({spec.key for spec in TASK_SPECS}) == len(TASK_SPECS) + group_keys = {key for key, _ in TASK_GROUPS} for spec in TASK_SPECS: assert spec.name and spec.description + # 分组必须真实存在:否则管理台二级 tab 里会出现「无主」任务 + assert spec.group in group_keys, spec.key assert TASK_BY_KEY[spec.key] is spec @@ -315,10 +320,16 @@ def test_tasks_returns_runtime_snapshot(admin_client): _app, client = admin_client body = client.get("/api/tasks").json() assert isinstance(body["server_time"], int) and body["server_time"] > 0 + assert body["groups"] == [ + {"value": "account", "label": "账号维护"}, + {"value": "model", "label": "模型与渠道"}, + {"value": "ops", "label": "运维与数据"}, + ] keys = [item["key"] for item in body["tasks"]] assert "quota_probe" in keys and "retention" in keys probe = next(item for item in body["tasks"] if item["key"] == "quota_probe") assert probe["name"] == "额度探测" + assert probe["group"] == "account" assert probe["interval_seconds"] == 3600.0 assert probe["last_ok"] is True # 启动首轮探测(zen/kilo 虚拟凭证)也是真实执行 assert probe["last_report"] == { @@ -358,9 +369,16 @@ def test_settings_snapshot_carries_task_owner(tmp_path): assert by_key["activity_report_enabled"]["task"] == "activity" assert by_key["default_model"]["task"] is None # 无任务归属的项带网关卡组,供前端分一级 tab - assert by_key["default_model"]["group"] == "routing" - assert by_key["codebuddy_chat_min_interval"]["group"] == "channels" + assert by_key["default_model"]["group"] == "model_routing" + assert by_key["context_compress_enabled"]["group"] == "compress" + assert by_key["stream_idle_timeout_seconds"]["group"] == "stream_thinking" + assert by_key["gateway_paused"]["group"] == "gateway_control" + assert by_key["codebuddy_chat_min_interval"]["group"] == "chat_throttle" + assert by_key["codearts_chat_min_interval"]["group"] == "codearts_throttle" assert by_key["quota_probe_minutes"]["group"] is None + # 卡片内分节只对任务项下发 + assert by_key["alert_for_ticks"]["section"] == "基础与迟滞" + assert by_key["default_model"]["section"] is None def test_build_runner_accepts_shared_status_store(repo): @@ -446,6 +464,30 @@ async def test_run_task_bypasses_activity_hour_window(repo): assert provider.activity_calls == 1 +async def test_run_task_bypasses_checkin_window(repo): + """手动触发签到同样绕过 due() 时点窗口:窗口外也真跑。 + + 回归:此前只有 activity 特判 manual,签到恒走 `_sync_checkin`, + 管理台点「立即运行签到」在非时点窗口返回 ran=False(与文档契约不符)。 + """ + credentials, _db = repo + credentials.add(provider="codebuddy", credential_data={"bearer_token": "t"}) + runner = _runner(repo, StubProvider()) + calls: list[str] = [] + + async def fake_run_once(**kwargs): + calls.append("ran") + return {"succeeded": 1} + + runner._checkin.due = lambda: False + runner._checkin.run_once = fake_run_once # type: ignore[method-assign] + + result = await runner.run_task("checkin") + + assert result["ran"] is True + assert calls == ["ran"] + + async def test_run_task_skips_when_already_running(repo): """同一任务已在跑时立刻返回 busy,不排队、不并发打上游。""" credentials, _db = repo diff --git a/tests/tasks/test_upstream_status.py b/tests/tasks/test_upstream_status.py index a8edc3f3..3820f660 100644 --- a/tests/tasks/test_upstream_status.py +++ b/tests/tasks/test_upstream_status.py @@ -218,6 +218,38 @@ async def slow_run_once(self): assert warmup.cancelled() or warmup.done() +def test_shutdown_cancels_all_pending_warmups(tmp_path, monkeypatch): + """关闭时三个后台预热(模型列表 / 价表 / 上游状态)都要取消。 + + 三个都 monkeypatch 成挂住,是为了让**取消分支确定性覆盖**:此前只挂上游 + 状态页,模型/价表预热靠「关闭时正好还没跑完」才走到取消块——CI(无 `.env`、 + 无外网)里它们先跑完,`main` 的取消分支变成未覆盖,100% 门槛随机红 + (实测 2026-11-02 CI 报 `src/main.py 717-719` 缺失)。 + """ + import asyncio + + from fastapi.testclient import TestClient + + from src.config import Settings + from src.main import build_app + from tests.helpers.constants import SECRET + + async def hang(*_args, **_kwargs): + await asyncio.sleep(3600) + + monkeypatch.setattr("src.main._warm_model_list", hang) + monkeypatch.setattr("src.main._warm_model_catalog", hang) + monkeypatch.setattr(UpstreamStatusTask, "run_once", hang) + app = build_app(Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path))) + + with TestClient(app): + warmups = [app.state.model_warmup_task, app.state.catalog_warmup_task, + app.state.upstream_warmup_task] + assert all(not task.done() for task in warmups) + + assert all(task.cancelled() or task.done() for task in warmups) + + @pytest.mark.parametrize("level", [LEVEL_OPERATIONAL, LEVEL_DEGRADED]) async def test_run_once_counts_operational_as_normal(level): """operational 之外才算异常——包括维护与未知。""" diff --git a/tests/test_error_text.py b/tests/test_error_text.py new file mode 100644 index 00000000..264b40ac --- /dev/null +++ b/tests/test_error_text.py @@ -0,0 +1,42 @@ +"""异常文本助手:空消息不能打出无因日志;带凭证形态的消息不得原样进日志。""" + +from __future__ import annotations + +from src.error_text import error_text + + +def test_error_text_keeps_type_and_message(): + error = ValueError("bad value") + assert error_text(error) == "ValueError: bad value" + + +def test_error_text_uses_type_name_when_message_empty(): + """`TimeoutError` / 部分 `ConnectError` 的 str 为空 → 至少留类名。""" + assert error_text(TimeoutError()) == "TimeoutError" + assert error_text(ConnectionError()) == "ConnectionError" + + +def test_error_text_collapses_whitespace_and_ignores_blank_message(): + assert error_text(RuntimeError(" bad\n\t value ")) == "RuntimeError: bad value" + assert error_text(RuntimeError(" ")) == "RuntimeError" + + +def test_error_text_truncates_long_message(): + text = error_text(RuntimeError("x" * 500)) + assert text == "RuntimeError: " + "x" * 200 + "…" + + +def test_error_text_drops_message_that_looks_like_a_credential(): + """上游错误体可能回显鉴权料:命中脱敏规则时只留类名。 + + `UpstreamHTTPError` 的消息带 `body_hint`(最长 2 KB 的上游响应体)。日志 + 缓冲层的 `redact_or_drop` 会把整行丢掉,但那条正则漏了不少形态;在产生 + 日志文本这一处先判,等于不让它进日志。 + """ + assert error_text(RuntimeError('{"access_token": "abc123"}')) == "RuntimeError" + assert error_text(RuntimeError("Authorization: Bearer eyJhbGciOi.xx.yy")) == "RuntimeError" + assert error_text(RuntimeError("api_key=sk-live-123")) == "RuntimeError" + + # 普通文本(上游业务码/文案)照常保留,排障信息不能一起丢 + assert error_text(RuntimeError("InferHub.001001020.406 not multimodal")) == ( + "RuntimeError: InferHub.001001020.406 not multimodal") diff --git a/tests/test_instance_lock.py b/tests/test_instance_lock.py new file mode 100644 index 00000000..0d64ebf1 --- /dev/null +++ b/tests/test_instance_lock.py @@ -0,0 +1,136 @@ +"""单实例锁:同一 DATA_DIR 只能有一个进程。 + +背景:SQLite 允许多进程并发写(WAL + 忙等),所以「起了两个实例」不会报错, +只会安静地互相覆盖——两个进程各自跑后台任务(额度探测、签到、模型目录刷新), +同一凭证被探两次、同一小时汇总写两遍。更常见的形态是 launchd 崩溃循环: +端口被占 → 绑定失败 → 退出 → KeepAlive 每 10s 重拉(实测刷了 2377 次 +`[Errno 48]`,见 2026-09-19~10-17 的 logs/launchd.err.log)。 + +锁用 `flock` 而不是端口探测:端口探测有 TOCTOU 窗口,且只能保护「同端口」 +的实例;两个实例配不同 PORT 时照样双写同一个库。flock 由内核在进程退出 +(含 SIGKILL)时自动释放,不留需要人工清理的陈旧锁。 +""" + +from __future__ import annotations + +import os + +import pytest + +from src.instance_lock import LOCK_NAME, AlreadyRunning, InstanceLock, hold_instance_lock + + +def test_acquire_creates_lock_file_with_pid(tmp_path): + """拿到锁后锁文件里写着本进程 PID(仅用于报错文案)。""" + lock = InstanceLock(tmp_path) + lock.acquire() + try: + assert (tmp_path / LOCK_NAME).read_text(encoding="utf-8").strip() == str( + os.getpid()) + finally: + lock.release() + + +def test_second_acquire_in_same_process_is_rejected(tmp_path): + """同进程内第二次取锁也失败:flock 是进程级的,重入不递增计数。""" + first = InstanceLock(tmp_path) + first.acquire() + try: + with pytest.raises(AlreadyRunning): + InstanceLock(tmp_path).acquire() + finally: + first.release() + + +def test_second_process_is_rejected(tmp_path): + """真正的并发场景:子进程持锁期间,父进程取不到。""" + import subprocess + import sys + import textwrap + + holder = subprocess.Popen( + [sys.executable, "-c", textwrap.dedent(f""" + import sys, time + sys.path.insert(0, {str(os.getcwd())!r}) + from src.instance_lock import InstanceLock + lock = InstanceLock({str(tmp_path)!r}) + lock.acquire() + print("locked", flush=True) + time.sleep(30) + """)], + stdout=subprocess.PIPE, text=True, + ) + try: + assert holder.stdout is not None + assert holder.stdout.readline().strip() == "locked" + with pytest.raises(AlreadyRunning) as excinfo: + InstanceLock(tmp_path).acquire() + # 报错文案必须带持有者 PID:崩溃循环里这行是唯一线索 + assert "PID" in str(excinfo.value) + assert excinfo.value.holder.strip() != str(os.getpid()) + finally: + holder.kill() + holder.wait(timeout=10) + + +def test_release_allows_reacquire(tmp_path): + """释放后可以再取(重启场景)。""" + lock = InstanceLock(tmp_path) + lock.acquire() + lock.release() + again = InstanceLock(tmp_path) + again.acquire() + again.release() + + +def test_release_is_idempotent(tmp_path): + """重复 release 不抛:finally 里可能被调用两次。""" + lock = InstanceLock(tmp_path) + lock.acquire() + lock.release() + lock.release() + + +def test_context_manager_releases_on_exception(tmp_path): + """异常路径也要释放,否则下一个实例永远起不来。""" + with pytest.raises(ValueError), InstanceLock(tmp_path): + raise ValueError("boom") + InstanceLock(tmp_path).acquire() + + +def test_missing_lock_file_reports_unknown_holder(tmp_path): + """锁文件读不到时 holder 回落 unknown,不让报错路径自己崩掉。""" + lock = InstanceLock(tmp_path) + assert lock._read_holder() == "unknown" + + +def test_empty_lock_file_reports_unknown_holder(tmp_path): + """空内容同样回落 unknown(被 truncate 但还没写 PID 的瞬间)。""" + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / LOCK_NAME).write_text(" \n", encoding="utf-8") + assert InstanceLock(tmp_path)._read_holder() == "unknown" + + +def test_hold_instance_lock_creates_data_dir(tmp_path): + """DATA_DIR 还不存在时也能取锁(首次启动)。""" + target = tmp_path / "fresh" + lock = hold_instance_lock(target) + try: + assert (target / LOCK_NAME).is_file() + finally: + lock.release() + + +def test_already_running_message_is_actionable(tmp_path): + """报错文案必须说清「同目录只能一个实例」与怎么解除。""" + first = InstanceLock(tmp_path) + first.acquire() + try: + with pytest.raises(AlreadyRunning) as excinfo: + InstanceLock(tmp_path).acquire() + text = str(excinfo.value) + assert "另一个实例" in text + assert "只能起一个进程" in text + assert "删掉" in text + finally: + first.release() diff --git a/tests/webapp/test_handlers.py b/tests/webapp/test_handlers.py index 4a55b5fb..0c487b91 100644 --- a/tests/webapp/test_handlers.py +++ b/tests/webapp/test_handlers.py @@ -9,6 +9,7 @@ from fastapi.testclient import TestClient from starlette.requests import ClientDisconnect +from src.db.crypto import CredentialDecryptError from src.main import build_app from tests.helpers.auth import isolated_settings @@ -37,3 +38,29 @@ async def _disconnected(_request): assert response.json()["error"]["code"] == "client_disconnected" assert response.json()["error"]["status"] == 499 assert response.headers["x-request-id"] == "req-disconnect" + + +def test_credential_decrypt_error_returns_actionable_500(tmp_path, monkeypatch): + """凭证解密失败 → 500 + 可行动提示(不是裸 traceback)。 + + 解密失败本身已在仓储层被收成「跳过该凭证」(见 + `CredentialRepository.credential_data`),故这条路径只剩**其它**解密 + 调用点(如直接用 cipher 的代码)冒泡到处理器时的兜底映射——处理器必须 + 仍在,否则异常会冒泡成默认 500 + 全量 ASGI Traceback。 + """ + settings = isolated_settings(tmp_path) + app = build_app(settings) + key = app.state.api_keys.create("root")["api_key"] + + async def _boom(_request): + raise CredentialDecryptError("credential decryption failed") + + monkeypatch.setattr("src.api.endpoints.read_json_body", _boom) + with TestClient(app) as client: + response = client.post( + "/v1/chat/completions", + headers={"Authorization": f"Bearer {key}", "X-Request-ID": "req-decrypt"}, + json={"messages": [{"role": "user", "content": "hi"}]}) + assert response.status_code == 500 + assert response.json()["error"]["code"] == "credential_decrypt_failed" + assert response.headers["x-request-id"] == "req-decrypt" diff --git a/tests/webapp/test_logging.py b/tests/webapp/test_logging.py index f2a3a693..21db76bd 100644 --- a/tests/webapp/test_logging.py +++ b/tests/webapp/test_logging.py @@ -141,3 +141,49 @@ def test_audit_logger_not_downgraded(): logging.getLogger().handlers = [] configure_logging("INFO") assert logging.getLogger("src.api.admin_credentials").getEffectiveLevel() == logging.INFO + + +def test_log_line_carries_request_id_from_context(): + """平台日志(journald / launchd 文件)也能按一次请求串起来。 + + 值必须与响应头 `X-Request-ID`、日志页过滤用的是同一个 ID,否则两种视图 + 对不上;format 走真实 handler(filter 在 emit 前注入)。 + """ + from src.request_context import set_request_id + + logging.getLogger().handlers = [] + configure_logging("INFO") + handler = _root_handlers()[0] + + set_request_id("req-abc-123") + record = logging.LogRecord("src.engine.executor", logging.INFO, __file__, 1, + "换号 cred_1", None, None) + handler.handle(record) # 会走 filter + formatter + assert record.request_id == "req-abc-123" + assert "[req-abc-123]" in handler.format(record) + + +def test_log_line_without_request_context_uses_dash(): + """后台任务没有请求上下文 → `-`,不能把上一次请求的 ID 留在日志里。""" + from src.request_context import set_request_id + + logging.getLogger().handlers = [] + configure_logging("INFO") + handler = _root_handlers()[0] + + set_request_id(None) # 模拟后台任务(无入站请求) + record = logging.LogRecord("src.tasks.runner", logging.INFO, __file__, 1, + "签到任务完成", None, None) + handler.handle(record) + assert record.request_id == "-" + assert "[-]" in handler.format(record) + + +def test_request_id_filter_is_not_duplicated(): + """重复 configure(每个测试一次 build_app)不能叠加 filter。""" + logging.getLogger().handlers = [] + configure_logging("INFO") + configure_logging("INFO") + handler = _root_handlers()[0] + filters = [item for item in handler.filters if item.__class__.__name__ == "_RequestIdFilter"] + assert len(filters) == 1 diff --git a/tests/webapp/test_request_id.py b/tests/webapp/test_request_id.py index 6d5bb6c9..d9a62e15 100644 --- a/tests/webapp/test_request_id.py +++ b/tests/webapp/test_request_id.py @@ -172,6 +172,24 @@ async def keyed(): assert get_api_key_id() is None +def test_middleware_resets_request_id_after_request(): + """request_id 随请求结束复原:超时存活的子任务不能继承旧 ID 写统计。""" + from src.request_context import get_request_id + + app = FastAPI() + app.add_middleware(RequestIdMiddleware) + + @app.get("/rid") + async def rid(): + from src.request_context import set_request_id + set_request_id("stale-after-this") + return {"rid": get_request_id()} + + with TestClient(app) as client: + assert client.get("/rid").json() == {"rid": "stale-after-this"} + assert get_request_id() is None + + def test_middleware_resets_api_key_id_even_when_handler_raises(): from src.request_context import get_api_key_id, set_api_key_id @@ -248,6 +266,44 @@ def test_session_id_from_headers_prefers_earlier_header_names(): assert session_id_from_headers([(b"X-Session-Id", b"mixed")]) == "mixed" +def test_session_id_from_headers_accepts_codex_hyphenated_headers(): + """Codex 自 2026 起只发 `thread-id` / `session-id`(openai/codex#22193)。""" + from src.request_context import session_id_from_headers + + assert session_id_from_headers([(b"thread-id", b"t-1")]) == "t-1" + assert session_id_from_headers([(b"session-id", b"s-1")]) == "s-1" + # 带前缀的 x-session-id 语义更具体,仍排第一 + assert session_id_from_headers([(b"thread-id", b"t"), (b"session-id", b"s"), + (b"x-session-id", b"x")]) == "x" + # thread-id 比 session-id / conversation-id 更具体,两者都在时选它 + assert session_id_from_headers([(b"session-id", b"s"), + (b"thread-id", b"t")]) == "t" + assert session_id_from_headers([(b"conversation-id", b"c"), + (b"thread-id", b"t")]) == "t" + # 头名大小写不敏感 + assert session_id_from_headers([(b"Thread-Id", b"mixed")]) == "mixed" + + +def test_session_id_from_headers_accepts_claude_code_session_header(): + """Claude Code 2.1.x 只在头 `X-Claude-Code-Session-Id` 里给会话标识。 + + body 只剩 `metadata.user_id` 的 JSON 串,不收这个头就只能靠「消息前缀」 + 兜底,上下文被压缩/裁剪后粘性会断。 + """ + from src.request_context import session_id_from_headers + + assert session_id_from_headers( + [(b"x-claude-code-session-id", b"cc-1")]) == "cc-1" + assert session_id_from_headers( + [(b"X-Claude-Code-Session-Id", b"cc-2")]) == "cc-2" + # 更具体的 x-session-id 仍然优先 + assert session_id_from_headers([ + (b"x-claude-code-session-id", b"cc"), (b"x-session-id", b"x")]) == "x" + # Codex 的 thread-id 排在它之后 + assert session_id_from_headers([ + (b"thread-id", b"t"), (b"x-claude-code-session-id", b"cc")]) == "cc" + + def test_session_id_from_headers_skips_invalid_and_returns_none(): from src.request_context import session_id_from_headers @@ -275,5 +331,7 @@ async def sess(): with _TestClient(app) as client: response = client.get("/sess", headers={"x-session-id": "abc-123"}) assert response.json()["session"] == "abc-123" + # Codex 现行写法(连字符形式)同样经中间件进入 contextvar + assert client.get("/sess", headers={"thread-id": "t-9"}).json()["session"] == "t-9" # 复原:下一个请求不带头时读不到上一次的值 assert client.get("/sess").json()["session"] is None diff --git a/tests/webapp/test_static.py b/tests/webapp/test_static.py index 5762c566..1c675f97 100644 --- a/tests/webapp/test_static.py +++ b/tests/webapp/test_static.py @@ -96,3 +96,54 @@ def test_frontend_dist_falls_back_to_container_and_cwd(tmp_path, monkeypatch): found = static.frontend_dist() assert found is not None and found.name == "dist" + + +def test_unmatched_write_method_on_api_prefix_returns_json_404(tmp_path, monkeypatch): + """未匹配的写方法也要回 JSON 404(此前是 405 `{"detail":…}`)。 + + PROPOSAL §6「未匹配的 /api、/v1 返回 JSON 404」:客户端拼错端点 + 错 + 方法时若拿到裸 `detail` 形状,按统一错误体解析的调用方会踩空。 + """ + with _spa_client(tmp_path, monkeypatch=monkeypatch) as client: + for method in ("post", "put", "patch", "delete"): + response = getattr(client, method)("/api/nonexistent") + assert response.status_code == 404, method + body = response.json() + assert body["error"]["code"] == "invalid_request" + assert body["error"]["status"] == 404 + + +def test_unmatched_write_method_on_v1_prefix_returns_json_404(tmp_path, monkeypatch): + with _spa_client(tmp_path, monkeypatch=monkeypatch) as client: + response = client.post("/v1/nonexistent") + assert response.status_code == 404 + assert response.json()["error"]["code"] == "invalid_request" + + +def test_unmatched_write_method_on_plain_path_serves_spa(tmp_path, monkeypatch): + """非 API 前缀的写方法仍走 SPA(前端路由回 index.html)。""" + with _spa_client(tmp_path, monkeypatch=monkeypatch) as client: + response = client.post("/some-page") + assert response.status_code == 200 + assert "spa" in response.text + + +def test_unmatched_write_method_without_frontend_build_reports_missing( + tmp_path, monkeypatch): + """前端没构建时,写方法也给出可执行提示(不是 404 掩盖「前端缺失」)。""" + with _spa_client(tmp_path, build=False, monkeypatch=monkeypatch) as client: + response = client.post("/some-page") + assert response.status_code == 503 + assert "pnpm build" in response.text + + +def test_unmatched_write_method_without_index_reports_missing(tmp_path, monkeypatch): + """产物目录在但 index.html 缺失:同样回可执行提示(不是 404/500)。""" + dist = tmp_path / "dist" + dist.mkdir(parents=True) + monkeypatch.setattr("src.webapp.static.frontend_dist", lambda: dist) + settings = Settings(_env_file=None, APP_SECRET=SECRET, DATA_DIR=str(tmp_path)) + with TestClient(build_app(settings)) as client: + response = client.post("/some-page") + assert response.status_code == 503 + assert "pnpm build" in response.text diff --git a/uv.lock b/uv.lock index 7b06c000..8d51cae6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [[package]] @@ -35,11 +35,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, ] [[package]] @@ -138,7 +138,7 @@ wheels = [ [[package]] name = "coding2api" -version = "0.9.1" +version = "0.13.1" source = { editable = "." } dependencies = [ { name = "cryptography" }, @@ -361,7 +361,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.143.3" +version = "0.162.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -371,9 +371,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/d5/b0aeb0b303650ac87b0411de4aaf3f901b06042dcc3fb00f30b97b7ff585/fastapi-0.143.3.tar.gz", hash = "sha256:b9ab567ee57afb4454004ce04c889f8f0160ac47946b99795887cd6e88484848", size = 469834, upload-time = "2026-10-16T14:14:13.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/1a/03d246befcf307219bb2161c97d8c5d314f84125bf7dd245730a28589828/fastapi-0.143.3-py3-none-any.whl", hash = "sha256:75e8bd68e2f52f77d29b2879749c257d64de2d85282d25270caf2bfccce5efe5", size = 144849, upload-time = "2026-10-16T14:14:11.763Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" }, ] [[package]] @@ -576,7 +576,7 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.16.0" +version = "2.16.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -584,9 +584,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/31/21d21e0e38a8f5f7a7f0cde22820117b6d17d6bc36f348129527f915d7f4/pydantic_settings-2.16.2.tar.gz", hash = "sha256:3dc08ffabed2bbd5dc2dd55ead7b78b4e4c5cd776aecd04310db5f1224caaf46", size = 309225, upload-time = "2026-10-28T11:14:28.066Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" }, + { url = "https://files.pythonhosted.org/packages/39/88/4131c51ff87b7cd7f82a1194d0c0a2b7ca68c72d9da04c3b7e69ab91a79f/pydantic_settings-2.16.2-py3-none-any.whl", hash = "sha256:8d523e5c129b097686bde019bd6109bff53f4c8c3f63f1ddf9933ffa42e5ab4f", size = 81994, upload-time = "2026-10-28T11:14:26.716Z" }, ] [[package]] @@ -600,7 +600,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.1.1" +version = "9.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -609,22 +609,22 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.4.0" +version = "1.4.1.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/7f/c60a4338420b33453d4f814ac0c930abd399f162b9bdd21af4b9e363037f/pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2", size = 145360, upload-time = "2026-11-01T18:26:03.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/adbc7578959cfab07e5e7c05cf6d54224a51e7667ea2c07458b6bc571e75/pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165", size = 17140, upload-time = "2026-11-01T18:26:02.168Z" }, ] [[package]] @@ -664,27 +664,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.17.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, - { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, - { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, - { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, - { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, - { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/3d154514b5bad104f7e1c13169c2d09aa9959fccdc06eb47610ccdfa83ed/ruff-0.17.4.tar.gz", hash = "sha256:f32d178b50e826e00f6c6c1be3f56333068acb9b67fe167143527fa9b81e890d", size = 5357750, upload-time = "2026-11-05T18:52:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/61/c83c78bbb6108f6c6270a106cf12b54175f4f42da088b9bb880f18e31289/ruff-0.17.4-py3-none-linux_armv6l.whl", hash = "sha256:2b08c4ff98dc234f4a3871a3105353b922fa875ea1073ca31001eb8c06bb4d89", size = 10252195, upload-time = "2026-11-05T18:51:36.594Z" }, + { url = "https://files.pythonhosted.org/packages/0a/12/dd158708378866e8b53e145a465dbc51a7cccb9fa197a74d67eaedde7234/ruff-0.17.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:13e781c3c5f663e7fba737eaaeb70dd943dd55171daf316afb25cd5268cfe0c7", size = 10266044, upload-time = "2026-11-05T18:51:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/89a94a8eb11fd505a381ae6f04e190d038d9958695209dc1786ce5cfcda3/ruff-0.17.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ed2f416349944f908f9574215994602242b0836844eba48eec61473033afca0", size = 9432227, upload-time = "2026-11-05T18:51:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/a392dc38b215798ae72e639d228b7f6c02d826a95d1c0ad7c4c0ee49ef5b/ruff-0.17.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ea6e0017c785f7282092b11e46338e4ebed1b9e575ebf2e55a8a1eda968241a", size = 10136900, upload-time = "2026-11-05T18:51:44.562Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8f/a880f60f42005f6ffdf58f77af34c7ee327000b5607f48a2f6a69d8b242c/ruff-0.17.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e061dcc0933c7031a616ac8d66ac540309b2aa21ab558101a8f53e9ac7a1185", size = 10051245, upload-time = "2026-11-05T18:51:46.7Z" }, + { url = "https://files.pythonhosted.org/packages/09/55/98844264108af7d5ec27ca3a9cd5a989094ca657a14599c4b69a537a0e9a/ruff-0.17.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e95a1515161138be3cbd1b06c06e739d8feb38ade35564556999fffc3686e", size = 10704341, upload-time = "2026-11-05T18:51:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/5edf97659c3ac7c73f978003bd314936f671037fdac6b978e9de3e4b2b65/ruff-0.17.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f28cf0dd37cb172fa5a8d2e92e6ada3a9dbd0609f094ed24039929eef0b7524", size = 11394465, upload-time = "2026-11-05T18:51:51.534Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8f/51371ae856b2bef4d15a044a574695fbc0f6ae63aafa42a2a22428070fb6/ruff-0.17.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b21c771b296888e8a35c6f48555f4b44e4483f61675d6ed51810a5203469ce6", size = 10988262, upload-time = "2026-11-05T18:51:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/1b/62874f14c7873f27c7887d2b1ea41de990e14ba9518801ffe8eacb5d51a6/ruff-0.17.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cd025509b2e15721b426fc70fd946d71f43b3db3b08e946dfec2d696d911690", size = 10572829, upload-time = "2026-11-05T18:51:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/98/30/860b9dae67432b16b88558a9cb2d3194825fa62838f8b9cc49f5d9022095/ruff-0.17.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:49b77f7b3921669466882a4881168801bdb06ec2cd83fb936049016cf5fdece1", size = 10617545, upload-time = "2026-11-05T18:51:58.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/08/781a4b41a1c83fd6e30fea610d6502a6f94e49cf94693fd37bdcbc6bfe11/ruff-0.17.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3b84eb74f260d1c2dbd236fd9d4c011d4fdd0a52bdf8aea57a10e3a1b71fca5", size = 10111955, upload-time = "2026-11-05T18:52:00.59Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/94b1c7b677120f30839254518924f73a8157e9bcfa200edcd5796fecdb99/ruff-0.17.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:20e43d7a08e712e33067ba887e6574546c79bd4831596ce8f16803ac7ff9ad90", size = 10058355, upload-time = "2026-11-05T18:52:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/27/5f/a825910b9f309336dc33cb8c0774126662c1d2f765699b3e0a1bd538d01f/ruff-0.17.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0d1f4848fd2ec0499be4046db9b7dfcc952666885f00233cc7033515282556a8", size = 10415932, upload-time = "2026-11-05T18:52:06.264Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b8/0796468423413ea4df1a6e2442ae26cf49b8f7dda0eecf246e0d5d0dde54/ruff-0.17.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6007e7f3a0e55b0fbdb947af69cf24c21b4928df6be8ef3e767c69b5ccccf496", size = 10826033, upload-time = "2026-11-05T18:52:08.675Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/d8e407669ffe5014429baf780c2df47e001968a6e4c17f615e2e7b13a9ba/ruff-0.17.4-py3-none-win32.whl", hash = "sha256:3971a78cba76298b284c8321e6d40d93045ed348bec5c369d85099efc97fb7cf", size = 9931993, upload-time = "2026-11-05T18:52:11.169Z" }, + { url = "https://files.pythonhosted.org/packages/bd/15/42af8a72d8946e3c9efd2bbf6641e462b6832783b0d043d63ef5b280ba7f/ruff-0.17.4-py3-none-win_amd64.whl", hash = "sha256:67c5a894fc054cf41f75f7afa24815de5a74329d97634126c2a1015740ff4e8c", size = 10292922, upload-time = "2026-11-05T18:52:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/f1dbffcfb8ff25dadbc21f87a8b73c3848abe4abada9d4b08ccef8bec40c/ruff-0.17.4-py3-none-win_arm64.whl", hash = "sha256:181ae38a9a8fb94925bf7d0794327b48d7fd7f4b50cfd84c0e999cc09ef8d131", size = 9889396, upload-time = "2026-11-05T18:52:15.549Z" }, ] [[package]] diff --git a/web/package.json b/web/package.json index b4cef811..317272e8 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "coding2api-web", "private": true, - "version": "0.9.1", + "version": "0.13.1", "packageManager": "pnpm@10.18.0", "type": "module", "scripts": { @@ -13,34 +13,33 @@ }, "dependencies": { "@fontsource-variable/geist": "^5.3.0", - "@lobehub/icons": "^6.0.0", - "@tanstack/react-query": "^5.104.3", + "@lobehub/icons": "^6.15.0", + "@tanstack/react-query": "^5.107.1", "class-variance-authority": "^0.7.1", - "cn": "^0.5.0", - "lucide-react": "^1.58.0", + "cn": "^0.5.2", + "lucide-react": "^1.70.0", "radix-ui": "^1.8.1", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-router-dom": "^7.18.4", + "react-router-dom": "^7.18.5", "recharts": "^3.10.1", "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@tailwindcss/vite": "^4.3.3", - "@testing-library/dom": "^10.4.1", + "@tailwindcss/vite": "^4.3.5", + "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.8", - "@types/node": "^26.6.5", + "@types/node": "^26.6.6", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^6.1.2", - "@vitest/coverage-v8": "^5.0.5", - "jsdom": "^30.1.3", - "shadcn": "^4.22.0", - "tailwindcss": "^4.3.3", + "@vitejs/plugin-react": "^6.2.1", + "@vitest/coverage-v8": "^5.1.0", + "jsdom": "^31.0.0", + "tailwindcss": "^4.3.5", "typescript": "^7.0.2", - "vite": "^8.3.4", - "vitest": "^5.0.5" + "vite": "^8.3.6", + "vitest": "^5.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7bf29788..d5d1195b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -12,20 +12,20 @@ importers: specifier: ^5.3.0 version: 5.3.0 '@lobehub/icons': - specifier: ^6.0.0 - version: 6.0.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + specifier: ^6.15.0 + version: 6.15.0(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-query': - specifier: ^5.104.3 - version: 5.104.3(react@19.3.1) + specifier: ^5.107.1 + version: 5.107.1(@types/react@19.3.0)(react@19.3.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 cn: - specifier: ^0.5.0 - version: 0.5.0 + specifier: ^0.5.2 + version: 0.5.2 lucide-react: - specifier: ^1.58.0 - version: 1.58.0(@types/react@19.3.0)(react@19.3.1) + specifier: ^1.70.0 + version: 1.70.0(@types/react@19.3.0)(react@19.3.1) radix-ui: specifier: ^1.8.1 version: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -36,8 +36,8 @@ importers: specifier: ^19.3.1 version: 19.3.1(react@19.3.1) react-router-dom: - specifier: ^7.18.4 - version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + specifier: ^7.18.5 + version: 7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1) recharts: specifier: ^3.10.1 version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1) @@ -46,23 +46,23 @@ importers: version: 1.4.0 devDependencies: '@tailwindcss/vite': - specifier: ^4.3.3 - version: 4.3.3(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + specifier: ^4.3.5 + version: 4.3.5(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)) '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.1 + specifier: ^10.4.2 + version: 10.4.2 '@testing-library/jest-dom': specifier: ^7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.5) + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0) '@testing-library/react': specifier: ^16.3.0 - version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@testing-library/user-event': specifier: ^14.6.8 - version: 14.6.8(@testing-library/dom@10.4.1) + version: 14.6.8(@testing-library/dom@10.4.2) '@types/node': - specifier: ^26.6.5 - version: 26.6.5 + specifier: ^26.6.6 + version: 26.6.6 '@types/react': specifier: ^19.2.0 version: 19.3.0 @@ -70,231 +70,69 @@ importers: specifier: ^19.2.0 version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': - specifier: ^6.1.2 - version: 6.1.2(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + specifier: ^6.2.1 + version: 6.2.1(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)) '@vitest/coverage-v8': - specifier: ^5.0.5 - version: 5.0.5(vitest@5.0.5) + specifier: ^5.1.0 + version: 5.1.0(vitest@5.1.0) jsdom: - specifier: ^30.1.3 - version: 30.1.3 - shadcn: - specifier: ^4.22.0 - version: 4.22.0 + specifier: ^31.0.0 + version: 31.0.0 tailwindcss: - specifier: ^4.3.3 - version: 4.3.3 + specifier: ^4.3.5 + version: 4.3.5 typescript: specifier: ^7.0.2 version: 7.0.2 vite: - specifier: ^8.3.4 - version: 8.3.4(@types/node@26.6.5)(jiti@2.7.0) + specifier: ^8.3.6 + version: 8.3.6(@types/node@26.6.6)(jiti@2.7.0) vitest: - specifier: ^5.0.5 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + specifier: ^5.1.0 + version: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)) packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@asamuzakjp/css-color@7.1.6': - resolution: {integrity: sha512-jZJUZX0mR6JqnjQGb79yTYbR5WXmACmcLoFSgZk+7W+0qMJ8jWGRp9ncYvE1ihwL3dwbmfeCe6/O0rRwWTko4g==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@asamuzakjp/dom-selector@10.0.3': - resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.8': - resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-annotate-as-pure@7.29.7': - resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-create-class-features-plugin@7.29.7': - resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-member-expression-to-functions@7.29.7': - resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-optimise-call-expression@7.29.7': - resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-replace-supers@7.29.7': - resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': - resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + '@babel/helper-string-parser@7.29.13': + resolution: {integrity: sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==} engines: {node: '>=6.9.0'} '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.10': - resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.9': - resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + '@babel/parser@7.29.13': + resolution: {integrity: sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-syntax-jsx@7.29.7': - resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.29.7': - resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.29.7': - resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typescript@7.29.9': - resolution: {integrity: sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-typescript@7.29.7': - resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.10': resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + '@babel/runtime@7.29.13': + resolution: {integrity: sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.10': - resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + '@babel/types@7.29.11': + resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@bramus/specificity@2.4.3': - resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} - hasBin: true - - '@csstools/color-helpers@6.1.2': - resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} - engines: {node: '>=20.19.0'} - - '@csstools/css-calc@3.4.3': - resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.2 - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-color-parser@4.2.6': - resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.2 - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-parser-algorithms@4.0.2': - resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-syntax-patches-for-csstree@1.1.15': - resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - - '@csstools/css-tokenizer@4.0.2': - resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} - engines: {node: '>=20.19.0'} - - '@dotenvx/dotenvx@1.75.1': - resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} - hasBin: true - - '@dotenvx/primitives@0.8.0': - resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} - '@exodus/bytes@1.16.0': resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -322,12 +160,6 @@ packages: '@fontsource-variable/geist@5.3.0': resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==} - '@hono/node-server@2.1.5': - resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} - engines: {node: '>=20'} - peerDependencies: - hono: ^4 || ^5.0.0-0 - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -344,36 +176,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lobehub/icons@6.0.0': - resolution: {integrity: sha512-DlL5xpjU9FJJWs40pfHzvo8HPklxY/hxj3Z73VQrIv9DsfyirMVlE8EWTNqeItaqcJkgTk+Qg3ZuIOcLmgfCmA==} + '@lobehub/icons@6.15.0': + resolution: {integrity: sha512-kDCGVDNGitAhz7hVzTG3e8f9nKBvyvpoNxBgFp0YnLdyqQ+wPYDYO7A6nuBkPHFqVhDSflT4bKSS05EW8JcAuQ==} peerDependencies: '@lobehub/ui': ^6.0.0 + lucide-react: ^0.469.0 || ^1.0.0 react: ^19.0.0 react-dom: ^19.0.0 - '@modelcontextprotocol/sdk@1.32.1': - resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@oxc-project/types@0.153.0': - resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} @@ -1076,92 +888,98 @@ packages: react-redux: optional: true - '@rolldown/binding-android-arm-eabi@1.2.13': - resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + '@rolldown/binding-android-arm-eabi@1.2.16': + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.13': - resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + '@rolldown/binding-android-arm64@1.2.16': + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.13': - resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + '@rolldown/binding-darwin-arm64@1.2.16': + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.13': - resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + '@rolldown/binding-darwin-x64@1.2.16': + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.13': - resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + '@rolldown/binding-freebsd-x64@1.2.16': + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': - resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.13': - resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + '@rolldown/binding-linux-arm64-gnu@1.2.16': + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.13': - resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + '@rolldown/binding-linux-arm64-musl@1.2.16': + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.13': - resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.13': - resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + '@rolldown/binding-linux-s390x-gnu@1.2.16': + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.13': - resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + '@rolldown/binding-linux-x64-gnu@1.2.16': + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.13': - resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + '@rolldown/binding-linux-x64-musl@1.2.16': + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.13': - resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + '@rolldown/binding-openharmony-arm64@1.2.16': + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.13': - resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + '@rolldown/binding-win32-arm64-msvc@1.2.16': + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.13': - resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + '@rolldown/binding-win32-x64-msvc@1.2.16': + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1169,84 +987,76 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@sec-ant/readable-stream@0.4.1': - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - - '@shadcn/registry@0.2.0': - resolution: {integrity: sha512-m3TeqU/+av2kgOLfESHkWzsmhuQuY4NXZd5Y1Rd4lzy4IX3JJ5+k2mYfkq6OT8oMuk2W4GQPAkO7+CC7KlpSbw==} - engines: {node: '>=20.18.1'} - - '@sindresorhus/merge-streams@4.0.0': - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} - engines: {node: '>=18'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@tailwindcss/node@4.3.3': - resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + '@tailwindcss/node@4.3.5': + resolution: {integrity: sha512-yT53FzV4ke0cr/BguDxhWMpm5MJu1O2muzk+tKw6lKHjODAsYArBbWwFbJiaksfzFv1tpkO9QHGzmfs1hJT66g==} - '@tailwindcss/oxide-android-arm64@4.3.3': - resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + '@tailwindcss/oxide-android-arm64@4.3.5': + resolution: {integrity: sha512-Se0z5xMqcSQanJD1IYmO0cGpK3iv0fp3ZsIT7xRJZ1Da5PghvNoc46zz4jIQn+aan3dV9T8AermFNN9QPmsh0Q==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.3.3': - resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + '@tailwindcss/oxide-darwin-arm64@4.3.5': + resolution: {integrity: sha512-UPrgTDuUbvmqGMZuCnmI3FKee/tYKzKdp00/kuB4oSGB213VYg8QTdF7lVhnPoBXq/mIfJ+ILoBFqNOboWcMwQ==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.3': - resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + '@tailwindcss/oxide-darwin-x64@4.3.5': + resolution: {integrity: sha512-E+p7HEtjI0b8D7kbbWxH067cGui6d4c79COGko9fAPVE3rwc1E7ld2HFCbaG86AOGWS5VKQYqZOaf38ya4uxTQ==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.3': - resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + '@tailwindcss/oxide-freebsd-x64@4.3.5': + resolution: {integrity: sha512-yWEWcWyAG7rWOcTpJTjdhQ3pcaFY2h+0bqTRfgU3G2fz87ZU3AKdvdZKhslD+kyHnjBUWKvNdRYwIYC5dI1tIg==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': - resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.5': + resolution: {integrity: sha512-WmeVjtJhZlr+smpqcdR2kQkkn6arHIJnUIBxTO2EJcTLGY1ecVXNo2hbSn8xc0IH2aEtys936iMWKGyj0GaKnA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': - resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.5': + resolution: {integrity: sha512-DTCSyucc6ShMp1QMqV5R94Z7xlgNgUzkGmFF1rL5HH2Qjy4eSmEqy+YLGkqqYvWa4sILtgrBt1JjRKbneQtiFQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.3.3': - resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.5': + resolution: {integrity: sha512-H9lsvO4x9E4nE4Hi8h7NHHw0O/kcVSu+iOyFHNGdYzazGgDjFHZbE6S3hQ8kMcbNXSBlMdNi67LV5EVqtpaJtw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.3.3': - resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.5': + resolution: {integrity: sha512-4uxQvtBvPuIdl13WfUBj9xMZHH14PtHZ8OZdtLmdyDtG66sCa1t599NZT+jagkUzRtWhuul1qRZQ1nAJscXN5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.3.3': - resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + '@tailwindcss/oxide-linux-x64-musl@4.3.5': + resolution: {integrity: sha512-0JQAzs7jwbA7O6+z/adU+Jk3AXwsu3npAhtr5escXS7TqEqWcaDV7qHuI+0Vy/KQRuVGnLQREcSJWYauyV3oqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.3.3': - resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] + '@tailwindcss/oxide-wasm32-wasi@4.3.5': + resolution: {integrity: sha512-OJhFmlEfdzMr+avACdi40RPtnkW0B6hXyxL/8r+tnEHBWZji36CYRjEXrrvgBmaYsD3oEOBqwYFRKsYvzFe7Bw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} bundledDependencies: - '@napi-rs/wasm-runtime' - '@emnapi/core' @@ -1255,37 +1065,41 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': - resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.5': + resolution: {integrity: sha512-9o8jgthi+QE5eandnboiJT64VQfnIArfZzSTdgbhEK5aafdiu2jTW9Honi/mwYHxBZuH1vAC57IFyrh+0iK5Gg==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.3': - resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.5': + resolution: {integrity: sha512-XiKFfIy1RWe7qK4WqByxNW7Oz1/nkOy8o/BWhHminyV1PmpUd8Eedj5v2A8h2kpqeVlz6xRnwk9Fr/ZXNLcZfg==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.3.3': - resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + '@tailwindcss/oxide@4.3.5': + resolution: {integrity: sha512-PynqGLZsZvwM9tqD/CUxkOIfyd6K5elZ2MooauzJryP/UqXYgi3yYbtt3N0Xe+MHV/eFKPFQCEfIKAqlAS1CBg==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.3.3': - resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + '@tailwindcss/vite@4.3.5': + resolution: {integrity: sha512-BCNiY0a1ZjdOHVqw7kyb7U72BwfMF3dfUuN+tQsLWky/J5JOI/r7abibglXfygrZhVO/3u3V+E25kkKTeMkXaQ==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/query-core@5.104.3': - resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==} + '@tanstack/query-core@5.107.1': + resolution: {integrity: sha512-PSyAik11oQBEVOiVy5hjZWHmoUqOXqK1WYy4v55mcxozEfKaQgpUMsgnXhWjuESzSht8JfzqMS1m6M/9CpNgvg==} - '@tanstack/react-query@5.104.3': - resolution: {integrity: sha512-5Ts1ImwJKqoJgPkLxl9WrPQy1JEoEmti3MaaODCfBamHUGFk57IFrc+AIksPq+BAm10EsroX56qm398Z42LG7w==} + '@tanstack/react-query@5.107.1': + resolution: {integrity: sha512-udCOOLp3ZAheUrIHLCe+ZZge6nr2ZUrGMSWcCuXsjY1Bf2JeeE09oxst97PILiCY6N8xo/7bIo3Hvz13zh06FA==} peerDependencies: + '@types/react': ^18 || ^19 react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} '@testing-library/jest-dom@7.0.1': @@ -1319,9 +1133,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@ts-morph/common@0.27.0': - resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1358,11 +1169,11 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} - '@types/node@26.6.5': - resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} @@ -1375,9 +1186,6 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - '@types/validate-npm-package-name@4.0.2': - resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1498,13 +1306,13 @@ packages: cpu: [x64] os: [win32] - '@vitejs/plugin-react@6.1.2': - resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + '@vitejs/plugin-react@6.2.1': + resolution: {integrity: sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 || ^0.3.0 babel-plugin-react-compiler: ^1.0.0 - oxc-transform-react: ^0.152.0 + oxc-transform-react: '>=0.152.0 <0.157.0' vite: ^8.0.0 peerDependenciesMeta: '@rolldown/plugin-babel': @@ -1514,25 +1322,25 @@ packages: oxc-transform-react: optional: true - '@vitest/coverage-v8@5.0.5': - resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} + '@vitest/coverage-v8@5.1.0': + resolution: {integrity: sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==} peerDependencies: - '@vitest/browser': 5.0.5 - vitest: 5.0.5 + '@vitest/browser': 5.1.0 + vitest: 5.1.0 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/istanbul-lib-coverage@1.0.2': - resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + '@vitest/istanbul-lib-coverage@2.1.0': + resolution: {integrity: sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==} engines: {node: '>=22'} - '@vitest/istanbul-lib-report@1.0.2': - resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + '@vitest/istanbul-lib-report@2.1.0': + resolution: {integrity: sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==} engines: {node: '>=22'} - '@vitest/mocker@5.0.5': - resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + '@vitest/mocker@5.1.0': + resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==} peerDependencies: msw: ^2.4.9 || ^3.0.0 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1542,34 +1350,13 @@ packages: vite: optional: true - '@vitest/spy@5.0.5': - resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} + '@vitest/spy@5.1.0': + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.4.0: - resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} - engines: {node: '>=12'} - ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} @@ -1585,160 +1372,38 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-types@0.16.3: - resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} - engines: {node: '>=4'} - ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} - atomically@1.7.0: - resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} - engines: {node: '>=10.12.0'} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.11.29: - resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==} - engines: {node: '>=6.0.0'} - hasBin: true - - bidi-js@1.1.0: - resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} - - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} - engines: {node: '>=18'} - - brace-expansion@5.0.12: - resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} - engines: {node: 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.29.3: - resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - bundle-name@4.1.1: - resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} - engines: {node: '>=18'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - caniuse-lite@1.0.30001816: - resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} + bcp-47@2.1.2: + resolution: {integrity: sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==} chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cn@0.2.6: - resolution: {integrity: sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==} - engines: {node: '>=20'} - hasBin: true - - cn@0.5.0: - resolution: {integrity: sha512-JGEixZEpfFXm1P5uXwvEobOFLlHwIWi5v4tjd1ZwfiYmcEilkuD2l+054rvJcYCR1B+uVThHeUbgmNz1FwuxuA==} + cn@0.5.2: + resolution: {integrity: sha512-wm10E6iuJ37rALOw3D+3cfaXXZSLNMh2uxpQn3dNICqfJM59yXqgbA9/9Iiz155j5kYNLdK6WreNdW3NQtx3OQ==} engines: {node: '>=22'} hasBin: true - code-block-writer@13.0.3: - resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} - - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} - - conf@10.2.0: - resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} - engines: {node: '>=12'} - - content-disposition@2.0.1: - resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - content-type@2.1.0: - resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} - engines: {node: '>=18'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1790,57 +1455,12 @@ packages: resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} engines: {node: ^22.14.0 || >=24.0.0} - debounce-fn@4.0.0: - resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} - engines: {node: '>=10'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - dedent@1.7.2: - resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - default-browser-id@5.0.1: - resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} - engines: {node: '>=18'} - - default-browser@5.5.1: - resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} - engines: {node: '>=18'} - - define-lazy-prop@2.0.0: - resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} - engines: {node: '>=8'} - - define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} - engines: {node: '>=12'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1852,140 +1472,39 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} - engines: {node: '>=0.3.1'} - dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dot-prop@6.0.1: - resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} - engines: {node: '>=10'} - - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - electron-to-chromium@1.5.458: - resolution: {integrity: sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - enhanced-resolve@5.24.5: - resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + enhanced-resolve@5.26.1: + resolution: {integrity: sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==} engines: {node: '>=10.13.0'} - enquirer@2.4.1: - resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} - engines: {node: '>=8.6'} - - entities@8.1.0: - resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + entities@9.0.0: + resolution: {integrity: sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==} engines: {node: '>=20.19.0'} - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - es-toolkit@1.52.0: resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true + es-toolkit@1.53.2: + resolution: {integrity: sha512-iUqZ+NlwH4jUgRmXZUxFBsV/9762TQEa1O4IXLFBmDEjVHScBytKuseX23I0+v9ShZ5MYxEx6WKNzm6IPnex8g==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - eventsource-parser@3.1.1: - resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - execa@9.6.1: - resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} - engines: {node: ^18.19.0 || >=20.5.0} - expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.7.1: - resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.3.0: - resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} - engines: {node: '>= 18'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-uri@3.1.8: - resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} - - fastq@1.20.3: - resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1995,124 +1514,22 @@ packages: picomatch: optional: true - figures@6.1.0: - resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} - engines: {node: '>=18'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - - find-up@3.0.0: - resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} - engines: {node: '>=6'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - fs-extra@11.4.1: - resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} - engines: {node: '>=14.14'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - fuzzysort@3.1.0: - resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-east-asian-width@1.7.0: - resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} - engines: {node: '>=18'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-own-enumerable-keys@1.0.0: - resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} - engines: {node: '>=14.16'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-stream@9.0.1: - resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} - engines: {node: '>=18'} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - - hono@4.13.13: - resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} - engines: {node: '>=16.9.0'} - html-encoding-sniffer@7.0.0: resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} engines: {node: ^22.13.0 || >=24.0.0} - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - human-signals@8.0.1: - resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} - engines: {node: '>=18.18.0'} - - iconv-lite@0.7.3: - resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} - engines: {node: '>=0.10.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - immer@11.1.18: resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} @@ -2120,274 +1537,98 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.7.4: - resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} - engines: {node: '>= 12'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-in-ssh@1.0.0: - resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} - engines: {node: '>=20'} - - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - - is-interactive@2.0.0: - resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} - engines: {node: '>=12'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-obj@2.0.0: - resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} - engines: {node: '>=8'} - - is-obj@3.0.0: - resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} - engines: {node: '>=12'} - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - is-regexp@3.1.0: - resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} - engines: {node: '>=12'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-stream@4.0.1: - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} - engines: {node: '>=18'} - - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - - is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} - engines: {node: '>=18'} - - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - - is-wsl@3.1.1: - resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} - engines: {node: '>=16'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.12: - resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} - js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - jsdom@30.1.3: - resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==} + jsdom@31.0.0: + resolution: {integrity: sha512-xAkhD9z1eyFC5+QUi9K24CaZJgdCQ2GOGLW1GdVDoQvkp/AxEH9TQ0pAplPOlIiV92hfcgnFOADZypbjgbBT6Q==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: - canvas: ^3.2.3 + '@napi-rs/canvas': ^1.0.13 peerDependenciesMeta: - canvas: + '@napi-rs/canvas': optional: true - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@7.0.3: - resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonfile@6.2.1: - resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - lightningcss-darwin-arm64@1.33.0: resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - lightningcss-darwin-x64@1.33.0: resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - lightningcss-freebsd-x64@1.33.0: resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - lightningcss-linux-arm-gnueabihf@1.33.0: resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] + libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} @@ -2395,48 +1636,22 @@ packages: cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - lightningcss-win32-x64-msvc@1.33.0: resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - lightningcss@1.33.0: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} - locate-path@3.0.0: - resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} - engines: {node: '>=6'} - - log-symbols@6.0.0: - resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} - engines: {node: '>=18'} - lru-cache@11.5.3: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.469.0: - resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - lucide-react@1.58.0: - resolution: {integrity: sha512-hXaFNm6mk6QVtBnDGBn1YAs8j2tGUk0A4s/CpclIcczfxeXiW1rdOLVo6zCsoIih9XXsGvDODQerl749a2n7bA==} + lucide-react@1.70.0: + resolution: {integrity: sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==} peerDependencies: '@types/react': '*' react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2448,246 +1663,54 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magic-string@1.4.3: - resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mdn-data@2.27.1: - resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - - media-typer@1.1.1: - resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} - engines: {node: '>= 0.8'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - mimic-fn@3.1.0: - resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} - engines: {node: '>=8'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@10.2.6: - resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} - engines: {node: 18 || 20 || >=22} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - negotiator@1.1.0: - resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} - engines: {node: '>=18'} - - node-releases@2.0.59: - resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} - engines: {node: '>=18'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - npm-run-path@6.0.0: - resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} - engines: {node: '>=18'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-treeify@1.1.33: - resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} - engines: {node: '>= 10'} - obug@3.0.0: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - - open@11.0.4: - resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} - engines: {node: '>=20'} - - open@8.4.2: - resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} - engines: {node: '>=12'} + parse5-parser-stream@8.1.0: + resolution: {integrity: sha512-58MPfNTA+DsIzro41rU94xghnu/h2N4yEPdPdGPlZVHM/QvA5jlVGYVo7IzlHbFLHYx3cZUnr9s9HBrA0tEYyw==} - ora@8.2.0: - resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} - engines: {node: '>=18'} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-locate@3.0.0: - resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} - engines: {node: '>=6'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - parse-ms@4.0.0: - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} - engines: {node: '>=18'} - - parse5@8.0.1: - resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-browserify@1.0.1: - resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - - path-exists@3.0.0: - resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} - engines: {node: '>=4'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - - path-to-regexp@8.4.2: - resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + parse5@8.1.0: + resolution: {integrity: sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - - pkg-up@3.1.0: - resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} - engines: {node: '>=8'} - polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} - postcss-selector-parser@7.1.6: - resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} - engines: {node: '>=4'} - - postcss@8.5.29: - resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.30: - resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} engines: {node: ^10 || ^12 || >=14} - powershell-utils@0.2.1: - resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} - engines: {node: '>=20'} - pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-ms@9.3.1: - resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} - engines: {node: '>=18'} - - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - - proxy-addr@2.0.8: - resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} - engines: {node: '>= 0.10'} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.16.1: - resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} - engines: {node: '>=0.6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - radix-ui@1.8.1: resolution: {integrity: sha512-HPfpVGizO9xANlsFSauruLhRe/3+iOt3vPX0MXYle/UQi3gorQrSYtBcdiv0BZYewcl2rvL59qrIuLFMGAmUrw==} peerDependencies: @@ -2701,14 +1724,6 @@ packages: '@types/react-dom': optional: true - range-parser@1.3.0: - resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - react-dom@19.3.1: resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} peerDependencies: @@ -2749,15 +1764,15 @@ packages: '@types/react': optional: true - react-router-dom@7.18.4: - resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==} + react-router-dom@7.18.5: + resolution: {integrity: sha512-9HcfDMU6JJ/fp6gJZYGwNE6RknVGS4kBjH1HkNw959mCJi70TVwdipQWv2FEol9uGpi+SZTFtVpJPtEQfayeOw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.18.4: - resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} + react-router@7.18.5: + resolution: {integrity: sha512-dTgvcXDOrLPrlLHrcZAqWveiK1hMSqN+rGAJzegP8HtmNkfRxOoshGWBNX3ZJ0cZjOHkRdlUJtaIhZMLLyGyPw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -2780,10 +1795,6 @@ packages: resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} engines: {node: '>=0.10.0'} - recast@0.23.21: - resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} - engines: {node: '>= 4'} - recharts@3.10.1: resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} engines: {node: '>=18'} @@ -2804,40 +1815,14 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rolldown@1.2.13: - resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + rolldown@1.2.16: + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - run-applescript@7.1.0: - resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} - engines: {node: '>=18'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -2845,139 +1830,22 @@ packages: scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shadcn@4.22.0: - resolution: {integrity: sha512-QKR3XUnatWlgo8IbZqGHrpsxjgdsfpPSAvJvLntQqAxPEf5U2sNsjDYLAS2xmFrOkC9lpj5UpZbrh/BuHrE2CQ==} - engines: {node: '>=20.18.1'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.1: - resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} - engines: {node: '>= 0.4'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - - socks@2.8.10: - resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} + set-cookie-parser@2.7.3: + resolution: {integrity: sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==} source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} - stdin-discarder@0.2.2: - resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} - engines: {node: '>=18'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - stringify-object@5.0.0: - resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} - engines: {node: '>=14.16'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-final-newline@4.0.0: - resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} - engines: {node: '>=18'} - strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} - systeminformation@5.33.15: - resolution: {integrity: sha512-s5RloL0F7xhN0ZjfHtLGnbf7EW74EI0N8NFL+1xR7Oxz9QA33obNZsl7lyfnJOhN04E/kGFPoJPlvY7TZ5fzmw==} - engines: {node: '>=10.0.0'} - os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] - hasBin: true - - tailwindcss@4.3.3: - resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + tailwindcss@4.3.5: + resolution: {integrity: sha512-FcQk9BuffmEHJP2G0XmB2SplU1aLTI7j07Kunk+U74Ny+XiWOtzXz9aujFZ/avntw1e2cOv/3PkgzflQ7gX2vQ==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -2986,8 +1854,8 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinybench@6.2.2: - resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + tinybench@6.2.3: + resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==} engines: {node: '>=20.0.0'} tinyexec@1.3.1: @@ -3002,50 +1870,27 @@ packages: resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} - tldts-core@7.4.18: - resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + tldts-core@7.4.20: + resolution: {integrity: sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==} - tldts@7.4.18: - resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + tldts@7.4.19: + resolution: {integrity: sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==} hasBin: true - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tough-cookie@6.0.2: - resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + tough-cookie@6.1.0: + resolution: {integrity: sha512-FTxc8u8Kl2tz0Lat1u8ksvcy8bwHRyEMawwTWybo51JqaC6O7SvfuhAKg+Ld6VqyIe2QRJBzY9HA7y2WyFRT2w==} engines: {node: '>=16'} tr46@7.0.0: resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} engines: {node: ^22.14.0 || >=24.0.0} - ts-morph@26.0.0: - resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} - - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} - typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -3054,32 +1899,10 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - undici@7.30.1: - resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} - engines: {node: '>=20.18.1'} - - undici@8.12.2: - resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==} + undici@8.12.4: + resolution: {integrity: sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==} engines: {node: '>=22.19.0'} - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - update-browserslist-db@1.3.4: - resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -3105,27 +1928,16 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - validate-npm-package-name@7.0.2: - resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} - engines: {node: ^20.17.0 || >=22.9.0} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vite@8.3.4: - resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + vite@8.3.6: + resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.7.1 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -3162,20 +1974,20 @@ packages: yaml: optional: true - vitest@5.0.5: - resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + vitest@5.1.0: + resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.5 - '@vitest/browser-preview': 5.0.5 + '@vitest/browser-playwright': 5.1.0 + '@vitest/browser-preview': 5.1.0 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.5 - '@vitest/coverage-v8': 5.0.5 - '@vitest/ui': 5.0.5 + '@vitest/coverage-istanbul': 5.1.0 + '@vitest/coverage-v8': 5.1.0 + '@vitest/ui': 5.1.0 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -3215,317 +2027,52 @@ packages: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} - whatwg-url@17.3.0: - resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} - engines: {node: ^22.14.0 || >=24.0.0} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - which@4.0.0: - resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} - engines: {node: ^16.13.0 || >=18.0.0} - hasBin: true - - why-is-node-running@3.2.1: - resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} - engines: {node: '>=20.11'} - hasBin: true - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - wsl-utils@1.0.1: - resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==} - engines: {node: '>=20'} - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yocto-spinner@1.2.2: - resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==} - engines: {node: '>=18.19'} - - yoctocolors@2.2.0: - resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} - engines: {node: '>=18'} - - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@4.6.7: - resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} - -snapshots: - - '@adobe/css-tools@4.5.0': {} - - '@asamuzakjp/css-color@7.1.6': - dependencies: - '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - lru-cache: 11.5.3 - - '@asamuzakjp/dom-selector@10.0.3': - dependencies: - bidi-js: 1.1.0 - css-tree: 3.2.1 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.3 - - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.7': {} - - '@babel/core@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.10 - '@babel/parser': 7.29.9 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.10 - '@babel/types': 7.29.8 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.8': - dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-annotate-as-pure@7.29.7': - dependencies: - '@babel/types': 7.29.8 - - '@babel/helper-compilation-targets@7.29.7': - dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.29.3 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.10 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-member-expression-to-functions@7.29.7': - dependencies: - '@babel/traverse': 7.29.10 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.10 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-optimise-call-expression@7.29.7': - dependencies: - '@babel/types': 7.29.8 - - '@babel/helper-plugin-utils@7.29.7': {} - - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': - dependencies: - '@babel/traverse': 7.29.10 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/helper-validator-option@7.29.7': {} - - '@babel/helpers@7.29.10': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - - '@babel/parser@7.29.9': - dependencies: - '@babel/types': 7.29.8 - - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color - - '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color - - '@babel/runtime@7.29.10': {} + whatwg-url@17.3.0: + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} + engines: {node: ^22.14.0 || >=24.0.0} - '@babel/runtime@7.29.7': {} + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} - '@babel/traverse@7.29.10': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - '@babel/types@7.29.8': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 +snapshots: - '@bcoe/v8-coverage@1.0.2': {} + '@adobe/css-tools@4.5.0': {} - '@bramus/specificity@2.4.3': + '@babel/code-frame@7.29.7': dependencies: - css-tree: 3.2.1 + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 - '@csstools/color-helpers@6.1.2': {} + '@babel/helper-string-parser@7.29.13': {} - '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 + '@babel/helper-validator-identifier@7.29.7': {} - '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + '@babel/parser@7.29.13': dependencies: - '@csstools/color-helpers': 6.1.2 - '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 + '@babel/types': 7.29.11 - '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/css-tokenizer': 4.0.2 + '@babel/runtime@7.29.10': {} - '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 + '@babel/runtime@7.29.13': {} - '@csstools/css-tokenizer@4.0.2': {} + '@babel/runtime@7.29.7': {} - '@dotenvx/dotenvx@1.75.1': + '@babel/types@7.29.11': dependencies: - '@dotenvx/primitives': 0.8.0 - commander: 11.1.0 - conf: 10.2.0 - dotenv: 17.4.2 - enquirer: 2.4.1 - env-paths: 2.2.1 - execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.7) - ignore: 5.3.2 - object-treeify: 1.1.33 - open: 8.4.2 - picomatch: 4.0.7 - systeminformation: 5.33.15 - undici: 7.30.1 - which: 4.0.0 - yocto-spinner: 1.2.2 + '@babel/helper-string-parser': 7.29.13 + '@babel/helper-validator-identifier': 7.29.7 - '@dotenvx/primitives@0.8.0': {} + '@bcoe/v8-coverage@1.0.2': {} '@exodus/bytes@1.16.0': {} @@ -3548,10 +2095,6 @@ snapshots: '@fontsource-variable/geist@5.3.0': {} - '@hono/node-server@2.1.5(hono@4.13.13)': - dependencies: - hono: 4.13.13 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -3571,49 +2114,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@lobehub/icons@6.0.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + '@lobehub/icons@6.15.0(lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: - es-toolkit: 1.52.0 - lucide-react: 0.469.0(react@19.3.1) + es-toolkit: 1.53.2 + lucide-react: 1.70.0(@types/react@19.3.0)(react@19.3.1) polished: 4.3.1 react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - '@modelcontextprotocol/sdk@1.32.1': - dependencies: - '@hono/node-server': 2.1.5(hono@4.13.13) - ajv: 8.20.0 - ajv-formats: 3.0.1 - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.1 - express: 5.3.0 - express-rate-limit: 8.7.1(express@5.3.0) - hono: 4.13.13 - jose: 6.2.12 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.6.7 - zod-to-json-schema: 3.25.2(zod@4.6.7) - transitivePeerDependencies: - - supports-color - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.3 - - '@oxc-project/types@0.153.0': {} + '@oxc-project/types@0.156.0': {} '@radix-ui/number@1.1.3': {} @@ -4379,165 +2888,135 @@ snapshots: react: 19.3.1 react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1) - '@rolldown/binding-android-arm-eabi@1.2.13': + '@rolldown/binding-android-arm-eabi@1.2.16': optional: true - '@rolldown/binding-android-arm64@1.2.13': + '@rolldown/binding-android-arm64@1.2.16': optional: true - '@rolldown/binding-darwin-arm64@1.2.13': + '@rolldown/binding-darwin-arm64@1.2.16': optional: true - '@rolldown/binding-darwin-x64@1.2.13': + '@rolldown/binding-darwin-x64@1.2.16': optional: true - '@rolldown/binding-freebsd-x64@1.2.13': + '@rolldown/binding-freebsd-x64@1.2.16': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.13': + '@rolldown/binding-linux-arm64-gnu@1.2.16': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.13': + '@rolldown/binding-linux-arm64-musl@1.2.16': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.13': + '@rolldown/binding-linux-ppc64-gnu@1.2.16': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.13': + '@rolldown/binding-linux-s390x-gnu@1.2.16': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.13': + '@rolldown/binding-linux-x64-gnu@1.2.16': optional: true - '@rolldown/binding-linux-x64-musl@1.2.13': + '@rolldown/binding-linux-x64-musl@1.2.16': optional: true - '@rolldown/binding-openharmony-arm64@1.2.13': + '@rolldown/binding-openharmony-arm64@1.2.16': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.13': + '@rolldown/binding-win32-arm64-msvc@1.2.16': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.13': + '@rolldown/binding-win32-x64-msvc@1.2.16': optional: true '@rolldown/pluginutils@1.0.1': {} - '@sec-ant/readable-stream@0.4.1': {} - - '@shadcn/registry@0.2.0': - dependencies: - '@babel/core': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@dotenvx/dotenvx': 1.75.1 - cn: 0.2.6 - code-block-writer: 13.0.3 - deepmerge: 4.3.1 - execa: 9.6.1 - fast-glob: 3.3.3 - fs-extra: 11.4.1 - fuzzysort: 3.1.0 - kleur: 4.1.5 - ora: 8.2.0 - postcss: 8.5.30 - prompts: 2.4.2 - recast: 0.23.21 - socks: 2.8.10 - stringify-object: 5.0.0 - tsconfig-paths: 4.2.0 - type-fest: 4.41.0 - undici: 7.30.1 - zod: 4.6.7 - transitivePeerDependencies: - - supports-color - - '@sindresorhus/merge-streams@4.0.0': {} - '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} - '@tailwindcss/node@4.3.3': + '@tailwindcss/node@4.3.5': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.24.5 + enhanced-resolve: 5.26.1 jiti: 2.7.0 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.3.3 + lightningcss: 1.33.0 + magic-string: 1.4.5 + source-map-js: 1.2.2 + tailwindcss: 4.3.5 - '@tailwindcss/oxide-android-arm64@4.3.3': + '@tailwindcss/oxide-android-arm64@4.3.5': optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.3': + '@tailwindcss/oxide-darwin-arm64@4.3.5': optional: true - '@tailwindcss/oxide-darwin-x64@4.3.3': + '@tailwindcss/oxide-darwin-x64@4.3.5': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.3': + '@tailwindcss/oxide-freebsd-x64@4.3.5': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.5': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.5': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + '@tailwindcss/oxide-linux-arm64-musl@4.3.5': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + '@tailwindcss/oxide-linux-x64-gnu@4.3.5': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.3': + '@tailwindcss/oxide-linux-x64-musl@4.3.5': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.3': + '@tailwindcss/oxide-wasm32-wasi@4.3.5': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.5': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + '@tailwindcss/oxide-win32-x64-msvc@4.3.5': optional: true - '@tailwindcss/oxide@4.3.3': + '@tailwindcss/oxide@4.3.5': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.3 - '@tailwindcss/oxide-darwin-arm64': 4.3.3 - '@tailwindcss/oxide-darwin-x64': 4.3.3 - '@tailwindcss/oxide-freebsd-x64': 4.3.3 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 - '@tailwindcss/oxide-linux-x64-musl': 4.3.3 - '@tailwindcss/oxide-wasm32-wasi': 4.3.3 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - - '@tailwindcss/vite@4.3.3(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0))': - dependencies: - '@tailwindcss/node': 4.3.3 - '@tailwindcss/oxide': 4.3.3 - tailwindcss: 4.3.3 - vite: 8.3.4(@types/node@26.6.5)(jiti@2.7.0) - - '@tanstack/query-core@5.104.3': {} - - '@tanstack/react-query@5.104.3(react@19.3.1)': - dependencies: - '@tanstack/query-core': 5.104.3 + '@tailwindcss/oxide-android-arm64': 4.3.5 + '@tailwindcss/oxide-darwin-arm64': 4.3.5 + '@tailwindcss/oxide-darwin-x64': 4.3.5 + '@tailwindcss/oxide-freebsd-x64': 4.3.5 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.5 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.5 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.5 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.5 + '@tailwindcss/oxide-linux-x64-musl': 4.3.5 + '@tailwindcss/oxide-wasm32-wasi': 4.3.5 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.5 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.5 + + '@tailwindcss/vite@4.3.5(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.5 + '@tailwindcss/oxide': 4.3.5 + tailwindcss: 4.3.5 + vite: 8.3.6(@types/node@26.6.6)(jiti@2.7.0) + + '@tanstack/query-core@5.107.1': {} + + '@tanstack/react-query@5.107.1(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@tanstack/query-core': 5.107.1 react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 - '@testing-library/dom@10.4.1': + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.10 @@ -4548,37 +3027,31 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.5)': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0)': dependencies: '@adobe/css-tools': 4.5.0 - '@testing-library/dom': 10.4.1 + '@testing-library/dom': 10.4.2 aria-query: 5.3.0 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)) - '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 + '@testing-library/dom': 10.4.2 react: 19.3.1 react-dom: 19.3.1(react@19.3.1) optionalDependencies: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.2)': dependencies: - '@testing-library/dom': 10.4.1 - - '@ts-morph/common@0.27.0': - dependencies: - fast-glob: 3.3.3 - minimatch: 10.2.6 - path-browserify: 1.0.1 + '@testing-library/dom': 10.4.2 '@types/aria-query@5.0.4': {} @@ -4613,9 +3086,9 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/estree@1.0.9': {} + '@types/estree@1.0.10': {} - '@types/node@26.6.5': + '@types/node@26.6.6': dependencies: undici-types: 8.9.0 @@ -4629,8 +3102,6 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@types/validate-npm-package-name@4.0.2': {} - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -4691,66 +3162,42 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0))': + '@vitejs/plugin-react@6.2.1(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@26.6.5)(jiti@2.7.0) + vite: 8.3.6(@types/node@26.6.6)(jiti@2.7.0) - '@vitest/coverage-v8@5.0.5(vitest@5.0.5)': + '@vitest/coverage-v8@5.1.0(vitest@5.1.0)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/istanbul-lib-report': 1.0.2 + '@vitest/istanbul-lib-coverage': 2.1.0 + '@vitest/istanbul-lib-report': 2.1.0 ast-v8-to-istanbul: 1.0.7 magicast: 0.5.5 obug: 3.0.0 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)) - '@vitest/istanbul-lib-coverage@1.0.2': {} + '@vitest/istanbul-lib-coverage@2.1.0': {} - '@vitest/istanbul-lib-report@1.0.2': + '@vitest/istanbul-lib-report@2.1.0': dependencies: - '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 2.1.0 - '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0))': + '@vitest/mocker@5.1.0(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.5 + '@vitest/spy': 5.1.0 estree-walker: 3.0.3 - magic-string: 1.4.3 + magic-string: 1.4.5 optionalDependencies: - vite: 8.3.4(@types/node@26.6.5)(jiti@2.7.0) - - '@vitest/spy@5.0.5': {} + vite: 8.3.6(@types/node@26.6.6)(jiti@2.7.0) - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.1.0 - - ajv-formats@2.1.1: - dependencies: - ajv: 8.20.0 - - ajv-formats@3.0.1: - dependencies: - ajv: 8.20.0 - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.8 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-colors@4.1.3: {} + '@vitest/spy@5.1.0': {} ansi-regex@5.0.1: {} - ansi-regex@6.4.0: {} - ansi-styles@5.2.0: {} aria-hidden@1.2.6: @@ -4763,147 +3210,34 @@ snapshots: assertion-error@2.0.1: {} - ast-types@0.16.3: - dependencies: - tslib: 2.8.1 - ast-v8-to-istanbul@1.0.7: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 js-tokens: 10.0.0 - atomically@1.7.0: {} - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.11.29: {} - - bidi-js@1.1.0: - dependencies: - require-from-string: 2.0.2 - - body-parser@2.3.0: - dependencies: - bytes: 3.1.2 - content-type: 2.1.0 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.3 - on-finished: 2.4.1 - qs: 6.16.1 - raw-body: 3.0.2 - type-is: 2.1.0 - transitivePeerDependencies: - - supports-color - - brace-expansion@5.0.12: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.29.3: - dependencies: - baseline-browser-mapping: 2.11.29 - caniuse-lite: 1.0.30001816 - electron-to-chromium: 1.5.458 - node-releases: 2.0.59 - update-browserslist-db: 1.3.4(browserslist@4.29.3) - - bundle-name@4.1.1: - dependencies: - run-applescript: 7.1.0 - - bytes@3.1.2: {} + bcp-47-match@2.0.3: {} - call-bind-apply-helpers@1.0.2: + bcp-47@2.1.2: dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - caniuse-lite@1.0.30001816: {} + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 chai@6.3.0: {} - chalk@5.6.2: {} - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-spinners@2.9.2: {} - clsx@2.1.1: {} - cn@0.2.6: {} - - cn@0.5.0: {} - - code-block-writer@13.0.3: {} - - commander@11.1.0: {} - - commander@14.0.3: {} - - conf@10.2.0: - dependencies: - ajv: 8.20.0 - ajv-formats: 2.1.1 - atomically: 1.7.0 - debounce-fn: 4.0.0 - dot-prop: 6.0.1 - env-paths: 2.2.1 - json-schema-typed: 7.0.3 - onetime: 5.1.2 - pkg-up: 3.1.0 - semver: 7.8.5 - - content-disposition@2.0.1: {} - - content-type@1.0.5: {} - - content-type@2.1.0: {} - - convert-source-map@2.0.0: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} + cn@0.5.2: {} cookie@1.1.1: {} - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - css-tree@3.2.1: - dependencies: - mdn-data: 2.27.1 - source-map-js: 1.2.2 - css.escape@1.5.1: {} - cssesc@3.0.0: {} - csstype@3.2.3: {} d3-array@3.2.4: @@ -4949,407 +3283,98 @@ snapshots: whatwg-mimetype: 5.0.0 whatwg-url: 17.3.0 transitivePeerDependencies: - - '@noble/hashes' - - debounce-fn@4.0.0: - dependencies: - mimic-fn: 3.1.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js-light@2.5.1: {} - - decimal.js@10.6.0: {} - - dedent@1.7.2: {} - - deepmerge@4.3.1: {} - - default-browser-id@5.0.1: {} - - default-browser@5.5.1: - dependencies: - bundle-name: 4.1.1 - default-browser-id: 5.0.1 - - define-lazy-prop@2.0.0: {} - - define-lazy-prop@3.0.0: {} - - depd@2.0.0: {} - - dequal@2.0.3: {} - - detect-libc@2.1.2: {} - - detect-node-es@1.1.0: {} - - diff@8.0.4: {} - - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - - dot-prop@6.0.1: - dependencies: - is-obj: 2.0.0 - - dotenv@17.4.2: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ee-first@1.1.1: {} - - electron-to-chromium@1.5.458: {} - - emoji-regex@10.6.0: {} - - encodeurl@2.0.0: {} - - enhanced-resolve@5.24.5: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - - enquirer@2.4.1: - dependencies: - ansi-colors: 4.1.3 - strip-ansi: 6.0.1 - - entities@8.1.0: {} - - env-paths@2.2.1: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@2.3.2: {} - - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - - es-toolkit@1.52.0: {} - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - esprima@4.0.1: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - etag@1.8.1: {} - - eventemitter3@5.0.4: {} - - eventsource-parser@3.1.1: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.1.1 - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - execa@9.6.1: - dependencies: - '@sindresorhus/merge-streams': 4.0.0 - cross-spawn: 7.0.6 - figures: 6.1.0 - get-stream: 9.0.1 - human-signals: 8.0.1 - is-plain-obj: 4.1.0 - is-stream: 4.0.1 - npm-run-path: 6.0.0 - pretty-ms: 9.3.1 - signal-exit: 4.1.0 - strip-final-newline: 4.0.0 - yoctocolors: 2.2.0 - - expect-type@1.4.0: {} - - express-rate-limit@8.7.1(express@5.3.0): - dependencies: - debug: 4.4.3 - express: 5.3.0 - ip-address: 10.7.4 - transitivePeerDependencies: - - supports-color - - express@5.3.0: - dependencies: - accepts: 2.0.0 - body-parser: 2.3.0 - content-disposition: 2.0.1 - content-type: 2.1.0 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.8 - qs: 6.16.1 - range-parser: 1.3.0 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.1.0 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-uri@3.1.8: {} - - fastq@1.20.3: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.7): - optionalDependencies: - picomatch: 4.0.7 - - figures@6.1.0: - dependencies: - is-unicode-supported: 2.1.0 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - find-up@3.0.0: - dependencies: - locate-path: 3.0.0 - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - fs-extra@11.4.1: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - fuzzysort@3.1.0: {} - - gensync@1.0.0-beta.2: {} - - get-east-asian-width@1.7.0: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - math-intrinsics: 1.1.0 - - get-nonce@1.0.1: {} - - get-own-enumerable-keys@1.0.0: {} - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 - - get-stream@6.0.1: {} - - get-stream@9.0.1: - dependencies: - '@sec-ant/readable-stream': 0.4.1 - is-stream: 4.0.1 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - has-symbols@1.1.0: {} - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - - hono@4.13.13: {} - - html-encoding-sniffer@7.0.0: - dependencies: - '@exodus/bytes': 1.16.0 - transitivePeerDependencies: - - '@noble/hashes' - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - human-signals@2.1.0: {} - - human-signals@8.0.1: {} - - iconv-lite@0.7.3: - dependencies: - safer-buffer: 2.1.2 + - '@noble/hashes' - ignore@5.3.2: {} + decimal.js-light@2.5.1: {} - immer@11.1.18: {} + decimal.js@10.6.0: {} - indent-string@4.0.0: {} + dequal@2.0.3: {} - inherits@2.0.4: {} + detect-libc@2.1.2: {} - internmap@2.0.3: {} + detect-node-es@1.1.0: {} - ip-address@10.7.4: {} + dom-accessibility-api@0.5.16: {} - ipaddr.js@1.9.1: {} + dom-accessibility-api@0.6.3: {} - is-docker@2.2.1: {} + enhanced-resolve@5.26.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 - is-docker@3.0.0: {} + entities@9.0.0: {} - is-extglob@2.1.1: {} + es-module-lexer@2.3.2: {} - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 + es-toolkit@1.52.0: {} - is-in-ssh@1.0.0: {} + es-toolkit@1.53.2: {} - is-inside-container@1.0.0: + estree-walker@3.0.3: dependencies: - is-docker: 3.0.0 - - is-interactive@2.0.0: {} - - is-number@7.0.0: {} + '@types/estree': 1.0.10 - is-obj@2.0.0: {} + eventemitter3@5.0.4: {} - is-obj@3.0.0: {} + expect-type@1.4.0: {} - is-plain-obj@4.1.0: {} + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 - is-potential-custom-element-name@1.0.1: {} + fsevents@2.3.3: + optional: true - is-promise@4.0.0: {} + get-nonce@1.0.1: {} - is-regexp@3.1.0: {} + graceful-fs@4.2.11: {} - is-stream@2.0.1: {} + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' - is-stream@4.0.1: {} + immer@11.1.18: {} - is-unicode-supported@1.3.0: {} + indent-string@4.0.0: {} - is-unicode-supported@2.1.0: {} + internmap@2.0.3: {} - is-wsl@2.2.0: - dependencies: - is-docker: 2.2.1 + is-alphabetical@2.0.1: {} - is-wsl@3.1.1: + is-alphanumerical@2.0.1: dependencies: - is-inside-container: 1.0.0 + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 - isexe@2.0.0: {} + is-decimal@2.0.1: {} - isexe@3.1.5: {} + is-potential-custom-element-name@1.0.1: {} jiti@2.7.0: {} - jose@6.2.12: {} - js-tokens@10.0.0: {} js-tokens@4.0.0: {} - jsdom@30.1.3: + jsdom@31.0.0: dependencies: - '@asamuzakjp/css-color': 7.1.6 - '@asamuzakjp/dom-selector': 10.0.3 - '@bramus/specificity': 2.4.3 - '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) '@exodus/bytes': 1.16.0 - css-tree: 3.2.1 + bcp-47: 2.1.2 + bcp-47-match: 2.0.3 data-urls: 8.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 7.0.0 is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.3 - parse5: 8.0.1 + parse5: 8.1.0 + parse5-parser-stream: 8.1.0 saxes: 6.0.0 - tough-cookie: 6.0.2 - undici: 8.12.2 + tough-cookie: 6.1.0 + undici: 8.12.4 w3c-xmlserializer: 6.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -5358,108 +3383,39 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - jsesc@3.1.0: {} - - json-schema-traverse@1.0.0: {} - - json-schema-typed@7.0.3: {} - - json-schema-typed@8.0.2: {} - - json5@2.2.3: {} - - jsonfile@6.2.1: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - kleur@3.0.3: {} - - kleur@4.1.5: {} - - lightningcss-android-arm64@1.32.0: - optional: true - lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: - optional: true - lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: - optional: true - lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: - optional: true - lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: - optional: true - lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: - optional: true - lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: - optional: true - lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: - optional: true - lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 @@ -5476,27 +3432,9 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 - locate-path@3.0.0: - dependencies: - p-locate: 3.0.0 - path-exists: 3.0.0 - - log-symbols@6.0.0: - dependencies: - chalk: 5.6.2 - is-unicode-supported: 1.3.0 - lru-cache@11.5.3: {} - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@0.469.0(react@19.3.1): - dependencies: - react: 19.3.1 - - lucide-react@1.58.0(@types/react@19.3.0)(react@19.3.1): + lucide-react@1.70.0(@types/react@19.3.0)(react@19.3.1): dependencies: react: 19.3.1 optionalDependencies: @@ -5504,219 +3442,52 @@ snapshots: lz-string@1.5.0: {} - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - - magic-string@1.4.3: + magic-string@1.4.5: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 magicast@0.5.5: dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + '@babel/parser': 7.29.13 + '@babel/types': 7.29.11 source-map-js: 1.2.2 - math-intrinsics@1.1.0: {} - - mdn-data@2.27.1: {} - - media-typer@1.1.1: {} - - merge-descriptors@2.0.0: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mimic-fn@2.1.0: {} - - mimic-fn@3.1.0: {} - - mimic-function@5.0.1: {} - min-indent@1.0.1: {} - minimatch@10.2.6: - dependencies: - brace-expansion: 5.0.12 - - minimist@1.2.8: {} - - ms@2.1.3: {} - nanoid@3.3.20: {} - negotiator@1.1.0: - dependencies: - content-type: 2.1.0 - - node-releases@2.0.59: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - npm-run-path@6.0.0: - dependencies: - path-key: 4.0.0 - unicorn-magic: 0.3.0 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - object-treeify@1.1.33: {} - obug@3.0.0: {} - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - - open@11.0.4: - dependencies: - default-browser: 5.5.1 - define-lazy-prop: 3.0.0 - is-in-ssh: 1.0.0 - is-inside-container: 1.0.0 - powershell-utils: 0.2.1 - wsl-utils: 1.0.1 - - open@8.4.2: + parse5-parser-stream@8.1.0: dependencies: - define-lazy-prop: 2.0.0 - is-docker: 2.2.1 - is-wsl: 2.2.0 + parse5: 8.1.0 - ora@8.2.0: + parse5@8.1.0: dependencies: - chalk: 5.6.2 - cli-cursor: 5.0.0 - cli-spinners: 2.9.2 - is-interactive: 2.0.0 - is-unicode-supported: 2.1.0 - log-symbols: 6.0.0 - stdin-discarder: 0.2.2 - string-width: 7.2.0 - strip-ansi: 7.2.0 - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-locate@3.0.0: - dependencies: - p-limit: 2.3.0 - - p-try@2.2.0: {} - - parse-ms@4.0.0: {} - - parse5@8.0.1: - dependencies: - entities: 8.1.0 - - parseurl@1.3.3: {} - - path-browserify@1.0.1: {} - - path-exists@3.0.0: {} - - path-key@3.1.1: {} - - path-key@4.0.0: {} - - path-to-regexp@8.4.2: {} + entities: 9.0.0 picocolors@1.1.1: {} - picomatch@2.3.2: {} - picomatch@4.0.7: {} - pkce-challenge@5.0.1: {} - - pkg-up@3.1.0: - dependencies: - find-up: 3.0.0 - polished@4.3.1: dependencies: - '@babel/runtime': 7.29.10 - - postcss-selector-parser@7.1.6: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss@8.5.29: - dependencies: - nanoid: 3.3.20 - picocolors: 1.1.1 - source-map-js: 1.2.2 + '@babel/runtime': 7.29.13 - postcss@8.5.30: + postcss@8.5.32: dependencies: nanoid: 3.3.20 picocolors: 1.1.1 source-map-js: 1.2.2 - powershell-utils@0.2.1: {} - pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 ansi-styles: 5.2.0 react-is: 17.0.2 - pretty-ms@9.3.1: - dependencies: - parse-ms: 4.0.0 - - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - - proxy-addr@2.0.8: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - punycode@2.3.1: {} - qs@6.16.1: - dependencies: - es-define-property: 1.0.1 - side-channel: 1.1.1 - - queue-microtask@1.2.3: {} - radix-ui@1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: '@radix-ui/primitive': 1.2.0 @@ -5780,15 +3551,6 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) - range-parser@1.3.0: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.3 - unpipe: 1.0.0 - react-dom@19.3.1(react@19.3.1): dependencies: react: 19.3.1 @@ -5824,17 +3586,17 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - react-router-dom@7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + react-router-dom@7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - react-router: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-router: 7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - react-router@7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + react-router@7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: cookie: 1.1.1 react: 19.3.1 - set-cookie-parser: 2.7.2 + set-cookie-parser: 2.7.3 optionalDependencies: react-dom: 19.3.1(react@19.3.1) @@ -5848,14 +3610,6 @@ snapshots: react@19.3.1: {} - recast@0.23.21: - dependencies: - ast-types: 0.16.3 - esprima: 4.0.1 - source-map: 0.6.1 - tiny-invariant: 1.3.3 - tslib: 2.8.1 - recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1))(react@19.3.1) @@ -5887,55 +3641,28 @@ snapshots: redux@5.0.1: {} - require-from-string@2.0.2: {} - reselect@5.2.0: {} - restore-cursor@5.1.0: + rolldown@1.2.16: dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - - reusify@1.1.0: {} - - rolldown@1.2.13: - dependencies: - '@oxc-project/types': 0.153.0 + '@oxc-project/types': 0.156.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.13 - '@rolldown/binding-android-arm64': 1.2.13 - '@rolldown/binding-darwin-arm64': 1.2.13 - '@rolldown/binding-darwin-x64': 1.2.13 - '@rolldown/binding-freebsd-x64': 1.2.13 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 - '@rolldown/binding-linux-arm64-gnu': 1.2.13 - '@rolldown/binding-linux-arm64-musl': 1.2.13 - '@rolldown/binding-linux-ppc64-gnu': 1.2.13 - '@rolldown/binding-linux-s390x-gnu': 1.2.13 - '@rolldown/binding-linux-x64-gnu': 1.2.13 - '@rolldown/binding-linux-x64-musl': 1.2.13 - '@rolldown/binding-openharmony-arm64': 1.2.13 - '@rolldown/binding-win32-arm64-msvc': 1.2.13 - '@rolldown/binding-win32-x64-msvc': 1.2.13 - - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - - run-applescript@7.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - safer-buffer@2.1.2: {} + '@rolldown/binding-android-arm-eabi': 1.2.16 + '@rolldown/binding-android-arm64': 1.2.16 + '@rolldown/binding-darwin-arm64': 1.2.16 + '@rolldown/binding-darwin-x64': 1.2.16 + '@rolldown/binding-freebsd-x64': 1.2.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 + '@rolldown/binding-linux-arm64-gnu': 1.2.16 + '@rolldown/binding-linux-arm64-musl': 1.2.16 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 + '@rolldown/binding-linux-s390x-gnu': 1.2.16 + '@rolldown/binding-linux-x64-gnu': 1.2.16 + '@rolldown/binding-linux-x64-musl': 1.2.16 + '@rolldown/binding-openharmony-arm64': 1.2.16 + '@rolldown/binding-win32-arm64-msvc': 1.2.16 + '@rolldown/binding-win32-x64-msvc': 1.2.16 saxes@6.0.0: dependencies: @@ -5943,176 +3670,23 @@ snapshots: scheduler@0.28.0: {} - semver@6.3.1: {} - - semver@7.8.5: {} - - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.3.0 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - set-cookie-parser@2.7.2: {} - - setprototypeof@1.2.0: {} - - shadcn@4.22.0: - dependencies: - '@babel/core': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@dotenvx/dotenvx': 1.75.1 - '@modelcontextprotocol/sdk': 1.32.1 - '@shadcn/registry': 0.2.0 - '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.29.3 - cn: 0.2.6 - commander: 14.0.3 - dedent: 1.7.2 - deepmerge: 4.3.1 - diff: 8.0.4 - execa: 9.6.1 - fast-glob: 3.3.3 - fs-extra: 11.4.1 - fuzzysort: 3.1.0 - kleur: 4.1.5 - open: 11.0.4 - ora: 8.2.0 - postcss: 8.5.30 - postcss-selector-parser: 7.1.6 - prompts: 2.4.2 - recast: 0.23.21 - socks: 2.8.10 - stringify-object: 5.0.0 - ts-morph: 26.0.0 - tsconfig-paths: 4.2.0 - undici: 7.30.1 - validate-npm-package-name: 7.0.2 - zod: 4.6.7 - transitivePeerDependencies: - - '@cfworker/json-schema' - - babel-plugin-macros - - supports-color - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@3.0.7: {} - - signal-exit@4.1.0: {} - - sisteransi@1.0.5: {} - - smart-buffer@4.2.0: {} - - socks@2.8.10: - dependencies: - ip-address: 10.7.4 - smart-buffer: 4.2.0 - - source-map-js@1.2.1: {} + set-cookie-parser@2.7.3: {} source-map-js@1.2.2: {} - source-map@0.6.1: {} - - statuses@2.0.2: {} - std-env@4.3.0: {} - stdin-discarder@0.2.2: {} - - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.7.0 - strip-ansi: 7.2.0 - - stringify-object@5.0.0: - dependencies: - get-own-enumerable-keys: 1.0.0 - is-obj: 3.0.0 - is-regexp: 3.1.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.4.0 - - strip-bom@3.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-final-newline@4.0.0: {} - strip-indent@3.0.0: dependencies: min-indent: 1.0.1 - systeminformation@5.33.15: {} - - tailwindcss@4.3.3: {} + tailwindcss@4.3.5: {} tapable@2.3.3: {} tiny-invariant@1.3.3: {} - tinybench@6.2.2: {} + tinybench@6.2.3: {} tinyexec@1.3.1: {} @@ -6123,49 +3697,24 @@ snapshots: tinyrainbow@3.2.0: {} - tldts-core@7.4.18: {} - - tldts@7.4.18: - dependencies: - tldts-core: 7.4.18 + tldts-core@7.4.20: {} - to-regex-range@5.0.1: + tldts@7.4.19: dependencies: - is-number: 7.0.0 + tldts-core: 7.4.20 - toidentifier@1.0.1: {} - - tough-cookie@6.0.2: + tough-cookie@6.1.0: dependencies: - tldts: 7.4.18 + tldts: 7.4.19 tr46@7.0.0: dependencies: punycode: 2.3.1 - ts-morph@26.0.0: - dependencies: - '@ts-morph/common': 0.27.0 - code-block-writer: 13.0.3 - - tsconfig-paths@4.2.0: - dependencies: - json5: 2.2.3 - minimist: 1.2.8 - strip-bom: 3.0.0 - tslib@2.8.1: {} tw-animate-css@1.4.0: {} - type-fest@4.41.0: {} - - type-is@2.1.0: - dependencies: - content-type: 2.1.0 - media-typer: 1.1.1 - mime-types: 3.0.2 - typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -6191,21 +3740,7 @@ snapshots: undici-types@8.9.0: {} - undici@7.30.1: {} - - undici@8.12.2: {} - - unicorn-magic@0.3.0: {} - - universalify@2.0.1: {} - - unpipe@1.0.0: {} - - update-browserslist-db@1.3.4(browserslist@4.29.3): - dependencies: - browserslist: 4.29.3 - escalade: 3.2.0 - picocolors: 1.1.1 + undici@8.12.4: {} use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.1): dependencies: @@ -6226,12 +3761,6 @@ snapshots: dependencies: react: 19.3.1 - util-deprecate@1.0.2: {} - - validate-npm-package-name@7.0.2: {} - - vary@1.1.2: {} - victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 @@ -6249,38 +3778,38 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0): + vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.29 - rolldown: 1.2.13 + postcss: 8.5.32 + rolldown: 1.2.16 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.6.5 + '@types/node': 26.6.6 fsevents: 2.3.3 jiti: 2.7.0 - vitest@5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)): + vitest@5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@31.0.0)(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)) + '@vitest/mocker': 5.1.0(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.3 + magic-string: 1.4.5 obug: 3.0.0 picomatch: 4.0.7 std-env: 4.3.0 - tinybench: 6.2.2 + tinybench: 6.2.3 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@26.6.5)(jiti@2.7.0) + vite: 8.3.6(@types/node@26.6.6)(jiti@2.7.0) why-is-node-running: 3.2.1 optionalDependencies: - '@types/node': 26.6.5 - '@vitest/coverage-v8': 5.0.5(vitest@5.0.5) - jsdom: 30.1.3 + '@types/node': 26.6.6 + '@vitest/coverage-v8': 5.1.0(vitest@5.1.0) + jsdom: 31.0.0 transitivePeerDependencies: - msw @@ -6300,37 +3829,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - which@2.0.2: - dependencies: - isexe: 2.0.0 - - which@4.0.0: - dependencies: - isexe: 3.1.5 - why-is-node-running@3.2.1: {} - wrappy@1.0.2: {} - - wsl-utils@1.0.1: - dependencies: - is-wsl: 3.1.1 - powershell-utils: 0.2.1 - xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} - - yallist@3.1.1: {} - - yocto-spinner@1.2.2: - dependencies: - yoctocolors: 2.2.0 - - yoctocolors@2.2.0: {} - - zod-to-json-schema@3.25.2(zod@4.6.7): - dependencies: - zod: 4.6.7 - - zod@4.6.7: {} diff --git a/web/src/App.tsx b/web/src/App.tsx index cb96b51c..9a32f0b0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,11 @@ -import { lazy, Suspense } from "react"; +import { useEffect, lazy, Suspense } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { Layout } from "./Layout"; import { useSession } from "./api/hooks"; +import { setUnauthorizedHandler } from "./api/unauthorized"; import { ChangePasswordDialog } from "./components/ChangePasswordDialog"; +import { FirstRunWizard } from "./components/FirstRunWizard"; import { ActivatePage } from "./pages/ActivatePage"; import { LoginPage } from "./pages/LoginPage"; @@ -16,8 +19,10 @@ const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ defa const UsersPage = lazy(() => import("./pages/UsersPage").then(m => ({ default: m.UsersPage }))); const AuditPage = lazy(() => import("./pages/AuditPage").then(m => ({ default: m.AuditPage }))); const AlertsPage = lazy(() => import("./pages/AlertsPage").then(m => ({ default: m.AlertsPage }))); +const BackupsPage = lazy(() => import("./pages/BackupsPage").then(m => ({ default: m.BackupsPage }))); const LogsPage = lazy(() => import("./pages/LogsPage").then(m => ({ default: m.LogsPage }))); const UpstreamStatusPage = lazy(() => import("./pages/UpstreamStatusPage").then(m => ({ default: m.UpstreamStatusPage }))); +const BillingPage = lazy(() => import("./pages/BillingPage").then(m => ({ default: m.BillingPage }))); const LOADING = (
载入中…
@@ -45,6 +50,22 @@ function RequireOperator({ isOperator, children }: { export function App() { const session = useSession(); + const queryClient = useQueryClient(); + + // 会话过期(任意页面请求 401)→ 清掉带权限的缓存并让 session 立即变「未登录」, + // 下面的分支随之渲染登录页。不做整页跳转:路由本来就跟 session 数据走, + // 而 window.location 跳转会丢 SPA 状态且绕开 MemoryRouter(测试里也会炸)。 + useEffect(() => { + setUnauthorizedHandler(() => { + // 只丢受保护数据,保留 session key 并显式置空:删掉 session 查询会让 + // observer 立刻重取,而 Cookie 已经失效,等于白发一次 401 请求。 + queryClient.removeQueries({ + predicate: (query) => query.queryKey[0] !== "session", + }); + queryClient.setQueryData(["session"], null); + }); + return () => setUnauthorizedHandler(null); + }, [queryClient]); if (session.isLoading) { return LOADING; @@ -68,7 +89,9 @@ export function App() { } return ( - + <> + + } /> }> @@ -81,12 +104,15 @@ export function App() { } /> } /> } /> + } /> } /> + } /> } /> } /> } /> - + + ); } diff --git a/web/src/Layout.tsx b/web/src/Layout.tsx index 2d892f03..bcf56ef1 100644 --- a/web/src/Layout.tsx +++ b/web/src/Layout.tsx @@ -9,6 +9,7 @@ import { ChangePasswordDialog } from "./components/ChangePasswordDialog"; import { ThemeToggle } from "./components/ThemeToggle"; import { UserMenu } from "./components/UserMenu"; import { BrandMark, SidebarNav } from "./components/Sidebar"; +import { ErrorBoundary } from "./components/ErrorBoundary"; import { useDialogFocus } from "./hooks/useDialogFocus"; // 兼容既有引用(登录页/激活页从 Layout 取品牌标记) @@ -105,7 +106,10 @@ export function Layout({ session }: { session: SessionInfo }) {
- + {/* key=pathname:切换页面即重建边界,把上一页的错误态清掉 */} + + +
Coding2API · 仅供学习研究,未做安全审计 diff --git a/web/src/__tests__/App.test.tsx b/web/src/__tests__/App.test.tsx index f912c457..4a7be543 100644 --- a/web/src/__tests__/App.test.tsx +++ b/web/src/__tests__/App.test.tsx @@ -1,10 +1,11 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../App"; import { Layout } from "../Layout"; +import { notifyUnauthorized } from "../api/unauthorized"; function renderWithProviders(ui: React.ReactNode) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -196,6 +197,33 @@ describe("App 路由守卫", () => { expect(await screen.findByTestId("credentials-page")).toBeInTheDocument(); }); + it("会话过期(任意页面 401)后回到登录页,而不是停在错误态", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/auth/session") { + return new Response(JSON.stringify({ + username: "root", is_admin: true, is_operator: true, role: "admin", + must_change_password: false, + }), { status: 200 }); + } + if (url === "/api/nav-badges") return new Response(JSON.stringify({}), { status: 200 }); + return new Response(JSON.stringify({ credentials: [], viewer: "root", is_admin: true }), { + status: 200, + }); + }), + ); + renderWithProviders(); + expect(await screen.findByTestId("credentials-page")).toBeInTheDocument(); + + // 模拟「停留期间会话被撤销」:某次请求 401 → client 通知 → App 清缓存回登录 + await act(async () => notifyUnauthorized()); + + expect(await screen.findByTestId("login-username")).toBeInTheDocument(); + expect(screen.queryByTestId("credentials-page")).not.toBeInTheDocument(); + }); + it("会话加载中显示载入提示", () => { vi.stubGlobal("fetch", vi.fn(() => new Promise(() => undefined))); renderWithProviders(); diff --git a/web/src/api/__tests__/client.test.tsx b/web/src/api/__tests__/client.test.tsx index 6e488876..ec1c8496 100644 --- a/web/src/api/__tests__/client.test.tsx +++ b/web/src/api/__tests__/client.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { api, ApiError } from "../client"; +import { api, ApiError, REQUEST_TIMEOUT_MS } from "../client"; +import { setUnauthorizedHandler } from "../unauthorized"; describe("api client", () => { beforeEach(() => { @@ -110,4 +111,112 @@ describe("api client", () => { expect.objectContaining({ credentials: "same-origin" }), ); }); + + it("错误体不是 JSON(反代 HTML 页)时仍保留真实 HTTP 状态", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("502 Bad Gateway", { + status: 502, + statusText: "Bad Gateway", + })), + ); + const error = await api.credentials().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(502); + expect((error as ApiError).code).toBe("unknown"); + // 兜底文案不能是空串:至少给 statusText,页面才不会弹一个空白提示 + expect((error as ApiError).message).toBe("Bad Gateway"); + }); + + it("2xx 但不是 JSON 时抛错,不能把故障伪装成空数据", async () => { + // 反代 try_files 回落 SPA、网关截断都会返回 200 + HTML。若当成 `null` + // 成功返回,凭证页会显示「还没有凭证」、session 会判未登录——「请求挂了」 + // 重新变成「没有数据」,正是本次要消灭的故障形态。 + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("index", { status: 200 })), + ); + const error = await api.credentials().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).code).toBe("invalid_response"); + expect((error as ApiError).status).toBe(200); + }); + + it("合法的 null 响应体照常返回,不被误判成非 JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("null", { status: 200 })), + ); + await expect(api.credentials()).resolves.toBeNull(); + }); + + it("请求超时归一为 ApiError(timeout),不把页面挂在 loading", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi.fn((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + })), + ); + // 先挂上断言再推进定时器:否则 rejection 先发生、处理器后挂上, + // vitest 会把这次「超时」记成未处理拒绝(假报错)。 + const assertion = expect(api.credentials()).rejects.toMatchObject({ + name: "ApiError", + status: 0, + code: "timeout", + }); + await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS); + await assertion; + vi.useRealTimers(); + }); + + it("调用方主动取消时原样上抛 AbortError(不被误判为超时)", async () => { + const controller = new AbortController(); + vi.stubGlobal( + "fetch", + vi.fn((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + })), + ); + const pending = api.playgroundModels(controller.signal); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("受保护端点 401 触发会话过期处理器", async () => { + const handler = vi.fn(); + setUnauthorizedHandler(handler); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ error: { code: "unauthorized" } }), { status: 401 }), + ), + ); + + await expect(api.users()).rejects.toMatchObject({ status: 401 }); + expect(handler).toHaveBeenCalledTimes(1); + setUnauthorizedHandler(null); + }); + + it("登录/会话端点的 401 不触发会话过期(密码错不该踢人)", async () => { + const handler = vi.fn(); + setUnauthorizedHandler(handler); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ error: { code: "unauthorized" } }), { status: 401 }), + ), + ); + + await expect(api.login("root", "wrong")).rejects.toMatchObject({ status: 401 }); + await expect(api.session()).rejects.toMatchObject({ status: 401 }); + expect(handler).not.toHaveBeenCalled(); + setUnauthorizedHandler(null); + }); }); diff --git a/web/src/api/__tests__/display.test.ts b/web/src/api/__tests__/display.test.ts index 6651d107..b5d062fa 100644 --- a/web/src/api/__tests__/display.test.ts +++ b/web/src/api/__tests__/display.test.ts @@ -18,6 +18,7 @@ import { credentialState, formatDuration, formatNumber, + formatRate, formatTime, formatTimeParts, upstreamLevelLabel, @@ -110,11 +111,33 @@ describe("formatChartValue", () => { expect(formatChartValue(12.34, "cost")).toBe("¥12.34 (元)"); }); + it("比率指标:0..1 转百分比(一位小数);空值给 —", () => { + expect(formatChartValue(0.9567, "success_rate")).toBe("95.7%"); + expect(formatChartValue(0.4, "cache_rate")).toBe("40.0%"); + }); + + it("单请求成本:金额 + 「/ 次」", () => { + expect(formatChartValue(0.0312, "cost_per_request")).toBe("¥0.0312 / 次"); + }); + it("未知指标回退请求次数", () => { expect(formatChartValue(12, "bogus")).toBe("12 (次)"); }); }); +describe("formatRate", () => { + it("比率乘 100 保留一位小数", () => { + expect(formatRate(0.9)).toBe("90.0%"); + expect(formatRate(1)).toBe("100.0%"); + expect(formatRate(0)).toBe("0.0%"); + }); + + it("无样本给 —(不渲染成 0%)", () => { + expect(formatRate(null)).toBe("—"); + expect(formatRate(undefined)).toBe("—"); + }); +}); + describe("formatSpeed", () => { it("输出速度带 tok/s;估算值前置 ≈;无样本显示 —", () => { expect(formatSpeed(94.1)).toBe("94.1 tok/s"); @@ -143,6 +166,17 @@ describe("formatAxisValue", () => { expect(formatAxisValue(1500.5, "cost")).toBe("1,500.5"); }); + it("单请求成本:按量级给 3–4 位小数(两位会让相邻刻度都显示 0.03)", () => { + expect(formatAxisValue(0.028, "cost_per_request")).toBe("0.028"); + expect(formatAxisValue(0.0312, "cost_per_request")).toBe("0.0312"); + expect(formatAxisValue(0.5, "cost_per_request")).toBe("0.5"); + }); + + it("比率:整数百分比(轴宽有限)", () => { + expect(formatAxisValue(0.9, "success_rate")).toBe("90%"); + expect(formatAxisValue(1, "cache_rate")).toBe("100%"); + }); + it("轴顶单位映射:耗时/首字不重复标单位", () => { expect(METRIC_AXIS_UNIT.requests).toBe("次"); expect(METRIC_AXIS_UNIT.tokens).toBe("tokens"); @@ -153,18 +187,18 @@ describe("formatAxisValue", () => { }); describe("expiringQuotaLabel", () => { - it("窗口内有过期积分时给出量级与剩余时间", () => { - expect(expiringQuotaLabel(100, 129600)).toBe("100 积分将在 1.5 天内过期"); - expect(expiringQuotaLabel(25.5, 3600)).toBe("25.5 积分将在 1.0 小时内过期"); + it("窗口内有过期积分时给出量级与窗口(措辞点明是窗口,非倒计时)", () => { + expect(expiringQuotaLabel(100, 129600)).toBe("100 积分将在 1.5 天窗口内到期"); + expect(expiringQuotaLabel(25.5, 3600)).toBe("25.5 积分将在 1.0 小时窗口内到期"); }); it("次窗口措辞区分开,避免两行同句式分不出优先级", () => { - expect(expiringQuotaLabel(140, 604800, "secondary")).toBe("7.0 天内共 140 积分将过期"); + expect(expiringQuotaLabel(140, 604800, "secondary")).toBe("7.0 天窗口内共 140 积分将到期"); }); it("CodeArts 折成积分后与其余渠道同单位(默认措辞即为积分)", () => { - expect(expiringQuotaLabel(999.5, 3600)).toBe("999.5 积分将在 1.0 小时内过期"); - expect(expiringQuotaLabel(140, 604800, "secondary")).toBe("7.0 天内共 140 积分将过期"); + expect(expiringQuotaLabel(999.5, 3600)).toBe("999.5 积分将在 1.0 小时窗口内到期"); + expect(expiringQuotaLabel(140, 604800, "secondary")).toBe("7.0 天窗口内共 140 积分将到期"); }); it("无到期信息、窗口关闭或零积分时不展示", () => { @@ -363,11 +397,15 @@ describe("display helpers", () => { provider: "kilo", quota_cycle_end: null }) as never); expect(kilo).toBe("免费层(无额度接口)"); - // Qoder 与 CodeBuddy 同属周期重置(未探测到重置时间时退化口径) + // Qoder:签到/赠送积分逐笔到期;cycle_end 是最早到期日,不是重置日 const qoder = quotaSemantics(makeCredential({ + provider: "qoder", quota_cycle_end: 1_800_000_000 }) as never); + expect(qoder).toContain("逐笔到期"); + expect(qoder).toContain("最早"); + expect(qoder).not.toContain("重置"); + const qoderNoEnd = quotaSemantics(makeCredential({ provider: "qoder", quota_cycle_end: null }) as never); - expect(qoder).toContain("本周期剩余"); - expect(qoder).not.toContain("单调递减"); + expect(qoderNoEnd).toBe("积分余额(签到/赠送逐笔到期)"); // CodeArts 是积分余额(内置模型按积分计费),按积分包到期日作废; // 有 cycle_end 时点出最早到期日,不说「本周期」也不套「每日额度池」 @@ -424,10 +462,16 @@ describe("display helpers", () => { expect(usageErrorLabel("rate_limit")).toBe("额度耗尽"); expect(usageErrorLabel("client_disconnect")).toBe("客户端中断"); expect(usageErrorLabel("model_unavailable")).toBe("该模型暂时不可用"); + expect(usageErrorLabel("network_error")).toBe("本机网络故障"); expect(usageErrorLabel("no_healthy_credential")).toBe("无可用凭证"); + // 「上游没这个模型」与「请求无效」是两回事(2026-10-26 独立成类) + expect(usageErrorLabel("model_not_found")).toBe("上游无此模型"); + expect(usageErrorLabel("invalid_request")).toBe("请求无效"); // A1 流式超时:两个新枚举都要有中文文案(不能落到兜底「失败(xxx)」) expect(usageErrorLabel("upstream_first_event_timeout")).toBe("上游首字超时"); expect(usageErrorLabel("upstream_idle_timeout")).toBe("上游流中断"); + // 截断流:上游正常关闭但没走到终态(2026-10-28 新增,不冷却凭证) + expect(usageErrorLabel("truncated_stream")).toBe("上游截断"); expect(usageErrorLabel(null)).toBe("失败"); expect(usageErrorLabel(undefined)).toBe("失败"); // 后端新增枚举时前端不能显示空白:兜底文案带原值便于对照排查 diff --git a/web/src/api/__tests__/keyQuota.test.tsx b/web/src/api/__tests__/keyQuota.test.tsx index faeb9d0b..30882a27 100644 --- a/web/src/api/__tests__/keyQuota.test.tsx +++ b/web/src/api/__tests__/keyQuota.test.tsx @@ -84,7 +84,15 @@ describe("keyQuota 纯函数", () => { expect(usageTodayLabel({ requests: 0, tokens: 0, cost_usd: 0 })).toBe("今日未用"); expect(usageTodayLabel({ requests: 2, tokens: 30, cost_usd: 0 })).toBe("2 次 · 30 token"); expect(usageTodayLabel({ requests: 2, tokens: 30, cost_usd: 0.5 })) - .toBe("2 次 · 30 token · $0.5000"); + .toBe("2 次 · 30 token · $0.5"); + }); + + it("usageTodayLabel 格式化大数:请求数千分位、token 紧凑、成本按量级取位数", () => { + expect(usageTodayLabel({ requests: 2974, tokens: 365_309_724, cost_usd: 7.4879 })) + .toBe("2,974 次 · 3.7亿 token · $7.49"); + // 小额成本不能被两位小数抹成 $0 + expect(usageTodayLabel({ requests: 1, tokens: 100, cost_usd: 0.0042 })) + .toBe("1 次 · 100 token · $0.0042"); }); }); @@ -287,4 +295,24 @@ describe("ApiKeysPage 配额 UI", () => { await userEvent.click(screen.getByTestId("key-quota-close")); expect(screen.queryByTestId("key-quota-dialog")).not.toBeInTheDocument(); }); + + it("Esc 关闭对话框(焦点管理:MASTER §6「对话框焦点管理与 Esc 关闭」)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") return jsonResponse({ ok: true }); + return jsonResponse({ api_keys: [KEY] }); + }), + ); + renderPage(); + await settle(); + + await userEvent.click(screen.getByTestId("edit-quota-key_1")); + expect(screen.getByTestId("key-quota-dialog")).toBeInTheDocument(); + + // Esc 关闭:此前这个对话框没接 useDialogFocus,Esc 收不掉它 + await userEvent.keyboard("{Escape}"); + await waitFor(() => + expect(screen.queryByTestId("key-quota-dialog")).not.toBeInTheDocument()); + }); }); diff --git a/web/src/api/__tests__/providers.test.ts b/web/src/api/__tests__/providers.test.ts index 9516fd71..cf41cab2 100644 --- a/web/src/api/__tests__/providers.test.ts +++ b/web/src/api/__tests__/providers.test.ts @@ -32,14 +32,14 @@ describe("渠道展示元数据", () => { it("每个已知渠道都有标签、缩写与专属配色(三者对齐)", () => { const providers = Object.keys(PROVIDER_LABEL); expect(providers.sort()).toEqual([ - "codearts", "codebuddy", "codebuddy_intl", "kilo", "officeace", "qoder", - "trae", "zen", + "codearts", "codebuddy", "codebuddy_intl", "kilo", "lingxi", "officeace", + "qoder", "raccoon", "trae", "zen", ]); for (const provider of providers) { expect(PROVIDER_ABBR[provider as keyof typeof PROVIDER_ABBR]).toBeTruthy(); expect( PROVIDER_CHART_COLOR[provider as keyof typeof PROVIDER_CHART_COLOR], - ).toMatch(/^var\(--chart-\d\)$/); + ).toMatch(/^var\(--chart-\d+\)$/); } }); diff --git a/web/src/api/__tests__/unauthorized.test.ts b/web/src/api/__tests__/unauthorized.test.ts new file mode 100644 index 00000000..15f0a2ab --- /dev/null +++ b/web/src/api/__tests__/unauthorized.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isSessionExpiry, + notifyUnauthorized, + setUnauthorizedHandler, +} from "../unauthorized"; + +describe("unauthorized(401 统一出口)", () => { + it("受保护端点的 401 视为会话过期", () => { + expect(isSessionExpiry("/api/users", 401)).toBe(true); + expect(isSessionExpiry("/api/api-keys", 401)).toBe(true); + }); + + it("免登录端点的 401 是业务语义,不算会话过期", () => { + for (const path of ["/api/auth/session", "/api/auth/login", "/api/auth/activate"]) { + expect(isSessionExpiry(path, 401)).toBe(false); + } + }); + + it("非 401 状态不算会话过期", () => { + expect(isSessionExpiry("/api/users", 403)).toBe(false); + expect(isSessionExpiry("/api/users", 500)).toBe(false); + expect(isSessionExpiry("/api/users", 200)).toBe(false); + }); + + it("注册后通知处理器;注销后不再调用", () => { + const handler = vi.fn(); + setUnauthorizedHandler(handler); + notifyUnauthorized(); + expect(handler).toHaveBeenCalledTimes(1); + + setUnauthorizedHandler(null); + notifyUnauthorized(); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 45c0bc86..0e07d5eb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -5,8 +5,14 @@ import type { ActivationIssued, AlertsResponse, AuditResponse, + BackupEntry, + BackupListResponse, + PendingRestore, BatchAction, + BatchImportEntry, + BatchImportResult, BatchResult, + CheckinEvent, CheckinResult, CheckinStatus, CredentialLimits, @@ -27,7 +33,10 @@ import type { StatsMetric, StatsOverview, TimelinePoint, + TosRisk, + BillingReport, ModelTimelineResponse, + PercentileTimelineResponse, NavBadges, StatsEventsResponse, StatsEventFilters, @@ -41,6 +50,7 @@ import type { UserStats, UserRow, } from "./types"; +import { isSessionExpiry, notifyUnauthorized } from "./unauthorized"; export class ApiError extends Error { constructor( @@ -53,25 +63,110 @@ export class ApiError extends Error { } } -async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(path, { - ...init, - headers: { - // CSRF 纵深(服务端要求写请求带自定义头,PROPOSAL §6): - // 浏览器跨站表单/简单请求无法携带自定义头 - "X-Requested-With": "XMLHttpRequest", - ...(init.body ? { "Content-Type": "application/json" } : {}), - ...init.headers, +/** + * 管理台请求超时:后端挂死(连接池耗尽、上游不返回)时不能让页面永久 loading。 + * + * 只作用于 `request()` 走的一般端点;SSE(`/api/stats/events/stream`)与 CSV + * 导出各有独立实现,不受此值影响(长流不能被 30s 砍断)。 + */ +export const REQUEST_TIMEOUT_MS = 30_000; + +/** 把「调用方 signal(react-query 取消)」与「超时」合成一个 signal,并可清理定时器。 */ +function timeoutGuard(init: RequestInit): { + signal: AbortSignal; + dispose: () => void; + timedOut: () => boolean; +} { + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, REQUEST_TIMEOUT_MS); + const external = init.signal; + const forward = () => controller.abort(); + if (external) { + if (external.aborted) controller.abort(); + else external.addEventListener("abort", forward, { once: true }); + } + return { + signal: controller.signal, + dispose: () => { + clearTimeout(timer); + external?.removeEventListener("abort", forward); }, - credentials: "same-origin", - }); + timedOut: () => timedOut, + }; +} + +/** + * 读响应体:非 JSON(反向代理 HTML 错误页、网关截断)时不抛 `SyntaxError`, + * 否则真实的 HTTP 状态会被解析异常掩盖成「未知错误」。 + * + * 返回 `undefined` 表示「响应体不是合法 JSON」——与 `null`(合法的 `null` + * 字面量)区分开,调用方据此在 2xx 上抛错而不是把故障当成功数据返回。 + */ +const NOT_JSON = Symbol("not-json"); +async function readBody(response: Response): Promise { const text = await response.text(); - const body = text ? (JSON.parse(text) as unknown) : null; + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return NOT_JSON; + } +} + +async function request(path: string, init: RequestInit = {}): Promise { + const guard = timeoutGuard(init); + let response: Response; + let body: unknown; + try { + response = await fetch(path, { + ...init, + signal: guard.signal, + headers: { + // CSRF 纵深(服务端要求写请求带自定义头,PROPOSAL §6): + // 浏览器跨站表单/简单请求无法携带自定义头 + "X-Requested-With": "XMLHttpRequest", + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...init.headers, + }, + credentials: "same-origin", + }); + // 读体也在超时窗口内:反代「头秒回、体挂住」时,只给 fetch 加超时 + // 等于没加——定时器在响应头到达时就被 dispose 了,页面照样永久 loading。 + body = await readBody(response); + } catch (error) { + // 超时归一为可展示的 ApiError;调用方主动取消(切页/翻页)原样上抛, + // react-query 依赖它是 AbortError 来判定「取消」而非「失败」。 + if (guard.timedOut()) { + throw new ApiError(0, "timeout", `请求超时(${REQUEST_TIMEOUT_MS / 1000} 秒)`); + } + throw error; + } finally { + guard.dispose(); + } if (!response.ok) { + if (isSessionExpiry(path, response.status)) notifyUnauthorized(); const error = (body as { error?: { code?: string; message?: string } } | null)?.error; - throw new ApiError(response.status, error?.code ?? "unknown", error?.message ?? response.statusText); + throw new ApiError( + response.status, + error?.code ?? "unknown", + error?.message || response.statusText || `HTTP ${response.status}`, + ); + } + // 2xx 但不是 JSON:反代/网关的 HTML 错误页、被截断的响应。必须抛错, + // 不能把 `null` 当成功数据返回——那会让「请求挂了」重新伪装成「没有数据」 + // (凭证页显示「还没有凭证」、session 判未登录),正是本次要消灭的故障形态。 + if (body === NOT_JSON) { + throw new ApiError( + response.status, + "invalid_response", + "服务端返回了非 JSON 响应(可能是反向代理错误页),请检查服务是否正常。", + ); } return body as T; } @@ -126,6 +221,12 @@ export const api = { `/api/users/${encodeURIComponent(username)}/reset-password`, { method: "POST" }, ), + /** 用户级策略(Playground):模型白名单 + 配额。传空串 = 不限制。 */ + setUserPolicy: (username: string, allowedModels: string, quota: string) => + request<{ ok: boolean }>(`/api/users/${encodeURIComponent(username)}/policy`, { + method: "PUT", + ...json({ allowed_models: allowedModels, quota }), + }), // ---------------------------------------------------------------- 审计 audit: (filters: { actor?: string; action?: string; limit?: number; offset?: number; @@ -134,13 +235,98 @@ export const api = { // ---------------------------------------------------------------- 凭证 credentials: (sort?: string, order?: string) => request(`/api/credentials${query({ sort, order })}`), + billing: (month?: string) => + request(`/api/billing${query({ month })}`), + /** 账单导出:返回原始响应,页面自己落盘(CSV 有 BOM,JSONL 逐行)。 */ + billingExport: async (month: string, format: "csv" | "jsonl") => { + const response = await fetch( + `/api/billing${query({ month, format })}`, { credentials: "same-origin" }); + if (!response.ok) { + let message = `导出失败(HTTP ${response.status})`; + try { + const body = await response.json(); + if (body?.error?.message) message = body.error.message; + } catch { + // 非 JSON 错误体:保留 HTTP 状态提示 + } + throw new ApiError(response.status, "export_failed", message); + } + return { blob: await response.blob(), rowCount: response.headers.get("X-Export-Row-Count") }; + }, importCredential: (provider: string, credential: unknown, nickname = "") => - request<{ id: string }>("/api/credentials", { + request<{ id: string; tos: TosRisk }>("/api/credentials", { method: "POST", ...json({ provider, credential, nickname }), }), - reviveCredential: (id: string) => - request<{ ok: boolean }>(`/api/credentials/${id}/revive`, { method: "POST" }), + /** 单条凭证导出:返回 JSON 文件内容,页面触发下载(含明文令牌)。 */ + exportCredential: async (id: string) => { + const response = await fetch( + `/api/credentials/${encodeURIComponent(id)}/export`, + { credentials: "same-origin" }); + if (!response.ok) { + let message = `导出失败(HTTP ${response.status})`; + try { + const body = await response.json(); + if (body?.error?.message) message = body.error.message; + } catch { + // 非 JSON 错误体:保留 HTTP 状态提示 + } + throw new ApiError(response.status, "export_failed", message); + } + return { blob: await response.blob() }; + }, + // ---------------------------------------------------------------- 备份 + backups: () => request("/api/backups"), + createBackup: () => + request<{ backup: BackupEntry }>("/api/backups", { method: "POST", ...json({}) }), + deleteBackup: (name: string) => + request<{ ok: boolean }>(`/api/backups/${encodeURIComponent(name)}`, { method: "DELETE" }), + /** 上传备份文件:原始字节 + 自定义头(CSRF 纵深),重启后生效。 */ + restoreBackup: async (file: Blob, filename: string) => { + const response = await fetch( + `/api/backups/restore?filename=${encodeURIComponent(filename)}`, + { + method: "POST", + body: file, + credentials: "same-origin", + headers: { + "X-Requested-With": "XMLHttpRequest", + "Content-Type": "application/octet-stream", + }, + }, + ); + if (!response.ok) { + let message = `恢复失败(HTTP ${response.status})`; + try { + const body = await response.json(); + if (body?.error?.message) message = body.error.message; + } catch { + // 非 JSON 错误体:保留 HTTP 状态提示 + } + throw new ApiError(response.status, "restore_failed", message); + } + return (await response.json()) as { staged: boolean; restart_required: boolean } + & PendingRestore; + }, + /** 下载已有备份的字节(「一键恢复」先取回再上传)。 */ + downloadBackup: async (name: string) => { + const response = await fetch(`/api/backups/${encodeURIComponent(name)}`, { + credentials: "same-origin", + }); + if (!response.ok) throw new ApiError(response.status, "download_failed", "下载备份失败"); + return response.blob(); + }, + /** 批量导入:逐条校验,坏条目只进 failed、不连累其他条目。 */ + batchImportCredentials: (items: BatchImportEntry[]) => + request("/api/credentials/batch-import", { + method: "POST", + ...json({ items }), + }), + reviveCredential: (id: string, force = false) => + request<{ ok: boolean }>(`/api/credentials/${id}/revive`, { + method: "POST", + ...json({ force }), + }), toggleCredential: (id: string, enabled: boolean) => request<{ ok: boolean }>(`/api/credentials/${id}/toggle`, { method: "POST", ...json({ enabled }) }), pinCredential: (id: string | null) => @@ -161,6 +347,18 @@ export const api = { method: "PUT", ...json({ tags }), }), + /** 写入 / 清除 Qoder 网页 Cookie(空串 = 清除);Cookie 不回显,只提交。 */ + setCredentialWebCookie: (id: string, cookie: string) => + request<{ ok: boolean }>(`/api/credentials/${id}/web-cookie`, { + method: "PUT", + ...json({ cookie }), + }), + /** 调度权重(null = 恢复默认);只改同健康档内的选号次序。 */ + setCredentialWeight: (id: string, weight: number | null) => + request<{ routing_weight: number | null }>(`/api/credentials/${id}/weight`, { + method: "PUT", + ...json({ weight }), + }), /** 批量运维:一次对多条凭证执行同一动作,后端逐条隔离返回结果。 */ batchCredentials: (action: BatchAction, ids: string[], enabled?: boolean) => request("/api/credentials/batch", { @@ -171,6 +369,9 @@ export const api = { request(`/api/credentials/${id}/checkin`, { method: "POST" }), checkinStatus: (id: string) => request<{ status: CheckinStatus }>(`/api/credentials/${id}/checkin`), + checkinEvents: (id: string, limit?: number) => + request<{ events: CheckinEvent[] }>( + `/api/credentials/${id}/checkin-events${query({ limit })}`), runGrowth: (id: string) => request(`/api/credentials/${id}/growth`, { method: "POST" }), growthHistory: (id: string) => @@ -200,7 +401,7 @@ export const api = { ...json({ provider, state }), }), upstreamComplete: (provider: string, state: string, url: string) => - request<{ status: "success"; credential_id: string }>("/api/auth/upstream/complete", { + request<{ status: "success"; credential_id: string; merged?: boolean }>("/api/auth/upstream/complete", { method: "POST", ...json({ provider, state, url }), }), @@ -284,6 +485,11 @@ export const api = { metric?: StatsMetric) => request( `/api/stats/model-timeline${query({ username, since, until, metric })}`), + /** 延迟分位时序(P50 中位数 + P95 上界):读明细表,仅覆盖 90 天。 */ + statsPercentileTimeline: (username?: string, since?: number, until?: number, + metric?: "latency" | "ttfb") => + request( + `/api/stats/percentile-timeline${query({ username, since, until, metric })}`), statsEvents: (username?: string, since?: number, until?: number, before?: number, limit?: number, sort?: string, order?: string, offset?: number, filters?: StatsEventFilters) => diff --git a/web/src/api/display.ts b/web/src/api/display.ts index f04131d2..905591a6 100644 --- a/web/src/api/display.ts +++ b/web/src/api/display.ts @@ -73,7 +73,17 @@ export function quotaSemantics(credential: Credential): string { if (credential.provider === "officeace") { return "积分余额(逐笔到期,新用户与每日签到各自计时)"; } - // CodeBuddy / Qoder 随周期重置;未探测到重置时间时退化为本周期口径。 + // Qoder:套餐内额度按周期重置,但签到/活动赠送的积分逐笔到期;网页 Cookie + // 探测时 `quota_cycle_end` 是**最早一个包的到期日**,不是重置日,所以文案 + // 必须说「到期」而不是「重置」。 + if (credential.provider === "qoder") { + if (!credential.quota_cycle_end) { + return "积分余额(签到/赠送逐笔到期)"; + } + const date = new Date(credential.quota_cycle_end * 1000).toLocaleDateString("zh-CN"); + return `积分余额(签到/赠送逐笔到期),最早 ${date} 到期`; + } + // CodeBuddy 随周期重置;未探测到重置时间时退化为本周期口径。 if (!credential.quota_cycle_end) { return "本周期剩余(未探测到重置时间)"; } @@ -96,6 +106,11 @@ export const QUOTA_UNIT = "积分"; * 主/次两个窗口共用一个函数:措辞区分开,否则两行同样的句式看不出 * 谁是第一优先级(主窗口 36h 打平时才轮到次窗口 7 天)。 * + * **措辞必须带「窗口」二字**:这里的时长是调度窗口(主 36h / 次 7 天),不是 + * 某笔额度的剩余到期时间。早先写成「将在 1.5 天内过期」会被读成倒计时—— + * 实测用户反馈「积分今天到期,显示到期 1.5 天」。窗口是「在此时间段内会到期」, + * 其中可能今天到期、也可能第 7 天才到期。 + * * `unit` 默认「积分」,全渠道同口径。 */ export function expiringQuotaLabel( @@ -109,8 +124,8 @@ export function expiringQuotaLabel( const amount = formatNumber(credits); const window = formatDuration(windowSeconds); return wording === "secondary" - ? `${window}内共 ${amount} ${unit}将过期` - : `${amount} ${unit}将在 ${window}内过期`; + ? `${window}窗口内共 ${amount} ${unit}将到期` + : `${amount} ${unit}将在 ${window}窗口内到期`; } export function cooldownRemaining(coolingUntil: number | null, now = Date.now() / 1000): number { @@ -269,6 +284,7 @@ export const TASK_REPORT_LABEL: Record = { rolled_up: "汇总小时", purged: "清理明细", expired_coolings: "回收冷却", + expired_account_coolings: "回收账号冷却", purged_credit_events: "回收流水", purged_alerts: "回收告警", evaluated: "评估", @@ -414,11 +430,14 @@ export function formatSpeedPair( visible: number | null | undefined, total: number | null | undefined, estimated = false, + withUnit = true, ): string { if (visible == null && total == null) return "—"; const one = (value: number | null | undefined) => value == null ? "—" : value.toFixed(1); - return `${estimated ? "≈" : ""}${one(visible)} / ${one(total)} tok/s`; + const body = `${estimated ? "≈" : ""}${one(visible)} / ${one(total)}`; + // withUnit=false:列头已经写了 tok/s,每行重复一遍要多占约 45px + return withUnit ? `${body} tok/s` : body; } /** 图表 hover 数值:按指标格式化,单位用 () 包裹(耗时/首字数已含 ms/s)。 */ @@ -431,11 +450,28 @@ export function formatChartValue(value: number, metric: string): string { case "ttfb": case "latency": return formatLatency(value); + case "success_rate": + case "cache_rate": + return formatRate(value); + case "cost_per_request": + return `${formatMoney(value, "CNY", false)} / 次`; default: return `${formatCompact(value)} (次)`; } } +/** + * 比率指标的展示:入参是 0..1 的小数,输出百分比。 + * + * 与 `formatCacheRate` 的区别:那个收「命中 / 输入」两个原始计数并自己相除, + * 用于表格;这里收**后端已算好的比率**(图表值),两者不能混用——把 0.4 再 + * 除一次输入会得到 0.4%。空值(无样本)给 —,不渲染 0%。 + */ +export function formatRate(value: number | null | undefined): string { + if (value === null || value === undefined) return "—"; + return `${(value * 100).toFixed(1)}%`; +} + /** * Y 轴刻度文案。 * @@ -447,14 +483,28 @@ export function formatChartValue(value: number, metric: string): string { * - 成本:保留两位小数(单请求常是零点几元,`formatCompact` 的「万/亿」不适用) * - 耗时 / 首字:复用 `formatLatency`,与 hover 完全同口径(`850 ms` / `8.2 s`), * 刻度自带单位所以不再另标 + * - 比率:整百分比(轴刻度不需要小数,`40%` 比 `40.0%` 更省宽度) + * - 单请求成本:四位有效小数的金额(量级常在 0.01–0.3 元,两位会全变成 0.00) */ export function formatAxisValue(value: number, metric: string): string { if (metric === "ttfb" || metric === "latency") { return formatLatency(value); } + if (metric === "cost_per_request") { + // 单请求成本量级常在 0.01–0.3 元:两位小数会让 0.028 与 0.031 都显示 + // 「0.03」,刻度看起来重复且无法分辨。按量级给 3–4 位小数。 + const digits = Math.abs(value) >= 0.1 ? 3 : 4; + return value.toLocaleString("zh-CN", { + minimumFractionDigits: 0, + maximumFractionDigits: digits, + }); + } if (metric === "cost") { return value.toLocaleString("zh-CN", { maximumFractionDigits: 2 }); } + if (metric === "success_rate" || metric === "cache_rate") { + return `${Math.round(value * 100)}%`; + } return formatCompact(value); } @@ -462,37 +512,66 @@ export function formatAxisValue(value: number, metric: string): string { * 图表 Y 轴单位文案,写在轴顶(刻度本身不带单位,避免每格重复)。 * * 耗时 / 首字不出现在这里:它们的刻度自带 `ms` / `s`,再标一次是啰嗦。 + * 比率同理(刻度自带 `%`)。 */ export const METRIC_AXIS_UNIT: Record = { requests: "次", tokens: "tokens", cost: "元", + cost_per_request: "元/次", }; +/** 比率类指标(0..1):Y 轴与 hover 都要乘 100,且空值要断线而非归零。 */ +export const RATE_METRICS = new Set(["success_rate", "cache_rate"]); + +/** 均值 / 比率类指标:空档(null)代表「无样本」,不是 0,图线必须断开。 */ +export const SPARSE_METRICS = new Set([ + "latency", + "ttfb", + "success_rate", + "cache_rate", + "cost_per_request", + "cost", +]); + /** 统计明细的失败类型(线值,来自后端 CONTROLLED_ERROR_TYPES)。 * 注意与 ProbeFailureReason 不是同一套:那是探测失败,这是请求失败。 */ export type UsageErrorType = + | "account_restricted" | "client_disconnect" + | "context_overflow" | "credential_unavailable" | "invalid_request" + | "model_capability" + | "model_not_found" | "model_unavailable" + | "network_error" | "no_healthy_credential" | "rate_limit" + | "truncated_stream" | "upstream_error" | "upstream_protocol" + | "upstream_timeout" | "upstream_first_event_timeout" | "upstream_idle_timeout"; /** 明细失败类型的中文说明(TECHNICAL §11.2:界面只展示稳定枚举的翻译)。 */ export const USAGE_ERROR_LABEL: Record = { + account_restricted: "账号被上游限制", client_disconnect: "客户端中断", + context_overflow: "上下文超限", credential_unavailable: "凭证失效", invalid_request: "请求无效", + model_capability: "模型不支持该形态", + model_not_found: "上游无此模型", model_unavailable: "该模型暂时不可用", + network_error: "本机网络故障", no_healthy_credential: "无可用凭证", rate_limit: "额度耗尽", + truncated_stream: "上游截断", upstream_error: "渠道错误", upstream_protocol: "渠道响应异常", + upstream_timeout: "上游超时", upstream_first_event_timeout: "上游首字超时", upstream_idle_timeout: "上游流中断", }; diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts index 789c6456..b5df5e8f 100644 --- a/web/src/api/hooks.ts +++ b/web/src/api/hooks.ts @@ -203,7 +203,7 @@ export function useResolveAlert(username?: string) { } export function useStatsOverview(username?: string, target?: string, since?: number, - until?: number) { + until?: number, enabled = true) { return useQuery({ queryKey: adminKey(username, "stats-overview", target, since, until), queryFn: () => api.statsOverview(target, since, until), @@ -211,55 +211,81 @@ export function useStatsOverview(username?: string, target?: string, since?: num // `overview.isLoading` 时整体切骨架屏,若不带占位数据,每次改时间都会把 // 筛选控件一起卸载重挂——用户正在编辑的输入框会消失。 placeholderData: keepPreviousData, + enabled, }); } export function useStatsByProvider(username?: string, target?: string, since?: number, - until?: number, sort?: string, order?: string) { + until?: number, sort?: string, order?: string, + enabled = true) { return useQuery({ queryKey: adminKey(username, "stats-providers", target, since, until, sort, order), queryFn: () => api.statsByProvider(target, since, until, sort, order), + enabled, }); } export function useStatsByModel(username?: string, target?: string, since?: number, - until?: number, sort?: string, order?: string) { + until?: number, sort?: string, order?: string, + enabled = true) { return useQuery({ queryKey: adminKey(username, "stats-models", target, since, until, sort, order), queryFn: () => api.statsByModel(target, since, until, sort, order), + enabled, }); } export function useStatsByUser(username?: string, target?: string, since?: number, - until?: number, sort?: string, order?: string) { + until?: number, sort?: string, order?: string, + enabled = true) { return useQuery({ queryKey: adminKey(username, "stats-users", target, since, until, sort, order), queryFn: () => api.statsByUser(target, since, until, sort, order), + enabled, }); } /** 按凭证聚合:数据源是明细表(仅 90 天),非 admin/operator 看不到他人凭证。 */ export function useStatsByCredential(username?: string, target?: string, since?: number, - until?: number, sort?: string, order?: string) { + until?: number, sort?: string, order?: string, + enabled = true) { return useQuery({ queryKey: adminKey(username, "stats-credentials", target, since, until, sort, order), queryFn: () => api.statsByCredential(target, since, until, sort, order), + enabled, }); } export function useStatsTimeline(username?: string, target?: string, since?: number, - until?: number, metric?: string) { + until?: number, metric?: string, enabled = true) { return useQuery({ queryKey: adminKey(username, "stats-timeline", target, since, until, metric), queryFn: () => api.statsTimeline(target, since, until, metric as StatsMetric), + enabled, }); } export function useStatsModelTimeline(username?: string, target?: string, since?: number, - until?: number, metric?: string) { + until?: number, metric?: string, enabled = true) { return useQuery({ queryKey: adminKey(username, "stats-model-timeline", target, since, until, metric), queryFn: () => api.statsModelTimeline(target, since, until, metric as StatsMetric), + enabled, + }); +} + +/** 延迟分位时序(P50 中位数 + P95 上界):读明细表,仅覆盖 90 天。 + * + * `enabled=false` 时不发请求:该图只在延迟类指标下展示,其余指标用不上, + * 没必要为每次切指标都扫一遍明细表。 */ +export function useStatsPercentileTimeline(username?: string, target?: string, + since?: number, until?: number, + metric?: "latency" | "ttfb", + enabled = true) { + return useQuery({ + queryKey: adminKey(username, "stats-percentile", target, since, until, metric), + queryFn: () => api.statsPercentileTimeline(target, since, until, metric), + enabled, }); } @@ -274,7 +300,8 @@ export function useStatsModelTimeline(username?: string, target?: string, since? export function useStatsEvents(username?: string, since?: number, until?: number, before?: number, limit?: number, sort?: string, order?: string, offset?: number, - filters?: StatsEventFilters & { ok?: number }) { + filters?: StatsEventFilters & { ok?: number }, + enabled = true) { const provider = filters?.provider; const model = filters?.model; const credentialId = filters?.credential_id; @@ -289,6 +316,7 @@ export function useStatsEvents(username?: string, since?: number, until?: number { provider, model, credential_id: credentialId, ok } as StatsEventFilters), placeholderData: keepPreviousData, + enabled, }); } diff --git a/web/src/api/keyQuota.ts b/web/src/api/keyQuota.ts index 00641cdb..631c5da7 100644 --- a/web/src/api/keyQuota.ts +++ b/web/src/api/keyQuota.ts @@ -10,6 +10,8 @@ * 失败按不限制处理,与服务端 `load_key_quota` 同一方针)。 */ +import { formatCompact, formatMoney, formatNumber } from "./display"; + /** 六项配额;0 或缺省 = 该项关闭。与服务端 `src/key_quota.py` 一一对应。 */ export interface KeyQuota { daily_requests: number; @@ -110,11 +112,18 @@ export function usageTodayOf(row: unknown): KeyUsageToday { }; } -/** 「当日已用」文案:请求数 + token(有成本时附上)。 */ +/** + * 「当日已用」文案:请求数 + token(有成本时附上)。 + * + * 三个量级差得远,一律过格式化:请求数千分位(`2,974 次`)、token 走紧凑 + * 单位(`3.7亿 token`,原样 `365309724` 会把这一列撑到几百 px)、成本按 + * 绝对值自适应小数位(`$7.49` / 小额 `$0.0042`)。原样透传原始数字会让 + * 表格在窄屏下被这一列挤变形。 + */ export function usageTodayLabel(usage: KeyUsageToday): string { if (!usage.requests) return "今日未用"; - const parts = [`${usage.requests} 次`, `${usage.tokens} token`]; - if (usage.cost_usd) parts.push(`$${usage.cost_usd.toFixed(4)}`); + const parts = [`${formatNumber(usage.requests)} 次`, `${formatCompact(usage.tokens)} token`]; + if (usage.cost_usd) parts.push(formatMoney(usage.cost_usd, "USD", false)); return parts.join(" · "); } diff --git a/web/src/api/providers.ts b/web/src/api/providers.ts index 417f9378..84b5c406 100644 --- a/web/src/api/providers.ts +++ b/web/src/api/providers.ts @@ -16,6 +16,8 @@ export const PROVIDER_LABEL: Record = { qoder: "Qoder", codearts: "CodeArts", officeace: "OfficeAce", + raccoon: "小浣熊", + lingxi: "灵犀", }; /** 模型下拉里的渠道缩写(如 `CB x0.29`)。 */ @@ -28,6 +30,8 @@ export const PROVIDER_ABBR: Record = { qoder: "QD", codearts: "CA", officeace: "OA", + raccoon: "RC", + lingxi: "LX", }; /** @@ -45,6 +49,8 @@ export const PROVIDER_CHART_COLOR: Record = { qoder: "var(--chart-5)", codearts: "var(--chart-6)", officeace: "var(--chart-8)", + raccoon: "var(--chart-9)", + lingxi: "var(--chart-10)", }; /** 未知渠道(老数据/未来渠道)的兜底色。 */ @@ -67,8 +73,29 @@ export const PROVIDER_ORDER: Provider[] = [ "qoder", "codearts", "officeace", + "raccoon", + "lingxi", ]; +/** + * 支持每日签到的渠道(与后端能力探测同口径:provider 上有 `checkin` / + * `checkin_scope` 才会被签到任务跑到)。 + * + * Zen / Kilo 最薄,没有签到接口;CodeBuddy 国际站上游活动为空 + * (`active: false`,后端把 `checkin*` 三项置 None)。其余渠道都接了签到。 + * 集中在这里而不是在页面里写死:组头汇总、行内按钮、批量操作都按同一份 + * 判定,否则会出现「组头算它是未签、行内却没有签到按钮」的错配。 + */ +export const CHECKIN_PROVIDERS: ReadonlySet = new Set([ + "codebuddy", + "trae", + "qoder", + "codearts", + "officeace", + "raccoon", + "lingxi", +]); + /** 渠道排序权重;未知渠道排在已知渠道之后。 */ export function providerRank(provider: string): number { const index = PROVIDER_ORDER.indexOf(provider as Provider); diff --git a/web/src/api/statsErrors.ts b/web/src/api/statsErrors.ts index b0976d6f..9ff859a6 100644 --- a/web/src/api/statsErrors.ts +++ b/web/src/api/statsErrors.ts @@ -15,6 +15,15 @@ export interface StatsErrorRow { error_type: string | null; /** 该 (渠道, 模型, 错误类型) 组合的失败次数 */ failures: number; + /** + * 其中「换号已救回」的次数:同一用户同一 request_id 另有成功行。 + * + * 换号重试是常态——`invalid_request` 每次上游拒绝都落一行,随后换渠道成功 + * 也留着这条失败。`failures` 是**尝试**口径,`failures - rescued` 是 + * **未被救回的失败尝试数**(同一请求多次失败会算多次,不等于受损请求数)。 + * 老库历史行 request_id 为 NULL,恒算未救回。 + */ + rescued: number; /** 最近一次失败的时刻(UTC 秒) */ last_ts: number; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6f135bec..c9f41132 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -13,7 +13,9 @@ export type Provider = | "kilo" | "qoder" | "codearts" - | "officeace"; + | "officeace" + | "raccoon" + | "lingxi"; /** 健康度三态:null = 未探测;-1 = 已耗尽;0-100 = 已知剩余百分比。 * 展示层把 null 再分成「未探测」(付费渠道探测失败)与「无探测」(zen/kilo @@ -26,8 +28,13 @@ export interface QuotaPackage { name: string; total: number; used: number; - /** 到期 epoch;无到期信息为 null */ + /** 到期 epoch;无到期信息为 null。CodeArts 为整份套餐到期日,非每笔积分到期日 */ end: number | null; + /** 7 天内即将到期的积分(CodeArts 分项包才给);上游未提供为 null */ + expiring?: number | null; + /** 该池是否真的可用(缺省视为可用)。小浣熊的充值池被冻结时为 false —— + * 「看得见用不了」,与「已用完」不是一回事。 */ + usable?: boolean; } /** 一条 (凭证, 模型) 级冷却:模型级限流或「该渠道无此模型」负缓存。 */ @@ -54,6 +61,8 @@ export interface Credential { pinned: 0 | 1; health: Health; cooling_until: number | null; + /** 冷却来源(ErrKind 值,如 soft/credit/plan/restricted);null=无冷却或来源未知 */ + cooling_kind: string | null; err_count: number; quota_remaining: number | null; quota_total: number | null; @@ -67,6 +76,8 @@ export interface Credential { quota_expiry_ladder: [number, number][] | null; /** 额度包明细(含包名,仅供展示);未探测或上游未提供时为 null */ quota_packages: QuotaPackage[] | null; + /** 网页明细源状态:null=未配置/未知,ok=正常,expired=网页会话失效(仅提示) */ + web_cookie_state: "ok" | "expired" | null; /** 生效中的模型级冷却(模型级限流/负缓存);无则为空数组 */ model_cooldowns: ModelCooldown[]; quota_probed_at: number | null; @@ -78,12 +89,20 @@ export interface Credential { growth_last_run_at: number | null; /** 该轮一行中文汇报 */ growth_last_result: string | null; + /** + * 今日签到(北京时间自然日)本地审计日志里的最近一条结果;今日无记录为 + * null(任务未轮到 / 已暂停 / 渠道不支持都算「没有结论」,不是失败)。 + * 组头据此汇总「已签 / 失败 / 未签」,行内「签到记录」抽屉是同一份数据。 + */ + checkin_today?: CheckinEvent | null; created_at: number; added_by: string | null; /** 凭证级限额护栏;null = 未配置(不受限) */ limits: CredentialLimits | null; /** 运维标签(仅列表展示与筛选,不参与调度);无标签为空数组 */ tags: string[]; + /** 调度权重:null = 默认;正数越优先、负数当备用(同健康档内生效) */ + routing_weight: number | null; } /** 凭证级限额护栏(全部按北京自然日;0/null = 该护栏关闭)。 */ @@ -218,21 +237,33 @@ export interface CredentialStats extends GroupStats { credential_name: string | null; } -/** 图表指标:请求次数 / token 消耗 / 成本 / 平均耗时 / 平均首字延迟。 */ -export type StatsMetric = "requests" | "tokens" | "cost" | "latency" | "ttfb"; - -/** 小时粒度时间序列点(usage_hourly 聚合,跨 model 汇合)。值语义随 metric 变化。 */ +/** 图表指标:请求次数 / token 消耗 / 成本 / 平均耗时 / 平均首字延迟 / 成功率 / 缓存命中率 / 单请求成本。 */ +export type StatsMetric = + | "requests" + | "tokens" + | "cost" + | "latency" + | "ttfb" + | "success_rate" + | "cache_rate" + | "cost_per_request"; + +/** 小时粒度时间序列点(usage_hourly 聚合,跨 model 汇合)。值语义随 metric 变化。 + * + * 时间轴是**稠密**的:后端按小时补齐缺失桶(见 `StatsQuery.timeline`), + * 因此相邻点的 `hour` 差值恒为 3600。缺失桶的取值按指标语义区分——可加量 + * (请求数 / token)补 0,均值与比率补 `null`(前端据此断线,不画成 0)。 */ export interface TimelinePoint { /** UTC 秒,对齐到小时起点 */ hour: number; - /** 各渠道指标值(渠道名为动态键:codebuddy / trae / zen…) */ - [provider: string]: number; + /** 各渠道指标值(渠道名为动态键:codebuddy / trae / zen…);无样本为 null */ + [provider: string]: number | null; } /** 按模型趋势:每小时各 Top N 模型的指标值(模型名为动态键)。 */ export interface ModelTimelinePoint { hour: number; - [model: string]: number; + [model: string]: number | null; } export interface ModelTimelineResponse { @@ -240,6 +271,24 @@ export interface ModelTimelineResponse { points: ModelTimelinePoint[]; } +/** 分位时序点:某小时的 P50(中位数)与 P95(上界)延迟,读明细表(仅 90 天)。 */ +export interface PercentilePoint { + hour: number; + /** 该小时 P50;无成功样本为 null(断线) */ + p50: number | null; + /** 该小时 P95;无成功样本为 null(断线) */ + p95: number | null; + /** 该小时的样本条数(0 = 空档),用于标注「样本很少」 */ + samples: number; +} + +export interface PercentileTimelineResponse { + points: PercentilePoint[]; + metric: "latency" | "ttfb"; + /** 明细保留期(天):分位只能覆盖这段窗口 */ + window_days: number; +} + /** 逐请求明细(usage_events,仅保留 90 天)。 */ export interface UsageEventRow { rowid: number; @@ -348,6 +397,10 @@ export interface UserRow { created_by: string | null; /** 是否有待使用的一次性激活令牌(摘要本身不会下发) */ pending_activation: boolean; + /** 用户级模型白名单(Playground 直连用;fnmatch glob,逗号分隔,'' = 不限制) */ + allowed_models: string; + /** 用户级配额(JSON 文本,'' = 不限制);用量按用户名聚合 */ + quota: string; } /** 创建/重置用户时返回:明文令牌只出现这一次。 */ @@ -385,6 +438,78 @@ export interface ActivationDescribe { valid: boolean; } +/** 渠道 ToS 风险登记(运营判断):导入对话框展示,不参与调度。 */ +export interface BackupEntry { + name: string; + size: number; + created_at: number; +} + +export interface PendingRestore { + filename: string; + size: number; + uploaded_at: number; +} + +export interface BackupListResponse { + backups: BackupEntry[]; + /** 已上传、等重启生效的恢复文件;没有则为 null */ + pending: PendingRestore | null; +} + +export interface BatchImportEntry { + provider: string; + credential: unknown; + nickname?: string; + tags?: string[]; +} + +/** 批量导入结果:下标对应提交数组,前端按行保留失败/跳过项供重试。 */ +export interface BatchImportResult { + created: { index: number; id: string; tos: TosRisk }[]; + skipped: { index: number; reason: string }[]; + failed: { index: number; reason: string }[]; +} + +export interface TosRisk { + level: "green" | "gray" | "red"; + reason: string; + basis: string; +} + +/** 账单里的一行(按人 / 按 Key 共用形状;Key 行多 `key_name`)。 */ +export interface BillingRow { + username?: string | null; + api_key_id?: string; + key_name?: string | null; + requests: number; + ok_count: number; + input_tokens: number; + output_tokens: number; + cached_tokens: number | null; + credit: number | null; + cost_usd: number | null; + cost_cny: number | null; + success_rate?: number | null; + [key: string]: unknown; +} + +export interface BillingReport { + month: string; + start: number; + end: number; + users: BillingRow[]; + keys: BillingRow[]; + totals: { + requests: number; + input_tokens: number; + output_tokens: number; + cost_cny: number; + cost_usd: number; + success_rate: number | null; + }; +} + export interface CredentialsResponse { credentials: Credential[]; /** 调度主到期排序窗口(秒);与列表中各凭证的到期积分同一口径 */ @@ -397,6 +522,8 @@ export interface CredentialsResponse { is_admin: boolean; /** 凭证写门槛(admin 或 operator):与后端 require_operator 同口径 */ is_operator: boolean; + /** 渠道 → ToS 风险(green/gray/red + 理由),导入前提示 */ + tos_risks: Record; } export interface UpstreamAuthStart { @@ -404,11 +531,16 @@ export interface UpstreamAuthStart { state: string; auth_url: string | null; interval: number | null; + /** 回调轨道的落点(后端 PUBLIC_BASE_URL + /authorize);poll 轨道为 null。 + * 透出到界面是为了让「回调地址指错主机」这类静默失败可见。 */ + callback_url?: string | null; } export interface UpstreamAuthPoll { status: "pending" | "success"; credential_id?: string; + /** true = 同一上游账号重新登录,已更新既有凭证而非新建。 */ + merged?: boolean; } /** 后端保证是稳定的机器可读枚举,不是 Python 异常类名。 */ @@ -483,6 +615,24 @@ export interface GrowthEvent { trigger: string; } +/** 签到审计日志(checkin_events 表一行) */ +export interface CheckinEvent { + id: string; + credential_id: string; + /** 执行时刻(epoch 秒) */ + ts: number; + ok: 0 | 1; + /** 1=上游判「今日已签」(已签不算错误) */ + already: 0 | 1; + /** 本次领取积分;上游未给数量时为 null */ + credit: number | null; + /** 上游业务码;无此信息时为 null */ + code: number | null; + message: string; + /** auto=定时任务;manual=管理台手动执行 */ + trigger: string; +} + /** 变动流水:两次额度探测之间的**净变化**,不是动作归因。 */ export interface CreditEvent { id: string; @@ -683,6 +833,8 @@ export interface RuntimeSetting { task: string | null; /** task 为 null 时的网关卡组 key(见 SettingsResponse.groups);task 非空时为 null */ group: string | null; + /** 任务卡片内的分节标题(同一任务内聚合);null = 不分组 */ + section: string | null; } /** 网关卡组(后端下发顺序与文案),用于把无任务归属的项拆成一级 tab。 */ @@ -710,6 +862,8 @@ export interface TaskStatus { key: string; name: string; description: string; + /** 业务分组 key(见 TasksResponse.groups),页面二级 tab 用 */ + group: string; interval_seconds: number; /** 开关类任务(目前只有活跃上报)的当前状态;其余恒为 true */ enabled: boolean; @@ -726,6 +880,8 @@ export interface TaskStatus { export interface TasksResponse { tasks: TaskStatus[]; + /** 任务业务分组(顺序即展示顺序);空列表 = 无已装配任务 */ + groups: SettingsGroup[]; /** 服务端当前时间(epoch 秒):算「距今多久」用服务端时钟,避免浏览器偏移 */ server_time: number; } diff --git a/web/src/api/unauthorized.ts b/web/src/api/unauthorized.ts new file mode 100644 index 00000000..81eaeb79 --- /dev/null +++ b/web/src/api/unauthorized.ts @@ -0,0 +1,37 @@ +/** + * 401 的统一出口:会话过期(Cookie 失效 / 被撤销)时前端不能停在错误态。 + * + * `client.ts` 不认识 react-query,也不该反向依赖 `main.tsx`;这里只放一个 + * 可注册的处理器,由入口在启动时把「清缓存 + 回登录页」接上。保持单向依赖: + * client → unauthorized(无依赖),main → 两者。 + */ + +type UnauthorizedHandler = () => void; + +let handler: UnauthorizedHandler | null = null; + +/** 注册会话过期处理器(`main.tsx` 启动时调用;传 null 可注销)。 */ +export function setUnauthorizedHandler(next: UnauthorizedHandler | null): void { + handler = next; +} + +/** 通知会话过期;未注册处理器时静默(单测与 SSR 场景)。 */ +export function notifyUnauthorized(): void { + handler?.(); +} + +/** + * 免登录端点:这些路径上的 401 是业务语义而不是会话过期。 + * + * - `/api/auth/session`:未登录时的正常应答(App 据此渲染登录页); + * - `/api/auth/login`:密码错误; + * - `/api/auth/activate`:激活令牌无效/过期。 + * + * 若把它们算作会话过期,密码输错就会把用户「踢」一次并清空缓存。 + */ +const ANONYMOUS_PATHS = ["/api/auth/session", "/api/auth/login", "/api/auth/activate"]; + +/** 该响应是否代表「会话过期」(需要清缓存并回登录页)。 */ +export function isSessionExpiry(path: string, status: number): boolean { + return status === 401 && !ANONYMOUS_PATHS.some((prefix) => path.startsWith(prefix)); +} \ No newline at end of file diff --git a/web/src/assets/lingxi.png b/web/src/assets/lingxi.png new file mode 100644 index 00000000..1de6a21d Binary files /dev/null and b/web/src/assets/lingxi.png differ diff --git a/web/src/components/AddCredentialDialog.tsx b/web/src/components/AddCredentialDialog.tsx index 838ad79b..6aa4ff19 100644 --- a/web/src/components/AddCredentialDialog.tsx +++ b/web/src/components/AddCredentialDialog.tsx @@ -1,12 +1,18 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Plus, X } from "lucide-react"; import { api } from "../api/client"; import { PROVIDER_LABEL } from "../api/providers"; -import type { Provider } from "../api/types"; +import type { Provider, TosRisk } from "../api/types"; import { useDialogFocus } from "../hooks/useDialogFocus"; +import { + IMPORT_PROVIDERS, + detectProvider, + parseCredentialInput, +} from "../lib/credentialImport"; +import { safeHttpUrl } from "../lib/safeUrl"; import { Button, Field, Input, Notice, Select, Tabs, Textarea } from "../ui"; -/** 支持「登录渠道账号」按钮 / JSON 导入下拉的渠道(有 poll 轨道或需人工导入的渠道)。 */ +/** 支持「登录渠道账号」按钮的渠道(有 poll / callback / paste 轨道的渠道)。 */ const LOGIN_PROVIDERS: Provider[] = [ "codebuddy", "codebuddy_intl", @@ -16,6 +22,39 @@ const LOGIN_PROVIDERS: Provider[] = [ "officeace", ]; +/** 上次成功导入的渠道:重复加号时不用每次重新选。 */ +const IMPORT_PROVIDER_KEY = "coding2api.importProvider"; + +function storedImportProvider(): Provider { + try { + const saved = localStorage.getItem(IMPORT_PROVIDER_KEY); + if (saved && IMPORT_PROVIDERS.includes(saved as Provider)) return saved as Provider; + } catch { + // 隐私模式 / 存储被禁用:退回默认渠道即可,不影响导入 + } + return "codebuddy"; +} + +/** + * JSON 导入的默认模板:选定渠道时填进输入框。 + * + * 只在「用户还没写、或内容仍是上一渠道的模板」时替换——切渠道不该把已经 + * 粘贴好的真实凭据冲掉(凭据不可恢复,误清一次就得重新取)。键与各渠道 + * `import_credential` 的必填字段一致(见后端各 provider 的 credential 模块)。 + */ +const IMPORT_TEMPLATES: Partial> = { + codebuddy: '{\n "token": ""\n}', + codebuddy_intl: '{\n "token": ""\n}', + trae: '{\n "accessToken": "",\n "uid": "",\n "refreshToken": ""\n}', + qoder: '{\n "access_token": ""\n}', + codearts: + '{\n "access_key_id": "",\n "secret_access_key": "",\n "security_token": ""\n}', + officeace: + '{\n "model_app_key": "",\n "model_app_secret": "",\n "model_base_url": "https://modelgw-xxxx.officeace.cn-southwest-2.huaweicloud-agentarts.com"\n}', + raccoon: '{\n "access_token": ""\n}', + lingxi: '{\n "cookie": "wps_sid=…; uid=…"\n}', +}; + type TabValue = "login" | "import"; /** 只允许 http(s) 授权地址:上游返回的 auth_url 若为 `javascript:` / `data:` @@ -26,13 +65,14 @@ type TabValue = "login" | "import"; * `javascript:` 之类的脚本执行(已由 scheme 校验挡住)——能把用户导去钓鱼站 * 的攻击者已经掌握了上游响应,收益有限。 */ function safeAuthUrl(raw: string | undefined): string | null { - if (!raw) return null; - try { - const url = new URL(raw); - return url.protocol === "http:" || url.protocol === "https:" ? url.href : null; - } catch { - return null; - } + return safeHttpUrl(raw); +} + +/** 登录成功提示:合并更新与新增必须区分,否则用户不知道池子里是否多了一条。 */ +function loginSuccessNotice(merged: boolean | undefined): string { + return merged + ? "登录成功,已更新该账号的凭证。" + : "登录成功,凭证已保存。"; } /** @@ -51,9 +91,11 @@ export function AddCredentialDialog({ credentialCount, hasZen, hasKilo, + lingxiCount = 0, onImported, onNotice, onError, + tosRisks = {}, }: { open: boolean; onClose: () => void; @@ -61,8 +103,12 @@ export function AddCredentialDialog({ credentialCount: number; hasZen: boolean; hasKilo: boolean; + /** 已有灵犀凭证数:新增前提示 WPS 单浏览器会话会被新登录顶掉。 */ + lingxiCount?: number; /** 登录/导入成功后回调(关对话框;列表刷新由调用方统一做)。 */ onImported: (message: string) => void; + /** 渠道 → ToS 风险登记(来自 `GET /api/credentials`):导入前提示灰区。 */ + tosRisks?: Record; /** 登录过程消息透出到页面横幅。 */ onNotice: (message: string) => void; onError: (message: string) => void; @@ -76,11 +122,19 @@ export function AddCredentialDialog({ // CodeArts paste 轨道:授权后把浏览器地址栏里打不开的 127.0.0.1 回调链接粘回来 const [pasteProvider, setPasteProvider] = useState(null); const [pasteUrl, setPasteUrl] = useState(""); - // JSON 导入(原 ImportPanel 逻辑) - const [importProvider, setImportProvider] = useState("codebuddy"); + // 小浣熊:没有 OAuth 轨道,凭据是网页会话 JWT,直接在「登录渠道」页粘贴 + const [raccoonToken, setRaccoonToken] = useState(""); + // WPS 灵犀:没有 OAuth 轨道,凭据是 WPS 账号 cookie(wps_sid),直接粘贴 + const [lingxiCookie, setLingxiCookie] = useState(""); + // JSON / Cookie / cURL 导入(原 ImportPanel 逻辑) + const [importProvider, setImportProvider] = useState(storedImportProvider); + // 手选渠道优先于自动识别:用户改了输入就清掉,重新按内容识别 + const [manualProvider, setManualProvider] = useState(null); const [nickname, setNickname] = useState(""); const [raw, setRaw] = useState(""); const [importError, setImportError] = useState(null); + const detected = useMemo(() => detectProvider(raw), [raw]); + const effectiveProvider = manualProvider ?? detected ?? importProvider; // TRAE 轮询要比较「登录发起时 vs 现在」的凭证数量;ref 让 interval 里 // 读到的始终是最新值而不触发重渲染。 const credentialCountRef = useRef(credentialCount); @@ -136,14 +190,30 @@ export function AddCredentialDialog({ if (started.flow === "callback") { // TRAE:浏览器完成授权后 302 回本服务的 /authorize,那里直接落库。 // 前端无法轮询渠道,改为轮询凭证列表,出现新凭证即视为完成。 - onNotice("已在新标签页打开授权页,完成后本页自动刷新出凭证。"); + // 回调地址一并透出:它由服务端 PUBLIC_BASE_URL 决定,指错主机时 + // 浏览器会静默跳不回来(用户只看到「授权完成但没反应」)。 + // 同时给出粘贴入口:远程 / NAS 部署里 TRAE 跳回调常直接「网络错误」, + // 那条地址只在浏览器地址栏里,服务端永远收不到(issue #21)。 + setPasteProvider(provider); + setPasteUrl(""); + onNotice(started.callback_url + ? `已在新标签页打开授权页,完成后本页自动刷新出凭证。` + + `若浏览器打不开回调地址 ${started.callback_url}(显示网络错误),` + + `把地址栏里的整条链接粘贴到下方即可。` + : "已在新标签页打开授权页,完成后本页自动刷新出凭证。" + + "若浏览器打不开回调地址,把地址栏里的整条链接粘贴到下方即可。"); const deadline = Date.now() + 5 * 60 * 1000; const baseline = credentialCountRef.current; const timer = window.setInterval(async () => { const after = (await api.credentials()).credentials.length; if (after > baseline || Date.now() > deadline) { stopPolling(provider); - onNotice(after > baseline ? "登录成功,凭证已保存。" : "授权超时,请重新登录。"); + if (after > baseline) setPasteProvider(null); + onNotice(after > baseline + ? "登录成功,凭证已保存。" + : "授权超时:浏览器没有回到本服务。请检查授权后地址栏是否跳转到回调地址," + + "以及服务端 PUBLIC_BASE_URL 是否为浏览器可访问的地址;" + + "也可以把地址栏里的整条链接粘贴到下方完成登录。"); } }, 3000); loginTimersRef.current[provider] = timer; @@ -170,8 +240,9 @@ export function AddCredentialDialog({ const result = await api.upstreamPoll(provider, started.state); if (result.status === "success") { stopPolling(provider); - onNotice("登录成功,凭证已保存。"); - onImported("登录成功,凭证已保存。"); + const message = loginSuccessNotice(result.merged); + onNotice(message); + onImported(message); } } catch (caught) { stopPolling(provider); @@ -222,13 +293,14 @@ export function AddCredentialDialog({ setBusy(true); onError(""); try { - await api.upstreamComplete(pasteProvider, state, pasteUrl.trim()); + const result = await api.upstreamComplete(pasteProvider, state, pasteUrl.trim()); delete loginStatesRef.current[pasteProvider]; stopPolling(pasteProvider); setPasteProvider(null); setPasteUrl(""); - onNotice("登录成功,凭证已保存。"); - onImported("登录成功,凭证已保存。"); + const message = loginSuccessNotice(result.merged); + onNotice(message); + onImported(message); } catch (caught) { onError(caught instanceof Error ? caught.message : "登录失败,请检查粘贴的链接。"); } finally { @@ -239,19 +311,27 @@ export function AddCredentialDialog({ const submitImport = async (event: React.FormEvent) => { event.preventDefault(); setImportError(null); - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - setImportError('凭证必须是合法 JSON。CodeBuddy 可填 {"token":"..."},TRAE 填完整凭证对象'); + const parsed = parseCredentialInput(raw, effectiveProvider, manualProvider === null); + if (!parsed.ok) { + setImportError(parsed.error); return; } setBusy(true); try { - await api.importCredential(importProvider, parsed, nickname); + const created = await api.importCredential( + parsed.provider, parsed.credential, nickname); setRaw(""); setNickname(""); - onImported("凭证已导入。"); + setManualProvider(null); + setImportProvider(parsed.provider); + try { + localStorage.setItem(IMPORT_PROVIDER_KEY, parsed.provider); + } catch { + // 存储不可用不影响导入本身 + } + onImported(created?.tos && created.tos.level !== "green" + ? `凭证已导入。注意:该渠道 ToS 评级为 ${created.tos.level} —— ${created.tos.reason}` + : "凭证已导入。"); } catch (caught) { onError(caught instanceof Error ? caught.message : "导入失败"); } finally { @@ -350,7 +430,8 @@ export function AddCredentialDialog({

CodeBuddy 走设备码轮询(自动检测完成);TRAE 走浏览器回调 (授权后由 /authorize 直接落库,本对话框轮询凭证列表, - 期间勿关闭对话框);Qoder 走设备码登录;CodeArts 走 OAuth2 授权码登录 + 期间勿关闭对话框;浏览器打不开回调地址时把地址栏整条链接粘回来); + Qoder 走设备码登录;CodeArts 走 OAuth2 授权码登录 (授权后把地址栏打不开的 127.0.0.1 回调链接粘回本对话框); OfficeAce 由云端承接回调(本对话框轮询,多账号请用「换账号」)。

@@ -362,7 +443,9 @@ export function AddCredentialDialog({ data-testid="paste-callback-input" value={pasteUrl} onChange={(event) => setPasteUrl(event.target.value)} - placeholder="http://127.0.0.1:12800/oauth/callback?code=…&state=…" + placeholder={pasteProvider === "trae" + ? "/authorize?refreshToken=…&userInfo=…&loginTraceID=…" + : "http://127.0.0.1:12800/oauth/callback?code=…&state=…"} />
@@ -386,6 +469,87 @@ export function AddCredentialDialog({
)} +
+
+ 小浣熊没有 OAuth 登录轨道,凭据是网页会话 JWT:在浏览器 DevTools → + Application → Cookies 里复制 xhx_token 的值,粘到下方。 +
+
+
+ setRaccoonToken(event.target.value)} + /> +
+ +
+
+
+
+ 灵犀没有 OAuth 登录轨道,凭据是 WPS 账号 cookie:在浏览器打开 + lingxi.kdocs.cn 登录后,DevTools → Application → Cookies 里复制整段 + Cookie(含 wps_sid),粘到下方。有效期约一年,失效后重贴即可。 +
+
+
+ setLingxiCookie(event.target.value)} + /> +
+ +
+
OpenCode Zen / Kilo 免费层无需凭证/登录,删除其虚拟凭证后点下方按钮补回 @@ -440,11 +604,22 @@ export function AddCredentialDialog({