mslxdff 0.1.228 → 0.1.229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@
17
17
  | 无参启动 | `mslxdff` | 已有 daemon 显示 status,否则后台启动 |
18
18
  | daemon | `-d` / `--daemon` | 后台启动(只升不降,低版本不覆盖高版本) |
19
19
  | 状态 | `-status` / `--status` / `-s` | 打印 daemon/health/port/config、upstream providers(启用/key/baseUrl/allowlist/共享)、models(含 v0.1.59 体检表 avg首字/tps/啰嗦/p95)/群组/failover/recent calls(ts/model/status/dur)/last error/autostart/plugins — 全量聚合体检 |
20
- | 用量报表 | `-stats` / `--stats` `[--hours N] [--json] [--model <id>]` | 默认统计**今日 0 点至现在**,`--hours N` 切近 N 小时滚动(上限 168);输出 `Token 用量`(请求/输入/输出/思考/合计)与 `响应性能`(首字/总耗时/加权速度/**首字样本 n/N**)两张自适应边框表并含合计,长 id 不截断;k/M 缩写,`--json` 精确值。**首字 = 本次上游尝试开始 → 网关转发首个真实数据帧**(含排队/建连/判决预读,ADR-0039),`0ms` 有效、非流式不进分子与分母,N=0 显示 `—`;**思考四态**:上报值 / `~` 估算(思考字符÷4)/ `0` 真无 / `—` 未知(不再拿 0 冒充「不思考」);与 `-status`/`-model stats` 的 EMA 首字**不同源**;速度仍为窗口加权 `Σ输出÷Σ生成耗时`;不含失败请求与 `-chat` 直连 |
20
+ | 用量报表 | `-stats` / `--stats` `[--hours N] [--json] [--model <id>]` | 默认统计**今日 0 点至现在**,`--hours N` 切近 N 小时滚动(上限 168);输出 `Token 用量`(请求/输入/输出/**缓存命中**/思考/合计)与 `响应性能`(首字/总耗时/加权速度/**首字样本 n/N**)两张自适应边框表并含合计,长 id 不截断;k/M 缩写,`--json` 精确值。**首字 = 本次上游尝试开始 → 网关转发首个真实数据帧**(含排队/建连/判决预读,ADR-0039),`0ms` 有效、非流式不进分子与分母,N=0 显示 `—`;**思考四态**:上报值 / `~` 估算(思考字符÷4)/ `0` 真无 / `—` 未知(不再拿 0 冒充「不思考」);**缓存命中列**:`N (p%)` = 上游缓存读 ÷ 窗口输入总数(候选链与 `/v1/messages` usage 同源,见 `src/usage/cache-fields.js`),无命中读数显示 `—`;与 `-status`/`-model stats` 的 EMA 首字**不同源**;速度仍为窗口加权 `Σ输出÷Σ生成耗时`;不含失败请求与 `-chat` 直连 |
21
21
  | 日志 | `-log [N]` / `--log [N]` / `-logs N` | 最近 N 条事件 + `timeline.log` 人读时间线;按模型链路日志为 `logDir/<provider>-<model>.log`,逐阶段记录 request/route/upstream/peer/relay/result(安全摘要,不落 prompt/正文/凭据)。**事件面用黑名单**:默认全部可见,只排除 `peer-health`/`heartbeat`/`client-session`/`upstream-probe*`;决定类事件只渲染登记字段,含上游回显 `upstream=`/`account=`/`pick=`/`cooled=` |
22
22
  | 回路语料捕获(第四条观测通道,**缺省开**) | `MSLXDFF_TALK_FULL`(**未设置即捕获**;只有显式关闭词 `0`/`off`/`false`/`no`/`disable`(trim+lowercase)才停写、且不建目录;`1`/`true`/`on`/`yes` 与任意其它取值=开,旧 `=1` 姿势继续有效;改 env 后必须 `cmd /c .\restart-daemon.bat` 重启才生效)+ `MSLXDFF_TALK_CAP_CHARS`(响应正文桶上限:缺省开 2000000/显式关 400000,正数 env 覆盖优先)+ `MSLXDFF_TALK_FULL_MAX_MB`(50)/`MSLXDFF_TALK_FULL_KEEP`(3,`0`=不留旧份)/`MSLXDFF_TALK_FULL_MAX_LINE_MB`(16) | 机读全量语料落 `<日志根>/talk/full/<供应商>-<模型>-talkfull.jsonl`(`0600`、按字节轮转,不是 1h 环形):一行 = 一次上游尝试的 `system prompt` 全文 + 工具定义 + 完整历史(含往轮 tool 结果)+ 思考/正文/工具调用。**只捕 `hops=0` 的本机自发流量**——组员 `forwardToPeer` 转进来的对话(`hops>0`)**不落盘**,别把它当「全部流量」;`mslxdff -chat` 属 `hops=0` 会被捕。磁盘/内存账:缺省开 ⇒ 单模型最坏 `(KEEP+1)×MAX_MB`=200MB、在途请求正文桶上界 200 万字符。凭据落盘前双层脱敏;截断必显式(`meta.truncated`/`meta.cap`,`talk.log` 头行 `truncated=1`)。**读数(ADR-0046)**:`talk/*.log` 头行新增 `finish=<值>`(缺值 `finish=-`)与条件性 `relayMs=`(仅当 ≠`elapsed` 时出现),`elapsed=` 取**本次上游尝试墙钟**(缓冲重放不再报 2ms),零正文轮显式落 `[回答 · 0 字 · 本轮无正文(finish=…)]`,语料恒写 `meta.relayMs`;`events.log`(写前剥正文)、`<provider>-<model>.log`(不落正文)两条口径逐字不变,`talk/*.log` 除头行新字段与 `elapsed` 口径外不变(仍 1h+5MB、只留末条 user 提问)。止血 = 设 `MSLXDFF_TALK_FULL=0` 重启 + 删 `<日志根>/talk/full`。ADR-0045/0046 |
23
23
  | 回路语料视图(**不是** mslxdff 子命令,禁止当 CLI 命令幻觉) | `node scripts/talkfull-view.js [--file <path>] [--model <供应商/模型id>] [--session <key>] [--tail N] [--all]` | 只读脚本(不建目录/不写文件/不改权限):首行打印 `talk/full/` 目录总占用并提示**缺省已开**(`MSLXDFF_TALK_FULL=0` 可关)→ 按会话拼回路(`sessionKey` 形态 `sha1尾12-原值前8`)渲染 system 全文/工具清单/逐轮(与上一条前缀 diff 只打新增尾巴;逐轮 `elapsed` = 本次上游尝试墙钟,旧记录回落 `meta.relayMs` 并标注),并给「回路体检」五项读数(① 上下文膨胀曲线 ② 重复工具调用 同 name+同参数 ≥3 次 ③ 连续零输出轮 ≥2 ④ 思考循环措辞 ≥3 轮 ⑤ `finishReason=length` 且思考占比 ≥0.9),阈值随输出打印、缺判据不下结论;坏行跳过不致命;`--tail` 缺省 50,`--all` 出全文 |
@@ -50,7 +50,7 @@
50
50
  | 供应商选路 | `-provider <id> bench --via [--include-opencode] [--json] [--samples N] [--timeout N] [--apply]` / `-provider bench --via` | **家宽选路**:对比 `direct` vs 经每个在线 `peer` 的 `TTFB`(仅测 picks∩allowlist 交集,串行轻探针 `max_tokens=5`,`--json` 时进度走 `stderr`;默认跳过 `opencode`需 `--include-opencode`+TTY `y/N`;**deepseek 一律跳过**(防禁言);结果不写 state;空组直接引导;`--apply` 落盘 `via-routes.json` 供显式锁模型单路径择路) |
51
51
  | Cline 登录 | `-provider cline login` | Cline WorkOS 设备授权拿 refreshToken 落盘;`cline` 走 `refresh→workos:token`+指纹头,deepseek 家族免 403(含 `cline-free/deepseek-*`;非流式内部强制 stream 聚合成 JSON,对外仍按请求方 stream);多账号重复 login 追加(同邮箱替换不追加,`list` 显示邮箱);直连 workos 被墙则 `set HTTPS_PROXY=http://127.0.0.1:7890` 后重试 |
52
52
  | Cline 免费目录 | `-provider cline free [--json]` | 上游免费目录只读(`recommended-models` 的 `free`,5 个):列目录 + 与当前 allowlist 的差异,不写 state |
53
- | Cline 免费同步 | `-provider cline free sync [--yes] [--json] [--keep-extra]` | 把免费目录同步为 `cline` 的 allowlist(写裸 id):**默认 dry-run,`--yes` 才落盘**;`--keep-extra` 只增不删;**全量替换**(会挤掉 daemon auto-sync 并入的 `cline-pass/*`);**上游不可达时拒绝写盘**(不把内置兜底落成白名单) |
53
+ | Cline 免费同步 | `-provider cline free sync [--yes] [--json] [--keep-extra]` | 把免费目录同步为 `cline` 的 allowlist(写裸 id):**默认 dry-run,`--yes` 才落盘**;默认**双向收敛**(free 通道新增并入、下架移除,`cline-pass/*` 与手配保留不动,ADR-0067);`--keep-extra` 退回只增不删;**上游不可达时拒绝写盘**(不把内置兜底落成白名单) |
54
54
  | Cline 用量 | `-provider cline quota [--json] [--account <hash>] [--model <substr>]` | 账号×模型双口径只读统计(`cline-usage.jsonl`):free 显示本周期/已完成周期/累计,pass 显示近 24h/累计;`--json` 供脚本;空账本给引导 |
55
55
  | Cline 迁移 | `-provider cline migrate [--dry-run]` | 旧 `providerConfigs.clinebot` 合并进 `cline`(keys 去重 + 剔 `sk_`、allowlist 求并)后删旧键,幂等、改前备份;**cline 恒 local-only**(不走组员、不借 key、只走本地直连,历史别名同样硬排除) |
56
56
  | CodeArts 登录 | `-provider codearts login` | 华为云 CodeArts Agent(盘古助手)PKCE 浏览器授权:凭证 blob(refreshToken/codeVerifier/dpopJwk)落盘 `providerConfigs.codearts.keys`(一账号一 blob,多账号 keyring 轮转,默认 `allowAnyModels=true`);此后 `codearts/<modelId>` 前缀(恒 `stream:true`,STS 临期自动刷新 + refresh_token 轮换原位写回,死号提示重登);**恒 local-only** 不借 key(ADR-0027) |
@@ -79,11 +79,8 @@
79
79
  | zcode 额度 | `-provider zcode quota [--json]` | 查 ZCode 套餐名/状态/有效期 + 各模型余量(`billing/balance` 分组表格);空套餐给领取指引、401 给重登指引 |
80
80
  | raccoon 接入 | `-provider raccoon login` | Raccoon(商汤小浣熊)扫码登录:生成 `qrcode_code`→终端二维码 + `login/mp?code=` 链接→**用微信「扫一扫」扫**并确认(浏览器直接打开链接会 404,站点 SPA 无该路由)→轮询拿 token,落盘 `auths/raccoon-<uid>.json`+state(`keys` 一律以文档为准,续期后自愈)并把兜底 6 模型并入 allowlist;`raccoon/<modelId>` 前缀路由(**OpenAI 兼容网关**,**免验证码**);多号自动轮换:请求内撞积分不足/限流就当场换下一个号,**暂不支持手动指定用哪个号**;积分不足长冷 1h、限流短冷 30s;恒 local-only 不借出 |
81
81
  | raccoon 密码登录 | `-provider raccoon login --password` | Raccoon **手机号+密码登录**(多号免扫码):走官方同款 `POST /api/web/auth/v1/login_with_password`,**phone/password 须 AES-128-CFB 加密**(key 硬编码于前端,见 `crypto.js`;发明文会报「手机号或密码不正确」);TTY 下密码**关回显**(逐键 `*`),非 TTY 须 `--password=<密码>`;默认 `--nation 86`;失败给人话(「手机号或密码不正确」),回执回显手机号但**打码**、**不回显密码**;落盘/确认/领奖与扫码登录完全同路 |
82
- | raccoon 积分 | `-provider raccoon quota [--json]` / `checkin` | 逐号查积分五分项 / 领「桌面端登录奖励」——**这家没有每日签到**:每日 300 与注册礼包 3000 由服务端自动发(无端点,每日 300 当天 23:59:59 清零),唯一要领的登录奖励 3000 是**每号一次性**且 `login` 时已自动领掉,`checkin` 只是补领兜底;判据只认账单、不认上游 `code:0`(重复领会虚回成功);未登录给 login 指引;daemon 每日 09:00 兜底扫描未领过的号(`MSLXDFF_RACCOON_CHECKIN=0` 关,`_CHECKIN_HOUR` 改点) |
83
- | WorkBuddy 成长任务 | `-workbuddy growth [--json]` / `-wb growth` | 成长任务全自动(拉列表→参与→触发→领奖,串行 1.2s/1s,已领幂等跳过);三种触发通道分派——`growthEvent` 对话(`chat_5`/`automation_1`/`skill_1`/`Model_chat_GLM5.2`)、桌面指纹事件链(`RichMeow_Chat`/`Buddy_App`/`Buddy_App_QQ`/`create_canvas`/`template_5`/`playbook_prompt`)、专家真实对话回执(`expert_5`/`Expert_team_use_3`);**任务列表与领奖只在 `www.workbuddy.cn` 命中**,accept 前上游不下发 target 须 accept 后重读;仍 MANUAL 的 5 项(`Library_read`/`Expert_lighthouse`/`Hp_Appearance`/`WorkBuddy_Valorant`/`wb_wechat_oa_subscribe_task`)只认客户端真实行为,不空烧对话;daemon 每日 09:30 自动(`MSLXDFF_WORKBUDDY_GROWTH=0` 关,`_GROWTH_HOUR`/`_GROWTH_MODEL` 可配) |
84
- | WorkBuddy 积分盘点 | `-provider workbuddy quota [--json]` / `credits` / `balance` | **只读**逐号列未领任务(含积分/进度/状态)、连登天数、可兑档位、抽奖次数、未领合计;不发任何写请求 |
85
- | WorkBuddy 补领 | `-provider workbuddy checkin [--json]` / `claim` | 补领闭环:活跃上报点亮连登 → 兑换 7/14/28 档位 → 抽完全部次数 → 成长任务全量(含桌面/专家通道);输出到账项数/积分/跳过项/未计分告警 |
86
- | WorkBuddy 连登 | daemon 每日随成长排程 | **签到不点亮连登**——`streak.days` 只认 `/v2/report` 的 `chat_request_send` 事件;不发则 7/14/28 档位积分与抽奖次数永远拿不到(2026-10-20 实测签到成功但 streak 恒 0) |
82
+ | raccoon 积分 | `-provider raccoon quota [--json]` / `checkin` | 逐号查积分五分项 / 每日积分结算+补领登录奖励——**每日积分是惰性结算**:服务端每天挂账 500 分,当天第一次访问 `office/v3/setting_info` 才入账(有效期 7 天),`checkin` 触发它(幂等)再补一次性登录奖励 3000(每号一次性、`login` 时已自动领);判据只认账单、不认上游 `code:0`(重复领会虚回成功);未登录给 login 指引;daemon 每日 09:00 自动跑同一套(`MSLXDFF_RACCOON_CHECKIN=0` 关,`_CHECKIN_HOUR` 改点) |
83
+ | WorkBuddy 成长任务 | `-workbuddy growth [--json]` / `-wb growth` | 成长任务全自动(拉列表→参与→触发→领奖,串行 1.2s/1s,已领幂等跳过);可自动 `chat_5`/`automation_1`/`skill_1`/`Model_chat_GLM5.2`,需客户端任务标 MANUAL 不发包;daemon 每日 09:30 自动(`MSLXDFF_WORKBUDDY_GROWTH=0` 关,`_GROWTH_HOUR`/`_GROWTH_MODEL` 可配) |
87
84
  | WorkBuddy 猫猫旅行 | `-workbuddy travel [--json]` / `-wb travel` | 无猫自动同意协议+领养(+300,门槛未达自动补一次对话解锁);到站领奖 / 空闲派出(location 4)/ 旅行中跳过 |
88
85
  | WorkBuddy 余额 | `-workbuddy balance [--json]` / `-wb balance` | 查多号余额(`total/dailyPacks/nextExpire`,TTL 5min) |
89
86
  | WorkBuddy 列表 | `-workbuddy list` / `-wb list` | 列账号(`uid/domain/enterpriseId`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.228",
3
+ "version": "0.1.229",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { normalizeProviderId } from "../../../providers/model-id.js";
7
7
  import { fetchFreeCatalog, FALLBACK_FREE, FREE_CATALOG_PATH } from "../../../providers/cline/free-catalog.js";
8
+ import { planSync } from "../../../providers/cline/allowlist-sync.js";
8
9
 
9
10
  /** 上游不可达时退回内置兜底目录(标注 source=fallback,供人判断是否该落盘) */
10
11
  async function loadCatalog() {
@@ -18,14 +19,18 @@ async function loadCatalog() {
18
19
  };
19
20
  }
20
21
 
22
+ /**
23
+ * 默认与 daemon auto-sync 同口径:free 通道双向收敛(新增并入、下架移除),
24
+ * 非 free 通道(cline-pass/*、手配 id)原样保留。
25
+ * `--keep-extra` 退回历史语义:只增不删,目录外的既有条目全留。
26
+ */
21
27
  function computeNext(current, freeIds, keepExtra) {
22
- const next = keepExtra ? [...new Set([...current, ...freeIds])] : [...freeIds];
23
- return {
24
- next,
25
- added: next.filter((m) => !current.includes(m)),
26
- removed: current.filter((m) => !next.includes(m)),
27
- kept: next.filter((m) => current.includes(m)),
28
- };
28
+ if (keepExtra) {
29
+ const next = [...new Set([...current, ...freeIds])];
30
+ return { next, added: next.filter((m) => !current.includes(m)), removed: [], kept: current.length };
31
+ }
32
+ const { next, added, removed } = planSync(current, freeIds, { providerId: "cline" });
33
+ return { next, added, removed, kept: next.filter((m) => current.includes(m)).length };
29
34
  }
30
35
 
31
36
  function renderList(cat, current, next, plan) {
@@ -33,8 +38,13 @@ function renderList(cat, current, next, plan) {
33
38
  const w = Math.max(20, ...cat.ids.map((s) => s.length)) + 2;
34
39
  for (const m of cat.models) console.log(` + ${String(m.id).padEnd(w)}${m.name && m.name !== m.id ? m.name : ""}`);
35
40
  console.log(`当前 allowlist:${current.length} 个`);
36
- for (const id of current) console.log(` ${next.includes(id) ? "✓" : "-"} ${id}${next.includes(id) ? "" : " (不在 free 目录)"}`);
37
- console.log(`结果:allowlist ${current.length} → ${next.length}(+${plan.added.length} 新增 / -${plan.removed.length} 移除 / ${plan.kept.length} 保留)`);
41
+ const removed = new Set(plan.removed);
42
+ for (const id of current) {
43
+ if (removed.has(id)) console.log(` ✗ ${id} (free 通道已下架,将移除)`);
44
+ else if (next.includes(id)) console.log(` ✓ ${id}`);
45
+ else console.log(` - ${id} (非 free 通道,保留不动)`);
46
+ }
47
+ console.log(`结果:allowlist ${current.length} → ${next.length}(+${plan.added.length} 新增 / -${plan.removed.length} 移除 / ${plan.kept} 保留)`);
38
48
  }
39
49
 
40
50
  export async function handleClineFree(id, sub, rest = [], args = []) {
@@ -48,7 +48,7 @@ export async function handleProvider(args) {
48
48
  console.error(" mslxdff -provider zcode quota [--json] 查看套餐/余量(未登录给重登指引)");
49
49
  console.error(" mslxdff -provider raccoon login Raccoon(商汤小浣熊)扫码登录(终端二维码,免验证码)");
50
50
  console.error(" mslxdff -provider raccoon login --password Raccoon 手机号+密码登录(多号免扫码;密码输入不回显)");
51
- console.error(" mslxdff -provider raccoon quota|checkin [--json] 查积分余额 / 领每日登录积分");
51
+ console.error(" mslxdff -provider raccoon quota|checkin [--json] 查积分余额 / 每日积分结算+补领登录奖励");
52
52
  console.error(" mslxdff -provider traework login|checkin|quota [--json] SOLO 国内号:登录 / 每日签到领积分 / 查签到与积分额度(quota 只读,不烧配额)");
53
53
  console.error(" mslxdff -provider openrouter clear remove all keys");
54
54
  process.exit(1);
@@ -1,109 +1,156 @@
1
- // mslxdff -provider raccoon quota — 逐号查积分余额(五分项)
2
- // mslxdff -provider raccoon checkin — 领「桌面端登录奖励」
3
- // ⚠ 纠偏(2026-10-10 账单实测):这家**没有每日签到**。三种积分来源里只有登录奖励 3000 需要领,
4
- // 且**每号一次性**;每日 300 与注册礼包 3000 都由服务端自动发(无端点),每日 300 当天 23:59:59 清零。
5
- // 判据一律走账单:上游对重复领取照样回 code:0,只信回执就会谎报到账。
6
- import { normalizeProviderId } from "../../../providers/model-id.js";
7
- import { compatFetch } from "../../../compat.js";
8
- import { listRaccoonAccountDocs, raccoonCredentialFromDoc } from "../../../providers/raccoon/account-store.js";
9
- import { claimRaccoonLoginReward, fetchRaccoonBalance, raccoonClaimStatus } from "../../../providers/raccoon/credits.js";
10
- import { raccoonTokenFingerprint } from "../../../providers/raccoon/auth.js";
11
-
12
- const SUBS = new Set(["quota", "credits", "balance", "checkin", "signin"]);
13
-
14
- /** 逐号列出(旧实现只取 docs[0],多号时会漏看余额、漏领一次性礼包)。 */
15
- function targets(deps) {
16
- if (deps.credential) return [{ name: "", credential: deps.credential }];
17
- return listRaccoonAccountDocs(deps.dir ? { dirs: [deps.dir] } : {})
18
- .filter((d) => d.accessToken)
19
- .map((d) => ({ name: d.name || "", credential: raccoonCredentialFromDoc(d) }));
20
- }
21
-
22
- const STATUS_LINE = {
23
- claimed: (r) => `🎁 已领取 +${r.points} 分 —— 账单已确认入账(每号仅此一次)`,
24
- already: () => `已领过 —— 每号只有一次,没有"明天再来"这回事`,
25
- phantom: () => `⚠ 上游回执「成功」但账单里查不到这笔 → 判定未到账,别当领过`,
26
- unverified: () => `已提交,但账单暂时查不到 → 未确认,稍后用 quota 核对`,
27
- error: (r) => `❌ 领取失败:${r.msg || "未知原因"}`,
28
- };
29
-
30
- // row.account 在构造时已换成 token 指纹(凭据纪律:CLI 只打指纹,绝不打完整 token)
31
- const label = (row) => `[${row.account}${row.uid ? " · " + row.uid : ""}]`;
32
-
33
- function expiredHint(error) {
34
- return error?.authExpired || error?.status === 401 || error?.code === 200001 || error?.code === 200003
35
- ? "登录态已失效(access_token 只有约 3 小时寿命),重新扫码: mslxdff -provider raccoon login"
36
- : "";
37
- }
38
-
39
- export async function handleRaccoonQuota(id, sub, rest = [], deps = {}) {
40
- const pid = normalizeProviderId(id) || String(id || "").toLowerCase();
41
- if (pid !== "raccoon") return false;
42
- if (!SUBS.has(sub)) return false;
43
-
44
- const fetchImpl = deps.fetchImpl || compatFetch;
45
- const log = deps.log || ((m) => console.log(m));
46
- const exit = deps.exit || ((code) => process.exit(code));
47
- const json = rest.includes("--json");
48
- const isCheckin = sub === "checkin" || sub === "signin";
49
- const list = targets(deps);
50
-
51
- if (!list.length) {
52
- if (json) log(JSON.stringify({ error: "not_logged_in", hint: "mslxdff -provider raccoon login" }));
53
- else {
54
- log("未登录:还没有可用的 raccoon 账号");
55
- log(" 先运行: mslxdff -provider raccoon login(登录时会自动领一次性登录奖励)");
56
- }
57
- exit(1);
58
- return true;
59
- }
60
-
61
- const rows = [];
62
- for (const t of list) {
63
- const base = { account: raccoonTokenFingerprint(t.credential.access_token), uid: t.credential.uid, name: t.name };
64
- try {
65
- if (isCheckin) {
66
- const r = await claimRaccoonLoginReward({ credential: t.credential, fetchImpl, env: deps.env });
67
- const status = raccoonClaimStatus(r);
68
- rows.push({ ...base, ok: status === "claimed" || status === "already", status, points: r.points });
69
- } else {
70
- const b = await fetchRaccoonBalance({ credential: t.credential, fetchImpl, env: deps.env });
71
- rows.push({ ...base, ok: true, total: b.total, items: b.items });
72
- }
73
- } catch (e) {
74
- rows.push({ ...base, ok: false, status: "error", msg: String(e?.message ?? e).slice(0, 160), relogin: expiredHint(e) });
75
- }
76
- }
77
- const failed = rows.filter((r) => !r.ok).length;
78
-
79
- if (json) {
80
- log(JSON.stringify({ accounts: rows, ok: rows.length - failed, total: rows.length }));
81
- exit(failed === rows.length ? 1 : 0);
82
- return true;
83
- }
84
-
85
- if (isCheckin) {
86
- log(`raccoon 登录奖励(每号一次性)· 共 ${rows.length} 个号`);
87
- for (const r of rows) log(` ${label(r)} ${STATUS_LINE[r.status]?.(r) || r.status}`);
88
- if (rows.some((r) => r.status === "claimed")) log(` 查看余额: mslxdff -provider raccoon quota`);
89
- exit(failed === rows.length ? 1 : 0);
90
- return true;
91
- }
92
-
93
- log(`raccoon 积分 · 共 ${rows.length} 个号`);
94
- for (const r of rows) {
95
- if (!r.ok) {
96
- log(` ${label(r)} ❌ ${r.msg}`);
97
- if (r.relogin) log(` → ${r.relogin}`);
98
- continue;
99
- }
100
- const detail = r.items.length ? r.items.map((i) => `${i.label} ${i.value}`).join(" · ") : "(上游未返回分项明细)";
101
- log(` ${label(r)} 可用 ${r.total} ${detail}`);
102
- }
103
- log("");
104
- log(" ⚠ 「每日积分」当天 23:59:59 清零,当天不花就作废;奖励/充值积分不过期。上游按倍率自动先扣当日池。");
105
- log(" ⚠ 倍率 0 的模型不扣积分(`mslxdff -provider raccoon models` 看倍率列),想省积分优先用它们。");
106
- log(" 登录奖励每号只有一次,登录时已自动领;本命令的 checkin 只是补领兜底。");
107
- exit(failed === rows.length ? 1 : 0);
108
- return true;
109
- }
1
+ // mslxdff -provider raccoon quota — 逐号查积分余额(五分项)
2
+ // mslxdff -provider raccoon checkin — 结算每日积分(每天)+ 补领一次性登录奖励
3
+ // ⚠ 纠偏(2026-10-21 账单实测,推翻 2026-10-10 旧结论):每日积分是**惰性结算**——
4
+ // 服务端按天挂账,当天第一次访问 `/office/v3/setting_info` 才真正入账(入账即有效期到 T+6,
5
+ // 即官方文案「当天登录后延长至 7 天」)。不访问则当天 `daily_points` 恒 0、账单里没有 `daily_grant`。
6
+ // 当前档位是 500 分(不是旧注释的 300),且**每天都要触发一次**,不是一次性。
7
+ // 「桌面端登录奖励 3000」才是每号一次性的,登录时已自动领,checkin 只是兜底补领。
8
+ // 判据一律走账单:上游对重复领取照样回 code:0,只信回执就会谎报到账。
9
+ import { normalizeProviderId } from "../../../providers/model-id.js";
10
+ import { compatFetch } from "../../../compat.js";
11
+ import { listRaccoonAccountDocs, raccoonCredentialFromDoc } from "../../../providers/raccoon/account-store.js";
12
+ import {
13
+ claimRaccoonDailyPoints,
14
+ claimRaccoonLoginReward,
15
+ fetchRaccoonBalance,
16
+ raccoonClaimStatus,
17
+ raccoonDailyStatus,
18
+ } from "../../../providers/raccoon/credits.js";
19
+ import { raccoonTokenFingerprint } from "../../../providers/raccoon/auth.js";
20
+
21
+ const SUBS = new Set(["quota", "credits", "balance", "checkin", "signin"]);
22
+
23
+ /** 逐号列出(旧实现只取 docs[0],多号时会漏看余额、漏领一次性礼包)。 */
24
+ function targets(deps) {
25
+ if (deps.credential) return [{ name: "", credential: deps.credential }];
26
+ return listRaccoonAccountDocs(deps.dir ? { dirs: [deps.dir] } : {})
27
+ .filter((d) => d.accessToken)
28
+ .map((d) => ({ name: d.name || "", credential: raccoonCredentialFromDoc(d) }));
29
+ }
30
+
31
+ const STATUS_LINE = {
32
+ claimed: (r) => `🎁 已领取 +${r.points} 分 —— 账单已确认入账(每号仅此一次)`,
33
+ already: () => `已领过 —— 每号只有一次,没有"明天再来"这回事`,
34
+ phantom: () => `⚠ 上游回执「成功」但账单里查不到这笔 → 判定未到账,别当领过`,
35
+ unverified: () => `已提交,但账单暂时查不到 → 未确认,稍后用 quota 核对`,
36
+ error: (r) => `❌ 领取失败:${r.msg || "未知原因"}`,
37
+ };
38
+
39
+ const DAILY_LINE = {
40
+ claimed: (r) => `✅ 每日积分 +${r.dailyPoints} 分 已入账${r.dailyExpireAt ? `(可用至 ${r.dailyExpireAt})` : ""}`,
41
+ already: (r) => `每日积分今天已结算过${r.dailyPoints ? `(${r.dailyPoints} 分在账)` : ""}`,
42
+ unverified: () => `⚠ 已触发结算,但账单里没看到今天这笔 → 未确认,稍后用 quota 核对`,
43
+ error: () => `❌ 每日积分结算失败`,
44
+ };
45
+
46
+ // row.account 在构造时已换成 token 指纹(凭据纪律:CLI 只打指纹,绝不打完整 token)
47
+ const label = (row) => `[${row.account}${row.uid ? " · " + row.uid : ""}]`;
48
+
49
+ function expiredHint(error) {
50
+ return error?.authExpired || error?.status === 401 || error?.code === 200001 || error?.code === 200003
51
+ ? "登录态已失效(access_token 只有约 3 小时寿命),重新扫码: mslxdff -provider raccoon login"
52
+ : "";
53
+ }
54
+
55
+ export async function handleRaccoonQuota(id, sub, rest = [], deps = {}) {
56
+ const pid = normalizeProviderId(id) || String(id || "").toLowerCase();
57
+ if (pid !== "raccoon") return false;
58
+ if (!SUBS.has(sub)) return false;
59
+
60
+ const fetchImpl = deps.fetchImpl || compatFetch;
61
+ const log = deps.log || ((m) => console.log(m));
62
+ const exit = deps.exit || ((code) => process.exit(code));
63
+ const json = rest.includes("--json");
64
+ const isCheckin = sub === "checkin" || sub === "signin";
65
+ const list = targets(deps);
66
+
67
+ if (!list.length) {
68
+ if (json) log(JSON.stringify({ error: "not_logged_in", hint: "mslxdff -provider raccoon login" }));
69
+ else {
70
+ log("未登录:还没有可用的 raccoon 账号");
71
+ log(" 先运行: mslxdff -provider raccoon login(登录时会自动领一次性登录奖励)");
72
+ }
73
+ exit(1);
74
+ return true;
75
+ }
76
+
77
+ const rows = [];
78
+ for (const t of list) {
79
+ const base = { account: raccoonTokenFingerprint(t.credential.access_token), uid: t.credential.uid, name: t.name };
80
+ try {
81
+ if (isCheckin) {
82
+ // 每日积分结算在前(每天必做),一次性登录奖励补领在后(正常态是「已领过」)。
83
+ // 两件事互不影响:每日结算失败不该导致补领不跑。
84
+ let daily = "error";
85
+ let dailyPoints = 0;
86
+ let dailyExpireAt;
87
+ try {
88
+ const d = await claimRaccoonDailyPoints({ credential: t.credential, fetchImpl, env: deps.env });
89
+ daily = raccoonDailyStatus(d);
90
+ dailyPoints = d.points || 0;
91
+ if (d.expireAt !== undefined) dailyExpireAt = new Date(d.expireAt).toLocaleString("zh-CN", { hour12: false });
92
+ } catch {
93
+ daily = "error";
94
+ }
95
+ const r = await claimRaccoonLoginReward({ credential: t.credential, fetchImpl, env: deps.env });
96
+ const status = raccoonClaimStatus(r);
97
+ rows.push({
98
+ ...base,
99
+ ok: daily === "claimed" || daily === "already",
100
+ daily,
101
+ dailyPoints,
102
+ ...(dailyExpireAt === undefined ? {} : { dailyExpireAt }),
103
+ status,
104
+ points: r.points,
105
+ });
106
+ } else {
107
+ const b = await fetchRaccoonBalance({ credential: t.credential, fetchImpl, env: deps.env });
108
+ rows.push({ ...base, ok: true, total: b.total, items: b.items });
109
+ }
110
+ } catch (e) {
111
+ rows.push({ ...base, ok: false, status: "error", msg: String(e?.message ?? e).slice(0, 160), relogin: expiredHint(e) });
112
+ }
113
+ }
114
+ const failed = rows.filter((r) => !r.ok).length;
115
+
116
+ if (json) {
117
+ log(JSON.stringify({ accounts: rows, ok: rows.length - failed, total: rows.length }));
118
+ exit(failed === rows.length ? 1 : 0);
119
+ return true;
120
+ }
121
+
122
+ if (isCheckin) {
123
+ log(`raccoon 每日积分(每天结算 · 当前档位 500 分)· 共 ${rows.length} 个号`);
124
+ for (const r of rows) {
125
+ if (!r.ok) {
126
+ log(` ${label(r)} ❌ ${r.msg || "登录态异常"}`);
127
+ if (r.relogin) log(` → ${r.relogin}`);
128
+ continue;
129
+ }
130
+ log(` ${label(r)} ${DAILY_LINE[r.daily]?.(r) || r.daily}`);
131
+ // 一次性登录奖励:只有「真领到」才值得单独提一行;已领过是绝大多数情况,不再刷屏
132
+ if (r.status === "claimed") log(` ${STATUS_LINE.claimed(r)}`);
133
+ else if (r.status === "phantom" || r.status === "unverified") log(` ${STATUS_LINE[r.status](r)}`);
134
+ }
135
+ if (rows.some((r) => r.daily === "claimed")) log(` 查看余额: mslxdff -provider raccoon quota`);
136
+ exit(failed === rows.length ? 1 : 0);
137
+ return true;
138
+ }
139
+
140
+ log(`raccoon 积分 · 共 ${rows.length} 个号`);
141
+ for (const r of rows) {
142
+ if (!r.ok) {
143
+ log(` ${label(r)} ❌ ${r.msg}`);
144
+ if (r.relogin) log(` → ${r.relogin}`);
145
+ continue;
146
+ }
147
+ const detail = r.items.length ? r.items.map((i) => `${i.label} ${i.value}`).join(" · ") : "(上游未返回分项明细)";
148
+ log(` ${label(r)} 可用 ${r.total} ${detail}`);
149
+ }
150
+ log("");
151
+ log(" ⚠ 「每日积分」每天 00:00 挂账、当天首次访问才入账(登录即入账),入账后有效期 7 天。不登录不结算就拿不到。");
152
+ log(" ⚠ 倍率 0 的模型不扣积分(`mslxdff -provider raccoon models` 看倍率列),想省积分优先用它们。");
153
+ log(" 登录奖励每号只有一次,登录时已自动领;本命令的 checkin 是「每日结算 + 补领兜底」。");
154
+ exit(failed === rows.length ? 1 : 0);
155
+ return true;
156
+ }
@@ -39,7 +39,7 @@
39
39
 
40
40
  - 指纹头:UA `Cline/3.0.47` + `X-CLIENT-TYPE: cline-sdk` + `X-CLIENT-VERSION` 同 UA;对话链 = `POST /api/v1/auth/refresh`(**每次 refresh 轮换 refreshToken 必须回写**)→ `Bearer workos:<accessToken>` + 固定 `<base>/api/v1/chat/completions`(**baseUrl 已含 /api/v1 时只拼 `/chat/completions`,否则双 /api/v1 会 401/empty response**);deepseek/z-ai 免费通道必须 `stream:true` + SSE 聚合否则 `500 empty response content`;`X-Task-ID`/`session_id` 每次唯一。
41
41
  - 发上游剥 `provider/` 前缀(`cline/deepseek/X→deepseek/X`)恢复 forceStream 聚合 + 非 JSON 响应体 TypeError 加固(`test/cline-chat-id.test.js` 4 用例,0.1.91)。
42
- - 白名单自动同步(ADR-0033):`allowlist-sync.js` 纯函数 `planMerge`(只增不减)+ `createAllowlistSync`(无新增不写盘、异常吞掉留痕),由 `cline/index.js` 注入;`cline/models.js` 仅在**上游成功取数**路径调 `notifyRefresh`(内置兜底常量不写盘,防污染);`MSLXDFF_CLINE_AUTOSYNC=0` 关;**教训:`saveProviderAllowedModels`/`saveProviderConfig` 重建 cfg 时起手 `{baseUrl,keys}` 未带 `allowAnyModels` 会静默抹成 false**(已修,勿回退)。
42
+ - 白名单自动同步(ADR-0033 → **ADR-0067 双向收敛**):`allowlist-sync.js` 的 `planSync` 按 free 目录增删 `allowedModels`;移除只限 free 命名空间(`isFreeChannelId`),`cline-pass/*` 与手配 id 不动(目录接口只回 free,拿它判 pass 会误删付费通道),细节见 ADR-0067。`models.js` 仅在上游成功路径调 `notifyRefresh`(兜底不写盘);无增删零写盘;`MSLXDFF_CLINE_AUTOSYNC=0` 关(含移除);**教训:`saveProviderAllowedModels`/`saveProviderConfig` 重建 cfg 时起手 `{baseUrl,keys}` 未带 `allowAnyModels` 会静默抹成 false**(已修,勿回退)。
43
43
  - 登录走 WorkOS 设备授权流(client_id `client_01K3A541FN8TA3EPPHTD2325AR`),refreshToken 落 `providerConfigs.{cline}.keys`;bench 检测 keys 含 refreshToken 即走 refresh→指纹头→stream 测速专用分支。
44
44
 
45
45
  ## bench 与额度保护(`src/bench/`)
@@ -0,0 +1,225 @@
1
+ // AgentRouter 定制供应商:OpenAI 兼容聚合网关,有三道硬门槛(2026-10-21 现网 curl 实测):
2
+ // ① 客户端身份白名单 —— UA 为 `mslxdff`/`curl`/浏览器一律 401 `unauthorized client detected`,
3
+ // 只放行 `opencode/*` 与 `claude-cli/<ver> (external…)`;
4
+ // ② 模型分端点 —— `deepseek-v4-flash` 吃 /v1/chat/completions,`gpt-6.1-sol`/`gpt-6-astra`
5
+ // 对该端点回 400「请改用 /v1/responses」,只在 responses 通道可用;
6
+ // ③ 内容闸 —— 正文含 `x-anthropic` 字样稳定 500 `sensitive words detected`(连 user 消息也拦,
7
+ // 与 workbuddy 只扫 system/assistant 不同),见 stripAgentRouterSensitive。
8
+ // 故不复用 generic(其 UA 写死 `mslxdff`、responses 判定只认 models.dev npm 索引),这里自组装,
9
+ // 底层仍复用 base.js 的 runner 与 responses-channel 的协议互转。
10
+ import { createKeyRing } from "./keyring.js";
11
+ import { loadProviderBaseUrl, loadProviderKeys } from "../state.js";
12
+ import { createChatRunner, createAgent, envInt, getUndici, joinUrl, collectApiKeysGeneric } from "./base.js";
13
+ import { createResponsesChannel, resolveResponsesPath } from "./responses-channel.js";
14
+ import { compatFetch } from "../compat.js";
15
+ import { opencodeUa } from "../opencode-identity.js";
16
+ import { joinModelId } from "./model-id.js";
17
+
18
+ const { UndiciFetch } = getUndici();
19
+
20
+ export const DEFAULT_AGENTROUTER_BASE = "https://agentrouter.org";
21
+ export const MODELS_PATH = "/v1/models";
22
+ export const CHAT_PATH = "/v1/chat/completions";
23
+ export const RESPONSES_PATH = "/v1/responses";
24
+
25
+ // 只吃 responses 的模型族:现网实测 gpt-6* 打 chat 端点回 400 并显式要求改 /v1/responses。
26
+ const RESPONSES_PREFIXES = ["gpt-6"];
27
+
28
+ export function isAgentRouterResponsesModel(model) {
29
+ const m = String(model || "").trim().toLowerCase();
30
+ if (!m) return false;
31
+ return RESPONSES_PREFIXES.some((p) => m.startsWith(p));
32
+ }
33
+
34
+ // baseUrl 归一:缺省取 state/env,剥掉尾部 `/v1`(路径常量自带 `/v1`,避免 /v1/v1)
35
+ export function resolveAgentRouterBase(baseUrl) {
36
+ const raw = baseUrl || loadProviderBaseUrl("agentrouter") || DEFAULT_AGENTROUTER_BASE;
37
+ return String(raw).trim().replace(/\/+$/, "").replace(/\/v1$/i, "");
38
+ }
39
+
40
+ // responses 通道内部两处 stream 判据不一致(响应通道用 `!== false` 决定读法,body 转换用
41
+ // `=== true` 决定发什么):客户端不显式给 stream 时,会去把未转换的上游 JSON 当 chat 形状读。
42
+ // 这里先把意图钉成布尔,两条路径就同源了。
43
+ export function normalizeResponsesBody(body) {
44
+ if (!body || typeof body !== "object") return body;
45
+ return { ...body, stream: body.stream === true };
46
+ }
47
+
48
+ // AgentRouter 内容闸:稳定拦截正文里含 `x-anthropic` 字样的请求(2026-10-21 现网实测:
49
+ // `500 sensitive words detected` / `400 content-blocked`,连 `x-anthropic` 一个词也拦;
50
+ // `x-google`/`x-meta`/`x-openai`/`anthropic` 均放行——这是对 Anthropic 内部计费头/代理指纹的反代理风控)。
51
+ // 与 workbuddy 的 CC_SENTENCES 剥离同类,但**必须覆盖所有 role**(workbuddy 只扫 system/assistant,
52
+ // 而 agentrouter 连 user/tool 消息里出现该串也拦)。用零宽空格断开子串匹配、不动其余内容。
53
+ // 返回新对象(messages 浅拷贝),不污染调用方 body(dispatcher 可能复用同一 body 做兜底重试)。
54
+ const AR_SENSITIVE = /x-anthropic/gi;
55
+
56
+ export function stripAgentRouterSensitive(obj) {
57
+ if (!obj || typeof obj !== "object") return obj;
58
+ if (!Array.isArray(obj.messages)) return obj;
59
+ const mask = (text) => (typeof text === "string" && AR_SENSITIVE.test(text) ? text.replace(AR_SENSITIVE, "x​anthropic") : text);
60
+ const out = { ...obj, messages: obj.messages.map((m) => {
61
+ if (!m || typeof m !== "object") return m;
62
+ const nm = { ...m };
63
+ if (typeof m.content === "string") nm.content = mask(m.content);
64
+ else if (Array.isArray(m.content)) nm.content = m.content.map((p) => (p && typeof p === "object" && typeof p.text === "string" ? { ...p, text: mask(p.text) } : p));
65
+ if (typeof m.name === "string") nm.name = mask(m.name);
66
+ return nm;
67
+ }) };
68
+ return out;
69
+ }
70
+
71
+ export function createAgentRouterProvider({
72
+ id = "agentrouter",
73
+ baseUrl,
74
+ apiKeys,
75
+ apiKey,
76
+ connectTimeoutMs = envInt("MSLXDFF_AGENTROUTER_TIMEOUT_MS", 60_000),
77
+ cooldownMs = envInt("MSLXDFF_AGENTROUTER_COOLDOWN_MS", 30_000),
78
+ fetchImpl,
79
+ file,
80
+ } = {}) {
81
+ if (id !== "agentrouter") throw new Error(`agentrouter provider id mismatch: ${id}`);
82
+ const resolvedBase = resolveAgentRouterBase(baseUrl);
83
+ if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
84
+
85
+ const ring = createKeyRing(
86
+ collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {})),
87
+ { cooldownMs },
88
+ );
89
+
90
+ let agent = null; let dispatcher = null;
91
+ {
92
+ const a = createAgent({
93
+ keepAliveTimeout: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_TIMEOUT", 30_000),
94
+ keepAliveMaxTimeout: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_MAX_TIMEOUT", 60_000),
95
+ connections: envInt("MSLXDFF_AGENTROUTER_KEEPALIVE_CONNECTIONS", 20),
96
+ });
97
+ agent = a.agent; dispatcher = a.dispatcher;
98
+ }
99
+
100
+ // 身份 UA:白名单只认 opencode / claude-cli,缺了恒 401。单独 env 可覆盖,不联动全局。
101
+ function identityUa() {
102
+ return process.env.MSLXDFF_AGENTROUTER_UA || opencodeUa();
103
+ }
104
+
105
+ function buildHeaders(body, key) {
106
+ const isStream = body?.stream !== false;
107
+ const h = {
108
+ "Content-Type": "application/json",
109
+ Accept: isStream ? "text/event-stream" : "*/*",
110
+ "User-Agent": identityUa(),
111
+ };
112
+ if (key) h.Authorization = `Bearer ${key}`;
113
+ return h;
114
+ }
115
+
116
+ function jsonHeaders(key) {
117
+ const h = { Accept: "application/json", "User-Agent": identityUa() };
118
+ if (key) h.Authorization = `Bearer ${key}`;
119
+ return h;
120
+ }
121
+
122
+ const { runChat } = createChatRunner({
123
+ id, ring, cooldownMs, fetchImpl, dispatcher, buildHeaders,
124
+ getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
125
+ connectTimeoutMs,
126
+ // 非流式走原生(SDK 通道只服务 opencode zen 的形状),responses 另走专用通道
127
+ sdkDispatch: { enabled: false },
128
+ });
129
+
130
+ const responses = createResponsesChannel({
131
+ id,
132
+ url: joinUrl(resolvedBase, resolveResponsesPath(id, RESPONSES_PATH)),
133
+ connectTimeoutMs,
134
+ cooldownMs,
135
+ // 关 SDK:其缺省会强塞 include:["reasoning.encrypted_content"] / store:false,agentrouter 未验证支持
136
+ sdkEnabled: false,
137
+ buildHeaders,
138
+ fetchImpl,
139
+ dispatcher,
140
+ });
141
+
142
+ async function chat(body, opts) {
143
+ const sourceKey = `MSLXDFF_AGENTROUTER_KEY`;
144
+ const clean = stripAgentRouterSensitive(body);
145
+ if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), ring, sourceKey, opts);
146
+ return runChat(clean, ring, sourceKey);
147
+ }
148
+
149
+ async function chatWithKeys(body, keys, opts) {
150
+ const tmp = createKeyRing(keys, { cooldownMs });
151
+ const clean = stripAgentRouterSensitive(body);
152
+ if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), tmp, "shared provider keys", opts);
153
+ const scoped = createChatRunner({
154
+ id, ring: tmp, cooldownMs, fetchImpl, dispatcher, buildHeaders,
155
+ getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
156
+ connectTimeoutMs,
157
+ sdkDispatch: { enabled: false },
158
+ });
159
+ return scoped.runChat(clean, tmp, "shared provider keys");
160
+ }
161
+
162
+ let modelsCache = { at: 0, list: [] };
163
+ const MODELS_TTL_MS = 10 * 60 * 1000;
164
+
165
+ async function fetchModels() {
166
+ const controller = new AbortController();
167
+ const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
168
+ try {
169
+ const key = ring.next() || ring.keys[0];
170
+ const opts = { headers: jsonHeaders(key), signal: controller.signal };
171
+ if (dispatcher) opts.dispatcher = dispatcher;
172
+ const res = await fetchImpl(joinUrl(resolvedBase, MODELS_PATH), opts);
173
+ if (!res.ok) throw new Error(`${id} models: http ${res.status}`);
174
+ const json = await res.json().catch(() => ({}));
175
+ return Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ }
180
+
181
+ async function listModels() {
182
+ if (modelsCache.list.length && Date.now() - modelsCache.at < MODELS_TTL_MS) return modelsCache.list;
183
+ let raw = [];
184
+ try {
185
+ raw = await fetchModels();
186
+ } catch {
187
+ return modelsCache.list; // 拉取失败给上次缓存(首次为空),不阻断启动
188
+ }
189
+ const list = raw
190
+ .filter((m) => m && typeof m.id === "string" && m.id.trim())
191
+ .map((m) => ({
192
+ id: joinModelId(id, m.id),
193
+ object: "model",
194
+ name: m.name || m.id,
195
+ owned_by: m.owned_by || "agentrouter",
196
+ ...(m.created ? { created: m.created } : {}),
197
+ }));
198
+ modelsCache = { at: Date.now(), list };
199
+ return list;
200
+ }
201
+
202
+ async function preheat() {
203
+ try {
204
+ const m = await listModels();
205
+ return { ok: m.length > 0, models: m.length };
206
+ } catch (err) {
207
+ return { ok: false, error: String(err?.message || err).slice(0, 120) };
208
+ }
209
+ }
210
+
211
+ async function close() { if (agent?.close) try { await agent.close(); } catch {} }
212
+
213
+ return {
214
+ id,
215
+ chat,
216
+ chatWithKeys,
217
+ listModels,
218
+ preheat,
219
+ close,
220
+ agent,
221
+ keyRing: ring,
222
+ baseUrl: resolvedBase,
223
+ responsesUrl: responses.url,
224
+ };
225
+ }