Skip to content

Commit 9d4bd7e

Browse files
committed
feat(zen): 免费模型显式标 x0 倍率
免费层不消耗额度,fetch_models 给存活候选 credit_rate=0.0(而非 None): 列表 UI 显示 x0,Playground 默认选中(取最小倍率)与排序都把免费模型 排在最省一档。留 None 的话 UI 当「无倍率」不显示,也与免费这个事实不符。 实测 /v1/models 的 zen 条目均带 credit_rate: 0.0。
1 parent ab5f2f8 commit 9d4bd7e

5 files changed

Lines changed: 11 additions & 4 deletions

File tree

‎PROPOSAL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
| Q37 | 池健康与多 Key 出口 | `GET /healthz` 返回 `{status, service, version, credentials:{total,ready,cooling,paused,disabled}}`(保留 `GET /health` 作纯存活探针):`ready` 复用调度器 `Candidate.is_selectable` 口径,五类互斥且合计 = total——**计划原文只列 4 类**,但项目已区分「系统禁用」与「用户暂停」(Q33),少一类会让计数对不上,故补 `paused`。`api_keys` 增 `provider_binding`(`codebuddy`/`trae`/空 = 自动)与 `allowed_ips`(`SCHEMA_VERSION` 12→13);`deps.api_key_user` 升级为返回 `ApiKeyPrincipal`,并**在鉴权当场**判定来源 IP。IP 白名单**默认不信 `X-Forwarded-For`**(客户端可写),仅 `TRUST_PROXY=true` 时采信且取 XFF **最后一个**条目,故只适用于「本服务前恰好一层受信反代」。绑定渠道在 `executor` 收窄候选上游:模型归属别家渠道时 400 并给出实际归属,目录未就绪时保守放行。**不做**每 Key 配额 / 多租户(与 Q10 冲突) |
4646
| Q38 | 后台任务可视化(「任务与配置」页) | 管理台原「运行时配置」页与后台任务**合并**:13 项配置按 `HotSetting.task` 归属进任务卡片,无归属的进「网关与调度」区;新增 `GET /api/tasks`(admin)下发 6 类任务运行态,前端 30s 刷新。**运行态只存进程内、不落库**(`tasks/status.py`):重启归零比编造重启前记录更诚实,也省掉新表 + 保留期清理 + 老库迁移,**无 schema 变更**。**no-op 轮次不入账**(返回 `None` = 未到点 / 未开启),否则签到会显示成「刚刚跑过」而当天其实没签;异常入账(`last_error`),否则「一直在失败」会显示成「尚未执行」。周期与开关取**当前生效值**,不是装配快照 |
4747
| Q39 | 用户账号体系(B5) | **用户从 `users.txt` 迁入 SQLite**(`users` + `audit_events` 两表,`SCHEMA_VERSION` 13→14):`users.txt` 降级为**一次性引导导入**(首个 admin 仍可用 `scripts/hash_password.py` 或新 `scripts/create_user.py` 建),老文件不删、可作为回滚;`ADMIN_USERNAMES` 只标**引导期**提权。三角色(S1):`admin` 管用户与配置、`operator` 管凭证写操作、`viewer` 只读。会话吊销(S3)不建会话表,用 `users.session_epoch` 进签名 Cookie 的 `ep`——改密/降级/禁用/硬删一律 bump,**角色每请求现读 DB**,不进 Cookie。删除语义(S6):**禁用是主路径**(可逆、保住用量归属),硬删只在 `scripts/create_user.py --delete --force`;管理台故意不暴露 `DELETE`。建号/重置(S7)走**一次性激活令牌** + `/activate` 自设密码,明文仅响应回显一次、库里只存 SHA-256 摘要(无邮件设施下的最优解,与「API Key 明文仅一次」同一心智模型)。审计(S8):登录 + 账号变动 + 凭证写操作入 `audit_events`,**绝不记密码/令牌明文**。防锁死三层:Web 端 self_target + last-admin 守卫,CLI 端删最后活跃 admin 拒绝,bootstrap 无活跃 admin 直接启动失败 |
48-
| Q40 | OpenCode Zen 免费层(第三渠道 `zen`) | 把 `opencode.ai/zen` 免费层接成第三个 provider(`KNOWN_PROVIDERS` 加 `"zen"`,**无 schema 变更**)。**协议是标准 OpenAI SSE,不逆向**:无需私有信封解析,唯一渠道私有的是**免费层门禁伪装**——UA `opencode/<version>`(≥1.18.0,低于阈值 426、无版本号 403)、`x-opencode-session`(`ses_` + 12 hex + 14 base62)、body `stream:true`、body `tools` 必须**同时**含 `bash` 与 `read`。门禁要求的这两个工具客户端从未声明过,故**注入空壳 + 过滤回包**:只在缺失时补骨架工具,模型若真的调用注入名(含按 `index` 跟踪的流式分片)则整条丢弃,且该流无其他真 tool_call 时把 `finish_reason` 收敛为 `stop`(用户自带同名工具时不注入、不过滤)。**凭证模型用虚拟凭证行**:Zen 无凭证/无额度接口,池里种一条空凭证复用现有调度/冷却/统计(`probe_quota` 恒 `probe_failed=True` → health NULL「未知」,**不是耗尽**);删除后重启复活,也可在凭证页「登录渠道账号」点「添加 OpenCode Zen」立即补回(复用通用导入端点),永久停用请用「暂停」。**免费模型清单完全动态(现拉现探)**:上游 `/zen/v1/models` 免鉴权但返回全部模型(含 70 多个付费模型)且**不带任何免费标记**(`owned_by` 恒 `opencode`、无 cost 字段)。唯一权威信号是匿名可用性(付费恒 401 `Missing API key.`)。故两步过滤:按 `-free` 后缀收窄候选 → 并发探活只保留 2xx(下线 400 / 区域 403 / 故障 5xx / 超时全剔除)。无静态白名单,上游增删自动跟随;候选全灭时抛错让 `/v1/models` 用缓存兜底。zen 的 401 归 `INVALID` 而非 `DEAD`(无凭证,401 只表示该模型需要付费 key),避免强制付费模型把整条渠道硬禁用。`timeline()` 从硬编码两列改为按 `(hour, provider)` 动态 pivot(键名 `codebuddy`/`trae` 不变,无 zen 数据时旧契约不变)。新增 `ZEN_API_ENDPOINT` / `ZEN_ALLOWED_ENDPOINTS`(端点白名单,Zen 不带真实 Token)/ `ZEN_OPENCODE_VERSION`(门禁 UA 版本,上游改阈值时改 env) |
48+
| Q40 | OpenCode Zen 免费层(第三渠道 `zen`) | 把 `opencode.ai/zen` 免费层接成第三个 provider(`KNOWN_PROVIDERS` 加 `"zen"`,**无 schema 变更**)。**协议是标准 OpenAI SSE,不逆向**:无需私有信封解析,唯一渠道私有的是**免费层门禁伪装**——UA `opencode/<version>`(≥1.18.0,低于阈值 426、无版本号 403)、`x-opencode-session`(`ses_` + 12 hex + 14 base62)、body `stream:true`、body `tools` 必须**同时**含 `bash` 与 `read`。门禁要求的这两个工具客户端从未声明过,故**注入空壳 + 过滤回包**:只在缺失时补骨架工具,模型若真的调用注入名(含按 `index` 跟踪的流式分片)则整条丢弃,且该流无其他真 tool_call 时把 `finish_reason` 收敛为 `stop`(用户自带同名工具时不注入、不过滤)。**凭证模型用虚拟凭证行**:Zen 无凭证/无额度接口,池里种一条空凭证复用现有调度/冷却/统计(`probe_quota` 恒 `probe_failed=True` → health NULL「未知」,**不是耗尽**);删除后重启复活,也可在凭证页「登录渠道账号」点「添加 OpenCode Zen」立即补回(复用通用导入端点),永久停用请用「暂停」。**免费模型清单完全动态(现拉现探)**:上游 `/zen/v1/models` 免鉴权但返回全部模型(含 70 多个付费模型)且**不带任何免费标记**(`owned_by` 恒 `opencode`、无 cost 字段)。唯一权威信号是匿名可用性(付费恒 401 `Missing API key.`)。故两步过滤:按 `-free` 后缀收窄候选 → 并发探活只保留 2xx(下线 400 / 区域 403 / 故障 5xx / 超时全剔除)。无静态白名单,上游增删自动跟随;候选全灭时抛错让 `/v1/models` 用缓存兜底。判活结果按 30 分钟缓存(`MODELS_CACHE_TTL_SECONDS`),避免服务层每 300s 重拉列表就重探一轮。免费模型显式标 **x0 倍率**(`credit_rate=0.0`),列表 UI 显示 x0 且排序时排最省一档。zen 的 401 归 `INVALID` 而非 `DEAD`(无凭证,401 只表示该模型需要付费 key),避免强制付费模型把整条渠道硬禁用。`timeline()` 从硬编码两列改为按 `(hour, provider)` 动态 pivot(键名 `codebuddy`/`trae` 不变,无 zen 数据时旧契约不变)。新增 `ZEN_API_ENDPOINT` / `ZEN_ALLOWED_ENDPOINTS`(端点白名单,Zen 不带真实 Token)/ `ZEN_OPENCODE_VERSION`(门禁 UA 版本,上游改阈值时改 env) |
4949
| Q41 | 模型列表按渠道凭证加载 | `/v1/models` 与 `/api/playground/models` 只合并「当前有可用凭证」的渠道(`candidates(selectable_only=True)`:未暂停、未硬禁用)——**从未接入 / 全部暂停 / 会话失效的渠道既不拉取上游也不展示**,避免把根本打不通的渠道模型混进列表(此前无凭证也调用 `list_models({})`,CB/TRAE 回退静态表、zen 匿名拉取,导致幽灵模型)。判定在缓存分支之前:没凭证就不读缓存也不合并;冷却中的凭证仍算「有凭证」,渠道接了只是暂时限流,列表不跟着闪没。冷启动只有 zen(自带虚拟凭证)在列,接入 CB/TRAE 后下一次列表请求(该渠道无缓存)才拉取;由此启动预热也不再对无凭证渠道白打上游。渠道重新接入后若缓存仍在 TTL 内则直接复用,不重复打上游 |
5050
| Q42 | TRAE tool_call 分片续块保留(issue #1 死循环根因) | TRAE 上游工具调用是**按 `index` 的分片流**:每个 index 首片带 `function_call.name`,后续片**只有 `arguments` 增量、没有 name**。`_normalize_solo_tool_call` 原按「无 name 即丢弃」过滤,把续片全部吃掉 → 客户端按 index 拼出**截断的参数 JSON** → 工具执行报错 → 原样重试同一轮 → 死循环(WorkBuddy / Cherry Studio 都复现)。改为与 CodeBuddy/Zen 同一条规则:**只丢「无 name 且 arguments 为空」的噪声**(`_is_blank_solo_tool_call`:name 空,且 arguments 为 `None`、空串、空 JSON 串或空对象),带实际 arguments 的续片保留并归一为 OpenAI `function{arguments}` 形状。此前 32d8cc8 的「空名噪声过滤」误伤续片,本轮把判据收敛到「空名且空参」。无 schema / 配置变更 |
5151
| Q43 | macOS 部署模板去本地路径(占位符 + 安装脚本渲染) | `deploy/launchd/com.coding2api.plist` 与 `deploy/newsyslog/coding2api.conf` 原把开发机家目录绝对路径与用户名(`<user>:staff` 属主)写死在仓库里——克隆到别处不可用,还泄露本机目录结构。launchd 与 newsyslog **都不展开 `$HOME` / 环境变量**,路径必须写死,无法像 systemd 模板那样用约定路径,所以改成模板占位符 `__PROJECT_ROOT__`(路径)+ `__LOG_OWNER__`(属主),由 `scripts/install-launchd.sh`(新增,渲染 plist → `~/Library/LaunchAgents` → bootout/bootstrap,bootout 异步需重试)与 `scripts/install-newsyslog.sh`(改为渲染后写入 `/etc/newsyslog.d/`)在安装时替换成本机实际值。`test_deployment_assets.py` 锁三条不变量:全仓库无个人家目录路径、模板含占位符、安装脚本渲染占位符;newsyslog 与 launchd 模板的路径一致性改为**模板对模板**比对(渲染后仍校验本机已装 plist)。systemd / logrotate 的 `/opt/coding2api`、`/var/log/coding2api` 是约定部署路径非个人路径,保留不动。无 schema / 配置变更 |

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- **OpenAI 兼容出口**:`/v1/chat/completions`(流式 + 非流式)、`/v1/responses`(Codex CLI)、`/v1/models`、`/v1/user/balance`(DeepSeek 兼容余额)
1212
- **统一调度**:扁平模型名按健康度自动选号,`模型@渠道` 强制指定;三态健康度 + 分级冷却避开坏号。模型级限流或「该渠道无此模型」只避让那一个模型,同账号其他模型立刻可用
1313
- **模型列表按渠道凭证加载**:`/v1/models` 与 Playground 只展示**当前有可用凭证**的渠道(未暂停、未会话失效);从未接入的渠道不出现幽灵模型,暂停或凭证失效时其模型暂时消失,恢复即回来
14-
- **OpenCode Zen 免费层**(第三个渠道):`opencode.ai/zen` 的免费模型接成 `zen` 渠道,标准 OpenAI 协议、无需登录;上游清单混着付费模型且无免费标记,本服务按 `-free` 后缀收窄候选再逐个探活,**只展示匿名真正可用的免费模型**(每次动态拉取并探活,判活结果按 30 分钟缓存以避开每 5 分钟重探一次的开销,不设静态白名单);请求侧自动满足免费层门禁,响应侧过滤门禁注入的伪工具调用。无凭证概念——池里一条虚拟凭证让它和其它渠道一样可调度、可暂停、可统计
14+
- **OpenCode Zen 免费层**(第三个渠道):`opencode.ai/zen` 的免费模型接成 `zen` 渠道,标准 OpenAI 协议、无需登录;上游清单混着付费模型且无免费标记,本服务按 `-free` 后缀收窄候选再逐个探活,**只展示匿名真正可用的免费模型**(每次动态拉取并探活,判活结果按 30 分钟缓存以避开每 5 分钟重探一次的开销,不设静态白名单;免费模型显式标注 **x0 倍率**);请求侧自动满足免费层门禁,响应侧过滤门禁注入的伪工具调用。无凭证概念——池里一条虚拟凭证让它和其它渠道一样可调度、可暂停、可统计
1515
- **到期积分优先消化**:主窗口 36h 内将过期的积分多者先用(避免过期浪费),打平再比 7 天窗口;管理台凭证列表显示到期积分与逐个额度包明细
1616
- **会话粘性**:同一对话多轮粘住同一凭证,出错才轮换
1717
- **公共凭证池**:admin 集中维护、全员共享;按人统计用量
@@ -130,7 +130,7 @@ curl http://127.0.0.1:8000/v1/chat/completions \
130130

131131
### OpenCode Zen 免费层
132132

133-
`zen` 渠道接的是 [opencode.ai/zen](https://opencode.ai) 的**免费模型**,无需账号或登录。上游模型清单里混着付费模型且不带免费标记,本服务按 `-free` 后缀收窄候选、再逐个探活,**只把匿名真正可用的免费模型**放进 `GET /v1/models`(每次动态拉取并探活,判活结果按 30 分钟缓存,不维护静态白名单);用 `模型@zen` 强制指定,或由调度器自动路由。
133+
`zen` 渠道接的是 [opencode.ai/zen](https://opencode.ai) 的**免费模型**,无需账号或登录。上游模型清单里混着付费模型且不带免费标记,本服务按 `-free` 后缀收窄候选、再逐个探活,**只把匿名真正可用的免费模型**放进 `GET /v1/models`(每次动态拉取并探活,判活结果按 30 分钟缓存,不维护静态白名单;免费模型显式标 **x0**);用 `模型@zen` 强制指定,或由调度器自动路由。
134134

135135
几点要知道:
136136

‎TECHNICAL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,8 @@ UA 版本走 `ZEN_OPENCODE_VERSION` 配置(上游改阈值改 env,不硬编
581581

582582
**判活结果缓存(`MODELS_CACHE_TTL_SECONDS`,30 分钟)**:探活是模型列表链路里最贵的一步(逐个真发一次推理,总耗时等于最慢那个,实测 12–15s),而免费模型增删很慢。故 `fetch_models` 缓存判活集,TTL 取 30 分钟、比服务层的 `MODEL_LIST_TTL_SECONDS`(300s)长一档:服务层每 5 分钟到期重拉列表时直接复用判活结果,只有超过 30 分钟才真的重探。代价是免费模型下线后最多多留 30 分钟(选中收到 400/401,按无效请求处理,不会误冷却凭证)。
583583

584+
**免费模型显式 x0 倍率**:免费层不消耗额度,`fetch_models` 给每个存活候选 `credit_rate=0.0`(而非 `None`)。这样列表 UI 显示 `x0`、Playground 的默认选中(取最小倍率)与排序都把免费模型排在最省一档;若留 `None`,UI 会当作「无倍率」不显示,也和「免费」这个事实不符。
585+
584586
**401 不算凭证失效**:Zen 无凭证,401(`Missing API key.`)只说明**该模型需要付费 key**,不是虚拟凭证失效。故 zen 的 `classify_status(401)` / `classify_error_code(401)` 归 `ErrKind.INVALID`(跳过该渠道并回 400),不归 `DEAD`——否则一次 `模型@zen` 强制付费模型就会把整条 zen 渠道硬禁用,只能重启复活。模型列表已不含付费模型,此分支只兜底强制指定的情形。
585587

586588
**统计时间序列泛化**:`stats/query.py::timeline()` 原先把 `codebuddy`/`trae` 两列写死在 SQL 的 `CASE WHEN` 里。改为按 `(hour_utc, provider)` 分组、Python 侧 pivot:渠道集合动态取自数据(新增渠道无需改 SQL),某渠道在该小时无数据时补 0(同一批点的键集合一致,前端不会断线)。渠道按名排序,恰为 `codebuddy`/`trae`/`zen` 字典序,**无 zen 数据时旧契约不变**。前端对应地按 points 的键动态生成曲线(`UsageChart.chartProviders`),颜色随渠道稳定(`--chart-1/3/4`)。

‎src/provider/zen/client.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,11 @@ async def fetch_models(self) -> list[Model]:
291291
if (isinstance(model_id, str) and model_id
292292
and model_id.lower().endswith(self.free_suffix)):
293293
owner = item.get("owned_by")
294+
# 免费层不消耗额度:显式给 0(而非 None),让列表 UI 显示 x0,
295+
# 排序时也天然排在最省的一档。
294296
candidates.append(Model(id=model_id,
295-
name=owner if isinstance(owner, str) else ""))
297+
name=owner if isinstance(owner, str) else "",
298+
credit_rate=0.0))
296299
if not candidates:
297300
raise zen_events.UpstreamProtocolViolation("models api returned no free candidates")
298301
alive = await self._probe_alive([model.id for model in candidates])

‎tests/test_zen.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,8 @@ def handler(request: httpx.Request) -> httpx.Response:
399399
assert [m.id for m in models] == ["alpha-free", "GAMMA-FREE", "no-owner-free"]
400400
assert models[0].name == "opencode"
401401
assert models[2].name == ""
402+
# 免费层显式标 0 倍率(列表 UI 显示 x0,排序时也排最省的一档)
403+
assert all(m.credit_rate == 0.0 for m in models)
402404

403405

404406
async def test_fetch_models_custom_suffix_and_probe_network_error():

0 commit comments

Comments
 (0)