dsh-connect-doubao 0.3.1 → 0.4.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,146 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## 0.4.1 — 2026-11-05
8
+
9
+ > 「工作任务」模型接入批次:把豆包客户端里那三个**只能在工作任务模式跑**的条目
10
+ > (2.1 Lite `seed-lite-7b`、2.1 Pro `5`、自动 `9`)真正接到 DSH 里,走的正是会员/
11
+ > 活动付费额度。上一版把它们整个藏起来,理由是「外部客户端注册不进 CUA 运行时」——
12
+ > 那个理由对了一半:运行时确实接不进去,但它不是必需的,`tool_local` 的三条 HTTP
13
+ > 接口就够把一轮任务回合走完。新增 4 例回归测试(另改写 2 例旧的「必须隐藏」断言),
14
+ > 全仓 189 例。
15
+
16
+ ### 新增
17
+
18
+ - **豆包 2.1 Lite / 2.1 Pro / 自动 进入模型选择器** —— 选中任一条,请求自动切到
19
+ 「工作任务」模式(`mode_id: "3"`、`agent_mode: 1`、`need_deep_think: 10001`、
20
+ 新会话 `conversation_mode: 2`、`model_extra_params.input_token_limit: "256k"`,
21
+ `seed-lite-7b` 另带 `reasoning_effort`),`general_task_param` 带 `task_input_json`。
22
+ 实机跑通:Lite 与 Pro 各一次纯文本回合,约 5.8 s,`server_model_tag` 为 `38`。
23
+ - **`tool_local` 应答器** —— 任务模式下服务端会按 `${unique_key}:${device_id}:${aid}:message:${seq}`
24
+ 的顺序下发本地工作区指令;插件并发轮询 `msg/ack`,遇到 `command: 6003`(`cmd_call`,
25
+ 如 `local.workspace.apply`)就回 `upload_command_result` + `msg/complete`,遇到
26
+ `command: 6004`(`agent_finished`)收尾。**这是解开 `710010702` 的全部原因。**
27
+ - **`CHUNK_DELTA` 增量帧** —— 任务模式的正文增量以无 `block_type` 包裹的
28
+ `{ text, id }` 形式下发,按 `id` 分流到 `reasoning_content` / 正文。
29
+
30
+ ### 修复
31
+
32
+ - **选 `seed-lite-7b` / `5` 会被静默换成「豆包 快速」** —— `findDoubaoModel` 只在内置
33
+ 清单里查,查不到就回落到 `'0'`。现在三条任务模型都在清单里,不再回落。
34
+ - **`msg/ack` 批量查询必失败** —— `{ msg_ids: [0..6] }` 会整体报
35
+ `{"code":710010702}`,因为批里只要含一个服务端还没生成的 `seq`,整批就废。
36
+ 现在严格单条轮询,逐 `seq` 递进。
37
+ - **自动发现把工作任务模型全部隐藏** —— 上一版按名称前缀一律排除。现在改为只排除
38
+ `DOUBAO_UNAVAILABLE_MODELS`(仅剩「即将下线」的 `4`),并按内置清单补齐,避免本机
39
+ IndexedDB 只缓存了对话子集时任务模型消失。
40
+
41
+ ### 已知边界
42
+
43
+ - 纯文本、不触发本地工具的小回合,服务端会在 `ext` 里标 `general_agent_task_mode: "simple"`
44
+ 与 `mode_downgrade_reason: "success"`,此时**不扣**活动赠送额度(实测调用前后
45
+ `usedAmount` 恒为 782)。要消耗付费额度,得让它真的执行多步工具/文档任务。
46
+ - `msg/ack` 的 `seq` 没有可查询的上界,应答器在流结束后最多再补 8 次轮询即退出。
47
+
48
+ ---
49
+
50
+ ## 0.4.0 — 2026-11-05
51
+
52
+ > 稳定性与额度准确性批次:让「回答」在中断时说实话,让「停止」真的停下上游,让凭据不再经过
53
+ > 子进程环境变量,让设置卡片不再对任何本机页面敞开;额度读数改成看「还剩多少」,并把一直在
54
+ > 扣的活动赠送额度显示出来;卡片重做了一遍视觉。全部改动都带回归测试(新增 36 例,全仓
55
+ > 187 例)。
56
+
57
+ ### 修复
58
+
59
+ - **断流被当成完整回答** —— 豆包服务端提前断开连接时,插件只把缓冲区里已收到的那半截
60
+ 文本当结果返回,并照样写 `finish_reason: "stop"` 和 `[DONE]`。用户看到的是一个语法
61
+ 通顺、语义中断的回答,没有任何迹象表明它被截断了。现在以 `SSE_REPLY_END` 作为唯一
62
+ 的完成依据:连接先断而该帧未到,就报「回答不完整(已收到 N 字符)」而不是假装成功。
63
+ 特意**不可重试** —— 重试会新开一条线程,把这条线程上的上下文丢掉,比重打一次更糟。
64
+ - **停止按钮停不下上游** —— 客户端断开后没有任何东西通知豆包那边停止生成,那一轮会一直
65
+ 跑到结束,额度照扣,答案没人看。现在响应关闭即 `abort()`,并顺着 `fetch` 传到 socket。
66
+ 顺带补上空闲看门狗:`DOUBAO_STREAM_IDLE_TIMEOUT_MS`(300 秒)此前定义了却从没被使用,
67
+ 流静默这么久会主动中止,而不是把请求永远挂着。
68
+ - **所有认不出的失败都被说成网络问题** —— 分类器对未知错误一律 `network`,shim 又把它
69
+ 标成可重试的 `TIMEOUT`,于是 400、鉴权失败、插件内部异常都会触发宿主的整套重试,
70
+ 最后给出一句「检查网络」。现在按 HTTP 状态与失败来源分类(401/403 → 会话失效、
71
+ 429 → 额度耗尽、4xx → 上游错误不可重试、5xx 与连接失败 → 可重试、其余 → 上游错误),
72
+ 只有真正的传输失败才会被建议重试,错误信封里也带上 `x-should-retry`。
73
+ - **深度思考内容永远为空** —— 帧处理层把 `onThinking` 回调漏掉了(shim 侧的
74
+ `reasoning_content` 分支早就写好了),开启深度思考时只能看到答案、看不到推理过程。
75
+ - **凭据明文进了子进程环境变量** —— DPAPI 调用把整个会话以 base64 塞进
76
+ `DSH_DOUBAO_DPAPI_BLOB` 交给 PowerShell,存活了 `Add-Type` 编译那 0.5–1 秒;
77
+ `...process.env` 还把本机所有 API key 一起带了过去。现在改为 stdin 传入、环境变量走
78
+ 白名单,`powershell.exe` 也从 `System32` 绝对路径启动而不是靠 PATH 查找。
79
+ - **一个坏 profile 会让后面所有候选都不再尝试** —— 候选遍历里 `decryptFromProfile` 没有
80
+ 自己的 try/catch,某个目录的 `Local State` 损坏会直接冒到外层,把 D 盘上真正可用的
81
+ profile 全跳过。现在逐个隔离,失败信息里带上具体目录。
82
+
83
+ - **一次会话失效要等十二小时才自愈** —— 加密的凭据副本在磁盘上缓存 12 小时,服务端
84
+ 已经判定失效的 session 会被反复读回来,每次请求都以同一种方式失败;用户按提示重新
85
+ 登录,插件仍然拿旧副本,直到 TTL 到期。现在服务端说会话失效就删掉盘上副本,让重新
86
+ 登录立刻生效。顺带挡住时钟回拨:缓存时间在未来(改过系统时间、双系统、主板电池没电)
87
+ 一律视为过期,否则 TTL 永远算不出一个正数。
88
+ - **额度接口不可用时每一轮对话都要多等一次失败** —— 额度是对话前的前置检查,而失败的
89
+ 结果从来不进缓存,于是接口挂掉的每一轮都要再付一次注定失败的往返,正好压在首字节
90
+ 之前。失败结果现在缓存 30 秒:够短,恢复延迟只有一轮;够长,能把这种反复打穿。
91
+ - **设置卡片对本机任何网页敞开** —— 四条路由只校验 Origin 是不是 loopback,而浏览器
92
+ 在**同源**请求里根本不发 Origin 头,于是「没有 Origin」被当成可信 —— 本机另一个端口
93
+ 上的页面(或任何能解析到 127.0.0.1 的页面)都能改掉模型开关和数据目录。现在 Host
94
+ 必须是 loopback(挡住把域名解析到 127.0.0.1 的 DNS rebinding),带
95
+ `sec-fetch-site: cross-site` 的请求直接拒绝,写请求还必须带一个自定义头 —— 自定义头
96
+ 会触发 CORS 预检,而这几条路由从不应答预检,跨源页面因此发不出这个请求。同源正常
97
+ 使用完全不受影响。
98
+ - **声明了类型声明文件,但那文件从来不存在** —— `package.json` 的 `types` 指向
99
+ `lib/index.d.ts`,构建却关着 `dts`,任何按类型使用这个包的人都会碰到一个空引用。
100
+ 现在真的生成声明文件(`lib/index.d.ts` 等 5 个),`types` 与 `exports` 两条路径
101
+ 都对得上。同时把 `lib/` 移出版本管理:tsdown 用内容哈希给共享 chunk 命名,每构建
102
+ 一次整批文件就改名,diff 里看不出任何真实改动;发布不受影响(`prepack` 会构建,
103
+ `files` 从 tarball 里取 `lib`)。
104
+
105
+ - **额度卡片的「重置时间」是编出来的** —— 这台账号的套餐只返回一个周额度,且窗口的
106
+ `start_time`/`end_time` 都是 0(服务端不给时间戳)。旧代码于是拿「现在」当窗口起点、
107
+ 加上一个名义周期当重置时刻,卡片显示「6 天后重置」—— 真实重置可能就在二十分钟后。
108
+ 同一份载荷里还带着 `total_amount: "2,100"` / `used_amount: "0"` 这种带千分位的金额,
109
+ 旧代码完全没读,一旦 `used_percent` 缺失就只能显示 0%。现在:窗口还没开始计时就照实
110
+ 说「开始使用后计时」(豆包客户端本身就是这个措辞),有时间戳就显示倒计时,两者都没
111
+ 有才说「重置时间未知」;金额解析后作为百分比的兜底来源。顺带修掉 `doctor` 把「这个
112
+ 套餐没有 5 小时窗口」判成失败的误报 —— 账号形态不是插件故障,只有额度真正读不出来
113
+ 才算失败。
114
+ - **真正在消耗的那份额度没被显示** —— 额度接口返回三个 section,旧代码只读
115
+ `window_limit_section`。这台账号的「活动赠送额度」(`incentive_quota_section`,
116
+ 已用 782 / 1,600、11 月 21 日 09:46 到期)因此完全不出现在卡片上:用户看到「近 7 天
117
+ 已用 0%」,以为额度一点没动,实际上已经在扣赠送额度了。现在两个 section 一起读,
118
+ 赠送额度单独一行、用「已用 782 / 1,600 · 16d 后到期」这种带金额的写法,排在常规窗口
119
+ 前面。
120
+
121
+ ### 新增
122
+ - **卡片改成看「还剩多少」,并自己走动** —— 进度条过去画的是消耗比例(越用越满),
123
+ 与「还剩多少」的直觉相反;现在画剩余量,用完即为空,颜色阈值也按剩余量给。数字从
124
+ 「已用 782 / 1,600」改成「剩 818 / 1,600」,倒计时由本地时钟每秒从绝对时刻重算 ——
125
+ 既不用每秒请求,也不会因为标签页放久了而漂移。轮询从 5 分钟缩到 30 秒,且只在卡片
126
+ 可见时进行,切回标签页立刻刷一次;轮询走 POST 绕过宿主 5 分钟缓存,否则每 30 秒
127
+ 拿回的都是同一份读数。
128
+ - **卡片重做了一遍视觉** —— 原来五个分区的高度、字号、边框几乎一样,眼睛找不到落点。
129
+ 现在整张卡是一片带极淡高光的表面,分区之间用一条发丝线分隔而不是各自套框;每个额度
130
+ 窗口是一块独立的面板,一行的重点只有一个:右边那个「还剩下多少」。倒计时和「已用
131
+ X / Y」退到第二行,不再和主数字抢宽度。进度条改成带方向感的渐变并带一点辉光,数字
132
+ 改用等宽字形 —— 每秒跳动的倒计时不会再让整行左右抖。
133
+
134
+ ### 测试
135
+ - `tests/upstream-terminal.test.ts`(12 例):终止帧、跨 chunk 拆帧、截断判定、思考透传、
136
+ 失败分类与重试矩阵。
137
+ - `tests/shim-lifecycle.test.ts`(5 例):客户端断开、正常结束后关闭、空闲超时、活动续命、
138
+ 释放后不再触发。
139
+ - `tests/auth-dpapi.test.ts`(4 例):源码级守住「明文不进 env」「不走 PATH」两条约束。
140
+ - `tests/save-route.test.ts`(+4 例):缺写入头、非 loopback Host、跨站标记三种拒绝路径,
141
+ 以及「同源 GET 不带 Origin 仍然放行」这条必须保持可用的例外。
142
+ - `tests/quota.test.ts`(8 例):用真实载荷做固定数据 —— 只有周额度、时间戳为 0、
143
+ 另含「活动赠送额度」组(782 / 1,600、只有到期时间、window_type 3);覆盖「不伪造
144
+ 重置时刻」「未开始计时标注 startsOnUse」「赠送额度只报到期」「金额推算百分比」
145
+ 「真实时间戳原样透传」五条契约。
146
+
7
147
  ## 0.3.1 — 2026-11-02
8
148
 
9
149
  > 相对 `0.3.0` 的 3 个提交。修「User Data 不在 C 盘就读不到登录信息」,并补上手动指定目录的
package/README.en.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # DSH Connect Doubao
2
2
 
3
3
  > Connect the sign-in **already held by the local Doubao desktop app** to DeepSeek
4
- > Harness as a Seed-family model provider, plus a **read-only** quota view
5
- > (5-hour window / weekly window, with reset countdowns).
4
+ > Harness: **chat models** and **「工作任务」 (task-mode) models** (2.1 Lite / 2.1 Pro /
5
+ > Auto, which spend the paid membership quota), **Seedream image generation**, plus a
6
+ > **read-only** quota panel (5-hour window / weekly window / activity grant, each with
7
+ > a reset countdown). Windows x64 only — credential decryption relies on Windows DPAPI.
6
8
 
7
9
  [![npm version](https://img.shields.io/npm/v/dsh-connect-doubao.svg)](https://www.npmjs.com/package/dsh-connect-doubao)
8
10
  [![license](https://img.shields.io/npm/l/dsh-connect-doubao.svg)](./LICENSE)
@@ -33,12 +35,14 @@ Without a signed-in Doubao app the plugin fails loudly rather than silently.
33
35
 
34
36
  ## Features
35
37
 
36
- - **Seed-family models** — the 6 entries the Doubao desktop app publishes (table below),
37
- exposed as a DSH model provider.
38
+ - **Two groups of Doubao models** — two 对话-mode entries (快速 / 2.1 Turbo·专家) and
39
+ three 「工作任务」-mode entries (2.1 Lite `seed-lite-7b`, 2.1 Pro `5`, Auto `9`).
40
+ The task models spend the **paid membership quota**; selecting one switches the
41
+ request into the full task-mode shape automatically.
38
42
  - **Three reasoning-effort levels** — low / mid / high, mapped to the measured upstream
39
43
  `reasoning_effort` values **3 / 4 / 5**.
40
- - **Read-only quota panel** — 5-hour and weekly windows as two bars, each with a
41
- **reset countdown**; manual refresh plus a 5-minute automatic poll.
44
+ - **Read-only quota panel** — 5-hour, weekly and activity-grant windows as bars, each
45
+ with a **reset countdown**; manual refresh plus a 5-minute automatic poll.
42
46
  - **Model checklist** — ticked entries appear in DSH's model picker and are sent as
43
47
  `model_item_key`.
44
48
  - **Account status line** — distinguishes signed in / not signed in / sign-in could
@@ -72,7 +76,10 @@ works everywhere):
72
76
  - ✅ **All six catalog entries render** — 豆包 快速 / 2.1 Lite (`0921 新版`) /
73
77
  2.1 Turbo (`专家`) / 2.1 Pro / 2.1 Turbo (`即将下线`) / 自动, with badges matching
74
78
  the desktop app and a count of `已启用 6 ↑`.
75
- - ✅ **A 「豆包(Doubao)」 group appears in DSH's model picker** with the six entries
79
+ > This screenshot is from **0.4.0**: back then the task-mode models were only
80
+ > rendered, not wired up, and the badges used the client's own names. Since 0.4.1
81
+ > they carry the `工作任务` badge and actually answer; the screenshot is not re-shot.
82
+ - ✅ **A 「豆包(Doubao)」 group appears in DSH's model picker** with the entries
76
83
  listed individually. This is the **end-to-end evidence that provider registration
77
84
  and the catalog both work**.
78
85
  - ✅ **The checklist is interactive** — one checkbox per row plus `刷新` / `全选` /
@@ -84,7 +91,7 @@ works everywhere):
84
91
 
85
92
  - `doctor` passes all five checks: 29 cookies decrypted with 9/9 required session
86
93
  cookies present, the account accepted upstream, the shim bound to a loopback port with
87
- a wrong bearer refused (401), and both quota windows readable.
94
+ a wrong bearer refused (401), all three quota windows readable, and 6 models offered.
88
95
  - Reasoning effort low=3 / mid=4 / high=5 was read by **diffing the configuration the
89
96
  client re-serialises when the level is switched**, then independently confirmed
90
97
  against `reasoning_effort_config` in the client's own config.
@@ -104,14 +111,18 @@ the document the server hands the app) — not reconstructed by guesswork. The
104
111
  `model_item_key` **is** the DSH model id, on purpose: one string instead of a
105
112
  translation layer that could only be wrong.
106
113
 
107
- | id | name | badge | reasoning |
108
- | --- | --- | --- | --- |
109
- | `0` | 豆包 快速 | — | ✅ |
110
- | `seed-lite-7b` | 豆包 2.1 Lite | `0921 新版` | ✅ |
111
- | `3` | 豆包 2.1 Turbo | `专家` | ✅ |
112
- | `5` | 豆包 2.1 Pro | — | ✅ |
113
- | `4` | 豆包 2.1 Turbo | `即将下线` | ✅ |
114
- | `9` | 自动 | — | ✅ |
114
+ | id | name | badge | mode | reasoning |
115
+ | --- | --- | --- | --- | --- |
116
+ | `0` | 豆包 快速 | — | 对话 | ✅ |
117
+ | `3` | 豆包 2.1 Turbo | `专家` | 对话 | ✅ |
118
+ | `seed-lite-7b` | 豆包 2.1 Lite | `工作任务` | 工作任务 | ✅ |
119
+ | `5` | 豆包 2.1 Pro | `工作任务` | 工作任务 | ✅ |
120
+ | `9` | 自动 | `工作任务` | 工作任务 | ✅ |
121
+
122
+ The three `工作任务` rows switch the request into task mode (`mode_id: "3"`,
123
+ `agent_mode: 1`, `need_deep_think: 10001`) and are answered by the plugin's own
124
+ `tool_local` responder — see 「工作任务」 mode below. They are the entries that spend
125
+ the **paid membership quota**.
115
126
 
116
127
  **Reasoning effort**: low = `3`, mid = `4`, high = `5`. These values were read by
117
128
  **diffing the configuration the client re-serialises when the level is switched**,
@@ -119,7 +130,8 @@ then independently confirmed against `reasoning_effort_config` in the client's o
119
130
  config. They are observed, not inferred.
120
131
 
121
132
  > 「即将下线」 is not a property of the Turbo model: the app publishes **two** Turbo
122
- > entries and only the retiring one carries that badge.
133
+ > entries and only the retiring one carries that badge. The retiring one (`4`) is
134
+ > the only entry withheld from the picker.
123
135
 
124
136
  ---
125
137
 
@@ -139,7 +151,13 @@ DSH (pi-ai provider)
139
151
  (`bot_id` + `reasoning_effort`).
140
152
  3. **Streaming** — upstream's three-line frames are parsed into pi-ai's event stream;
141
153
  tool calls, compaction and permissions stay Harness-owned.
142
- 4. **Read-only routes** — `/plugins/dsh-connect-doubao/quota` and `/catalog` serve quota
154
+ 4. **Task mode** — for the `工作任务` entries, `doubao-upstream` additionally runs a
155
+ `tool_local` responder: it polls the server's `msg/ack` **one `msg_id` at a time**
156
+ (a batch containing a not-yet-generated seq fails the whole batch with
157
+ `710010702`), answers `cmd_call` via `upload_command_result` + `msg/complete`, and
158
+ stops on `agent_finished`. Incremental `CHUNK_DELTA` frames are split into
159
+ thinking/answer by their block id.
160
+ 5. **Read-only routes** — `/plugins/dsh-connect-doubao/quota` and `/catalog` serve quota
143
161
  and catalog from an **explicit allow-list** (no upstream field is passed through),
144
162
  and refuse non-loopback origins.
145
163
 
@@ -257,22 +275,34 @@ npm run check # all of the above
257
275
  1. **It uses a non-public interface.** Doubao publishes no third-party API; this
258
276
  plugin reverses the behaviour of its own client. Any upstream change can break it,
259
277
  without notice.
260
- 2. **`'4'` (豆包 2.1 Turbo, "即将下线") is unverified.** The other 5 entries were
261
- exercised in real conversations.
262
- 3. **There is no response-side field that proves "the server really used that model".**
278
+ 2. **`'4'` (豆包 2.1 Turbo, "即将下线") is unverified** — and withheld from the
279
+ picker. The other 5 entries were exercised in real conversations, including both
280
+ task-mode models.
281
+ 3. **Task mode works, but whether a turn is billed depends on what it did.** In task
282
+ mode the server hands each turn to `Agent-GeneralTask`, and the plugin runs a
283
+ `tool_local` responder alongside (`msg/ack` → `upload_command_result` →
284
+ `msg/complete`) so the local workspace commands complete instead of failing with
285
+ `710010702`. **For a plain-text turn that triggers no local tool, the server marks
286
+ the round `general_agent_task_mode: "simple"` with `mode_downgrade_reason: "success"`,
287
+ and the activity-grant `usedAmount` does not move** (measured: unchanged at 782
288
+ across a call — i.e. downgraded, not billed). To actually spend the paid quota, the
289
+ turn has to do multi-step tool/document work. This is a measurement, not a promise.
290
+ 4. **There is no response-side field that proves "the server really used that model".**
263
291
  The upstream SSE carries an opaque marker in `message.ext` (observed as `38`); it is
264
292
  **not** a version number or a tier, and ByteDance has never published its meaning.
265
293
  What this plugin can establish is that it sends `model_item_key` with the request
266
294
  (confirmed by packet capture). How the server interprets that key is the server's
267
- own business.
268
- 4. **Windows only** — credential decryption relies on DPAPI. `User Data` does not
295
+ own business — sending an invented key still returns HTTP 200, so the field is not
296
+ validated. What 0.4.1 offers is **the whole task-mode request shape for a given
297
+ key** (verified to answer), not a guarantee that Pro rather than Lite did the work.
298
+ 5. **Windows only** — credential decryption relies on DPAPI. `User Data` does not
269
299
  have to live on `C:` (see the five layers above); a browser-only host has no
270
300
  native folder dialog, so the picker button reports that instead of failing
271
301
  silently — use `DSH_DOUBAO_PROFILE` there.
272
- 5. **Risk-control sliders cannot be automated.** When one fires, send one message in
302
+ 6. **Risk-control sliders cannot be automated.** When one fires, send one message in
273
303
  the Doubao desktop app to clear it.
274
- 6. **No image input.** The provider sends text blocks only.
275
- 7. **Single account.** No account pool, no region switching, no check-in.
304
+ 7. **No image input.** The provider sends text blocks only.
305
+ 8. **Single account.** No account pool, no region switching, no check-in.
276
306
 
277
307
  ---
278
308
 
package/README.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # DSH Connect Doubao
2
2
 
3
- > 把**本机豆包(Doubao)桌面客户端已有的登录态**接入 DeepSeek Harness,作为 Seed 系列模型 provider,
4
- > 并在设置面板里提供一个**只读**的额度概览(5 小时限额 / 周额度 + 重置倒计时)。
3
+ > 把**本机豆包(Doubao)桌面客户端已有的登录态**接入 DeepSeek Harness:**对话模型**与
4
+ > **「工作任务」模型**(2.1 Lite / 2.1 Pro / 自动,走会员付费额度)、**Seedream 生图**,
5
+ > 外加一块**只读**额度看板(5 小时限额 / 周额度 / 活动赠送额度,含重置倒计时)。
6
+ > 仅 Windows x64 —— 凭据解密依赖 Windows DPAPI。
5
7
 
6
8
  [![npm version](https://img.shields.io/npm/v/dsh-connect-doubao.svg)](https://www.npmjs.com/package/dsh-connect-doubao)
7
9
  [![license](https://img.shields.io/npm/l/dsh-connect-doubao.svg)](./LICENSE)
@@ -30,10 +32,12 @@ Chromium cookie(通过 Windows DPAPI 在本进程内解密),把它当作
30
32
 
31
33
  ## 功能特性
32
34
 
33
- - **豆包对话接入** —— 对话模式真正可用的两个条目(见下表),作为 DSH 的模型 provider。
35
+ - **两组豆包模型** —— 对话模式两条(快速 / 2.1 Turbo·专家)与「工作任务」模式三条
36
+ (2.1 Lite `seed-lite-7b`、2.1 Pro `5`、自动 `9`)。任务模型走的是**会员付费额度**,
37
+ 选中即自动切到任务模式的完整请求形态。
34
38
  - **生图(Seedream 5.0)** —— 模型选择器里选「豆包 生图」,回复即以 Markdown 图片返回真实生成图,可直接下载落盘。2026-11-02 实测端到端通过。
35
39
  - **推理强度三档** —— 低 / 中 / 高,对应上游 `reasoning_effort` 实测值 **3 / 4 / 5**。
36
- - **只读额度面板** —— 5 小时限额与周额度两条进度条,各带**重置倒计时**;设置面板里手动刷新 + 5 分钟自动刷新。
40
+ - **只读额度面板** —— 5 小时限额、周额度、活动赠送额度三条进度条,各带**重置倒计时**;设置面板里手动刷新 + 5 分钟自动刷新。
37
41
  - **模型勾选清单** —— 勾选的条目会出现在 DSH 的模型选择器中,请求时按 `model_item_key` 指定模型。
38
42
  - **账号状态行** —— 区分「已登录 / 未登录 / 登录信息读不出来」等状态,各自给出不同的处置建议。
39
43
  - **独立 loopback shim** —— 对话请求走一个只监听 `127.0.0.1` 的本地端点,带每进程共享密钥,任何非本机请求一律拒绝。
@@ -59,6 +63,8 @@ Chromium cookie(通过 Windows DPAPI 在本进程内解密),把它当作
59
63
  `周额度 已用 9% · 5d 21h 后重置`、`账号状态: 已登录`、`每 5 分钟自动刷新`、配置目录路径。
60
64
  - ✅ **模型清单六个条目全部渲染** —— 豆包 快速 / 2.1 Lite(`0921 新版`)/ 2.1 Turbo(`专家`)/
61
65
  2.1 Pro / 2.1 Turbo(`即将下线`)/ 自动,角标与桌面包一致,计数显示 `已启用 6 ↑`。
66
+ > 这张截图来自 **0.4.0**:当时工作任务模型只被渲染出来、还没接通,徽章取客户端原名。
67
+ > 0.4.1 起它们带 `工作任务` 徽章并且真的能对话,截图尚未重拍。
62
68
  - ✅ **模型选择器里出现「豆包(Doubao)」分组** —— 六个条目逐一列出。
63
69
  这是 **provider 注册成功 + catalog 生效** 的端到端证据。
64
70
  - ✅ **勾选交互可用** —— 每行一个勾选框,配 `刷新` / `全选` / `全不选` / `保存` 四个按钮。
@@ -67,8 +73,8 @@ Chromium cookie(通过 Windows DPAPI 在本进程内解密),把它当作
67
73
 
68
74
  ### 代码级 / 抓包级证据(非截图)
69
75
 
70
- - `doctor` 四段检查全绿:凭据 29 cookie 解密且 9/9 必需会话 cookie 齐备、账号被上游接受、
71
- shim 绑定回环端口且错误 bearer 返回 401、两个额度窗口可读。
76
+ - `doctor` 五项检查全绿:凭据 29 cookie 解密且 9/9 必需会话 cookie 齐备、账号被上游接受、
77
+ shim 绑定回环端口且错误 bearer 返回 401、三个额度窗口可读、模型发现开放 6 个。
72
78
  - 推理强度 低=3 / 中=4 / 高=5:由**抓取客户端切换档位时重新序列化的配置 diff**读出,
73
79
  并由客户端配置里的 `reasoning_effort_config` 独立确认。
74
80
  - 请求体带 `model_item_key` 与 `reasoning_effort`:抓包确认。
@@ -89,20 +95,40 @@ Chromium cookie(通过 Windows DPAPI 在本进程内解密),把它当作
89
95
  | 对话 | 豆包 快速(`0`)、豆包 2.1 Turbo「专家」(`3`) |
90
96
  | 工作任务 | 自动(`9`)、2.1 Lite(`seed-lite-7b`)、2.1 Pro(`5`)、Turbo(`3`) |
91
97
 
92
- **本插件只提供「对话」模式的两条**:
98
+ **本插件提供这两组里的五条**(外加生图别名):
93
99
 
94
- | id | 名称 | 徽章 | 推理强度 |
95
- | --- | --- | --- | --- |
96
- | `0` | 豆包 快速 | — | ✅ |
97
- | `3` | 豆包 2.1 Turbo | `专家` | ✅ |
100
+ | id | 名称 | 徽章 | 模式 | 推理强度 |
101
+ | --- | --- | --- | --- | --- |
102
+ | `0` | 豆包 快速 | — | 对话 | ✅ |
103
+ | `3` | 豆包 2.1 Turbo | `专家` | 对话 | ✅ |
104
+ | `seed-lite-7b` | 豆包 2.1 Lite | `工作任务` | 工作任务 | ✅ |
105
+ | `5` | 豆包 2.1 Pro | `工作任务` | 工作任务 | ✅ |
106
+ | `9` | 自动 | `工作任务` | 工作任务 | ✅ |
98
107
 
99
- 「工作任务」的四条**故意不列**:它们的执行侧是豆包客户端进程内的 CUA 运行时
100
- (`tasks-mode-resource` + `sbox`),外部客户端注册不进去,每一轮都会得到
101
- `710010702`。与其在模型选择器里摆出选不中的条目,不如不摆。
108
+ **「工作任务」三条为什么能用了。** 上一版把它们整个隐藏,理由是执行侧是豆包客户端进程内的
109
+ CUA 运行时(`tasks-mode-resource` + `sbox`),外部客户端注册不进去,每一轮必然得到
110
+ `710010702`。那个判断只对了一半:CUA 进程确实接不进去,但**它不是必需的** —— 服务端
111
+ 真正等待的只是三条 HTTP 应答:
112
+
113
+ ```
114
+ POST /alice/office/tool_local/msg/ack # 轮询本地指令
115
+ POST /alice/office/tool_local/upload_command_result # 回传执行结果
116
+ POST /alice/office/tool_local/msg/complete # 标记该指令完成
117
+ ```
118
+
119
+ 服务端按 `${unique_key}:${device_id}:${aid}:message:${seq}` 顺序下发指令(`seq = 0,1,2,…`):
120
+ `command: 6003`(`cmd_call`,如 `local.workspace.apply`)需要回传结果,
121
+ `command: 6004`(`agent_finished`)表示这一轮结束。插件在流式生成期间并发跑一个应答器
122
+ 把这些走完,于是任务回合不再落到 `710010702`。**实测 2026-11-05 跑通,Lite 与 Pro
123
+ 各一次纯文本回合。**
124
+
125
+ > ⚠️ **`msg/ack` 必须单条查询。** `{ msg_ids: [0,1,2,3,4,5,6] }` 会整体失败并返回
126
+ > `{"code":710010702,"msg":"系统错误"}` —— 批里只要含一个服务端尚未生成的 `seq`,
127
+ > 整批就废。这是逆向时最容易踩的坑,`tests/` 里没有钉它(需要真实上游才能复现)。
102
128
 
103
129
  **推理强度**:低 = `3`、中 = `4`、高 = `5`(由客户端切换档位时重新序列化的配置 diff 读出,
104
130
  并由配置里的 `reasoning_effort_config` 独立确认)。深度思考开关本身走
105
- `need_deep_think: 0`,档位只由 `reasoning_effort` 携带 —— 这是 2026-10-22 CDP 抓包更正的。
131
+ `need_deep_think: 0`;任务模式下抓到的真实请求固定发 `10001`,档位仍只由 `reasoning_effort` 携带。
106
132
 
107
133
  ---
108
134
 
@@ -146,7 +172,9 @@ Chromium cookie(通过 Windows DPAPI 在本进程内解密),把它当作
146
172
  `0x22` 是 Latin-1、`0x63` 是 UTF-16LE。**实测确认 `0x63` 的长度字段是字节数而不是字符数**
147
173
  —— 按字符数会正好读多一倍,模型名后面会吃掉下一个字段的字节。
148
174
  - 客户端给「工作任务」专属模型的名称自带前缀(`工作任务 Pro`、`工作任务 Auto`),
149
- 所以模式归属直接从缓存里就能读出来:带前缀的一律不开放(它们需要 App 内的 CUA 运行时)。
175
+ 所以模式归属直接从缓存里就能读出来。**但带前缀不再等于隐藏**:0.4.1 起它们会被
176
+ 标记成 `taskMode` 并正常开放,只有 `DOUBAO_UNAVAILABLE_MODELS` 里的条目(目前仅
177
+ 「即将下线」的 `4`)被排除。缓存里缺了某条任务模型时,会从内置清单补齐。
150
178
  - 缓存读不到、或解析结果不可信时,**回落到内置清单**,绝不清空用户的选择器。
151
179
  - 结果缓存 10 分钟;卡片上的「刷新」(POST)会丢弃记忆重新读盘。
152
180
  - `doctor` 第 5 项会报告这次到底是从缓存发现的、还是回落的。
@@ -154,7 +182,7 @@ Chromium cookie(通过 Windows DPAPI 在本进程内解密),把它当作
154
182
  当前这台机器上的实测输出:
155
183
 
156
184
  ```
157
- [ ok ] models 客户端缓存 5 个模型,开放 3 个(「工作任务」专属的按名字前缀排除)
185
+ [ ok ] models 客户端缓存 5 个模型,开放 6 个(仅排除「即将下线」等不可用条目)
158
186
  ```
159
187
 
160
188
  ## 实验性:工具调用(默认关闭)
@@ -309,7 +337,7 @@ dsh-connect-doubao-probe --prompt "hi" --model 5 --thinking high --json
309
337
  --timeout <ms> 超时(默认 120000)
310
338
  ```
311
339
 
312
- `doctor` 输出四段独立结论:凭据 / 账号 / provider 装配 / 额度。
340
+ `doctor` 输出五项独立结论:凭据 / 账号 / provider 装配 / 额度 / 模型发现。
313
341
  凭据只报**数量**,不报值。
314
342
 
315
343
  ---
@@ -350,21 +378,24 @@ npm run check # 以上全部
350
378
  5. **风控滑块无法自动处理。** 触发后需去豆包客户端手动发一条消息完成验证。
351
379
  6. **图片输入未提供。** 本 provider 只发送文本块,不转发图片。
352
380
  7. **单账号。** 没有账号池、没有区域切换、没有签到。
353
- 8. **「工作任务」跑不了。** `agent_mode` 的取值是 `2 = 普通对话`、`1 = 工作任务`(CDP 抓包实测,
354
- 2026-10-22)。任务模式下服务端会把每一轮交给 `Agent-GeneralTask`,而它的执行运行时是豆包
355
- 客户端进程里的 CUA(`tasks-mode-resource` + `sbox`),外部客户端注册不进去,必然回
356
- `710010702`。所以插件固定走对话模式;请求体里的 `general_task_param` 发的是客户端在普通
357
- 对话下的默认值 `{"server_do_compare":true}`。
358
- 9. **`model_item_key` 服务端根本不校验,所以选不了模型。** 三条证据(2026-10-23):
381
+ 8. **「工作任务」能跑,但扣不扣费取决于这一轮做了什么。** `agent_mode` 的取值是
382
+ `2 = 普通对话`、`1 = 工作任务`(CDP 抓包实测,2026-10-22)。任务模式下服务端把每一轮交给
383
+ `Agent-GeneralTask`,并由插件并发跑一个 `tool_local` 应答器(`msg/ack` →
384
+ `upload_command_result` → `msg/complete`)把本地指令走完,所以不再必然回 `710010702`。
385
+ **但纯文本、不触发本地工具的小回合,服务端会在 `ext` 里标
386
+ `general_agent_task_mode: "simple"` 与 `mode_downgrade_reason: "success"`,此时活动赠送
387
+ 额度的 `usedAmount` 不变**(实测调用前后恒为 782,即「走降级、不计费」)。想让任务模型
388
+ 真正消耗付费额度,得让它执行多步工具/文档任务。这一条是实测结论,不是产品承诺。
389
+ 9. **`model_item_key` 服务端不校验,所以「选中哪个模型」只能靠模式与账号决定。** 三条证据
390
+ (2026-10-23):
359
391
  1. CDP 抓到 App 选 2.1 Pro 时的真实请求:`model_item_key: "5"` + `mode_id: "3"` +
360
- `agent_mode: 1` + `general_task_param.runtime_type: 2`(本地电脑)。前三项我们能复制,
361
- 最后一项是 App 进程内的 CUA 运行时,复制不了 —— 这正是 `710010702` 的来源。
392
+ `agent_mode: 1` + `general_task_param.runtime_type: 2`(本地电脑)。
362
393
  2. 用 `mode_id=3` + `key=5` 在对话模式发请求:正常回答,但与 `key=0` 无可测差异
363
394
  (耗时 15.1–17.6 s、长度 311–340 字,均在噪声范围内)。
364
395
  3. **真正的对照**:把三处 `model_item_key` 全部改成一个不存在的值直接发送,服务端仍返回
365
396
  200 与完整正常流,没有任何错误帧 —— 它不校验这个字段。
366
- 所以「在 DSH 里指定 2.1 Pro / 2.1 Lite」做不到:模型由服务端按产品/模式/账号决定。
367
- 真要用这两个模型,只能在豆包客户端自己的「工作任务」里用。
397
+ 所以 0.4.1 能提供的是**「工作任务模式 + 该 key」这一整组请求形态**(实测能出答案),
398
+ 而不是「服务端一定会用 Pro 而不是 Lite 来算」。要更强的模型,仍建议在豆包客户端里确认。
368
399
  10. **本插件的设置不走宿主 settings,存在自己的 JSON 文件里。**
369
400
  `~/.dsh/dsh-connect-doubao.settings.json`,由插件自己写入(先写临时文件再原子重命名),
370
401
  读取也不依赖任何宿主服务在线。原因是宿主这条路对本插件**完全关闭**:它的 fiber 永远停在
package/lib/bin.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,18 @@
1
+ import { O as classifyDoubaoFailure, f as DoubaoUpstreamError } from "./doubao-upstream-DEeYQzAG.js";
2
+ //#region src/cli-doctor.d.ts
3
+ type CheckStatus = 'ok' | 'fail' | 'skip';
4
+ interface DoctorCheck {
5
+ name: string;
6
+ status: CheckStatus;
7
+ detail: string;
8
+ data?: Record<string, unknown>;
9
+ }
10
+ interface DoctorReport {
11
+ ok: boolean;
12
+ plugin: string;
13
+ checks: DoctorCheck[];
14
+ }
15
+ /** Run every check. Never throws: a failed check is data, not a crash. */
16
+ declare function runDoctor(): Promise<DoctorReport>;
17
+ //#endregion
18
+ export { CheckStatus, DoctorCheck, DoctorReport, DoubaoUpstreamError, classifyDoubaoFailure, runDoctor };
package/lib/cli-doctor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { A as probeAccount, B as PROFILE_ENV, F as REQUIRED_COOKIE_NAMES, V as describeProfileLocation, W as listDoubaoProfiles, at as DOUBAO_PROVIDER, ht as discoverDoubaoModels, it as DOUBAO_MODELS, m as DoubaoUpstreamError, n as fetchQuota, ot as DOUBAO_PROVIDER_DISPLAY_NAME, r as findWindow, w as classifyDoubaoFailure, z as loadDoubaoCredentials } from "./doubao-quota-SO5U9prN.js";
3
- import { g as createDoubaoAdapter, i as doubaoQuotaView, m as createDoubaoShim, n as DOUBAO_QUOTA_PATH, p as liveModels, u as discoverAllCachedModels } from "./doubao-status-BAUM_fsV.js";
2
+ import { D as classifyDoubaoFailure, G as PROFILE_ENV, K as describeProfileLocation, P as probeAccount, W as loadDoubaoCredentials, Y as listDoubaoProfiles, bt as discoverDoubaoModels, dt as DOUBAO_PROVIDER_DISPLAY_NAME, i as formatDuration, lt as DOUBAO_MODELS, m as DoubaoUpstreamError, n as fetchQuota, o as millisUntilReset, r as findWindow, ut as DOUBAO_PROVIDER, z as REQUIRED_COOKIE_NAMES } from "./doubao-quota-BYF0-RMF.js";
3
+ import { g as createDoubaoAdapter, i as doubaoQuotaView, m as createDoubaoShim, n as DOUBAO_QUOTA_PATH, p as liveModels, u as discoverAllCachedModels } from "./doubao-status-DI2Rj0U_.js";
4
4
  //#region src/cli-doctor.ts
5
5
  /**
6
6
  * `doctor` — verify the whole stack end to end without spending inference quota.
@@ -124,18 +124,33 @@ async function runDoctor() {
124
124
  });
125
125
  else {
126
126
  const quota = await fetchQuota({ force: true });
127
+ const describeWindow = (window) => {
128
+ if (window === void 0) return "not offered";
129
+ if (window.startsOnUse === true && window.resetUnknown === true) return `${window.usedPercent}% used, starts counting on first use`;
130
+ if (window.resetUnknown === true) return `${window.usedPercent}% used, reset time unknown`;
131
+ return `${window.usedPercent}% used, ${formatDuration(millisUntilReset(window))} to reset`;
132
+ };
133
+ const describeGrant = (window) => {
134
+ if (window === void 0) return "none";
135
+ const amounts = window.usedAmount !== void 0 && window.totalAmount !== void 0 ? `${window.usedAmount}/${window.totalAmount}` : `${window.usedPercent}%`;
136
+ return `${window.label ?? "bonus"} ${amounts}, ${formatDuration(millisUntilReset(window))} to expiry`;
137
+ };
127
138
  const fiveHour = findWindow(quota, "five_hour");
128
139
  const weekly = findWindow(quota, "weekly");
140
+ const grant = findWindow(quota, "incentive");
129
141
  const view = await doubaoQuotaView();
130
142
  checks.push({
131
143
  name: "quota",
132
- status: quota.error === void 0 && fiveHour !== void 0 && weekly !== void 0 ? "ok" : "fail",
133
- detail: quota.error ?? `${quota.plan ?? "unknown plan"}; 5-hour=${fiveHour?.usedPercent ?? "n/a"}% weekly=${weekly?.usedPercent ?? "n/a"}%`,
144
+ status: quota.error === void 0 ? "ok" : "fail",
145
+ detail: quota.error ?? `${quota.plan ?? "unknown plan"}; 5-hour=${describeWindow(fiveHour)}; weekly=${describeWindow(weekly)}; bonus=${describeGrant(grant)}`,
134
146
  data: {
135
147
  plan: quota.plan ?? null,
136
148
  exhausted: quota.exhausted,
137
149
  fiveHourPercent: fiveHour?.usedPercent ?? null,
138
150
  weeklyPercent: weekly?.usedPercent ?? null,
151
+ grantPercent: grant?.usedPercent ?? null,
152
+ grantUsedAmount: grant?.usedAmount ?? null,
153
+ grantTotalAmount: grant?.totalAmount ?? null,
139
154
  viewHasNoSecrets: !JSON.stringify(view).match(/sessionid|sid_tt|msToken|odin_tt|ttwid|passport_csrf/i)
140
155
  }
141
156
  });
@@ -145,7 +160,7 @@ async function runDoctor() {
145
160
  checks.push({
146
161
  name: "models",
147
162
  status: offered.length > 0 ? "ok" : "fail",
148
- detail: cached.length > 0 ? `客户端缓存 ${cached.length} 个模型,开放 ${offered.length} 个(「工作任务」专属的按名字前缀排除)` : `读不到客户端缓存,回落到内置清单(${offered.length} 个)`,
163
+ detail: cached.length > 0 ? `客户端缓存 ${cached.length} 个模型,开放 ${offered.length} 个(仅排除「即将下线」等不可用条目)` : `读不到客户端缓存,回落到内置清单(${offered.length} 个)`,
149
164
  data: {
150
165
  source: cached.length > 0 ? "client-cache" : "static-fallback",
151
166
  cached: cached.map((entry) => `${entry.key}=${entry.name}${entry.taskOnly ? "[工作任务]" : ""}`),
@@ -0,0 +1,4 @@
1
+ //#region src/cli-probe.d.ts
2
+ declare function main(): Promise<number>;
3
+ //#endregion
4
+ export { main };
package/lib/cli-probe.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { A as probeAccount, V as describeProfileLocation, i as formatDuration, it as DOUBAO_MODELS, j as streamChat, m as DoubaoUpstreamError, n as fetchQuota, o as millisUntilReset, r as findWindow, vt as findDoubaoModel, yt as findReasoningLevel, z as loadDoubaoCredentials } from "./doubao-quota-SO5U9prN.js";
2
+ import { Ct as findDoubaoModel, F as streamChat, K as describeProfileLocation, P as probeAccount, W as loadDoubaoCredentials, i as formatDuration, lt as DOUBAO_MODELS, m as DoubaoUpstreamError, n as fetchQuota, o as millisUntilReset, r as findWindow, wt as findReasoningLevel } from "./doubao-quota-BYF0-RMF.js";
3
3
  //#region src/cli-probe.ts
4
4
  /**
5
5
  * Doubao smoke probe.
@@ -79,8 +79,20 @@ async function main() {
79
79
  return 3;
80
80
  }
81
81
  const quota = await fetchQuota({ force: true });
82
+ const describeWindow = (window) => {
83
+ if (window === void 0) return "not offered by this plan";
84
+ if (window.startsOnUse === true && window.resetUnknown === true) return `${window.usedPercent}% used, starts counting on first use`;
85
+ if (window.resetUnknown === true) return `${window.usedPercent}% used, reset time unknown`;
86
+ return `${window.usedPercent}% used, ${formatDuration(millisUntilReset(window))} to reset`;
87
+ };
88
+ const describeGrant = (window) => {
89
+ if (window === void 0) return "none";
90
+ const amounts = window.usedAmount !== void 0 && window.totalAmount !== void 0 ? `${window.usedAmount} / ${window.totalAmount}` : `${window.usedPercent}%`;
91
+ return `${window.label ?? "bonus"}: ${amounts} used, ${formatDuration(millisUntilReset(window))} to expiry`;
92
+ };
82
93
  const fiveHour = findWindow(quota, "five_hour");
83
94
  const weekly = findWindow(quota, "weekly");
95
+ const grant = findWindow(quota, "incentive");
84
96
  out(options.json ? {
85
97
  event: "quota",
86
98
  plan: quota.plan ?? null,
@@ -91,6 +103,13 @@ async function main() {
91
103
  remainingPercent: fiveHour.remainingPercent,
92
104
  resetsIn: formatDuration(millisUntilReset(fiveHour))
93
105
  } : null,
106
+ grant: grant ? {
107
+ label: grant.label ?? null,
108
+ usedPercent: grant.usedPercent,
109
+ usedAmount: grant.usedAmount ?? null,
110
+ totalAmount: grant.totalAmount ?? null,
111
+ resetsIn: formatDuration(millisUntilReset(grant))
112
+ } : null,
94
113
  weekly: weekly ? {
95
114
  usedPercent: weekly.usedPercent,
96
115
  remainingPercent: weekly.remainingPercent,
@@ -99,8 +118,9 @@ async function main() {
99
118
  error: quota.error ?? null
100
119
  } : [
101
120
  `quota: ${quota.plan ?? "unknown plan"}${quota.planActive === false ? " (inactive)" : ""}`,
102
- ` 5-hour limit : ${fiveHour ? `${fiveHour.usedPercent}% used, resets in ${formatDuration(millisUntilReset(fiveHour))}` : "n/a"}`,
103
- ` weekly quota : ${weekly ? `${weekly.usedPercent}% used, resets in ${formatDuration(millisUntilReset(weekly))}` : "n/a"}`
121
+ ` 5-hour limit : ${describeWindow(fiveHour)}`,
122
+ ` weekly quota : ${describeWindow(weekly)}`,
123
+ ` bonus quota : ${describeGrant(grant)}`
104
124
  ].join("\n"));
105
125
  if (quota.exhausted) {
106
126
  out(options.json ? {