dsh-plugin-subscriptions 0.5.2 → 0.6.0

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.
Files changed (60) hide show
  1. package/README.md +79 -5
  2. package/README.zh.md +78 -4
  3. package/lib/auth/rpc.d.ts +64 -13
  4. package/lib/auth/rpc.js +75 -10
  5. package/lib/auth/store.d.ts +75 -17
  6. package/lib/auth/store.js +148 -27
  7. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  8. package/lib/client/SpeedSelect.d.ts +25 -2
  9. package/lib/client/SpeedSelect.js +10 -6
  10. package/lib/client/SubscriptionsSection.d.ts +83 -3
  11. package/lib/client/SubscriptionsSection.js +411 -62
  12. package/lib/client/VideoGenerateToolview.d.ts +1 -1
  13. package/lib/client/index.d.ts +1 -9
  14. package/lib/client/index.js +7 -4
  15. package/lib/client/locales.d.ts +46 -10
  16. package/lib/client/locales.js +46 -10
  17. package/lib/client.js +703 -132
  18. package/lib/client.js.map +1 -1
  19. package/lib/compat.d.ts +36 -0
  20. package/lib/compat.js +20 -0
  21. package/lib/index.d.ts +26 -1
  22. package/lib/index.js +2377 -309
  23. package/lib/model-defaults.d.ts +23 -0
  24. package/lib/model-defaults.js +237 -0
  25. package/lib/providers/accounts.d.ts +102 -0
  26. package/lib/providers/accounts.js +123 -0
  27. package/lib/providers/claude.d.ts +46 -7
  28. package/lib/providers/claude.js +125 -34
  29. package/lib/providers/codex.d.ts +45 -3
  30. package/lib/providers/codex.js +152 -26
  31. package/lib/providers/common.d.ts +87 -6
  32. package/lib/providers/common.js +185 -22
  33. package/lib/providers/copilot.d.ts +32 -3
  34. package/lib/providers/copilot.js +111 -19
  35. package/lib/providers/grok.d.ts +45 -4
  36. package/lib/providers/grok.js +136 -20
  37. package/lib/providers/pool-family.d.ts +56 -0
  38. package/lib/providers/pool-family.js +45 -0
  39. package/lib/providers/pool-health.d.ts +74 -0
  40. package/lib/providers/pool-health.js +148 -0
  41. package/lib/providers/pool-usage.d.ts +78 -0
  42. package/lib/providers/pool-usage.js +185 -0
  43. package/lib/providers/pool.d.ts +107 -0
  44. package/lib/providers/pool.js +371 -0
  45. package/lib/providers/rate-limit.d.ts +192 -0
  46. package/lib/providers/rate-limit.js +338 -0
  47. package/lib/tools/image-generate.d.ts +3 -3
  48. package/lib/tools/image-generate.js +2 -1
  49. package/lib/tools/video-generate.d.ts +2 -2
  50. package/lib/tools/video-generate.js +2 -1
  51. package/lib/tools/x-search.d.ts +2 -2
  52. package/lib/tools/x-search.js +2 -1
  53. package/lib/translate/anthropic.js +5 -4
  54. package/lib/translate/chat-completions.js +5 -4
  55. package/lib/translate/responses.js +5 -4
  56. package/package.json +21 -21
  57. package/lib/providers/antigravity.d.ts +0 -90
  58. package/lib/providers/antigravity.js +0 -392
  59. package/lib/translate/antigravity.d.ts +0 -110
  60. package/lib/translate/antigravity.js +0 -303
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [中文](README.zh.md)
4
4
 
5
- Use your **ChatGPT (Codex)**, **Claude**, and **Grok (X Premium)** subscriptions as LLM providers in [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — no API keys. Codex and Grok log in via OAuth in the dsh web UI (Settings → Subscriptions); Claude imports credentials from an existing Claude Code session when there is one (macOS Keychain or `~/.claude/.credentials.json`) and otherwise falls back to the same browser OAuth flow, so the Claude Code CLI is not required. Tokens live at `~/.dsh/plugins/subscriptions/auth.json` (mode 0600) and refresh automatically.
5
+ Use your **ChatGPT (Codex)**, **Claude**, **Grok (X Premium)**, and **GitHub Copilot** subscriptions as LLM providers in [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — no API keys. Codex and Grok log in via OAuth in the dsh web UI (Settings → Subscriptions), while Copilot uses the GitHub OAuth device flow; Claude imports credentials from an existing Claude Code session when there is one (macOS Keychain or `~/.claude/.credentials.json`) and otherwise falls back to the same browser OAuth flow, so the Claude Code CLI is not required. Tokens live at `~/.dsh/plugins/subscriptions/auth.json` (mode 0600) and refresh automatically.
6
6
 
7
7
  ## Demo
8
8
 
@@ -106,19 +106,32 @@ Either way, restart `dsh web` afterwards so the new version loads.
106
106
  ## Use
107
107
 
108
108
  1. `dsh web`, open the printed URL.
109
- 2. Settings → **Subscriptions**: click **Connect** on a provider. For Claude, credentials are imported instantly if you have run `claude` and logged in at least once; without them, Claude authorizes in the browser like the others. For Codex and Grok, authorize in the opened browser tab; if the browser flow can't complete (headless host), expand the manual fallback and paste the callback URL or code.
110
- 3. In any session, open the model picker (`/model`) and choose a model under **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)**.
109
+ 2. Settings → **Subscriptions**: click **Connect** on a provider. For Claude, credentials are imported instantly if you have run `claude` and logged in at least once; without them, Claude authorizes in the browser like the others. For Codex and Grok, authorize in the opened browser tab; Copilot shows a GitHub device code to enter at `github.com/login/device`; if a browser flow can't complete (headless host), expand the manual fallback and paste the callback URL or code.
110
+ 3. In any session, open the model picker (`/model`) and choose a model under **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)** / **GitHub Copilot**.
111
111
 
112
112
  Not logged in? The provider stays out of the picker, and requests fail with `MISSING_CREDENTIAL` pointing at the Settings page; nothing else breaks.
113
113
 
114
+ ### Multiple accounts
115
+
116
+ Every provider accepts several accounts: once one is connected, the card grows an **Add account** button (Claude offers **Browser authorization** and **Import Claude Code** separately). Accounts are keyed by their identity (email / login) — re-logging the same account updates it in place, a different account appends. Browser authorization signs in whichever account the browser currently uses, so switch accounts there first (or use an incognito window with the manual code) to add a different one. The ★ default account serves the direct provider routes; pool routes use every account. A Claude account imported from Claude Code stays synced with the CLI's credential store; OAuth-added Claude accounts refresh standalone so several accounts never fight over the Keychain entry.
117
+
118
+ ### Default reasoning effort per model
119
+
120
+ Every logged-in provider card in Settings → Subscriptions carries a collapsible **Default reasoning effort** section. It starts collapsed — the header shows how many models advertise reasoning levels and how many you have overridden — and the model list (with its live catalog lookup) loads only once you expand it, so a provider with dozens of models does not stretch the page or make it pay for a lookup nobody asked for. Expanded, each model that advertises reasoning levels gets a row whose options are the levels that provider's live catalog advertises for that exact model; past 8 such models the section also offers a name filter, and models without reasoning levels collapse into a single count line instead of one dead row each. With several accounts connected, the model list is the union across that provider's accounts, so a model any account advertises gets a row; the levels offered for it come from the first account whose catalog lists it (the ★ default account first), matching what the session picker resolves.
121
+
122
+ Pick a level to make the session model picker preselect it whenever you switch to the model — no more settling for the provider's own default (e.g. Claude shows `Default`, Codex models follow `default_reasoning_level`). Choose **Follow provider** to clear the override. The choice is stored in `~/.dsh/plugins/subscriptions/model-defaults.json` (mode 0600) and survives restarts.
123
+
114
124
  ## Config
115
125
 
116
126
  ```yaml
117
127
  - id: llm-subscriptions
118
128
  name: dsh-plugin-subscriptions
119
129
  config:
120
- providers: [codex, claude] # subset; default all three
130
+ providers: [codex, claude] # subset; default all four
121
131
  streamIdleTimeoutMs: 300000
132
+ rateLimit:
133
+ wait: true # wait out a closed rate-limit window (default)
134
+ maxWaitMs: 21600000 # ceiling on one wait; 6 h, covers a 5-hour session window
122
135
  models: # override the discovered/built-in catalogs
123
136
  codex:
124
137
  - { id: gpt-5.6-sol, name: GPT-5.6 Sol, contextWindow: 272000, inputModalities: [text, image] }
@@ -132,6 +145,67 @@ catalog does not know would otherwise default to `/chat/completions`, which
132
145
  responses-only families (gpt-5.5/5.6, …) reject. Pinning `chat-completions` also opts
133
146
  out of the tools+effort auto-reroute described above.
134
147
 
148
+ ## Model pools
149
+
150
+ When a provider has **two or more logged-in accounts**, the picker shows the **union** of every account's catalog (duplicates dropped). Pick `claude-sonnet-5` under Claude (or `gpt-5.4` under ChatGPT) as usual — there is no extra pool group and no new model id.
151
+
152
+ - **Shared models.** A model listed by ≥2 accounts failovers between them (sticky, quota-aware). Each account is discovered separately, so a Plus login is not asked to serve a Pro-only model.
153
+ - **Account-only models.** A model listed by only one account is sent to that account. It still appears in the picker even if that account is not the default.
154
+ - **Explicit account lists (`families`).** Replace the auto member list for one catalog model (same provider only; cross-provider members are ignored). Pin `account` or omit it for the default.
155
+ - **Tier extras (`tiers`, optional).** Extra picker rows with heterogeneous fallbacks, listed under the first member's provider. Not created automatically.
156
+
157
+ Selection is sticky per session (prompt caches survive) with two strategies: `priority` (first healthy member wins) and `quota_aware` (the default — each member is scored by its required burn rate, `remaining quota / time until window reset`, so a window about to reset with plenty left gets spent instead of wasted; the sticky member holds until a challenger out-scores it by `switchMargin`). Members past 95% on any usage window are gated out; failures fail over before the first stream chunk with cooldowns (`retry-after`, or the window's own disclosed reset when the provider sends one) — quota and rate-limit failures cool the whole account down (its quota is account-level; Claude's model-scoped lanes cool per member), transient server failures cool only the failing member. Copilot exposes no usage telemetry, so it scores zero and naturally serves as the fallback of last resort.
158
+
159
+ ```yaml
160
+ - id: llm-subscriptions
161
+ name: dsh-plugin-subscriptions
162
+ config:
163
+ pool:
164
+ enabled: true # default; needs ≥2 accounts of one provider
165
+ strategy: quota_aware # or priority
166
+ switchMargin: 2 # hysteresis factor for quota_aware
167
+ autoAccounts: true # pool each catalog model across that provider's accounts
168
+ families: # explicit account list for one catalog model (same provider)
169
+ claude-sonnet-5:
170
+ - { provider: claude, model: claude-sonnet-5 } # default account
171
+ - { provider: claude, account: bob@example.com, model: claude-sonnet-5 }
172
+ tiers: # optional extra picker rows
173
+ smart:
174
+ - { provider: claude, model: claude-sonnet-5 }
175
+ - { provider: codex, model: gpt-5.6-sol }
176
+ - { provider: grok, model: grok-4.6 }
177
+ ```
178
+
179
+ ### Waiting out a rate-limit window
180
+
181
+ A subscription plan is rate-limit shaped by design — a 5-hour session window, a weekly one, and on some plans a per-model weekly one — so a 429 is not a dead end: the window reopens at a time the provider discloses. Each route reads that reset off its own 429 and turns it into that account's pool cooldown (see Model pools above) instead of a fixed 5-minute guess.
182
+
183
+ Only a signal that names the window which actually rejected the request is read: Anthropic's `anthropic-ratelimit-unified-reset`, the seconds Codex puts on a `usage_limit_reached` rejection, the delay xAI names in the error body, or a plain `retry-after`. The per-bucket rollover snapshots (`anthropic-ratelimit-{requests,tokens,…}-reset`, `x-codex-*-reset-after-seconds`, `x-ratelimit-reset-*`) ride every response and cannot say which bucket refused — the earliest is usually one that still had room — so a 429 carrying nothing else is logged through the plugin's warning sink, naming the headers and the head of the body, rather than parking the turn (or the pool cooldown) on a guess.
184
+
185
+ Reading is confined to a 429. Every other failure keeps its short local backoff: those same headers ride a transient 500 too, and honouring them there would hold a turn for the rest of the window over an overload that clears in a second.
186
+
187
+ With a pool, this is what actually does the waiting: a 429'd account is parked until its own disclosed reset and the request fails over to another account of the same provider immediately — no wait, no lost turn. Only once **every** account (the whole pool) is cooling down does the adapter report a `RATE_LIMIT` carrying the pool's *earliest* reset as the wait to take. With a single account (no pool, or a provider with only one login), that same disclosed reset is reported directly.
188
+
189
+ Waiting on that reported delay is executed by [`@deepseek-ai/dsh-llm-retry`](https://www.npmjs.com/package/@deepseek-ai/dsh-llm-retry), which every route's retry policy is written for: add it to the composition, or nothing waits and a closed window fails the turn as before (falling back to whichever other pool accounts are healthy, if any).
190
+
191
+ ```yaml
192
+ - name: '@deepseek-ai/dsh-llm-retry'
193
+ ```
194
+
195
+ ```yaml
196
+ - name: dsh-plugin-subscriptions
197
+ config:
198
+ rateLimit:
199
+ wait: true # default; false keeps the previous seconds-scale behaviour
200
+ maxWaitMs: 21600000 # 6 h — covers a 5-hour session window with slack
201
+ ```
202
+
203
+ A reset further out than `maxWaitMs` — a weekly window days away, or a whole pool cooling down past it — fails the turn immediately with the reset time attached, rather than parking the session for days. `wait: false` drops back to local backoff alone.
204
+
205
+ All four routes share Claude Code's own retry shape: ten retries after the first attempt, backing off from 1 s with 20% jitter under a 60 s cap. These are consumer subscription endpoints that shed load in bursts, and the dsh-llm defaults (five retries from 500 ms to 10 s) give up after about fifteen seconds, which is short for that. A 429 that discloses no reset is now retried locally for roughly 17 minutes before the turn fails — about 5 minutes with `wait: false`, where the 60 s cap actually binds. Copilot currently uses the generic `retry-after` signal; unrecognized GitHub rate-limit headers are surfaced through the plugin warning sink for a future provider-specific reader.
206
+
207
+ One trade-off worth knowing: the delay ceiling is shared with that local backoff, so raising `maxWaitMs` also raises how long an unrelated transient failure (`TRANSPORT`, `SERVER`, `TIMEOUT`) can back off for before the finite retry budget runs out — up to 512 s on the last of the ten retries instead of the 60 s cap.
208
+
135
209
  ## Proxy
136
210
 
137
211
  Every subscription request — token exchanges, model-API streams, usage lookups, model discovery, and the `x_search` / `image_generate` / `video_generate` tools — can be routed through an HTTP(S) proxy. Configure it in **Settings → Subscriptions → Proxy → Configure…**: enable the flag, enter the proxy URL (`http://127.0.0.1:7890`), optional username/password, and an optional comma-separated bypass list of hostnames that stay direct (`127.0.0.1`, `localhost`, `*.example.com`). The password is stored in `~/.dsh/plugins/subscriptions/proxy.json` (mode 0600) and is never returned to the browser. A "Test" button probes one endpoint through the current configuration and shows the HTTP status/latency.
@@ -154,7 +228,7 @@ After `pnpm build`, restart `dsh web` to pick up changes.
154
228
 
155
229
  - `src/index.ts` — plugin entry: config schema, adapter registration, auth-change re-announce, RPC wiring
156
230
  - `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), Claude Code credential reader (Keychain/file), `/subscriptions-auth` RPC channel
157
- - `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s
231
+ - `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s, multi-account token plumbing (`accounts.ts`), the pool (`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`), and `rate-limit.ts` (reset-instant parsing + retry policy)
158
232
  - `src/translate/` — dsh `Message[]` ⟷ OpenAI Responses / Anthropic Messages wire formats, SSE → `StreamChunk`
159
233
  - `src/tools/` — `x_search`, `image_generate`, and `video_generate`
160
234
  - `src/client/` — the Settings → Subscriptions page (browser half, zh/en, theme-token aware)
package/README.zh.md CHANGED
@@ -106,19 +106,32 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
106
106
  ## 使用
107
107
 
108
108
  1. `dsh web`,打开打印的 URL。
109
- 2. **设置 → 订阅**:点对应 provider 的「连接」。若先运行过 `claude` 并登录,Claude 会即时导入凭据;没有凭据时,Claude 也和其他 provider 一样在浏览器里授权。Codex 和 Grok 在打开的标签页里授权;无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
110
- 3. 在任意会话里打开模型选择器(`/model`),选择 **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)** 下的模型。
109
+ 2. **设置 → 订阅**:点对应 provider 的「连接」。若先运行过 `claude` 并登录,Claude 会即时导入凭据;没有凭据时,Claude 也和其他 provider 一样在浏览器里授权。Codex 和 Grok 在打开的标签页里授权;Copilot 会显示 GitHub 设备码,需在 `github.com/login/device` 输入;无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
110
+ 3. 在任意会话里打开模型选择器(`/model`),选择 **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)** / **GitHub Copilot** 下的模型。
111
111
 
112
112
  未登录时:该 provider 不出现在选择器里;直接请求会报 `MISSING_CREDENTIAL` 并提示去设置页登录,不影响其他功能。
113
113
 
114
+ ### 多账号
115
+
116
+ 每个 provider 可以登录多个账号:连上第一个之后,卡片会出现「添加账号」按钮(Claude 拆分为「浏览器授权」和「导入 Claude Code」两种)。账号按身份(邮箱/用户名)归档——重复登录同一账号是覆盖更新,不同账号才是新增。浏览器授权以浏览器当前登录的账号为准,要添加不同账号请先在浏览器切换账号,或用无痕窗口走手动授权码。★ 默认账号服务直连路由;池路由会使用所有账号。从 Claude Code 导入的 Claude 账号会与 CLI 的凭据存储保持同步;OAuth 添加的 Claude 账号独立刷新,多个账号不会互相覆盖 Keychain。
117
+
118
+ ### 按模型的默认推理档
119
+
120
+ **设置 → 订阅**里每个已登录 provider 卡片都有一个可折叠的**默认推理档**区块。默认收起,标题栏直接给出「多少个模型声明了推理档 / 已覆盖多少个」;模型列表(以及它背后的 live 目录查询)只在展开时才加载 —— 这样模型数量很多的 provider(Copilot 动辄几十个)既不会把页面撑长,也不会白跑一次目录查询。展开后,凡声明了推理档的模型各占一行,可选档位就是该 provider live 目录为这个模型声明的档位;此类模型超过 8 个时区块还会给出一个名称筛选框;没有推理档的模型不再一行一条占位,而是合并成一行计数说明。登录了多个账号时,模型列表是该 provider 各账号目录的并集 —— 任一账号声明的模型都会出现;而某个模型可选的档位取自**第一个列出它的账号**(★ 默认账号优先),与会话选择器解析到的一致。
121
+
122
+ 选中某档后,会话模型选择器在切换到该模型时会自动预选该档位,不必再接受 provider 自己的默认值(例如 Claude 只显示 `Default`,Codex 模型跟随 `default_reasoning_level`)。选择「跟随服务商」可清除覆盖。配置存于 `~/.dsh/plugins/subscriptions/model-defaults.json`(权限 0600),重启后依然生效。
123
+
114
124
  ## 配置
115
125
 
116
126
  ```yaml
117
127
  - id: llm-subscriptions
118
128
  name: dsh-plugin-subscriptions
119
129
  config:
120
- providers: [codex, claude] # 子集;默认三个全启用
130
+ providers: [codex, claude] # 子集;默认四个全启用
121
131
  streamIdleTimeoutMs: 300000
132
+ rateLimit:
133
+ wait: true # 等待限流窗口重开(默认开启)
134
+ maxWaitMs: 21600000 # 单次等待上限;6 小时,足够覆盖 5 小时会话窗口
122
135
  models: # 覆盖实时发现/内置目录
123
136
  codex:
124
137
  - { id: gpt-5.6-sol, name: GPT-5.6 Sol, contextWindow: 272000, inputModalities: [text, image] }
@@ -131,6 +144,67 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
131
144
  responses-only 系列(gpt-5.5/5.6 等)会拒绝该端点。固定为 `chat-completions` 也会退出上文所述
132
145
  tools+effort 的自动改道。
133
146
 
147
+ ## 模型池
148
+
149
+ 同一订阅下登录了**两个及以上账号**时,选择器显示该 provider **所有账号目录的并集**(按模型 id 去重)。照常在 Claude 组选 `claude-sonnet-5`、在 ChatGPT 组选 `gpt-5.4`——不会多出一个池分组,也不会换 model id。
150
+
151
+ - **共有模型**:至少两个账号的目录都列出的模型,在这些账号之间 failover(粘性、可按配额调度)。每个账号各自做一次目录发现,Plus 不会被拿去打 Pro 才有的模型。
152
+ - **单账号模型**:只有一个账号目录里有的模型,请求就打到那个账号。即使它不是默认账号,选择器里也会出现。
153
+ - **显式账号列表(`families`)**:覆盖某个目录模型的自动成员(仅同一 provider;跨 provider 的成员会被忽略)。可钉 `account`,省略则用默认账号。
154
+ - **档位额外项(`tiers`,可选)**:额外的选择器条目,failover 可以跨模型;出现在首个成员所在的 provider 分组。不会自动创建。
155
+
156
+ 成员选择按会话粘性(prompt 缓存不失效),两种策略:`priority`(按顺序取第一个健康成员)和 `quota_aware`(默认——按"必需消耗速率 = 剩余配额 / 距重置时间"给成员打分,快重置且剩余多的窗口优先被用掉而不是浪费;粘性成员除非被挑战者以 `switchMargin` 倍分差击败否则不换)。任一用量窗口超过 95% 的成员会被硬门槛挡下;首个流式 chunk 之前的失败会记冷却并切换下一家(provider 给了 `retry-after` 就用它)——配额与认证类失败按整个账号冷却(配额是账号级的;Claude 的分模型窗口则只冷却出错成员),瞬时服务端失败只冷却出错成员。Copilot 没有用量接口,恒为 0 分,自然充当最后的保底。
157
+
158
+ ```yaml
159
+ - id: llm-subscriptions
160
+ name: dsh-plugin-subscriptions
161
+ config:
162
+ pool:
163
+ enabled: true # 默认开;需同一 provider ≥2 个账号
164
+ strategy: quota_aware # 或 priority
165
+ switchMargin: 2 # quota_aware 的滞后切换倍率
166
+ autoAccounts: true # 把该 provider 各账号自动池到每个目录模型
167
+ families: # 某个目录模型的显式账号列表(同一 provider)
168
+ claude-sonnet-5:
169
+ - { provider: claude, model: claude-sonnet-5 } # 默认账号
170
+ - { provider: claude, account: bob@example.com, model: claude-sonnet-5 }
171
+ tiers: # 可选的额外选择器条目
172
+ smart:
173
+ - { provider: claude, model: claude-sonnet-5 }
174
+ - { provider: codex, model: gpt-5.6-sol }
175
+ - { provider: grok, model: grok-4.6 }
176
+ ```
177
+
178
+ ### 等待限流窗口
179
+
180
+ 订阅套餐天然是按限流窗口计费的 —— 5 小时会话窗口、周窗口,部分套餐还有按模型的周窗口 —— 所以 429 并不是终点:窗口会在 provider 自己告知的时刻重开。每条路由从自己的 429 里读出这个时刻,把它变成该账号在模型池里的冷却时长(见上文「模型池」),而不是固定猜测的 5 分钟。
181
+
182
+ 只有能指明「是哪个窗口拒绝了这次请求」的信号才会被读取:Anthropic 的 `anthropic-ratelimit-unified-reset`、Codex 在 `usage_limit_reached` 上给出的秒数、xAI 在错误体里给出的延迟,或通用的 `retry-after`。各分桶的滚动快照(`anthropic-ratelimit-{requests,tokens,…}-reset`、`x-codex-*-reset-after-seconds`、`x-ratelimit-reset-*`)每个响应上都有,说不出是哪个桶拒绝的 —— 其中最早的那个往往正是还有余量的桶 —— 所以只带这些的 429 会通过插件的告警回调打印出相关 header 与响应体开头,而不是照着猜测把本轮(或池冷却)挂起。
183
+
184
+ 读取只发生在 429 上。其他失败仍走各自的短本地退避:同样这些 header 也会出现在瞬时 500 上,在那里照办等于为一次一秒就恢复的过载把本轮挂满整个窗口。
185
+
186
+ 有模型池时,真正在做等待这件事的其实是账号 failover:某个账号 429 了就按它自己披露的重开时刻冷却下来,请求立刻切到同 provider 的下一个账号 —— 不等待,也不丢本轮对话。只有**整个池**(所有账号)都在冷却时,adapter 才会上报一个 `RATE_LIMIT`,携带池里**最早**的重开时刻作为应等待的时长。单账号(没配池,或该 provider 只登了一个号)时,同样的披露时刻会被直接上报。
187
+
188
+ 真正执行这段等待的是 [`@deepseek-ai/dsh-llm-retry`](https://www.npmjs.com/package/@deepseek-ai/dsh-llm-retry),四条路由的重试策略都是为它写的:把它加进编排,否则不会有任何等待,关闭的窗口仍旧直接让本轮失败(如果池里还有别的健康账号,会先 failover 过去)。Copilot 当前使用通用的 `retry-after` 信号;GitHub 未识别的限流 header 会通过插件告警回调暴露出来,后续再添加 provider 专用解析器。
189
+
190
+ ```yaml
191
+ - name: '@deepseek-ai/dsh-llm-retry'
192
+ ```
193
+
194
+ ```yaml
195
+ - name: dsh-plugin-subscriptions
196
+ config:
197
+ rateLimit:
198
+ wait: true # 默认;false 恢复此前秒级的行为
199
+ maxWaitMs: 21600000 # 6 小时 —— 留有余量地覆盖 5 小时会话窗口
200
+ ```
201
+
202
+ 重开时刻超过 `maxWaitMs`(比如几天后才重置的周窗口,或者整个池的冷却时间超过这个上限)会立即失败并带上重开时刻,而不是把会话挂上好几天。`wait: false` 则只保留本地退避。
203
+
204
+ 四条路由共用 Claude Code 自己的重试形状:首次尝试之后重试 10 次,从 1 秒开始退避,带 20% 抖动,上限 60 秒。这些都是面向消费者的订阅端点,过载时按突发丢流量,而 dsh-llm 默认值(5 次重试,500 毫秒到 10 秒)约 15 秒就放弃,对这种场景偏短。没有给出重开时刻的 429 现在会本地重试约 17 分钟才让本轮失败 —— `wait: false` 下约 5 分钟,那时 60 秒上限才真正生效。
205
+
206
+ 一个需要知道的取舍:延迟上限与这份本地退避共用,调高 `maxWaitMs` 同时也抬高了无关瞬时失败(`TRANSPORT`、`SERVER`、`TIMEOUT`)在有限重试预算耗尽前的退避时长 —— 第 10 次重试最长会从 60 秒上限变成 512 秒。
207
+
134
208
  ## 代理
135
209
 
136
210
  所有订阅相关请求 —— token 交换、模型 API 流式调用、用量查询、模型目录发现,以及 `x_search` / `image_generate` / `video_generate` 工具 —— 都可以通过 HTTP(S) 代理发出。在 **设置 → 订阅 → 代理 → 配置…** 中设置:勾选启用,填写代理地址(`http://127.0.0.1:7890`)、可选用户名/密码,以及可选的逗号分隔绕过列表(保持直连的主机名,如 `127.0.0.1`、`localhost`、`*.example.com`)。密码保存在 `~/.dsh/plugins/subscriptions/proxy.json`(权限 0600),不会回传给浏览器;「测试」按钮会用当前配置探测一次端点,显示 HTTP 状态码与耗时。
@@ -153,7 +227,7 @@ pnpm test # 编译后跑 node --test 单测
153
227
 
154
228
  - `src/index.ts` —— 插件入口:配置 schema、adapter 注册、登录态变更通告、RPC 接线
155
229
  - `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` RPC 通道
156
- - `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现
230
+ - `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现,多账号 token 管理(`accounts.ts`),模型池(`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`),以及 `rate-limit.ts`(限流重开时刻解析 + 重试策略)
157
231
  - `src/translate/` —— dsh `Message[]` 与 OpenAI Responses / Anthropic Messages 格式互转,SSE → `StreamChunk`
158
232
  - `src/tools/` —— `x_search`、`image_generate` 与 `video_generate`
159
233
  - `src/client/` —— 设置 → 订阅页面(浏览器面,中英文,跟随明暗主题)
package/lib/auth/rpc.d.ts CHANGED
@@ -37,19 +37,30 @@ export interface SpeedController {
37
37
  /** Set one session's speed tier. */
38
38
  setSpeed(sessionId: string, tier: SpeedTier): Promise<void>;
39
39
  }
40
+ /** One logged-in account, as rendered by the Settings page. */
41
+ export interface AccountStatus {
42
+ /** Stable account key (store identity). */
43
+ key: string;
44
+ /** Display identity (email / login), when known. */
45
+ account?: string;
46
+ /** Epoch milliseconds at which the stored access token expires. */
47
+ expiresAt?: number;
48
+ /** Plan name the session carries (codex planType / claude subscriptionType), when known. */
49
+ plan?: string;
50
+ /** Whether direct (non-pool) routes serve this account. */
51
+ isDefault: boolean;
52
+ }
40
53
  /** Login state of one provider, as rendered by the Settings page. */
41
54
  export interface ProviderStatus {
42
- /** Whether a session exists in the store. */
43
- loggedIn: boolean;
44
55
  /** Whether a login attempt is currently waiting for its code. */
45
56
  busy: boolean;
46
- /** Epoch milliseconds at which the stored access token expires. */
47
- expiresAt?: number;
48
- /** Account email or account id, when known. */
49
- account?: string;
50
- /** Subscription detail (plan) or the last login error. */
57
+ /** Logged-in accounts, default first. */
58
+ accounts: AccountStatus[];
59
+ /** The last login error, shown until the next success. */
51
60
  detail?: string;
52
61
  }
62
+ /** How a Claude login should acquire credentials (other providers ignore it). */
63
+ export type LoginMethod = 'oauth' | 'keychain';
53
64
  /** Proxy config operations behind the `proxyGet/proxySet/proxyTest` endpoints. */
54
65
  export interface ProxyConfigController {
55
66
  /** Current proxy configuration (secrets omitted). */
@@ -62,17 +73,49 @@ export interface ProxyConfigController {
62
73
  proxy?: ProxyDraft;
63
74
  }): Promise<ProxyTestResult>;
64
75
  }
76
+ /** One model's default-effort picker state, as rendered by the Settings page. */
77
+ export interface ModelDefaultView {
78
+ /** Wire model id. */
79
+ id: string;
80
+ /** Human-readable display name. */
81
+ name: string;
82
+ /** Advertised effort levels, in catalog order (empty when the model has no reasoning). */
83
+ efforts: {
84
+ id: string;
85
+ name: string;
86
+ }[];
87
+ /** The user-configured default effort, when set. */
88
+ configured?: string;
89
+ }
90
+ /** One provider's default-effort picker state. */
91
+ export interface ModelDefaultsCatalog {
92
+ /** The subscription provider route. */
93
+ provider: ProviderId;
94
+ /** Models the picker can configure, in catalog order. */
95
+ models: ModelDefaultView[];
96
+ }
97
+ /** Default-effort picker operations behind the `modelDefaults/setModelDefault` endpoints. */
98
+ export interface ModelDefaultsController {
99
+ /** Per-provider picker state for the Settings page. */
100
+ catalog(): Promise<ModelDefaultsCatalog[]>;
101
+ /** Set one model's configured default effort; undefined clears the override. */
102
+ set(provider: ProviderId, model: string, effort: string | undefined): Promise<void>;
103
+ }
65
104
  /** Provider-agnostic auth operations the RPC handler delegates to. */
66
105
  export interface AuthController {
67
106
  /** Current status of one provider. */
68
107
  status(provider: ProviderId): Promise<ProviderStatus>;
69
108
  /**
70
109
  * Start a background login attempt.
110
+ * @param provider - the provider route.
111
+ * @param method - Claude only: force the OAuth browser flow or the Claude
112
+ * Code credential import; omitted keeps the auto behavior (import when
113
+ * available, else OAuth).
71
114
  * @returns the authorize URL for the user's browser; device-flow providers
72
115
  * (copilot) also return the `userCode` the user types at that URL.
73
116
  * @throws when an attempt is already running for this provider.
74
117
  */
75
- login(provider: ProviderId): Promise<{
118
+ login(provider: ProviderId, method?: LoginMethod): Promise<{
76
119
  authorizeUrl: string;
77
120
  userCode?: string;
78
121
  }>;
@@ -83,15 +126,19 @@ export interface AuthController {
83
126
  manual(provider: ProviderId, input: string): Promise<void>;
84
127
  /** Abort the pending attempt; a no-op when none is pending. */
85
128
  cancel(provider: ProviderId): Promise<void>;
86
- /** Delete the stored session. */
87
- logout(provider: ProviderId): Promise<void>;
129
+ /** Delete one account's stored session. */
130
+ logout(provider: ProviderId, account: string): Promise<void>;
131
+ /** Pin the account direct (non-pool) routes serve. */
132
+ setDefault(provider: ProviderId, account: string): Promise<void>;
88
133
  /**
89
- * Current subscription usage of one provider.
134
+ * Current subscription usage of one account.
90
135
  * @param signal - caller cancellation from the RPC transport.
136
+ * @param force - bypass a fresh cached snapshot for an honest re-check
137
+ * (the manual Refresh button); a live failure cooldown still applies.
91
138
  * @returns `{ supported: false }` when the provider has no usage endpoint.
92
139
  * @throws when logged out or the usage lookup fails.
93
140
  */
94
- usage(provider: ProviderId, signal: AbortSignal): Promise<ProviderUsage>;
141
+ usage(provider: ProviderId, account: string, signal: AbortSignal, force?: boolean): Promise<ProviderUsage>;
95
142
  /**
96
143
  * Read one image attachment's bytes for inline display.
97
144
  * @param ref - the full durable reference (`readImage` verifies against it).
@@ -110,11 +157,15 @@ export interface AuthController {
110
157
  */
111
158
  readVideo(name: string, signal: AbortSignal): Promise<VideoBytesResult>;
112
159
  }
160
+ /** Payload carried no usable provider id — an RPC client bug, not a server failure. */
161
+ export declare class BadRequest extends Error {
162
+ }
113
163
  /**
114
164
  * Register the `/subscriptions-auth` RPC channel when a host connection exists.
115
165
  * @param ctx - the plugin context (headless profiles have no `connection`).
116
166
  * @param controller - the auth operations backing the endpoints.
117
167
  * @param speed - the per-session speed-tier state backing the Speed toggle.
118
168
  * @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
169
+ * @param modelDefaults - optional per-model default-effort state backing `modelDefaults`/`setModelDefault`.
119
170
  */
120
- export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController, proxy?: ProxyConfigController | undefined): void;
171
+ export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController, proxy?: ProxyConfigController | undefined, modelDefaults?: ModelDefaultsController | undefined): void;
package/lib/auth/rpc.js CHANGED
@@ -13,7 +13,7 @@ const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
13
13
  /** Bare MP4 file names the `video` endpoint accepts (no path separators). */
14
14
  const VIDEO_NAME_PATTERN = /^[\w.-]+\.mp4$/;
15
15
  /** Payload carried no usable provider id — an RPC client bug, not a server failure. */
16
- class BadRequest extends Error {
16
+ export class BadRequest extends Error {
17
17
  }
18
18
  function ok(value) {
19
19
  return { ok: true, value };
@@ -42,6 +42,36 @@ function readString(payload, field) {
42
42
  }
43
43
  return value;
44
44
  }
45
+ /** Validate the `setModelDefault` endpoint's payload. */
46
+ function readModelDefaultInput(payload) {
47
+ const provider = readProvider(payload);
48
+ const model = readString(payload, 'model');
49
+ const record = payload;
50
+ let effort;
51
+ if (record.effort !== undefined) {
52
+ if (typeof record.effort !== 'string' || record.effort.length === 0) {
53
+ throw new BadRequest('payload.effort must be a non-empty string when present');
54
+ }
55
+ effort = record.effort;
56
+ }
57
+ return {
58
+ provider,
59
+ model,
60
+ ...(effort === undefined ? {} : { effort }),
61
+ };
62
+ }
63
+ /** Validate the optional Claude login method. */
64
+ function readLoginMethod(payload, provider) {
65
+ const method = payload.method;
66
+ if (method === undefined)
67
+ return undefined;
68
+ if (provider !== 'claude')
69
+ throw new BadRequest('payload.method is only valid for claude');
70
+ if (method !== 'oauth' && method !== 'keychain') {
71
+ throw new BadRequest('payload.method must be "oauth" or "keychain"');
72
+ }
73
+ return method;
74
+ }
45
75
  /** Validate the `setSpeed` endpoint's tier. */
46
76
  function readSpeedTier(payload) {
47
77
  const tier = payload.tier;
@@ -96,6 +126,17 @@ function readVideoName(payload) {
96
126
  }
97
127
  return name;
98
128
  }
129
+ /** Validate the `usage` endpoint's optional force flag. */
130
+ function readForce(payload) {
131
+ if (typeof payload !== 'object' || payload === null)
132
+ return false;
133
+ const force = payload.force;
134
+ if (force === undefined)
135
+ return false;
136
+ if (typeof force !== 'boolean')
137
+ throw new BadRequest('payload.force must be a boolean when present');
138
+ return force;
139
+ }
99
140
  /** Validate the session id both speed endpoints carry. */
100
141
  function readSessionId(payload) {
101
142
  if (typeof payload !== 'object' || payload === null)
@@ -184,14 +225,16 @@ function readProxyTestPayload(payload) {
184
225
  ...proxy === undefined ? {} : { proxy },
185
226
  };
186
227
  }
187
- async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
228
+ async function dispatch(controller, speed, proxy, modelDefaults, endpoint, payload, signal) {
188
229
  switch (endpoint) {
189
230
  case 'status': {
190
231
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
191
232
  return ok({ providers: Object.fromEntries(entries) });
192
233
  }
193
- case 'login':
194
- return ok(await controller.login(readProvider(payload)));
234
+ case 'login': {
235
+ const provider = readProvider(payload);
236
+ return ok(await controller.login(provider, readLoginMethod(payload, provider)));
237
+ }
195
238
  case 'manual': {
196
239
  const provider = readProvider(payload);
197
240
  await controller.manual(provider, readString(payload, 'input'));
@@ -200,11 +243,20 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
200
243
  case 'cancel':
201
244
  await controller.cancel(readProvider(payload));
202
245
  return ok({ ok: true });
203
- case 'logout':
204
- await controller.logout(readProvider(payload));
246
+ case 'logout': {
247
+ const provider = readProvider(payload);
248
+ await controller.logout(provider, readString(payload, 'account'));
249
+ return ok({ ok: true });
250
+ }
251
+ case 'setDefault': {
252
+ const provider = readProvider(payload);
253
+ await controller.setDefault(provider, readString(payload, 'account'));
205
254
  return ok({ ok: true });
206
- case 'usage':
207
- return ok(await controller.usage(readProvider(payload), signal));
255
+ }
256
+ case 'usage': {
257
+ const provider = readProvider(payload);
258
+ return ok(await controller.usage(provider, readString(payload, 'account'), signal, readForce(payload)));
259
+ }
208
260
  case 'image':
209
261
  return ok(await controller.readImage(readImageRef(payload), signal));
210
262
  case 'video':
@@ -226,6 +278,18 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
226
278
  if (proxy === undefined)
227
279
  throw new BadRequest('proxy configuration is unavailable');
228
280
  return ok(await proxy.test(readProxyTestPayload(payload)));
281
+ case 'modelDefaults':
282
+ if (modelDefaults === undefined)
283
+ throw new BadRequest('model defaults are unavailable');
284
+ return ok(await modelDefaults.catalog());
285
+ case 'setModelDefault':
286
+ if (modelDefaults === undefined)
287
+ throw new BadRequest('model defaults are unavailable');
288
+ {
289
+ const input = readModelDefaultInput(payload);
290
+ await modelDefaults.set(input.provider, input.model, input.effort);
291
+ }
292
+ return ok({ ok: true });
229
293
  default:
230
294
  throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
231
295
  }
@@ -236,8 +300,9 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
236
300
  * @param controller - the auth operations backing the endpoints.
237
301
  * @param speed - the per-session speed-tier state backing the Speed toggle.
238
302
  * @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
303
+ * @param modelDefaults - optional per-model default-effort state backing `modelDefaults`/`setModelDefault`.
239
304
  */
240
- export function registerAuthRpc(ctx, controller, speed, proxy = undefined) {
305
+ export function registerAuthRpc(ctx, controller, speed, proxy = undefined, modelDefaults = undefined) {
241
306
  // `connection` is not in this plugin's inject list (headless compositions
242
307
  // lack it), so its startup order is unconstrained: defer registration until
243
308
  // the service exists instead of probing once at apply time.
@@ -245,7 +310,7 @@ export function registerAuthRpc(ctx, controller, speed, proxy = undefined) {
245
310
  const connection = ctx.get('connection');
246
311
  ctx.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
247
312
  try {
248
- return await dispatch(controller, speed, proxy, endpoint, payload, signal);
313
+ return await dispatch(controller, speed, proxy, modelDefaults, endpoint, payload, signal);
249
314
  }
250
315
  catch (error) {
251
316
  return failure(error);