dsh-plugin-subscriptions 0.5.2 → 0.5.3
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/README.md +36 -1
- package/README.zh.md +36 -1
- package/lib/auth/rpc.d.ts +29 -12
- package/lib/auth/rpc.js +29 -6
- package/lib/auth/store.d.ts +75 -17
- package/lib/auth/store.js +148 -27
- package/lib/client/SubscriptionsSection.d.ts +9 -3
- package/lib/client/SubscriptionsSection.js +93 -65
- package/lib/client/locales.d.ts +18 -10
- package/lib/client/locales.js +18 -10
- package/lib/client.js +250 -127
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +21 -0
- package/lib/index.js +1482 -168
- package/lib/providers/accounts.d.ts +102 -0
- package/lib/providers/accounts.js +123 -0
- package/lib/providers/claude.d.ts +22 -4
- package/lib/providers/claude.js +91 -11
- package/lib/providers/codex.d.ts +24 -3
- package/lib/providers/codex.js +116 -17
- package/lib/providers/common.d.ts +17 -0
- package/lib/providers/common.js +67 -3
- package/lib/providers/copilot.d.ts +22 -3
- package/lib/providers/copilot.js +91 -12
- package/lib/providers/grok.d.ts +24 -4
- package/lib/providers/grok.js +100 -14
- package/lib/providers/pool-family.d.ts +56 -0
- package/lib/providers/pool-family.js +45 -0
- package/lib/providers/pool-health.d.ts +74 -0
- package/lib/providers/pool-health.js +148 -0
- package/lib/providers/pool-usage.d.ts +57 -0
- package/lib/providers/pool-usage.js +130 -0
- package/lib/providers/pool.d.ts +107 -0
- package/lib/providers/pool.js +371 -0
- package/lib/tools/image-generate.d.ts +3 -3
- package/lib/tools/image-generate.js +2 -1
- package/lib/tools/video-generate.d.ts +2 -2
- package/lib/tools/video-generate.js +2 -1
- package/lib/tools/x-search.d.ts +2 -2
- package/lib/tools/x-search.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -111,6 +111,10 @@ Either way, restart `dsh web` afterwards so the new version loads.
|
|
|
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
|
+
|
|
114
118
|
## Config
|
|
115
119
|
|
|
116
120
|
```yaml
|
|
@@ -132,6 +136,37 @@ catalog does not know would otherwise default to `/chat/completions`, which
|
|
|
132
136
|
responses-only families (gpt-5.5/5.6, …) reject. Pinning `chat-completions` also opts
|
|
133
137
|
out of the tools+effort auto-reroute described above.
|
|
134
138
|
|
|
139
|
+
## Model pools
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
- **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.
|
|
144
|
+
- **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.
|
|
145
|
+
- **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.
|
|
146
|
+
- **Tier extras (`tiers`, optional).** Extra picker rows with heterogeneous fallbacks, listed under the first member's provider. Not created automatically.
|
|
147
|
+
|
|
148
|
+
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` when the provider sends one) — quota and auth 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.
|
|
149
|
+
|
|
150
|
+
```yaml
|
|
151
|
+
- id: llm-subscriptions
|
|
152
|
+
name: dsh-plugin-subscriptions
|
|
153
|
+
config:
|
|
154
|
+
pool:
|
|
155
|
+
enabled: true # default; needs ≥2 accounts of one provider
|
|
156
|
+
strategy: quota_aware # or priority
|
|
157
|
+
switchMargin: 2 # hysteresis factor for quota_aware
|
|
158
|
+
autoAccounts: true # pool each catalog model across that provider's accounts
|
|
159
|
+
families: # explicit account list for one catalog model (same provider)
|
|
160
|
+
claude-sonnet-5:
|
|
161
|
+
- { provider: claude, model: claude-sonnet-5 } # default account
|
|
162
|
+
- { provider: claude, account: bob@example.com, model: claude-sonnet-5 }
|
|
163
|
+
tiers: # optional extra picker rows
|
|
164
|
+
smart:
|
|
165
|
+
- { provider: claude, model: claude-sonnet-5 }
|
|
166
|
+
- { provider: codex, model: gpt-5.6-sol }
|
|
167
|
+
- { provider: grok, model: grok-4.6 }
|
|
168
|
+
```
|
|
169
|
+
|
|
135
170
|
## Proxy
|
|
136
171
|
|
|
137
172
|
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 +189,7 @@ After `pnpm build`, restart `dsh web` to pick up changes.
|
|
|
154
189
|
|
|
155
190
|
- `src/index.ts` — plugin entry: config schema, adapter registration, auth-change re-announce, RPC wiring
|
|
156
191
|
- `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
|
|
192
|
+
- `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s, multi-account token plumbing (`accounts.ts`), and the pool (`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`)
|
|
158
193
|
- `src/translate/` — dsh `Message[]` ⟷ OpenAI Responses / Anthropic Messages wire formats, SSE → `StreamChunk`
|
|
159
194
|
- `src/tools/` — `x_search`, `image_generate`, and `video_generate`
|
|
160
195
|
- `src/client/` — the Settings → Subscriptions page (browser half, zh/en, theme-token aware)
|
package/README.zh.md
CHANGED
|
@@ -111,6 +111,10 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
|
|
|
111
111
|
|
|
112
112
|
未登录时:该 provider 不出现在选择器里;直接请求会报 `MISSING_CREDENTIAL` 并提示去设置页登录,不影响其他功能。
|
|
113
113
|
|
|
114
|
+
### 多账号
|
|
115
|
+
|
|
116
|
+
每个 provider 可以登录多个账号:连上第一个之后,卡片会出现「添加账号」按钮(Claude 拆分为「浏览器授权」和「导入 Claude Code」两种)。账号按身份(邮箱/用户名)归档——重复登录同一账号是覆盖更新,不同账号才是新增。浏览器授权以浏览器当前登录的账号为准,要添加不同账号请先在浏览器切换账号,或用无痕窗口走手动授权码。★ 默认账号服务直连路由;池路由会使用所有账号。从 Claude Code 导入的 Claude 账号会与 CLI 的凭据存储保持同步;OAuth 添加的 Claude 账号独立刷新,多个账号不会互相覆盖 Keychain。
|
|
117
|
+
|
|
114
118
|
## 配置
|
|
115
119
|
|
|
116
120
|
```yaml
|
|
@@ -131,6 +135,37 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
|
|
|
131
135
|
responses-only 系列(gpt-5.5/5.6 等)会拒绝该端点。固定为 `chat-completions` 也会退出上文所述
|
|
132
136
|
tools+effort 的自动改道。
|
|
133
137
|
|
|
138
|
+
## 模型池
|
|
139
|
+
|
|
140
|
+
同一订阅下登录了**两个及以上账号**时,选择器显示该 provider **所有账号目录的并集**(按模型 id 去重)。照常在 Claude 组选 `claude-sonnet-5`、在 ChatGPT 组选 `gpt-5.4`——不会多出一个池分组,也不会换 model id。
|
|
141
|
+
|
|
142
|
+
- **共有模型**:至少两个账号的目录都列出的模型,在这些账号之间 failover(粘性、可按配额调度)。每个账号各自做一次目录发现,Plus 不会被拿去打 Pro 才有的模型。
|
|
143
|
+
- **单账号模型**:只有一个账号目录里有的模型,请求就打到那个账号。即使它不是默认账号,选择器里也会出现。
|
|
144
|
+
- **显式账号列表(`families`)**:覆盖某个目录模型的自动成员(仅同一 provider;跨 provider 的成员会被忽略)。可钉 `account`,省略则用默认账号。
|
|
145
|
+
- **档位额外项(`tiers`,可选)**:额外的选择器条目,failover 可以跨模型;出现在首个成员所在的 provider 分组。不会自动创建。
|
|
146
|
+
|
|
147
|
+
成员选择按会话粘性(prompt 缓存不失效),两种策略:`priority`(按顺序取第一个健康成员)和 `quota_aware`(默认——按"必需消耗速率 = 剩余配额 / 距重置时间"给成员打分,快重置且剩余多的窗口优先被用掉而不是浪费;粘性成员除非被挑战者以 `switchMargin` 倍分差击败否则不换)。任一用量窗口超过 95% 的成员会被硬门槛挡下;首个流式 chunk 之前的失败会记冷却并切换下一家(provider 给了 `retry-after` 就用它)——配额与认证类失败按整个账号冷却(配额是账号级的;Claude 的分模型窗口则只冷却出错成员),瞬时服务端失败只冷却出错成员。Copilot 没有用量接口,恒为 0 分,自然充当最后的保底。
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
- id: llm-subscriptions
|
|
151
|
+
name: dsh-plugin-subscriptions
|
|
152
|
+
config:
|
|
153
|
+
pool:
|
|
154
|
+
enabled: true # 默认开;需同一 provider ≥2 个账号
|
|
155
|
+
strategy: quota_aware # 或 priority
|
|
156
|
+
switchMargin: 2 # quota_aware 的滞后切换倍率
|
|
157
|
+
autoAccounts: true # 把该 provider 各账号自动池到每个目录模型
|
|
158
|
+
families: # 某个目录模型的显式账号列表(同一 provider)
|
|
159
|
+
claude-sonnet-5:
|
|
160
|
+
- { provider: claude, model: claude-sonnet-5 } # 默认账号
|
|
161
|
+
- { provider: claude, account: bob@example.com, model: claude-sonnet-5 }
|
|
162
|
+
tiers: # 可选的额外选择器条目
|
|
163
|
+
smart:
|
|
164
|
+
- { provider: claude, model: claude-sonnet-5 }
|
|
165
|
+
- { provider: codex, model: gpt-5.6-sol }
|
|
166
|
+
- { provider: grok, model: grok-4.6 }
|
|
167
|
+
```
|
|
168
|
+
|
|
134
169
|
## 代理
|
|
135
170
|
|
|
136
171
|
所有订阅相关请求 —— 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 +188,7 @@ pnpm test # 编译后跑 node --test 单测
|
|
|
153
188
|
|
|
154
189
|
- `src/index.ts` —— 插件入口:配置 schema、adapter 注册、登录态变更通告、RPC 接线
|
|
155
190
|
- `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` RPC 通道
|
|
156
|
-
- `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter`
|
|
191
|
+
- `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现,多账号 token 管理(`accounts.ts`),以及模型池(`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`)
|
|
157
192
|
- `src/translate/` —— dsh `Message[]` 与 OpenAI Responses / Anthropic Messages 格式互转,SSE → `StreamChunk`
|
|
158
193
|
- `src/tools/` —— `x_search`、`image_generate` 与 `video_generate`
|
|
159
194
|
- `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
|
-
/**
|
|
47
|
-
|
|
48
|
-
/**
|
|
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). */
|
|
@@ -68,11 +79,15 @@ export interface AuthController {
|
|
|
68
79
|
status(provider: ProviderId): Promise<ProviderStatus>;
|
|
69
80
|
/**
|
|
70
81
|
* Start a background login attempt.
|
|
82
|
+
* @param provider - the provider route.
|
|
83
|
+
* @param method - Claude only: force the OAuth browser flow or the Claude
|
|
84
|
+
* Code credential import; omitted keeps the auto behavior (import when
|
|
85
|
+
* available, else OAuth).
|
|
71
86
|
* @returns the authorize URL for the user's browser; device-flow providers
|
|
72
87
|
* (copilot) also return the `userCode` the user types at that URL.
|
|
73
88
|
* @throws when an attempt is already running for this provider.
|
|
74
89
|
*/
|
|
75
|
-
login(provider: ProviderId): Promise<{
|
|
90
|
+
login(provider: ProviderId, method?: LoginMethod): Promise<{
|
|
76
91
|
authorizeUrl: string;
|
|
77
92
|
userCode?: string;
|
|
78
93
|
}>;
|
|
@@ -83,15 +98,17 @@ export interface AuthController {
|
|
|
83
98
|
manual(provider: ProviderId, input: string): Promise<void>;
|
|
84
99
|
/** Abort the pending attempt; a no-op when none is pending. */
|
|
85
100
|
cancel(provider: ProviderId): Promise<void>;
|
|
86
|
-
/** Delete
|
|
87
|
-
logout(provider: ProviderId): Promise<void>;
|
|
101
|
+
/** Delete one account's stored session. */
|
|
102
|
+
logout(provider: ProviderId, account: string): Promise<void>;
|
|
103
|
+
/** Pin the account direct (non-pool) routes serve. */
|
|
104
|
+
setDefault(provider: ProviderId, account: string): Promise<void>;
|
|
88
105
|
/**
|
|
89
|
-
* Current subscription usage of one
|
|
106
|
+
* Current subscription usage of one account.
|
|
90
107
|
* @param signal - caller cancellation from the RPC transport.
|
|
91
108
|
* @returns `{ supported: false }` when the provider has no usage endpoint.
|
|
92
109
|
* @throws when logged out or the usage lookup fails.
|
|
93
110
|
*/
|
|
94
|
-
usage(provider: ProviderId, signal: AbortSignal): Promise<ProviderUsage>;
|
|
111
|
+
usage(provider: ProviderId, account: string, signal: AbortSignal): Promise<ProviderUsage>;
|
|
95
112
|
/**
|
|
96
113
|
* Read one image attachment's bytes for inline display.
|
|
97
114
|
* @param ref - the full durable reference (`readImage` verifies against it).
|
package/lib/auth/rpc.js
CHANGED
|
@@ -42,6 +42,18 @@ function readString(payload, field) {
|
|
|
42
42
|
}
|
|
43
43
|
return value;
|
|
44
44
|
}
|
|
45
|
+
/** Validate the optional Claude login method. */
|
|
46
|
+
function readLoginMethod(payload, provider) {
|
|
47
|
+
const method = payload.method;
|
|
48
|
+
if (method === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (provider !== 'claude')
|
|
51
|
+
throw new BadRequest('payload.method is only valid for claude');
|
|
52
|
+
if (method !== 'oauth' && method !== 'keychain') {
|
|
53
|
+
throw new BadRequest('payload.method must be "oauth" or "keychain"');
|
|
54
|
+
}
|
|
55
|
+
return method;
|
|
56
|
+
}
|
|
45
57
|
/** Validate the `setSpeed` endpoint's tier. */
|
|
46
58
|
function readSpeedTier(payload) {
|
|
47
59
|
const tier = payload.tier;
|
|
@@ -190,8 +202,10 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
|
|
|
190
202
|
const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
|
|
191
203
|
return ok({ providers: Object.fromEntries(entries) });
|
|
192
204
|
}
|
|
193
|
-
case 'login':
|
|
194
|
-
|
|
205
|
+
case 'login': {
|
|
206
|
+
const provider = readProvider(payload);
|
|
207
|
+
return ok(await controller.login(provider, readLoginMethod(payload, provider)));
|
|
208
|
+
}
|
|
195
209
|
case 'manual': {
|
|
196
210
|
const provider = readProvider(payload);
|
|
197
211
|
await controller.manual(provider, readString(payload, 'input'));
|
|
@@ -200,11 +214,20 @@ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
|
|
|
200
214
|
case 'cancel':
|
|
201
215
|
await controller.cancel(readProvider(payload));
|
|
202
216
|
return ok({ ok: true });
|
|
203
|
-
case 'logout':
|
|
204
|
-
|
|
217
|
+
case 'logout': {
|
|
218
|
+
const provider = readProvider(payload);
|
|
219
|
+
await controller.logout(provider, readString(payload, 'account'));
|
|
205
220
|
return ok({ ok: true });
|
|
206
|
-
|
|
207
|
-
|
|
221
|
+
}
|
|
222
|
+
case 'setDefault': {
|
|
223
|
+
const provider = readProvider(payload);
|
|
224
|
+
await controller.setDefault(provider, readString(payload, 'account'));
|
|
225
|
+
return ok({ ok: true });
|
|
226
|
+
}
|
|
227
|
+
case 'usage': {
|
|
228
|
+
const provider = readProvider(payload);
|
|
229
|
+
return ok(await controller.usage(provider, readString(payload, 'account'), signal));
|
|
230
|
+
}
|
|
208
231
|
case 'image':
|
|
209
232
|
return ok(await controller.readImage(readImageRef(payload), signal));
|
|
210
233
|
case 'video':
|
package/lib/auth/store.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* On-disk OAuth session store at `~/.dsh/plugins/subscriptions/auth.json`.
|
|
3
3
|
*
|
|
4
|
-
* The file is a JSON object keyed by provider id
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The file is a JSON object keyed by provider id, each entry holding that
|
|
5
|
+
* provider's ACCOUNTS: a map of account key → session plus the default
|
|
6
|
+
* account's key. Writes are atomic (tmp file + rename) with mode 0600
|
|
7
|
+
* because they carry bearer tokens. Session shapes live here (not in the
|
|
8
|
+
* provider modules) because this file owns the durable format.
|
|
9
|
+
*
|
|
10
|
+
* Backward compatibility: entries written by single-account versions hold
|
|
11
|
+
* the session fields directly (no `accounts` wrapper); reads migrate them
|
|
12
|
+
* in memory, and the next write persists the new shape — existing logins
|
|
13
|
+
* survive the upgrade untouched.
|
|
8
14
|
*/
|
|
9
15
|
/** Provider routes this plugin can serve. */
|
|
10
16
|
export type ProviderId = 'codex' | 'claude' | 'grok' | 'copilot';
|
|
@@ -34,6 +40,11 @@ export interface ClaudeSession {
|
|
|
34
40
|
scopes: string;
|
|
35
41
|
emailAddress?: string;
|
|
36
42
|
subscriptionType?: string;
|
|
43
|
+
/**
|
|
44
|
+
* True when this account was imported from Claude Code's own credential
|
|
45
|
+
* store (Keychain/file): only bound accounts sync refreshes back to it.
|
|
46
|
+
*/
|
|
47
|
+
keychainBound?: boolean;
|
|
37
48
|
}
|
|
38
49
|
/** Stored Grok (X Premium / xAI) subscription session. */
|
|
39
50
|
export interface GrokSession {
|
|
@@ -64,15 +75,42 @@ export interface CopilotSession {
|
|
|
64
75
|
/** GitHub login name, for the status display. */
|
|
65
76
|
account?: string;
|
|
66
77
|
}
|
|
67
|
-
/**
|
|
78
|
+
/** One provider's accounts: account key → session, plus the default account. */
|
|
79
|
+
export interface ProviderAccounts<S> {
|
|
80
|
+
/** Key of the account direct (non-pool) routes serve; the first login wins. */
|
|
81
|
+
default?: string;
|
|
82
|
+
accounts: Record<string, S>;
|
|
83
|
+
}
|
|
84
|
+
/** The durable store shape: per provider, its accounts. */
|
|
68
85
|
export interface SessionMap {
|
|
69
|
-
codex?: CodexSession
|
|
70
|
-
claude?: ClaudeSession
|
|
71
|
-
grok?: GrokSession
|
|
72
|
-
copilot?: CopilotSession
|
|
86
|
+
codex?: ProviderAccounts<CodexSession>;
|
|
87
|
+
claude?: ProviderAccounts<ClaudeSession>;
|
|
88
|
+
grok?: ProviderAccounts<GrokSession>;
|
|
89
|
+
copilot?: ProviderAccounts<CopilotSession>;
|
|
73
90
|
}
|
|
74
91
|
/** Any stored session, for provider-agnostic plumbing. */
|
|
75
92
|
export type StoredSession = CodexSession | ClaudeSession | GrokSession | CopilotSession;
|
|
93
|
+
/** The session type one provider stores. */
|
|
94
|
+
export type SessionOf<K extends ProviderId> = NonNullable<SessionMap[K]>['accounts'][string];
|
|
95
|
+
/** One account entry as returned by {@link listAccounts} (default first). */
|
|
96
|
+
export interface AccountEntry<S> {
|
|
97
|
+
key: string;
|
|
98
|
+
session: S;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The stable identity of one session's account: codex keys on the always
|
|
102
|
+
* present `accountId` claim, the others on their display identity, falling
|
|
103
|
+
* back to a refresh-token hash for sessions stored before identity fields
|
|
104
|
+
* existed. Logging the same account in again lands on the same key, so a
|
|
105
|
+
* re-login updates in place instead of duplicating. (The hash fallback can
|
|
106
|
+
* miss that dedup once for a legacy session re-logged with a now-known
|
|
107
|
+
* identity — the duplicate is visible on the Settings page and can simply
|
|
108
|
+
* be logged out.)
|
|
109
|
+
* @param provider - the provider route.
|
|
110
|
+
* @param session - the session to key.
|
|
111
|
+
* @returns the account map key.
|
|
112
|
+
*/
|
|
113
|
+
export declare function accountKeyOf(provider: ProviderId, session: StoredSession): string;
|
|
76
114
|
/**
|
|
77
115
|
* Absolute path of the auth store file.
|
|
78
116
|
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
|
@@ -81,28 +119,48 @@ export declare function authFilePath(): string;
|
|
|
81
119
|
/**
|
|
82
120
|
* Read the whole store. A missing file is an empty store; malformed JSON or a
|
|
83
121
|
* malformed entry throws, because silently discarding tokens would strand the
|
|
84
|
-
* user without a diagnosis.
|
|
122
|
+
* user without a diagnosis. Single-account entries are migrated in memory;
|
|
123
|
+
* the next write persists the new shape.
|
|
85
124
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
86
125
|
* @returns the parsed session map.
|
|
87
126
|
*/
|
|
88
127
|
export declare function loadStore(path?: string): Promise<SessionMap>;
|
|
89
128
|
/**
|
|
90
|
-
*
|
|
129
|
+
* List one provider's accounts, default first.
|
|
130
|
+
* @param provider - the provider route.
|
|
131
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
132
|
+
* @returns the account entries in stable order (empty when logged out).
|
|
133
|
+
*/
|
|
134
|
+
export declare function listAccounts<K extends ProviderId>(provider: K, path?: string): Promise<AccountEntry<SessionOf<K>>[]>;
|
|
135
|
+
/**
|
|
136
|
+
* Read one account's session.
|
|
91
137
|
* @param provider - the provider route.
|
|
138
|
+
* @param account - the account key; defaults to the provider's default account.
|
|
92
139
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
93
|
-
* @returns the stored session, or `undefined` when
|
|
140
|
+
* @returns the stored session, or `undefined` when absent.
|
|
94
141
|
*/
|
|
95
|
-
export declare function
|
|
142
|
+
export declare function getAccountSession<K extends ProviderId>(provider: K, account?: string, path?: string): Promise<SessionOf<K> | undefined>;
|
|
96
143
|
/**
|
|
97
|
-
* Write one
|
|
144
|
+
* Write one account's session, preserving the others. The first account of a
|
|
145
|
+
* provider becomes its default.
|
|
98
146
|
* @param provider - the provider route.
|
|
147
|
+
* @param account - the account key (see {@link accountKeyOf}).
|
|
99
148
|
* @param session - the fresh session from a login or refresh.
|
|
100
149
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
101
150
|
*/
|
|
102
|
-
export declare function
|
|
151
|
+
export declare function saveAccountSession<K extends ProviderId>(provider: K, account: string, session: SessionOf<K>, path?: string): Promise<void>;
|
|
152
|
+
/**
|
|
153
|
+
* Delete one account's session (logout). Deleting the default moves the badge
|
|
154
|
+
* to the next remaining account.
|
|
155
|
+
* @param provider - the provider route.
|
|
156
|
+
* @param account - the account key.
|
|
157
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
158
|
+
*/
|
|
159
|
+
export declare function deleteAccountSession(provider: ProviderId, account: string, path?: string): Promise<void>;
|
|
103
160
|
/**
|
|
104
|
-
*
|
|
161
|
+
* Pin the account direct (non-pool) routes serve.
|
|
105
162
|
* @param provider - the provider route.
|
|
163
|
+
* @param account - the account key; must exist.
|
|
106
164
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
107
165
|
*/
|
|
108
|
-
export declare function
|
|
166
|
+
export declare function setDefaultAccount(provider: ProviderId, account: string, path?: string): Promise<void>;
|
package/lib/auth/store.js
CHANGED
|
@@ -1,16 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* On-disk OAuth session store at `~/.dsh/plugins/subscriptions/auth.json`.
|
|
3
3
|
*
|
|
4
|
-
* The file is a JSON object keyed by provider id
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The file is a JSON object keyed by provider id, each entry holding that
|
|
5
|
+
* provider's ACCOUNTS: a map of account key → session plus the default
|
|
6
|
+
* account's key. Writes are atomic (tmp file + rename) with mode 0600
|
|
7
|
+
* because they carry bearer tokens. Session shapes live here (not in the
|
|
8
|
+
* provider modules) because this file owns the durable format.
|
|
9
|
+
*
|
|
10
|
+
* Backward compatibility: entries written by single-account versions hold
|
|
11
|
+
* the session fields directly (no `accounts` wrapper); reads migrate them
|
|
12
|
+
* in memory, and the next write persists the new shape — existing logins
|
|
13
|
+
* survive the upgrade untouched.
|
|
8
14
|
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
9
16
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
10
17
|
import { dirname } from 'node:path';
|
|
11
18
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
12
19
|
/** Every provider route, in display order. */
|
|
13
20
|
export const PROVIDER_IDS = ['codex', 'claude', 'grok', 'copilot'];
|
|
21
|
+
/**
|
|
22
|
+
* The stable identity of one session's account: codex keys on the always
|
|
23
|
+
* present `accountId` claim, the others on their display identity, falling
|
|
24
|
+
* back to a refresh-token hash for sessions stored before identity fields
|
|
25
|
+
* existed. Logging the same account in again lands on the same key, so a
|
|
26
|
+
* re-login updates in place instead of duplicating. (The hash fallback can
|
|
27
|
+
* miss that dedup once for a legacy session re-logged with a now-known
|
|
28
|
+
* identity — the duplicate is visible on the Settings page and can simply
|
|
29
|
+
* be logged out.)
|
|
30
|
+
* @param provider - the provider route.
|
|
31
|
+
* @param session - the session to key.
|
|
32
|
+
* @returns the account map key.
|
|
33
|
+
*/
|
|
34
|
+
export function accountKeyOf(provider, session) {
|
|
35
|
+
switch (provider) {
|
|
36
|
+
case 'codex':
|
|
37
|
+
return session.accountId;
|
|
38
|
+
case 'claude':
|
|
39
|
+
return session.emailAddress ?? tokenHash(session.refreshToken);
|
|
40
|
+
case 'grok':
|
|
41
|
+
return session.account ?? tokenHash(session.refreshToken);
|
|
42
|
+
case 'copilot':
|
|
43
|
+
return session.account ?? tokenHash(session.refreshToken);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Short stable hash for sessions without an identity field. */
|
|
47
|
+
function tokenHash(refreshToken) {
|
|
48
|
+
return `token-${createHash('sha256').update(refreshToken).digest('hex').slice(0, 16)}`;
|
|
49
|
+
}
|
|
14
50
|
/**
|
|
15
51
|
* Absolute path of the auth store file.
|
|
16
52
|
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
|
@@ -22,22 +58,23 @@ export function authFilePath() {
|
|
|
22
58
|
function legacyAuthFilePath() {
|
|
23
59
|
return dshHomePath('plugins', 'router', 'auth.json');
|
|
24
60
|
}
|
|
25
|
-
/** Check that one durable
|
|
26
|
-
function assertSessionShape(provider, value) {
|
|
61
|
+
/** Check that one durable session carries the fields every session needs. */
|
|
62
|
+
function assertSessionShape(provider, account, value) {
|
|
27
63
|
if (typeof value !== 'object' || value === null) {
|
|
28
|
-
throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`);
|
|
64
|
+
throw new Error(`subscriptions auth store: entry "${provider}/${account}" is not an object; fix or delete the store file`);
|
|
29
65
|
}
|
|
30
66
|
const entry = value;
|
|
31
67
|
if (typeof entry.accessToken !== 'string' || entry.accessToken.length === 0
|
|
32
68
|
|| typeof entry.refreshToken !== 'string' || entry.refreshToken.length === 0
|
|
33
69
|
|| typeof entry.expiresAt !== 'number' || !Number.isFinite(entry.expiresAt)) {
|
|
34
|
-
throw new Error(`subscriptions auth store: entry "${provider}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
|
|
70
|
+
throw new Error(`subscriptions auth store: entry "${provider}/${account}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
|
|
35
71
|
}
|
|
36
72
|
}
|
|
37
73
|
/**
|
|
38
74
|
* Read the whole store. A missing file is an empty store; malformed JSON or a
|
|
39
75
|
* malformed entry throws, because silently discarding tokens would strand the
|
|
40
|
-
* user without a diagnosis.
|
|
76
|
+
* user without a diagnosis. Single-account entries are migrated in memory;
|
|
77
|
+
* the next write persists the new shape.
|
|
41
78
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
42
79
|
* @returns the parsed session map.
|
|
43
80
|
*/
|
|
@@ -67,7 +104,7 @@ export async function loadStore(path = authFilePath()) {
|
|
|
67
104
|
}
|
|
68
105
|
return parseStore(text, path);
|
|
69
106
|
}
|
|
70
|
-
/** Parse and
|
|
107
|
+
/** Parse, validate, and migrate store JSON read from `path`. */
|
|
71
108
|
function parseStore(text, path) {
|
|
72
109
|
let parsed;
|
|
73
110
|
try {
|
|
@@ -79,11 +116,36 @@ function parseStore(text, path) {
|
|
|
79
116
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
80
117
|
throw new Error(`subscriptions auth store at ${path} must be a JSON object keyed by provider; fix or delete the file`);
|
|
81
118
|
}
|
|
82
|
-
const
|
|
119
|
+
const raw = parsed;
|
|
120
|
+
const store = {};
|
|
83
121
|
for (const provider of PROVIDER_IDS) {
|
|
84
|
-
const entry =
|
|
85
|
-
if (entry
|
|
86
|
-
|
|
122
|
+
const entry = raw[provider];
|
|
123
|
+
if (entry === undefined)
|
|
124
|
+
continue;
|
|
125
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
126
|
+
throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`);
|
|
127
|
+
}
|
|
128
|
+
const record = entry;
|
|
129
|
+
if (typeof record.accessToken === 'string') {
|
|
130
|
+
// Single-account format: wrap the bare session, preserving every field.
|
|
131
|
+
assertSessionShape(provider, '(legacy)', record);
|
|
132
|
+
const session = record;
|
|
133
|
+
const key = accountKeyOf(provider, session);
|
|
134
|
+
store[provider] = { default: key, accounts: { [key]: session } };
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const accounts = record.accounts;
|
|
138
|
+
if (typeof accounts !== 'object' || accounts === null || Array.isArray(accounts)) {
|
|
139
|
+
throw new Error(`subscriptions auth store: entry "${provider}" has no accounts map; fix or delete the store file`);
|
|
140
|
+
}
|
|
141
|
+
if (record.default !== undefined && typeof record.default !== 'string') {
|
|
142
|
+
throw new Error(`subscriptions auth store: entry "${provider}" default is not a string; fix or delete the store file`);
|
|
143
|
+
}
|
|
144
|
+
for (const [account, session] of Object.entries(accounts)) {
|
|
145
|
+
assertSessionShape(provider, account, session);
|
|
146
|
+
}
|
|
147
|
+
;
|
|
148
|
+
store[provider] = record;
|
|
87
149
|
}
|
|
88
150
|
return store;
|
|
89
151
|
}
|
|
@@ -106,8 +168,8 @@ async function writeStore(store, path) {
|
|
|
106
168
|
/**
|
|
107
169
|
* One write chain per store path. Every mutation is a read-modify-write of a
|
|
108
170
|
* single JSON file, and the plugin has several independent writers — a login,
|
|
109
|
-
* a logout, and one token refresh per provider
|
|
110
|
-
* schedule. Overlapping them unserialized costs whichever
|
|
171
|
+
* a logout, and one token refresh per provider account, each on its own
|
|
172
|
+
* schedule. Overlapping them unserialized costs whichever account read the
|
|
111
173
|
* store first its entry.
|
|
112
174
|
*
|
|
113
175
|
* A chain is dropped once nothing is queued behind it, so the map holds an
|
|
@@ -136,38 +198,97 @@ async function serialize(path, action) {
|
|
|
136
198
|
}
|
|
137
199
|
}
|
|
138
200
|
/**
|
|
139
|
-
*
|
|
201
|
+
* List one provider's accounts, default first.
|
|
140
202
|
* @param provider - the provider route.
|
|
141
203
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
142
|
-
* @returns the
|
|
204
|
+
* @returns the account entries in stable order (empty when logged out).
|
|
143
205
|
*/
|
|
144
|
-
export async function
|
|
145
|
-
|
|
206
|
+
export async function listAccounts(provider, path = authFilePath()) {
|
|
207
|
+
const entry = (await loadStore(path))[provider];
|
|
208
|
+
if (entry === undefined)
|
|
209
|
+
return [];
|
|
210
|
+
const accounts = Object.entries(entry.accounts).map(([key, session]) => ({ key, session }));
|
|
211
|
+
accounts.sort((a, b) => Number(b.key === entry.default) - Number(a.key === entry.default));
|
|
212
|
+
return accounts;
|
|
146
213
|
}
|
|
147
214
|
/**
|
|
148
|
-
*
|
|
215
|
+
* Read one account's session.
|
|
149
216
|
* @param provider - the provider route.
|
|
217
|
+
* @param account - the account key; defaults to the provider's default account.
|
|
218
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
219
|
+
* @returns the stored session, or `undefined` when absent.
|
|
220
|
+
*/
|
|
221
|
+
export async function getAccountSession(provider, account, path = authFilePath()) {
|
|
222
|
+
const entry = (await loadStore(path))[provider];
|
|
223
|
+
if (entry === undefined)
|
|
224
|
+
return undefined;
|
|
225
|
+
const key = account ?? entry.default;
|
|
226
|
+
if (key === undefined)
|
|
227
|
+
return undefined;
|
|
228
|
+
return entry.accounts[key];
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Write one account's session, preserving the others. The first account of a
|
|
232
|
+
* provider becomes its default.
|
|
233
|
+
* @param provider - the provider route.
|
|
234
|
+
* @param account - the account key (see {@link accountKeyOf}).
|
|
150
235
|
* @param session - the fresh session from a login or refresh.
|
|
151
236
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
152
237
|
*/
|
|
153
|
-
export async function
|
|
238
|
+
export async function saveAccountSession(provider, account, session, path = authFilePath()) {
|
|
154
239
|
return serialize(path, async () => {
|
|
155
240
|
const store = await loadStore(path);
|
|
156
|
-
store[provider]
|
|
241
|
+
const entry = store[provider];
|
|
242
|
+
store[provider] = {
|
|
243
|
+
default: entry?.default ?? account,
|
|
244
|
+
accounts: { ...entry?.accounts, [account]: session },
|
|
245
|
+
};
|
|
157
246
|
await writeStore(store, path);
|
|
158
247
|
});
|
|
159
248
|
}
|
|
160
249
|
/**
|
|
161
|
-
* Delete one
|
|
250
|
+
* Delete one account's session (logout). Deleting the default moves the badge
|
|
251
|
+
* to the next remaining account.
|
|
162
252
|
* @param provider - the provider route.
|
|
253
|
+
* @param account - the account key.
|
|
163
254
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
164
255
|
*/
|
|
165
|
-
export async function
|
|
256
|
+
export async function deleteAccountSession(provider, account, path = authFilePath()) {
|
|
166
257
|
return serialize(path, async () => {
|
|
167
258
|
const store = await loadStore(path);
|
|
168
|
-
|
|
259
|
+
const entry = store[provider];
|
|
260
|
+
if (entry === undefined || !(account in entry.accounts))
|
|
169
261
|
return;
|
|
170
|
-
|
|
262
|
+
const accounts = { ...entry.accounts };
|
|
263
|
+
delete accounts[account];
|
|
264
|
+
if (Object.keys(accounts).length === 0) {
|
|
265
|
+
delete store[provider];
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
;
|
|
269
|
+
store[provider] = {
|
|
270
|
+
...entry.default === account ? { default: Object.keys(accounts)[0] } : { default: entry.default },
|
|
271
|
+
accounts,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
await writeStore(store, path);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Pin the account direct (non-pool) routes serve.
|
|
279
|
+
* @param provider - the provider route.
|
|
280
|
+
* @param account - the account key; must exist.
|
|
281
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
282
|
+
*/
|
|
283
|
+
export async function setDefaultAccount(provider, account, path = authFilePath()) {
|
|
284
|
+
return serialize(path, async () => {
|
|
285
|
+
const store = await loadStore(path);
|
|
286
|
+
const entry = store[provider];
|
|
287
|
+
if (entry === undefined || !(account in entry.accounts)) {
|
|
288
|
+
throw new Error(`no ${provider} account "${account}" is logged in`);
|
|
289
|
+
}
|
|
290
|
+
;
|
|
291
|
+
store[provider] = { ...entry, default: account };
|
|
171
292
|
await writeStore(store, path);
|
|
172
293
|
});
|
|
173
294
|
}
|