Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <value>` 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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 预刷新。
Expand Down Expand Up @@ -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`。

## 使用

Expand Down
2 changes: 2 additions & 0 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`(各包未必共享重置点)。
Expand Down
33 changes: 33 additions & 0 deletions src/api/admin_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,6 +72,11 @@
# 上限只为拦住误粘贴整段请求头/文件内容,不做格式校验(Cookie 结构随上游变)。
WEB_COOKIE_MAX_LENGTH = 10_000

# 调度权重范围:正数越优先、负数越靠后,0 = 默认。上限只为拦住误输入,
# 不参与归一化(同档内按数值稳定排序)。
WEIGHT_MIN = -1000
WEIGHT_MAX = 1000

# 批量的在途并发上限。后台任务用 Pacer 严格串行化上游请求,但那是跨任务
# 节流;批量端点由操作员手动触发,若完全串行则 50 个号要等几分钟才返回。
# 折中:限制同时在途的凭证数,既不打爆上游,也不至于让请求长时间挂住。
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions src/audit/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/db/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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], ...] = (
Expand All @@ -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"),
Expand Down
17 changes: 15 additions & 2 deletions src/db/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,))
Expand Down Expand Up @@ -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 上;每选一次号查一次库会抵消选号的开销优势。
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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):进度条满量程与「最后续期」
Expand Down
24 changes: 24 additions & 0 deletions src/engine/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 = 其余。

Expand Down
Loading
Loading