dsh-plugin-subscriptions 0.5.1 → 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 +42 -1
- package/README.zh.md +42 -1
- package/lib/auth/device-flow.d.ts +0 -9
- package/lib/auth/device-flow.js +2 -1
- package/lib/auth/rpc.d.ts +44 -13
- package/lib/auth/rpc.js +127 -9
- package/lib/auth/store.d.ts +75 -17
- package/lib/auth/store.js +148 -27
- package/lib/client/SubscriptionsSection.d.ts +26 -3
- package/lib/client/SubscriptionsSection.js +263 -67
- package/lib/client/index.js +11 -0
- package/lib/client/locales.d.ts +82 -10
- package/lib/client/locales.js +82 -10
- package/lib/client.js +837 -223
- package/lib/client.js.map +1 -1
- package/lib/http.d.ts +114 -0
- package/lib/http.js +402 -0
- package/lib/index.d.ts +21 -0
- package/lib/index.js +1938 -208
- package/lib/providers/accounts.d.ts +102 -0
- package/lib/providers/accounts.js +123 -0
- package/lib/providers/antigravity.d.ts +90 -0
- package/lib/providers/antigravity.js +392 -0
- package/lib/providers/claude.d.ts +22 -4
- package/lib/providers/claude.js +97 -16
- package/lib/providers/codex.d.ts +24 -3
- package/lib/providers/codex.js +121 -21
- package/lib/providers/common.d.ts +17 -0
- package/lib/providers/common.js +67 -3
- package/lib/providers/copilot.d.ts +23 -4
- package/lib/providers/copilot.js +99 -19
- package/lib/providers/grok.d.ts +24 -4
- package/lib/providers/grok.js +106 -19
- 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 +4 -2
- package/lib/tools/video-generate.d.ts +2 -2
- package/lib/tools/video-generate.js +4 -2
- package/lib/tools/x-search.d.ts +2 -2
- package/lib/tools/x-search.js +4 -2
- package/lib/translate/antigravity.d.ts +110 -0
- package/lib/translate/antigravity.js +303 -0
- package/package.json +14 -9
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,43 @@ 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
|
+
|
|
170
|
+
## Proxy
|
|
171
|
+
|
|
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.
|
|
173
|
+
|
|
174
|
+
Changes apply immediately to subsequent requests — no restart needed. The OAuth authorization page opens in your browser and follows the browser/system proxy, not this setting. SOCKS proxies are not supported.
|
|
175
|
+
|
|
135
176
|
## Develop
|
|
136
177
|
|
|
137
178
|
```sh
|
|
@@ -148,7 +189,7 @@ After `pnpm build`, restart `dsh web` to pick up changes.
|
|
|
148
189
|
|
|
149
190
|
- `src/index.ts` — plugin entry: config schema, adapter registration, auth-change re-announce, RPC wiring
|
|
150
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
|
|
151
|
-
- `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`)
|
|
152
193
|
- `src/translate/` — dsh `Message[]` ⟷ OpenAI Responses / Anthropic Messages wire formats, SSE → `StreamChunk`
|
|
153
194
|
- `src/tools/` — `x_search`, `image_generate`, and `video_generate`
|
|
154
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,43 @@ 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
|
+
|
|
169
|
+
## 代理
|
|
170
|
+
|
|
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 状态码与耗时。
|
|
172
|
+
|
|
173
|
+
保存后立即对后续请求生效,无需重启。OAuth 授权页在浏览器中打开,走浏览器/系统自身的代理设置,不受此配置影响;不支持 socks 代理。
|
|
174
|
+
|
|
134
175
|
## 开发
|
|
135
176
|
|
|
136
177
|
```sh
|
|
@@ -147,7 +188,7 @@ pnpm test # 编译后跑 node --test 单测
|
|
|
147
188
|
|
|
148
189
|
- `src/index.ts` —— 插件入口:配置 schema、adapter 注册、登录态变更通告、RPC 接线
|
|
149
190
|
- `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` RPC 通道
|
|
150
|
-
- `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`)
|
|
151
192
|
- `src/translate/` —— dsh `Message[]` 与 OpenAI Responses / Anthropic Messages 格式互转,SSE → `StreamChunk`
|
|
152
193
|
- `src/tools/` —— `x_search`、`image_generate` 与 `video_generate`
|
|
153
194
|
- `src/client/` —— 设置 → 订阅页面(浏览器面,中英文,跟随明暗主题)
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GitHub OAuth device-authorization flow (RFC 8628) for providers that cannot
|
|
3
|
-
* use the loopback redirect engine: no redirect URI, no PKCE, no client
|
|
4
|
-
* secret. The user opens a verification URL and types a short code while the
|
|
5
|
-
* plugin polls the token endpoint until GitHub releases the access token.
|
|
6
|
-
* The management model (one attempt per provider, `isBusy`/`pending`/`cancel`)
|
|
7
|
-
* mirrors {@link OAuthFlowManager} so the auth controller can treat both
|
|
8
|
-
* engines uniformly.
|
|
9
|
-
*/
|
|
10
1
|
/** Static per-provider device-flow facts. */
|
|
11
2
|
export interface DeviceFlowSpec {
|
|
12
3
|
/** OAuth App / GitHub App client id the device code is requested for. */
|
package/lib/auth/device-flow.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* mirrors {@link OAuthFlowManager} so the auth controller can treat both
|
|
8
8
|
* engines uniformly.
|
|
9
9
|
*/
|
|
10
|
+
import { proxiedFetch } from '../http.js';
|
|
10
11
|
/** Default poll interval when the device-code response omits one. */
|
|
11
12
|
const DEFAULT_INTERVAL_SEC = 5;
|
|
12
13
|
/** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
|
|
@@ -64,7 +65,7 @@ export class DeviceFlowManager {
|
|
|
64
65
|
if (this.attempts.has(provider)) {
|
|
65
66
|
throw new Error(`a ${provider} login attempt is already in progress`);
|
|
66
67
|
}
|
|
67
|
-
const fetchFn = spec.fetchFn ??
|
|
68
|
+
const fetchFn = spec.fetchFn ?? proxiedFetch;
|
|
68
69
|
const response = await fetchFn(spec.deviceCodeUrl, {
|
|
69
70
|
method: 'POST',
|
|
70
71
|
headers: {
|
package/lib/auth/rpc.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
8
8
|
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
|
|
9
9
|
import { type ProviderId } from './store.js';
|
|
10
10
|
import type { ProviderUsage } from '../providers/common.js';
|
|
11
|
+
import type { ProxyConfigView, ProxyDraft, ProxyInput, ProxyTestResult } from '../http.js';
|
|
11
12
|
/** The RPC channel this plugin registers on the host connection. */
|
|
12
13
|
export declare const SUBSCRIPTIONS_AUTH_CHANNEL = "/subscriptions-auth";
|
|
13
14
|
/** Decoded image bytes returned by the `image` endpoint. */
|
|
@@ -36,30 +37,57 @@ export interface SpeedController {
|
|
|
36
37
|
/** Set one session's speed tier. */
|
|
37
38
|
setSpeed(sessionId: string, tier: SpeedTier): Promise<void>;
|
|
38
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
|
+
}
|
|
39
53
|
/** Login state of one provider, as rendered by the Settings page. */
|
|
40
54
|
export interface ProviderStatus {
|
|
41
|
-
/** Whether a session exists in the store. */
|
|
42
|
-
loggedIn: boolean;
|
|
43
55
|
/** Whether a login attempt is currently waiting for its code. */
|
|
44
56
|
busy: boolean;
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
account?: string;
|
|
49
|
-
/** 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. */
|
|
50
60
|
detail?: string;
|
|
51
61
|
}
|
|
62
|
+
/** How a Claude login should acquire credentials (other providers ignore it). */
|
|
63
|
+
export type LoginMethod = 'oauth' | 'keychain';
|
|
64
|
+
/** Proxy config operations behind the `proxyGet/proxySet/proxyTest` endpoints. */
|
|
65
|
+
export interface ProxyConfigController {
|
|
66
|
+
/** Current proxy configuration (secrets omitted). */
|
|
67
|
+
get(): Promise<ProxyConfigView>;
|
|
68
|
+
/** Validate, persist, and apply one config. */
|
|
69
|
+
set(input: ProxyInput): Promise<ProxyConfigView>;
|
|
70
|
+
/** Probe one destination through the draft (unsaved) or stored proxy. */
|
|
71
|
+
test(payload: {
|
|
72
|
+
url?: string;
|
|
73
|
+
proxy?: ProxyDraft;
|
|
74
|
+
}): Promise<ProxyTestResult>;
|
|
75
|
+
}
|
|
52
76
|
/** Provider-agnostic auth operations the RPC handler delegates to. */
|
|
53
77
|
export interface AuthController {
|
|
54
78
|
/** Current status of one provider. */
|
|
55
79
|
status(provider: ProviderId): Promise<ProviderStatus>;
|
|
56
80
|
/**
|
|
57
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).
|
|
58
86
|
* @returns the authorize URL for the user's browser; device-flow providers
|
|
59
87
|
* (copilot) also return the `userCode` the user types at that URL.
|
|
60
88
|
* @throws when an attempt is already running for this provider.
|
|
61
89
|
*/
|
|
62
|
-
login(provider: ProviderId): Promise<{
|
|
90
|
+
login(provider: ProviderId, method?: LoginMethod): Promise<{
|
|
63
91
|
authorizeUrl: string;
|
|
64
92
|
userCode?: string;
|
|
65
93
|
}>;
|
|
@@ -70,15 +98,17 @@ export interface AuthController {
|
|
|
70
98
|
manual(provider: ProviderId, input: string): Promise<void>;
|
|
71
99
|
/** Abort the pending attempt; a no-op when none is pending. */
|
|
72
100
|
cancel(provider: ProviderId): Promise<void>;
|
|
73
|
-
/** Delete
|
|
74
|
-
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>;
|
|
75
105
|
/**
|
|
76
|
-
* Current subscription usage of one
|
|
106
|
+
* Current subscription usage of one account.
|
|
77
107
|
* @param signal - caller cancellation from the RPC transport.
|
|
78
108
|
* @returns `{ supported: false }` when the provider has no usage endpoint.
|
|
79
109
|
* @throws when logged out or the usage lookup fails.
|
|
80
110
|
*/
|
|
81
|
-
usage(provider: ProviderId, signal: AbortSignal): Promise<ProviderUsage>;
|
|
111
|
+
usage(provider: ProviderId, account: string, signal: AbortSignal): Promise<ProviderUsage>;
|
|
82
112
|
/**
|
|
83
113
|
* Read one image attachment's bytes for inline display.
|
|
84
114
|
* @param ref - the full durable reference (`readImage` verifies against it).
|
|
@@ -102,5 +132,6 @@ export interface AuthController {
|
|
|
102
132
|
* @param ctx - the plugin context (headless profiles have no `connection`).
|
|
103
133
|
* @param controller - the auth operations backing the endpoints.
|
|
104
134
|
* @param speed - the per-session speed-tier state backing the Speed toggle.
|
|
135
|
+
* @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
|
|
105
136
|
*/
|
|
106
|
-
export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController): void;
|
|
137
|
+
export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController, proxy?: ProxyConfigController | undefined): void;
|
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;
|
|
@@ -102,14 +114,98 @@ function readSessionId(payload) {
|
|
|
102
114
|
throw new BadRequest('payload must be an object');
|
|
103
115
|
return readString(payload, 'sessionId');
|
|
104
116
|
}
|
|
105
|
-
|
|
117
|
+
/** Validate a `proxySet` payload into a shape `ProxyInput` accepts. */
|
|
118
|
+
function readProxyInput(payload) {
|
|
119
|
+
if (typeof payload !== 'object' || payload === null)
|
|
120
|
+
throw new BadRequest('payload must be an object');
|
|
121
|
+
const record = payload;
|
|
122
|
+
if (typeof record.enabled !== 'boolean')
|
|
123
|
+
throw new BadRequest('payload.enabled must be a boolean');
|
|
124
|
+
if (typeof record.url !== 'string')
|
|
125
|
+
throw new BadRequest('payload.url must be a string');
|
|
126
|
+
let username;
|
|
127
|
+
if (record.username !== undefined) {
|
|
128
|
+
if (typeof record.username !== 'string')
|
|
129
|
+
throw new BadRequest('payload.username must be a string when present');
|
|
130
|
+
username = record.username;
|
|
131
|
+
}
|
|
132
|
+
let password;
|
|
133
|
+
if (record.password !== undefined) {
|
|
134
|
+
if (record.password !== null && typeof record.password !== 'string') {
|
|
135
|
+
throw new BadRequest('payload.password must be a string or null when present');
|
|
136
|
+
}
|
|
137
|
+
password = record.password;
|
|
138
|
+
}
|
|
139
|
+
let bypass;
|
|
140
|
+
if (record.bypass !== undefined) {
|
|
141
|
+
if (!Array.isArray(record.bypass) || record.bypass.some(entry => typeof entry !== 'string')) {
|
|
142
|
+
throw new BadRequest('payload.bypass must be an array of strings when present');
|
|
143
|
+
}
|
|
144
|
+
bypass = record.bypass;
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
enabled: record.enabled,
|
|
148
|
+
url: record.url,
|
|
149
|
+
...username === undefined ? {} : { username },
|
|
150
|
+
...password === undefined ? {} : { password },
|
|
151
|
+
...bypass === undefined ? {} : { bypass },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Validate a `proxyTest` payload (the destination URL and an optional draft). */
|
|
155
|
+
function readProxyTestPayload(payload) {
|
|
156
|
+
if (typeof payload !== 'object' || payload === null)
|
|
157
|
+
return {};
|
|
158
|
+
const record = payload;
|
|
159
|
+
const url = record.url;
|
|
160
|
+
if (url === undefined && record.proxy === undefined)
|
|
161
|
+
return {};
|
|
162
|
+
if (url !== undefined && (typeof url !== 'string' || url.length === 0)) {
|
|
163
|
+
throw new BadRequest('payload.url must be a non-empty string when present');
|
|
164
|
+
}
|
|
165
|
+
let proxy;
|
|
166
|
+
if (record.proxy !== undefined) {
|
|
167
|
+
if (typeof record.proxy !== 'object' || record.proxy === null) {
|
|
168
|
+
throw new BadRequest('payload.proxy must be an object when present');
|
|
169
|
+
}
|
|
170
|
+
const draftRecord = record.proxy;
|
|
171
|
+
if (typeof draftRecord.url !== 'string' || draftRecord.url.length === 0) {
|
|
172
|
+
throw new BadRequest('payload.proxy.url must be a non-empty string');
|
|
173
|
+
}
|
|
174
|
+
let username;
|
|
175
|
+
if (draftRecord.username !== undefined) {
|
|
176
|
+
if (typeof draftRecord.username !== 'string') {
|
|
177
|
+
throw new BadRequest('payload.proxy.username must be a string when present');
|
|
178
|
+
}
|
|
179
|
+
username = draftRecord.username;
|
|
180
|
+
}
|
|
181
|
+
let password;
|
|
182
|
+
if (draftRecord.password !== undefined) {
|
|
183
|
+
if (typeof draftRecord.password !== 'string') {
|
|
184
|
+
throw new BadRequest('payload.proxy.password must be a string when present');
|
|
185
|
+
}
|
|
186
|
+
password = draftRecord.password;
|
|
187
|
+
}
|
|
188
|
+
proxy = {
|
|
189
|
+
url: draftRecord.url,
|
|
190
|
+
...username === undefined ? {} : { username },
|
|
191
|
+
...password === undefined ? {} : { password },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
...url === undefined ? {} : { url },
|
|
196
|
+
...proxy === undefined ? {} : { proxy },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
|
|
106
200
|
switch (endpoint) {
|
|
107
201
|
case 'status': {
|
|
108
202
|
const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
|
|
109
203
|
return ok({ providers: Object.fromEntries(entries) });
|
|
110
204
|
}
|
|
111
|
-
case 'login':
|
|
112
|
-
|
|
205
|
+
case 'login': {
|
|
206
|
+
const provider = readProvider(payload);
|
|
207
|
+
return ok(await controller.login(provider, readLoginMethod(payload, provider)));
|
|
208
|
+
}
|
|
113
209
|
case 'manual': {
|
|
114
210
|
const provider = readProvider(payload);
|
|
115
211
|
await controller.manual(provider, readString(payload, 'input'));
|
|
@@ -118,11 +214,20 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
|
|
|
118
214
|
case 'cancel':
|
|
119
215
|
await controller.cancel(readProvider(payload));
|
|
120
216
|
return ok({ ok: true });
|
|
121
|
-
case 'logout':
|
|
122
|
-
|
|
217
|
+
case 'logout': {
|
|
218
|
+
const provider = readProvider(payload);
|
|
219
|
+
await controller.logout(provider, readString(payload, 'account'));
|
|
220
|
+
return ok({ ok: true });
|
|
221
|
+
}
|
|
222
|
+
case 'setDefault': {
|
|
223
|
+
const provider = readProvider(payload);
|
|
224
|
+
await controller.setDefault(provider, readString(payload, 'account'));
|
|
123
225
|
return ok({ ok: true });
|
|
124
|
-
|
|
125
|
-
|
|
226
|
+
}
|
|
227
|
+
case 'usage': {
|
|
228
|
+
const provider = readProvider(payload);
|
|
229
|
+
return ok(await controller.usage(provider, readString(payload, 'account'), signal));
|
|
230
|
+
}
|
|
126
231
|
case 'image':
|
|
127
232
|
return ok(await controller.readImage(readImageRef(payload), signal));
|
|
128
233
|
case 'video':
|
|
@@ -132,6 +237,18 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
|
|
|
132
237
|
case 'setSpeed':
|
|
133
238
|
await speed.setSpeed(readSessionId(payload), readSpeedTier(payload));
|
|
134
239
|
return ok({ ok: true });
|
|
240
|
+
case 'proxyGet':
|
|
241
|
+
if (proxy === undefined)
|
|
242
|
+
throw new BadRequest('proxy configuration is unavailable');
|
|
243
|
+
return ok(await proxy.get());
|
|
244
|
+
case 'proxySet':
|
|
245
|
+
if (proxy === undefined)
|
|
246
|
+
throw new BadRequest('proxy configuration is unavailable');
|
|
247
|
+
return ok(await proxy.set(readProxyInput(payload)));
|
|
248
|
+
case 'proxyTest':
|
|
249
|
+
if (proxy === undefined)
|
|
250
|
+
throw new BadRequest('proxy configuration is unavailable');
|
|
251
|
+
return ok(await proxy.test(readProxyTestPayload(payload)));
|
|
135
252
|
default:
|
|
136
253
|
throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
|
|
137
254
|
}
|
|
@@ -141,8 +258,9 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
|
|
|
141
258
|
* @param ctx - the plugin context (headless profiles have no `connection`).
|
|
142
259
|
* @param controller - the auth operations backing the endpoints.
|
|
143
260
|
* @param speed - the per-session speed-tier state backing the Speed toggle.
|
|
261
|
+
* @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
|
|
144
262
|
*/
|
|
145
|
-
export function registerAuthRpc(ctx, controller, speed) {
|
|
263
|
+
export function registerAuthRpc(ctx, controller, speed, proxy = undefined) {
|
|
146
264
|
// `connection` is not in this plugin's inject list (headless compositions
|
|
147
265
|
// lack it), so its startup order is unconstrained: defer registration until
|
|
148
266
|
// the service exists instead of probing once at apply time.
|
|
@@ -150,7 +268,7 @@ export function registerAuthRpc(ctx, controller, speed) {
|
|
|
150
268
|
const connection = ctx.get('connection');
|
|
151
269
|
ctx.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
|
|
152
270
|
try {
|
|
153
|
-
return await dispatch(controller, speed, endpoint, payload, signal);
|
|
271
|
+
return await dispatch(controller, speed, proxy, endpoint, payload, signal);
|
|
154
272
|
}
|
|
155
273
|
catch (error) {
|
|
156
274
|
return failure(error);
|
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>;
|