diff --git a/OPEN_SOURCE_COMPARISON.md b/OPEN_SOURCE_COMPARISON.md new file mode 100644 index 0000000..79d805f --- /dev/null +++ b/OPEN_SOURCE_COMPARISON.md @@ -0,0 +1,69 @@ +# 同类开源项目横向对照与候选功能清单(COD-7) + +调研日期:2026-11-12。仅调研与方案,不改生产代码、不加依赖。 +参照项目版本取各仓库 `releases/latest` 与最近 push 时间。 + +## 1. 参照项目 + +| 项目 | 定位 | 版本 / 最近更新 | 来源 | +|---|---|---|---| +| LiteLLM | 100+ 供应商统一网关 + Python SDK,虚拟 Key、花费追踪、护栏、负载均衡、管理台 | v1.109.0(2026-11-11),push 2026-11-11 | https://github.com/BerriAI/litellm | +| One API | 多供应商统一 API 与 Key 二次分发,令牌额度/过期、渠道分组、兑换码、失败重试、单二进制 | v0.6.10(2025-02-02),push 2026-01-09 | https://github.com/songquanpeng/one-api | +| New API | 自托管 AI 网关,OpenAI/Claude/Gemini 三协议、配额与订阅、缓存计费、表达式定价、Redis 多节点、7 语言管理台 | v1.0.0-rc.49(2026-11-08),push 2026-11-11 | https://github.com/Calcium-Ion/new-api | +| Portkey Gateway | 高性能网关,重试/回退、负载均衡、条件路由、护栏、MCP Gateway | v1.15.2(2026-01-12),push 2026-05-25 | https://github.com/portkey-ai/gateway | +| Claude Code Router | 面向编码 Agent 的本地控制面,多 Agent 统一入口、供应商/模型路由、凭据池轮换、有序回退、工具增强 | v3.1.3(2026-10-09),push 2026-10-10 | https://github.com/musistudio/claude-code-router | + +## 2. 逐项对照(现状 → 差距) + +Coding2API 已有:OpenAI Chat/Responses + Anthropic Messages、十路逆向渠道、公共凭证池与健康调度、凭证权重、跨渠道 fallback 组、Key 级日配额(请求/token/成本 + RPM/TPM/并发)、到期额度优先、三角色与审计、脱敏统计/账单导出、出口 IP 粘性、上游状态与告警、热更配置、Playground、备份恢复。 + +| 能力 | 参照实现 | Coding2API 现状 | 差距 | +|---|---|---|---| +| 多协议入口 | New API 覆盖 OpenAI/Claude/Gemini;CCR 覆盖 Gemini Generate Content | 仅 Chat、Responses、Messages(`src/api/`) | 缺 Gemini 原生入口与 `count_tokens` 之外的生态端点 | +| Key 治理 | One API 令牌额度+过期+IP/模型白名单;LiteLLM 虚拟 Key 预算周期 | 日配额 + 瞬时限速;无月度/总量预算、无 Key 到期 | 预算周期与生命周期不完整 | +| 可靠性路由 | Portkey 条件路由/负载均衡;LiteLLM Router;CCR 有序回退 | 健康度+权重+到期额度+fallback 组 | 缺按成本/延迟/时段的条件策略 | +| 成本 | LiteLLM 花费追踪/预算;New API 表达式定价 | OpenRouter 刊例静态价 + 实测扣费校准 | 价格表需手工更新,缺自动同步 | +| 缓存 | LiteLLM/Portkey 网关缓存;New API 缓存计费 | 仅透传上游 `prompt_cache_key`/`cache_control` | 无网关侧响应缓存 | +| 安全护栏 | LiteLLM/Portkey 请求/响应护栏 | 登录限流、CSRF、Host 白名单、日志脱敏 | 无内容/PII 护栏钩子 | +| 可观测 | LiteLLM/Portkey 标准导出;New API 审计+日志 | 进程内统计/SSE/告警/审计 | 无 Prometheus/OTel 标准导出 | +| 多租户 | One API 用户分组;New API 面向团队共享的配额/订阅 | 全局三角色,无团队维度 | 无团队级 Key/配额/账单 | +| 多节点 | New API Redis 共享限流/多节点 | 单实例锁 + SQLite,限流进程内 | 架构上不支持横向扩容 | +| 国际化 | New API 7 语言 | 管理台中文 | 无 i18n | +| 其他 | One API 充值/兑换码;Portkey MCP Gateway;CCR 桌面控制面/工具增强 | 内部共享池;已有 MCP 无关 | 商业/生态类能力不匹配定位 | + +## 3. 候选功能清单(按优先级) + +排序依据:先看内部用户(个人/小团队)的直接价值,再看实现成本,最后看与「单实例 + SQLite + 公共凭证池」架构的契合度。若后续确认转向对外多租户服务,团队隔离与护栏两项上调一档。 + +| # | 优先级 | 模块 | 候选功能 | 现状差距 | 建议 | 档位 | +|---|---|---|---|---|---|---| +| 1 | P0 | API 兼容层 | Gemini 原生入口 | 仅三协议 | 新增 `/v1beta/models/{model}:generateContent` 与 `:streamGenerateContent`,复用现有调度与统计;请求/响应映射放 `src/compat/gemini/` | 2 | +| 2 | P0 | 鉴权与配额 | Key 预算周期扩展 | 仅自然日配额 | `api_keys.quota` JSON 增 `monthly_*`、`total_*`、`expires_at`;准入判定与明细聚合复用 `key_quota.py` | 2 | +| 3 | P0 | 部署运维 | 标准可观测导出 | 仅内部页面/SSE | 增 `/metrics`(请求、token、成本、在途、凭证健康)并预留 OTel trace 导出;指标从现有统计层读取 | 2 | +| 4 | P1 | 安全边界 | 请求/响应护栏钩子 | 无内容治理 | 可插拔 `guardrail` 接口(内置敏感词/PII 规则,失败策略可配),默认关闭;命中记审计 | 3 | +| 5 | P1 | 调度与路由 | 条件路由策略 | 仅健康+权重 | 在现有排序前加规则表(按模型/渠道/成本/延迟/时段),规则存 DB、热更;保留现逻辑为默认策略 | 3 | +| 6 | P1 | 成本 | 模型价格表自动同步 | 刊例价静态 | 定时拉 LiteLLM/OpenRouter 价目,按模型归一键合并,冲突以实测扣费为准;价目快照留版本 | 2 | +| 7 | P1 | 统计计费 | 网关侧精确缓存 | 仅透传上游缓存 | 非流式按 `model+messages+tools` 哈希缓存,TTL 可配,命中不计上游用量;注意与「到期额度优先」策略的冲突,默认关闭 | 3 | +| 8 | P2 | 鉴权与配额 | 团队/租户隔离 | 全局三角色 | `teams` 表 + Key/统计/账单挂团队维度;迁移与权限模型改动大 | 3 | +| 9 | P2 | 管理端 | 管理台 i18n | 中文硬编码 | 抽词条表,先中/英双语;与现有模板/组件解耦 | 2 | + +## 4. 不纳入本次范围 + +| 功能 | 理由 | +|---|---| +| 充值、兑换码、订阅计费 | 定位是内部公共凭证池,无对外收款场景;引入会带来支付与合规边界 | +| MCP Gateway / ToolHub | 与「编码订阅转 API」核心链路无关,且上游渠道不提供对应能力 | +| 桌面客户端 / 本地代理控制面 | 服务端已是控制面,再套一层只增加部署复杂度 | +| 多节点 / Redis 共享限流 | 现有单实例锁 + SQLite 是明确设计取舍;改造成本高于当前收益 | +| Embeddings / 图像 / 音频端点 | 十个逆向渠道以编码对话为主,端点覆盖与上游能力不匹配 | +| 单二进制打包 | Python 技术栈,Docker 镜像已覆盖交付形态 | +| 语义缓存 / Prompt 模板管理 | 编码场景命中率低,且模板管理属产品层能力,非网关缺口 | + +## 5. 验证 + +本次仅新增本文档,生产代码零改动: + +```bash +uv run pytest -q --cov=src --cov-report=term --cov-fail-under=100 +pnpm --dir web exec vitest run +``` \ No newline at end of file diff --git a/OSS-COMPARISON.md b/OSS-COMPARISON.md new file mode 100644 index 0000000..78e501d --- /dev/null +++ b/OSS-COMPARISON.md @@ -0,0 +1,59 @@ +# 同类开源项目功能对照与候选升级清单 + +调研时间:2026-11-12。对照对象为 5 个自托管 LLM 网关 / 多凭据聚合项目,版本与更新时间取自各仓库 GitHub Release 与仓库元数据。本文只做差距分析,不改动生产代码。 + +## 1. 对照对象 + +| 项目 | 版本 / 更新时间 | 定位 | 与本项目重叠 | +|---|---|---|---| +| [songquanpeng/one-api](https://github.com/songquanpeng/one-api) | v0.6.10 / 2025-02-02 | 多供应商 Key 管理与二次分发,单二进制 | 渠道池、Key 配额、模型重定向、兑换码、OAuth 登录 | +| [Calcium-Ion/new-api](https://github.com/Calcium-Ion/new-api) | v1.0.0-rc.49 / 2026-11-08 | 商业向 AI 网关,OpenAI / Claude / Gemini 三协议 | 渠道分组、模型映射、计费、看板、OAuth、订阅 | +| [BerriAI/litellm](https://github.com/BerriAI/litellm) | v1.109.0 / 2026-11-11 | 100+ 供应商 SDK + 网关 | 路由回退、虚拟 Key 预算、缓存、护栏、可观测性 | +| [yym68686/uni-api](https://github.com/yym68686/uni-api) | v1.7.276 / 2026-09-12 | YAML 配置的轻量多供应商聚合 | 统一 OpenAI 出口、负载均衡、重试、限流 | +| [tbphp/gpt-load](https://github.com/tbphp/gpt-load) | v2.0.0-rc.63 / 2026-11-08 | 多渠道多凭据自托管网关 | 密钥/订阅账号池、加权调度、故障转移、请求日志与用量 | + +## 2. 逐项对照 + +| 能力 | 本项目现状 | 同类项目做法 | 结论 | +|---|---|---|---| +| 供应商接入 | 硬编码 10 个订阅渠道(`KNOWN_PROVIDERS`) | one-api / new-api / uni-api / LiteLLM 均允许任意 OpenAI 兼容 `base_url` | **缺口**,见 C1 | +| 模型映射 | 全局 `MODEL_ALIASES`,渠道内模型名由代码归一 | one-api / new-api 支持渠道级「模型重定向」 | **部分缺口**,见 C3 | +| 调度与容错 | 健康度、权重、冷却、到期额度、粘性会话、回退组 | LiteLLM Router、new-api 渠道分组、gpt-load 加权轮询 | 本项目不弱,保持 | +| 配额治理 | Key / 用户双层的日请求、日 token、日成本、RPM、TPM、并发;凭证护栏 | 竞品多为 Key / 用户 / 团队预算,部分支持 Redis 跨副本 | 本项目够用;团队层级见第 4 节 | +| 鉴权 | 本地账号、一次性激活、三角色、CSRF、IP / 模型白名单 | one-api / new-api 支持 GitHub / OIDC 等 OAuth | **缺口**,见 C4 | +| 可观测性 | Prometheus `/metrics`、环形日志、审计、九类告警 + Webhook | LiteLLM 接 OTel / Langfuse;new-api 数据看板 | **部分缺口**,见 C5 | +| 成本核算 | OpenRouter 刊例价 + 24h 实测扣费校准 | LiteLLM 维护价格表;new-api 支持自定义倍率 | **部分缺口**,见 C6 | +| 协议兼容 | OpenAI Chat / Responses、Claude Messages、模型列表 | new-api 另有原生 Gemini;LiteLLM 全协议 | **低优先缺口**,见 C7 | +| 运维动作 | 批量探活/签到/预刷新、备份、配置热更 85 项 | gpt-load / one-api 批量导入导出、渠道测试 | 本项目覆盖较全 | +| 计费与充值 | 不做按 token 计费(订阅制上游,见 PROPOSAL 非目标) | one-api / new-api 完整计费、兑换码、充值 | 不纳入,见第 4 节 | +| 对话留存 | 明确不存对话,只留用量明细 | 多数竞品记录请求/响应全文 | 不纳入,见第 4 节 | + +## 3. 候选功能清单(按优先级) + +优先级依据:用户价值(能否直接解决渠道可用性 / 运维成本 / 账单准确性)> 与现有架构契合度 > 实现成本。工作量档位:1 = ≤2 人天,2 = 3–7 人天,3 = ≥8 人天或跨模块改造。 + +| 优先级 | 编号 | 候选功能 | 所属模块 | 现状差距 | 升级 / 新增建议 | 档位 | +|---|---|---|---|---|---|---| +| P0 | C1 | 自定义 OpenAI 兼容渠道 | provider / credentials / 调度 | 新增供应商必须改代码与发版 | 新增 `generic_openai` 适配器:admin 配 `base_url`、密钥、模型列表;复用现有 executor、统计、冷却与配额 | 3 | +| P0 | C2 | 标签参与调度与限额 | engine / db / 管理台 | 标签仅展示,不参与选号或限额 | 候选过滤支持按标签(如仅 `prod`);凭证限额支持按标签聚合日用量 | 2 | +| P1 | C3 | 渠道级模型 ID 覆盖 | provider / model_resolver | 上游改模型 ID 需改代码;全局别名无法按渠道区分 | 增加 `provider → {统一名: 上游 ID}` 配置,解析优先级:渠道覆盖 > 全局别名 > 原值 | 1 | +| P1 | C4 | OIDC / SSO 登录 | auth / 管理台 | 只有本地账号与激活链接 | OIDC 授权码登录,claim 映射到现有三角色;保留本地账号兜底 | 2 | +| P1 | C5 | OpenTelemetry 链路导出 | observability | 有 request_id 与 Prometheus,但无 trace 导出 | 可选 OTLP exporter,复用 request_id 贯穿上游调用,支持采样与关闭 | 2 | +| P2 | C6 | 模型单价手工覆盖 | 统计 / 成本 | 成本依赖 OpenRouter 刊例价与实测校准,无法按合同价修正 | admin 可编辑每模型 USD/1M 单价,未配置时回落现有校准逻辑 | 1 | +| P2 | C7 | 原生 Gemini 入口 | api / compat | 仅 OpenAI 与 Claude 入站 | `/v1beta/models/*:generateContent` 入站翻译,复用现有三出口骨架 | 2 | + +## 4. 不纳入本次范围 + +| 功能 | 理由 | +|---|---| +| 按 token 计费、充值、兑换码、套餐订阅 | PROPOSAL 明确非目标:上游订阅制,成本不随 token 线性增长 | +| 多租户组织 / 团队层级 | 目标用户尚未定为团队或多租户(PO 待确认);现有 users + 三角色已覆盖单团队 | +| 请求 / 响应全文留存与回放 | 现有隐私设计明确不存对话;竞品做法不适用于本定位 | +| 语义缓存 / Prompt 缓存 | 会破坏用量与成本口径,订阅制下收益不明确 | +| Embeddings / Rerank / 图片 / 音频端点 | 十个上游以编码类 chat 为主,短期无真实需求 | +| 分布式限流与多副本 | 现架构为单实例锁 + SQLite;需先重构存储层,成本高于收益 | +| MCP 网关 / Agent 托管 | 本项目定位是 API 出口,不是 Agent 平台 | + +## 5. 建议排期 + +先做 C2(成本低、直接改善多号运维),再评估 C1 与 C4;C3、C6 可作为顺手项并入相邻迭代;C5、C7 等真实需求出现再排。 \ No newline at end of file diff --git a/README.en.md b/README.en.md index 57f0a31..c3dc5d8 100644 --- a/README.en.md +++ b/README.en.md @@ -13,7 +13,7 @@ Ten channels — **CodeBuddy** (CN + international, two separate channels), **TR ## Features - **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. 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. +- **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. @@ -123,7 +123,7 @@ echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password- - 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`. Subscriber limit 4, beyond → 429 + `Retry-After`. +- 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 diff --git a/README.md b/README.md index cb160e5..d0f1bd8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ## 特性 - **OpenAI 兼容出口**:`/v1/chat/completions`(流式 / 非流式)、`/v1/responses`(Codex CLI)、`/v1/messages`(Claude Code)、`/v1/models`(含单模型查询)、`/v1/user/balance`。 -- **统一调度**:扁平名按健康度选号;`模型@渠道` 强制。优先级:免费渠道 → 在途最少 → 到期额度 → 健康度 + 分级冷却 → 余额多者。目录里的 x0 会被最近 24h 的实测扣费校准(上游改计费不改目录时不至于永远占住免费档)。模型级限流只避让该模型;多轮对话粘同一凭证,出错才轮换。 +- **统一调度**:扁平名按健康度选号;`模型@渠道` 强制。优先级:免费渠道 → 在途最少 → 到期额度 → 健康度 + 分级冷却 → 余额多者。管理员可给凭证设**权重**(`-1000..1000`):只在同一健康档内重排(正数优先、负数当备用),不会把冷却/耗尽的号顶上来。目录里的 x0 会被最近 24h 的实测扣费校准(上游改计费不改目录时不至于永远占住免费档)。模型级限流只避让该模型;多轮对话粘同一凭证,出错才轮换。 - **到期额度优先**:主窗口(默认 36h)先烧将过期额度;打平再比 7 天窗口。单位统一积分。 - **模型列表按凭证加载**:只列有可用凭证的渠道 → 无幽灵模型。 - **公共凭证池**:admin 维护、全员共享、加密入库(`APP_SECRET`);设备码登录、多账号切换、额度探测、签到、token 预刷新。 @@ -123,7 +123,7 @@ echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password- - 只收本进程 `logging`;不读文件、不 tail。多进程各看各的。 - **脱敏是硬前提**:每行发出前过白名单。命中 `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`、`sk-…` 形状或 `Bearer <值>` → 整行丢弃。 - 缓冲 `LOG_BUFFER_LINES`(默认 500,可热更),环形覆盖最旧。`0` 关整页(两端点 404)。 -- 单行上限 4000 字符;每行带 `seq`、时间、级别、logger、`request_id`。订阅者上限 4,超限 429 + `Retry-After`。 +- 单行上限 4000 字符;每行带 `seq`、时间、级别、logger、`request_id`。**点行尾的 `#前8位`** 即按该 request_id 过滤,收起一次请求的全部日志(executor 每次换号 / 冷却 / 降级都带同一个 ID);也可以直接把响应头 `X-Request-ID` 粘进关键字框。stdout / journald / launchd 文件同样带 `[request_id]`(后台任务为 `-`)。订阅者上限 4,超限 429 + `Retry-After`。 ## 使用 diff --git a/TECHNICAL.md b/TECHNICAL.md index bb9e4ee..aef53b0 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -549,6 +549,8 @@ class Scheduler: 4. **账户剩余积分**多者优先——只作健康度**打平键**。 5. 仍同分按 `credential_id` 稳定。 +**管理员权重**(`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`(各包未必共享重置点)。 diff --git a/src/api/admin_credentials.py b/src/api/admin_credentials.py index 2feb4ed..4f49d1f 100644 --- a/src/api/admin_credentials.py +++ b/src/api/admin_credentials.py @@ -30,6 +30,7 @@ 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 @@ -71,6 +72,11 @@ # 上限只为拦住误粘贴整段请求头/文件内容,不做格式校验(Cookie 结构随上游变)。 WEB_COOKIE_MAX_LENGTH = 10_000 +# 调度权重范围:正数越优先、负数越靠后,0 = 默认。上限只为拦住误输入, +# 不参与归一化(同档内按数值稳定排序)。 +WEIGHT_MIN = -1000 +WEIGHT_MAX = 1000 + # 批量的在途并发上限。后台任务用 Pacer 严格串行化上游请求,但那是跨任务 # 节流;批量端点由操作员手动触发,若完全串行则 50 个号要等几分钟才返回。 # 折中:限制同时在途的凭证数,既不打爆上游,也不至于让请求长时间挂住。 @@ -498,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), diff --git a/src/audit/actions.py b/src/audit/actions.py index 6aac02f..481abb5 100644 --- a/src/audit/actions.py +++ b/src/audit/actions.py @@ -27,6 +27,7 @@ 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" @@ -49,6 +50,7 @@ ACTION_CREDENTIAL_PIN, ACTION_CREDENTIAL_REVIVE, ACTION_CREDENTIAL_SWITCH_ACCOUNT, 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, @@ -83,6 +85,7 @@ 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", diff --git a/src/db/migrate.py b/src/db/migrate.py index 6deea9f..af4d2cf 100644 --- a/src/db/migrate.py +++ b/src/db/migrate.py @@ -84,7 +84,10 @@ # 33:credentials 新增 web_cookie_state(网页 Cookie 状态:NULL/ok/expired)。 # 只在管理台提示「网页会话已失效,重贴 Cookie」,不参与调度;老库补 NULL = # 未知(下次探测写入)。额度本身仍以 statistics 汇总口径为准。 -SCHEMA_VERSION = 33 +# 34:credentials 新增 routing_weight(调度权重)。NULL/0 = 默认,正数越大越先 +# 被选中,用于管理员的显式偏好(灰度、保留备用号)。老库补 NULL 后选号顺序与 +# 补列前完全一致(所有比较键相等时仍按原链 cid 排序)。 +SCHEMA_VERSION = 34 # (表, 列定义):历史库升级时逐条补列 _MIGRATION_COLUMNS: tuple[tuple[str, str], ...] = ( @@ -106,6 +109,8 @@ # 避免为管理台展示改动调度排序行为 ("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"), diff --git a/src/db/repo.py b/src/db/repo.py index 9a7a9fb..48e8b32 100644 --- a/src/db/repo.py +++ b/src/db/repo.py @@ -250,6 +250,17 @@ 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,)) @@ -695,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 上;每选一次号查一次库会抵消选号的开销优势。 @@ -718,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 @@ -873,7 +886,7 @@ def list_all( "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, " + "web_cookie_state, routing_weight, " "token_expires_at, token_issued_at, " "tags " "FROM credentials ORDER BY created_at").fetchall() diff --git a/src/db/schema.sql b/src/db/schema.sql index 6cd4358..6cf915c 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS credentials ( quota_expiry_ladder TEXT, -- 到期阶梯 JSON [[epoch, 剩余积分]];无到期信息为 NULL 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):进度条满量程与「最后续期」 diff --git a/src/engine/scheduler.py b/src/engine/scheduler.py index 07ed915..601cfa2 100644 --- a/src/engine/scheduler.py +++ b/src/engine/scheduler.py @@ -199,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 @@ -314,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 @@ -334,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: @@ -523,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/webapp/logging.py b/src/webapp/logging.py index 42164c3..44f432d 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/tests/api/test_admin_credentials_weight.py b/tests/api/test_admin_credentials_weight.py new file mode 100644 index 0000000..acc7114 --- /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/db/test_repo_credit.py b/tests/db/test_repo_credit.py index 68aea87..eb14895 100644 --- a/tests/db/test_repo_credit.py +++ b/tests/db/test_repo_credit.py @@ -354,3 +354,24 @@ def test_save_quota_persists_web_cookie_state(tmp_path): # 重新粘贴/清除 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_migrate.py b/tests/db/test_repo_migrate.py index 3064403..88dde8a 100644 --- a/tests/db/test_repo_migrate.py +++ b/tests/db/test_repo_migrate.py @@ -640,3 +640,23 @@ def test_legacy_db_upgrade_adds_web_cookie_state(tmp_path): 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 a35046e..87d7087 100644 --- a/tests/db/test_users_repo.py +++ b/tests/db/test_users_repo.py @@ -224,7 +224,7 @@ 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 == 33 + assert SCHEMA_VERSION == 34 db.close() diff --git a/tests/engine/test_scheduler.py b/tests/engine/test_scheduler.py index 0fda40e..c5582b9 100644 --- a/tests/engine/test_scheduler.py +++ b/tests/engine/test_scheduler.py @@ -518,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/webapp/test_logging.py b/tests/webapp/test_logging.py index f2a3a69..21db76b 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/web/src/api/client.ts b/web/src/api/client.ts index cc9cc09..0e07d5e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -353,6 +353,12 @@ export const api = { 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", { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index a27638d..c9f4113 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -101,6 +101,8 @@ export interface Credential { limits: CredentialLimits | null; /** 运维标签(仅列表展示与筛选,不参与调度);无标签为空数组 */ tags: string[]; + /** 调度权重:null = 默认;正数越优先、负数当备用(同健康档内生效) */ + routing_weight: number | null; } /** 凭证级限额护栏(全部按北京自然日;0/null = 该护栏关闭)。 */ diff --git a/web/src/components/CredentialWeightDialog.tsx b/web/src/components/CredentialWeightDialog.tsx new file mode 100644 index 0000000..e6b955b --- /dev/null +++ b/web/src/components/CredentialWeightDialog.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { Gauge, X } from "lucide-react"; +import { api } from "../api/client"; +import type { Credential } from "../api/types"; +import { useDialogFocus } from "../hooks/useDialogFocus"; +import { Button, Field, Input, Notice } from "../ui"; + +/** 与后端 `src/api/admin_credentials.py` 的 WEIGHT_MIN / WEIGHT_MAX 对齐。 */ +export const MIN_WEIGHT = -1000; +export const MAX_WEIGHT = 1000; + +/** 空串 / `default` → null(恢复默认);非法或越界 → "invalid"。 */ +export function parseWeightInput(raw: string): number | null | "invalid" { + const text = raw.trim(); + if (!text || text.toLowerCase() === "default") return null; + if (!/^-?\d+$/.test(text)) return "invalid"; + const value = Number(text); + if (!Number.isSafeInteger(value) || value < MIN_WEIGHT || value > MAX_WEIGHT) { + return "invalid"; + } + return value; +} + +/** + * 调度权重编辑对话框。 + * + * 权重只改**同一健康分档内**的选号次序:正数越优先,负数越靠后(备用号), + * 0/留空 = 默认。它压不过健康分档本身——exhausted 的号即使权重再高也不会 + * 被选中,所以提示里必须说清「不是准入条件」。 + */ +export function CredentialWeightDialog({ + credential, + onClose, + onSaved, + onError, +}: { + credential: Credential; + onClose: () => void; + onSaved: (message: string) => void; + onError: (message: string) => void; +}) { + const current = credential.routing_weight; + const [raw, setRaw] = useState(current === null || current === 0 ? "" : String(current)); + const [busy, setBusy] = useState(false); + const [localError, setLocalError] = useState(null); + const dialogRef = useDialogFocus(busy ? undefined : onClose); + + const parsed = parseWeightInput(raw); + + const submit = async () => { + if (parsed === "invalid") { + setLocalError(`权重需为 ${MIN_WEIGHT} ~ ${MAX_WEIGHT} 的整数,留空 = 默认`); + return; + } + setLocalError(null); + setBusy(true); + try { + await api.setCredentialWeight(credential.id, parsed); + onSaved(parsed === null ? "权重已恢复默认。" : `权重已设为 ${parsed}。`); + } catch (caught) { + onError(caught instanceof Error ? caught.message : "权重保存失败"); + } finally { + setBusy(false); + } + }; + + return ( +
!busy && onClose()} + > +
event.stopPropagation()} + className="max-h-[90vh] w-full max-w-md animate-in overflow-y-auto rounded-xl border border-border bg-card p-5 shadow-xl fade-in-0 zoom-in-95" + > +
+
+

+ 设置调度权重 +

+

+ {credential.nickname || credential.id.slice(0, 12)} +

+
+ +
+ + + { + setRaw(event.target.value); + setLocalError(null); + }} + /> + + + {localError && ( +

+ {localError} +

+ )} + + + 权重只影响同类健康号之间的先后,不会把不可用(冷却中 / 已耗尽 / 已停用) + 的号顶上来。高权重会压过免费优先与在途分摊,建议小幅调整。 + + +
+ + +
+
+
+ ); +} diff --git a/web/src/components/__tests__/CredentialWeightDialog.test.tsx b/web/src/components/__tests__/CredentialWeightDialog.test.tsx new file mode 100644 index 0000000..f56f536 --- /dev/null +++ b/web/src/components/__tests__/CredentialWeightDialog.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { api } from "../../api/client"; +import { makeCredential } from "../../test-utils"; +import { + CredentialWeightDialog, + MAX_WEIGHT, + MIN_WEIGHT, + parseWeightInput, +} from "../CredentialWeightDialog"; + +const credential = makeCredential({ id: "cred_w", nickname: "W" }); + +function setup(overrides: Partial[0]> = {}) { + const onSaved = vi.fn(); + const onError = vi.fn(); + render( + , + ); + return { onSaved, onError }; +} + +describe("parseWeightInput", () => { + it("空 / default → null,合法整数原样返回", () => { + expect(parseWeightInput("")).toBeNull(); + expect(parseWeightInput(" ")).toBeNull(); + expect(parseWeightInput("default")).toBeNull(); + expect(parseWeightInput("7")).toBe(7); + expect(parseWeightInput("-10")).toBe(-10); + }); + + it("非整数 / 越界 / 小数都判非法", () => { + for (const raw of ["abc", "1.5", "1e3", String(MAX_WEIGHT + 1), + String(MIN_WEIGHT - 1)]) { + expect(parseWeightInput(raw)).toBe("invalid"); + } + }); +}); + +describe("CredentialWeightDialog", () => { + it("保存权重并回调成功文案", async () => { + const saved = vi.spyOn(api, "setCredentialWeight").mockResolvedValue({ routing_weight: 42 }); + const { onSaved } = setup(); + + await userEvent.type(screen.getByTestId("credential-weight-input"), "42"); + await userEvent.click(screen.getByTestId("credential-weight-save")); + + await waitFor(() => expect(saved).toHaveBeenCalledWith("cred_w", 42)); + expect(onSaved).toHaveBeenCalledWith(expect.stringContaining("42")); + }); + + it("留空提交 = 恢复默认(null)", async () => { + const saved = vi.spyOn(api, "setCredentialWeight").mockResolvedValue({ routing_weight: null }); + const { onSaved } = setup(); + + await userEvent.click(screen.getByTestId("credential-weight-save")); + + await waitFor(() => expect(saved).toHaveBeenCalledWith("cred_w", null)); + expect(onSaved).toHaveBeenCalledWith(expect.stringContaining("默认")); + }); + + it("非法输入本地拦截,不打后端", async () => { + const saved = vi.spyOn(api, "setCredentialWeight"); + setup(); + + await userEvent.type(screen.getByTestId("credential-weight-input"), "abc"); + await userEvent.click(screen.getByTestId("credential-weight-save")); + + expect(screen.getByTestId("credential-weight-error")).toHaveTextContent("整数"); + expect(saved).not.toHaveBeenCalled(); + }); + + it("回填当前权重;0 视为默认留空", () => { + setup({ + credential: makeCredential({ id: "cred_w", routing_weight: -5 }) as never, + }); + expect(screen.getByTestId("credential-weight-input")).toHaveValue("-5"); + }); + + it("后端报错走 onError,不误报成功", async () => { + vi.spyOn(api, "setCredentialWeight").mockRejectedValue(new Error("boom")); + const { onSaved, onError } = setup(); + + await userEvent.type(screen.getByTestId("credential-weight-input"), "1"); + await userEvent.click(screen.getByTestId("credential-weight-save")); + + await waitFor(() => expect(onError).toHaveBeenCalledWith("boom")); + expect(onSaved).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/pages/CredentialsPage.tsx b/web/src/pages/CredentialsPage.tsx index 7cd1b34..a965b42 100644 --- a/web/src/pages/CredentialsPage.tsx +++ b/web/src/pages/CredentialsPage.tsx @@ -56,6 +56,7 @@ import { AddCredentialDialog } from "../components/AddCredentialDialog"; import { BatchImportDialog } from "../components/BatchImportDialog"; import { CredentialLimitsDialog } from "../components/CredentialLimitsDialog"; import { CredentialTagsDialog } from "../components/CredentialTagsDialog"; +import { CredentialWeightDialog } from "../components/CredentialWeightDialog"; import { CredentialWebCookieDialog } from "../components/CredentialWebCookieDialog"; import { groupModelsByProvider, rateLabel, rateSummary } from "../components/ModelPicker"; import { ToastViewport, useToasts } from "../components/Toast"; @@ -340,6 +341,7 @@ interface Actions { checkinLog: (credential: Credential) => void; limits: (credential: Credential) => void; tags: (credential: Credential) => void; + weight: (credential: Credential) => void; webCookie: (credential: Credential) => void; export: (credential: Credential) => void; } @@ -372,6 +374,8 @@ export function CredentialsPage() { const [limitsFor, setLimitsFor] = useState(null); // 标签编辑对话框:同样存 id,取最新快照 const [tagsFor, setTagsFor] = useState(null); + // 调度权重对话框:同样存 id,取最新快照 + const [weightFor, setWeightFor] = useState(null); // Qoder 网页 Cookie 对话框:同样存 id,取最新快照 const [webCookieFor, setWebCookieFor] = useState(null); // 列表筛选:池子一大就没有「找到某个号」的路径,这里收搜索 + 渠道 + 状态 + 标签四件套。 @@ -472,6 +476,10 @@ export function CredentialsPage() { () => credentials.find((item) => item.id === tagsFor) ?? null, [credentials, tagsFor], ); + const weightCredential = useMemo( + () => credentials.find((item) => item.id === weightFor) ?? null, + [credentials, weightFor], + ); // 网页 Cookie 对话框当前编辑的凭证:同上 const webCookieCredential = useMemo( () => credentials.find((item) => item.id === webCookieFor) ?? null, @@ -753,6 +761,7 @@ export function CredentialsPage() { // 只开对话框;保存与刷新由对话框自己回调(见 CredentialLimitsDialog) limits: (credential) => setLimitsFor(credential.id), tags: (credential) => setTagsFor(credential.id), + weight: (credential) => setWeightFor(credential.id), // 单条导出:文件内容与「批量导入」的条目同形,可原样再导入。 // 只开对话框;保存与探测由对话框/后端负责 webCookie: (credential) => setWebCookieFor(credential.id), @@ -1477,6 +1486,19 @@ export function CredentialsPage() { /> )} + {canWrite && weightCredential && ( + setWeightFor(null)} + onSaved={(message) => { + setWeightFor(null); + showNotice(message); + void refresh(); + }} + onError={(message) => showError(message)} + /> + )} + {canWrite && webCookieCredential && ( {credential.tags.length ? "编辑标签" : "设置标签"} + actions.weight(credential)} + title="只调整同健康档内的选号次序:正数越优先、负数当备用,不会把不可用的号顶上来" + > + + {credential.routing_weight + ? `权重 ${credential.routing_weight}` + : "设置权重"} + {["qoder", "codearts"].includes(credential.provider) && ( actions.webCookie(credential)} diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index dbaf882..90588f6 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -34,15 +34,22 @@ const LEVEL_TONE: Record = { CRITICAL: "danger", }; -/** 按级别集合与关键字过滤(纯函数,页面与测试共用)。 */ +/** 按级别集合与关键字过滤(纯函数,页面与测试共用)。 + * + * 关键字同时匹配 message / logger / request_id:排查一条请求跨渠道重试时, + * 直接把响应头的 `X-Request-ID`(或页面上显示的 8 位前缀)贴进来,就能收起 + * 该请求的全部服务端日志——executor 每次换号、冷却、降级都带同一个上下文。 + */ export function filterLines(lines: LogLine[], levels: ReadonlySet, keyword: string): LogLine[] { const needle = keyword.trim().toLowerCase(); + const bare = needle.replace(/^#/, ""); return lines.filter((line) => { if (!levels.has(line.level)) return false; if (!needle) return true; return line.message.toLowerCase().includes(needle) - || line.logger.toLowerCase().includes(needle); + || line.logger.toLowerCase().includes(needle) + || (line.request_id ?? "").toLowerCase().includes(bare); }); } @@ -197,7 +204,7 @@ export function LogsPage() { id="logs-keyword" data-testid="logs-keyword" value={keyword} - placeholder="按消息或 logger 过滤" + placeholder="按消息 / logger / request_id 过滤" onChange={(event) => setKeyword(event.target.value)} /> @@ -270,13 +277,15 @@ export function LogsPage() { {line.message} {line.request_id && ( - setKeyword(line.request_id ?? "")} > #{line.request_id.slice(0, 8)} - + )} ))} diff --git a/web/src/pages/__tests__/CredentialsPage.test.tsx b/web/src/pages/__tests__/CredentialsPage.test.tsx index 3672a81..5168f4e 100644 --- a/web/src/pages/__tests__/CredentialsPage.test.tsx +++ b/web/src/pages/__tests__/CredentialsPage.test.tsx @@ -1839,3 +1839,35 @@ describe("CredentialsPage 批量导入", () => { expect(screen.queryByTestId("open-batch-import")).not.toBeInTheDocument(); }); }); + + it("设置调度权重:菜单打开对话框并提交 PUT", async () => { + const fetchSpy = mockFetch({ + "/api/credentials/cred_1/weight": { routing_weight: 5 }, + "/api/credentials": listBody([makeCredential({ id: "cred_1" })]), + }); + renderPage(, ADMIN); + await settle(); + await expandCredentialGroups(); + await userEvent.click(screen.getByTestId("actions-cred_1")); + await userEvent.click(screen.getByRole("menuitem", { name: "设置权重" })); + await screen.findByTestId("credential-weight-dialog"); + + await userEvent.type(screen.getByTestId("credential-weight-input"), "5"); + await userEvent.click(screen.getByTestId("credential-weight-save")); + + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + "/api/credentials/cred_1/weight", + expect.objectContaining({ method: "PUT", body: JSON.stringify({ weight: 5 }) }), + ), + ); + }); + + it("已有权重的凭证在菜单里显示当前值", async () => { + mockFetch({ "/api/credentials": listBody([makeCredential({ id: "cred_1", routing_weight: -3 })]) }); + renderPage(, ADMIN); + await settle(); + await expandCredentialGroups(); + await userEvent.click(screen.getByTestId("actions-cred_1")); + expect(screen.getByRole("menuitem", { name: "权重 -3" })).toBeInTheDocument(); + }); diff --git a/web/src/pages/__tests__/LogsPage.test.tsx b/web/src/pages/__tests__/LogsPage.test.tsx index 8a8fed9..0fe0d68 100644 --- a/web/src/pages/__tests__/LogsPage.test.tsx +++ b/web/src/pages/__tests__/LogsPage.test.tsx @@ -38,6 +38,19 @@ describe("LogsPage 纯函数", () => { .toEqual([1]); }); + it("按 request_id 过滤整条请求链路(支持页面上的 # 前缀)", () => { + const lines = [ + line({ seq: 1, message: "换号 cred_a", request_id: "ab12cd34ef" }), + line({ seq: 2, message: "换号 cred_b", request_id: "ab12cd34ef" }), + line({ seq: 3, message: "另一条请求", request_id: "ff00ff00ff" }), + line({ seq: 4, message: "后台任务(无请求上下文)" }), + ]; + expect(filterLines(lines, new Set(["INFO"]), "ab12cd34").map((item) => item.seq)) + .toEqual([1, 2]); + expect(filterLines(lines, new Set(["INFO"]), "#AB12CD34").map((item) => item.seq)) + .toEqual([1, 2]); + }); + it("时间戳格式化到毫秒,非法值给破折号", () => { expect(formatLogTime(0)).toMatch(/^\d{2}:\d{2}:\d{2}\.\d{3}$/); expect(formatLogTime(Number.NaN)).toBe("—"); @@ -175,7 +188,29 @@ describe("LogsPage", () => { await settle(); const chip = screen.getByTestId("log-request-id"); expect(chip).toHaveTextContent("#abcdef01"); - expect(chip).toHaveAttribute("title", "request_id=abcdef0123456789"); + expect(chip).toHaveAttribute( + "title", "只看这条请求的全部日志(request_id=abcdef0123456789)"); + }); + + it("点 request_id 徽标即筛出该请求的整条链路", async () => { + mockFetch({ + "/api/logs": { + capacity: 10, limit: 10, + lines: [ + line({ seq: 1, message: "换号 cred_a", request_id: "abcdef0123456789" }), + line({ seq: 2, message: "另一条请求", request_id: "1234567890abcdef" }), + ], + }, + }); + renderPage(, { username: "root", is_admin: true }); + await settle(); + expect(screen.getAllByTestId("log-row")).toHaveLength(2); + + await userEvent.click(screen.getAllByTestId("log-request-id")[0]); + expect(screen.getByTestId("logs-keyword")).toHaveValue("abcdef0123456789"); + await waitFor(() => { + expect(screen.getAllByTestId("log-row")).toHaveLength(1); + }); }); it("跟随最新可关闭(不改变已渲染内容)", async () => { diff --git a/web/src/test-utils/index.tsx b/web/src/test-utils/index.tsx index 0d2c26a..8b8ae9c 100644 --- a/web/src/test-utils/index.tsx +++ b/web/src/test-utils/index.tsx @@ -145,6 +145,7 @@ export function makeCredential(overrides: Record = {}) { added_by: "root", limits: null, tags: [], + routing_weight: null, ...overrides, }; }