dsh-plugin-subscriptions 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -6
- package/README.zh.md +14 -6
- package/lib/auth/device-flow.d.ts +64 -0
- package/lib/auth/device-flow.js +176 -0
- package/lib/auth/oauth-flow.js +1 -1
- package/lib/auth/rpc.d.ts +3 -1
- package/lib/auth/store.d.ts +20 -2
- package/lib/auth/store.js +45 -9
- package/lib/client/SubscriptionsSection.d.ts +1 -1
- package/lib/client/SubscriptionsSection.js +46 -4
- package/lib/client/locales.d.ts +8 -0
- package/lib/client/locales.js +8 -0
- package/lib/client.js +94 -4
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.js +1770 -156
- package/lib/providers/catalog-store.js +15 -0
- package/lib/providers/claude.d.ts +20 -1
- package/lib/providers/claude.js +44 -27
- package/lib/providers/codex.js +52 -8
- package/lib/providers/common.d.ts +32 -1
- package/lib/providers/common.js +48 -1
- package/lib/providers/copilot.d.ts +315 -0
- package/lib/providers/copilot.js +786 -0
- package/lib/providers/grok.d.ts +7 -2
- package/lib/providers/grok.js +46 -18
- package/lib/translate/anthropic.d.ts +47 -6
- package/lib/translate/anthropic.js +135 -20
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +363 -0
- package/lib/translate/responses.d.ts +49 -5
- package/lib/translate/responses.js +40 -7
- package/package.json +9 -10
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# dsh-plugin-subscriptions
|
|
1
|
+
# dsh-plugin-subscriptions [](https://awesome-dsh-plugin.com)
|
|
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
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Demo
|
|
8
8
|
|
|
9
|
-
Settings → **Subscriptions**: per-provider login/logout, no API keys. Claude imports credentials from Claude Code
|
|
9
|
+
Settings → **Subscriptions**: per-provider login/logout, no API keys. Claude imports credentials from Claude Code when available and otherwise uses OAuth, as Codex and Grok always do (account address masked in the screenshot):
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ Logged-in providers join the session model picker with their live model catalogs
|
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
|
-
Models that advertise reasoning levels get an **Effort** selector in the same menu — Codex models,
|
|
17
|
+
Models that advertise reasoning levels get an **Effort** selector in the same menu — Codex models, Grok 4.6 / 4.5, and Copilot's reasoning models (levels and defaults come from each provider's live catalog, not a hardcoded list; Copilot's `capabilities.supports.reasoning_effort` array is sent as `reasoning_effort` on chat completions and `reasoning.effort` on the Responses wire). Models listing both Copilot endpoints (gpt-5.4, gpt-5-mini) normally speak chat completions but reroute to `/responses` when a request combines function tools with an effort — Copilot rejects that combination on the chat wire:
|
|
18
18
|
|
|
19
19
|

|
|
20
20
|
|
|
@@ -41,10 +41,11 @@ The `video_generate` tool plays the generated clip inline:
|
|
|
41
41
|
| `codex` | ChatGPT Plus/Pro | live catalog from `chatgpt.com/backend-api/codex/models` |
|
|
42
42
|
| `claude` | Claude Pro/Max | all models available in your subscription (Opus, Sonnet, Haiku, Fable — static catalog, updated with the plugin) |
|
|
43
43
|
| `grok` | X Premium (xAI) | live catalog from `api.x.ai/v1/models` (chat models only); reasoning efforts from the Grok CLI catalog (`cli-chat-proxy.grok.com/v1/models`) |
|
|
44
|
+
| `copilot` | GitHub Copilot | live catalog from `api.githubcopilot.com/models` (chat models on both wires, with per-model vision flags and reasoning efforts); login uses the OAuth device flow (enter the shown code at `github.com/login/device`) |
|
|
44
45
|
|
|
45
46
|
Only logged-in providers appear in the session model picker; the lists above refresh on login/logout. Vision-capable models declare `['text', 'image']` input modalities, and image content is translated to each provider's wire format.
|
|
46
47
|
|
|
47
|
-
Logged-in cards also show **subscription usage** — per rate-limit window (5-hour session, weekly, and per-model weekly where the plan has one) with the used percentage, a progress bar, and the reset time, plus a Refresh button. Codex usage comes from `chatgpt.com/backend-api/wham/usage` (also reports the plan), Claude usage from `api.anthropic.com/api/oauth/usage`, and Grok usage from the Grok Build CLI proxy's `cli-chat-proxy.grok.com/v1/billing` (the source of the CLI's `/usage` panel; reports the shared weekly pool and the subscription tier).
|
|
48
|
+
Logged-in cards also show **subscription usage** — per rate-limit window (5-hour session, weekly, and per-model weekly where the plan has one) with the used percentage, a progress bar, and the reset time, plus a Refresh button. Codex usage comes from `chatgpt.com/backend-api/wham/usage` (also reports the plan), Claude usage from `api.anthropic.com/api/oauth/usage`, and Grok usage from the Grok Build CLI proxy's `cli-chat-proxy.grok.com/v1/billing` (the source of the CLI's `/usage` panel; reports the shared weekly pool and the subscription tier). Copilot exposes no usage endpoint, so its card shows no usage section.
|
|
48
49
|
|
|
49
50
|
Also included, registered when the matching provider is enabled:
|
|
50
51
|
|
|
@@ -105,7 +106,7 @@ Either way, restart `dsh web` afterwards so the new version loads.
|
|
|
105
106
|
## Use
|
|
106
107
|
|
|
107
108
|
1. `dsh web`, open the printed URL.
|
|
108
|
-
2. Settings → **Subscriptions**: click **Connect** on a provider. For Claude, credentials are imported instantly
|
|
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.
|
|
109
110
|
3. In any session, open the model picker (`/model`) and choose a model under **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)**.
|
|
110
111
|
|
|
111
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.
|
|
@@ -121,8 +122,16 @@ Not logged in? The provider stays out of the picker, and requests fail with `MIS
|
|
|
121
122
|
models: # override the discovered/built-in catalogs
|
|
122
123
|
codex:
|
|
123
124
|
- { id: gpt-5.6-sol, name: GPT-5.6 Sol, contextWindow: 272000, inputModalities: [text, image] }
|
|
125
|
+
copilot: # manual entries disable Copilot catalog discovery
|
|
126
|
+
- { id: gpt-5.6-sol, wire: responses } # copilot only: force the upstream protocol
|
|
124
127
|
```
|
|
125
128
|
|
|
129
|
+
`wire` (copilot entries only) pins a model to `chat-completions` or `responses`. Manual
|
|
130
|
+
entries keep working without it — the field exists because a configured model the live
|
|
131
|
+
catalog does not know would otherwise default to `/chat/completions`, which
|
|
132
|
+
responses-only families (gpt-5.5/5.6, …) reject. Pinning `chat-completions` also opts
|
|
133
|
+
out of the tools+effort auto-reroute described above.
|
|
134
|
+
|
|
126
135
|
## Develop
|
|
127
136
|
|
|
128
137
|
```sh
|
package/README.zh.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# dsh-plugin-subscriptions
|
|
1
|
+
# dsh-plugin-subscriptions [](https://awesome-dsh-plugin.com)
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 中文
|
|
4
4
|
|
|
5
|
-
把你的 **ChatGPT(Codex)**、**Claude**、**Grok(X Premium)** 订阅当作 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 LLM provider 使用 —— 不需要 API key。Codex 和 Grok 通过 dsh web 界面 OAuth 登录(设置 → 订阅);Claude
|
|
5
|
+
把你的 **ChatGPT(Codex)**、**Claude**、**Grok(X Premium)** 订阅当作 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 LLM provider 使用 —— 不需要 API key。Codex 和 Grok 通过 dsh web 界面 OAuth 登录(设置 → 订阅);Claude 在存在 Claude Code 会话时直接导入凭据(macOS Keychain 或 `~/.claude/.credentials.json`),否则回退到同样的浏览器 OAuth 流程,因此不要求安装 Claude Code CLI。Token 保存在 `~/.dsh/plugins/subscriptions/auth.json`(权限 0600),过期自动刷新。
|
|
6
6
|
|
|
7
7
|
## 演示
|
|
8
8
|
|
|
9
|
-
设置 → **订阅**:每个 provider 的登录/退出,无需 API key。Claude
|
|
9
|
+
设置 → **订阅**:每个 provider 的登录/退出,无需 API key。Claude 有 Claude Code 会话时导入凭据,否则和 Codex、Grok 一样走 OAuth(截图中账号已打码):
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
|
-
声明了推理等级的模型会在同一菜单里多出**推理等级**选择 —— Codex
|
|
17
|
+
声明了推理等级的模型会在同一菜单里多出**推理等级**选择 —— Codex 系列模型、Grok 4.6 / 4.5,以及 Copilot 的推理模型(档位和默认值来自各 provider 的实时目录,不是硬编码列表;Copilot 的 `capabilities.supports.reasoning_effort` 数组会按协议映射为 chat completions 的 `reasoning_effort` 或 Responses 的 `reasoning.effort`)。同时声明两个 Copilot 端点的模型(gpt-5.4、gpt-5-mini)默认走 chat completions,但请求同时携带函数工具和推理等级时会自动改走 `/responses` —— Copilot 在 chat 线路上拒绝这种组合:
|
|
18
18
|
|
|
19
19
|

|
|
20
20
|
|
|
@@ -41,10 +41,11 @@
|
|
|
41
41
|
| `codex` | ChatGPT Plus/Pro | 从 `chatgpt.com/backend-api/codex/models` 实时获取 |
|
|
42
42
|
| `claude` | Claude Pro/Max | 订阅内所有可用模型(Opus、Sonnet、Haiku、Fable —— 静态目录,随插件更新) |
|
|
43
43
|
| `grok` | X Premium (xAI) | 从 `api.x.ai/v1/models` 实时获取(仅对话模型);推理等级来自 Grok CLI 目录(`cli-chat-proxy.grok.com/v1/models`) |
|
|
44
|
+
| `copilot` | GitHub Copilot | 从 `api.githubcopilot.com/models` 实时获取(两种 wire 的对话模型,含按模型的视觉标记与推理等级);登录使用 OAuth 设备码流程(在 `github.com/login/device` 输入页面显示的验证码) |
|
|
44
45
|
|
|
45
46
|
只有已登录的 provider 才会出现在会话模型选择器里;登录/退出后列表自动刷新。支持视觉的模型会声明 `['text', 'image']` 输入模态,图片内容会被翻译成各 provider 的 wire 格式。
|
|
46
47
|
|
|
47
|
-
已登录的卡片还会显示**订阅用量**——按限额窗口(5 小时会话窗、每周窗,以及计划包含的按模型每周窗)展示已用百分比、进度条和重置时间,并带刷新按钮。Codex 用量来自 `chatgpt.com/backend-api/wham/usage`(同时报告计划类型),Claude 用量来自 `api.anthropic.com/api/oauth/usage`,Grok 用量来自 Grok Build CLI 代理的 `cli-chat-proxy.grok.com/v1/billing`(即 CLI `/usage` 面板的数据源,报告共享每周额度和订阅档位)。
|
|
48
|
+
已登录的卡片还会显示**订阅用量**——按限额窗口(5 小时会话窗、每周窗,以及计划包含的按模型每周窗)展示已用百分比、进度条和重置时间,并带刷新按钮。Codex 用量来自 `chatgpt.com/backend-api/wham/usage`(同时报告计划类型),Claude 用量来自 `api.anthropic.com/api/oauth/usage`,Grok 用量来自 Grok Build CLI 代理的 `cli-chat-proxy.grok.com/v1/billing`(即 CLI `/usage` 面板的数据源,报告共享每周额度和订阅档位)。Copilot 没有用量接口,其卡片不显示用量区块。
|
|
48
49
|
|
|
49
50
|
随 provider 启用自动注册的工具:
|
|
50
51
|
|
|
@@ -105,7 +106,7 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
|
|
|
105
106
|
## 使用
|
|
106
107
|
|
|
107
108
|
1. `dsh web`,打开打印的 URL。
|
|
108
|
-
2. **设置 → 订阅**:点对应 provider
|
|
109
|
+
2. **设置 → 订阅**:点对应 provider 的「连接」。若先运行过 `claude` 并登录,Claude 会即时导入凭据;没有凭据时,Claude 也和其他 provider 一样在浏览器里授权。Codex 和 Grok 在打开的标签页里授权;无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
|
|
109
110
|
3. 在任意会话里打开模型选择器(`/model`),选择 **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)** 下的模型。
|
|
110
111
|
|
|
111
112
|
未登录时:该 provider 不出现在选择器里;直接请求会报 `MISSING_CREDENTIAL` 并提示去设置页登录,不影响其他功能。
|
|
@@ -121,8 +122,15 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
|
|
|
121
122
|
models: # 覆盖实时发现/内置目录
|
|
122
123
|
codex:
|
|
123
124
|
- { id: gpt-5.6-sol, name: GPT-5.6 Sol, contextWindow: 272000, inputModalities: [text, image] }
|
|
125
|
+
copilot: # 手工条目会关闭 Copilot 目录发现
|
|
126
|
+
- { id: gpt-5.6-sol, wire: responses } # 仅 copilot:强制指定上游协议
|
|
124
127
|
```
|
|
125
128
|
|
|
129
|
+
`wire`(仅 copilot 条目)把模型固定到 `chat-completions` 或 `responses`。不加该字段手工条目照常
|
|
130
|
+
工作——它存在的原因是:实时目录不认识的手工模型否则会默认走 `/chat/completions`,而
|
|
131
|
+
responses-only 系列(gpt-5.5/5.6 等)会拒绝该端点。固定为 `chat-completions` 也会退出上文所述
|
|
132
|
+
tools+effort 的自动改道。
|
|
133
|
+
|
|
126
134
|
## 开发
|
|
127
135
|
|
|
128
136
|
```sh
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
/** Static per-provider device-flow facts. */
|
|
11
|
+
export interface DeviceFlowSpec {
|
|
12
|
+
/** OAuth App / GitHub App client id the device code is requested for. */
|
|
13
|
+
clientId: string;
|
|
14
|
+
/** Scope string requested at device-code time. */
|
|
15
|
+
scope: string;
|
|
16
|
+
/** Device-code endpoint (e.g. `https://github.com/login/device/code`). */
|
|
17
|
+
deviceCodeUrl: string;
|
|
18
|
+
/** Token polling endpoint (e.g. `https://github.com/login/oauth/access_token`). */
|
|
19
|
+
tokenUrl: string;
|
|
20
|
+
/** Fetch implementation (injectable for tests). */
|
|
21
|
+
fetchFn?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
/** One in-flight device-flow login attempt. */
|
|
24
|
+
export interface DeviceAttempt {
|
|
25
|
+
/** URL the user opens to authorize (e.g. `https://github.com/login/device`). */
|
|
26
|
+
readonly verificationUrl: string;
|
|
27
|
+
/** Short code the user types at the verification URL. */
|
|
28
|
+
readonly userCode: string;
|
|
29
|
+
/**
|
|
30
|
+
* Poll until GitHub releases the access token.
|
|
31
|
+
* @returns the GitHub OAuth access token; rejects on timeout, denial, or cancel.
|
|
32
|
+
*/
|
|
33
|
+
waitToken(): Promise<string>;
|
|
34
|
+
/** Abort the attempt; `waitToken` rejects with a cancellation error. */
|
|
35
|
+
cancel(): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Own the set of in-flight device-flow attempts, keyed by provider. One
|
|
39
|
+
* attempt per provider at a time; an attempt removes itself when it settles.
|
|
40
|
+
*/
|
|
41
|
+
export declare class DeviceFlowManager {
|
|
42
|
+
private attempts;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a device-flow attempt is running for one provider.
|
|
45
|
+
* @param provider - the provider route.
|
|
46
|
+
* @returns true while an attempt is polling.
|
|
47
|
+
*/
|
|
48
|
+
isBusy(provider: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The pending attempt for one provider, when any.
|
|
51
|
+
* @param provider - the provider route.
|
|
52
|
+
* @returns the in-flight attempt, or `undefined`.
|
|
53
|
+
*/
|
|
54
|
+
pending(provider: string): DeviceAttempt | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Start a device-flow attempt: request a device code, then poll the token
|
|
57
|
+
* endpoint in the background of `waitToken`.
|
|
58
|
+
* @param provider - the provider route (one attempt at a time).
|
|
59
|
+
* @param spec - static flow facts for this provider.
|
|
60
|
+
* @returns the live attempt; its `waitToken()` settles the login.
|
|
61
|
+
* @throws when an attempt is already running or the device-code request fails.
|
|
62
|
+
*/
|
|
63
|
+
start(provider: string, spec: DeviceFlowSpec): Promise<DeviceAttempt>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
/** Default poll interval when the device-code response omits one. */
|
|
11
|
+
const DEFAULT_INTERVAL_SEC = 5;
|
|
12
|
+
/** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
|
|
13
|
+
const DEFAULT_EXPIRES_IN_SEC = 900;
|
|
14
|
+
/** Sleep for `ms`, rejecting early when the signal aborts. */
|
|
15
|
+
function sleep(ms, signal) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
if (signal.aborted) {
|
|
18
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
signal.removeEventListener('abort', onAbort);
|
|
23
|
+
resolve();
|
|
24
|
+
}, ms);
|
|
25
|
+
timer.unref();
|
|
26
|
+
const onAbort = () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
|
|
29
|
+
};
|
|
30
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Own the set of in-flight device-flow attempts, keyed by provider. One
|
|
35
|
+
* attempt per provider at a time; an attempt removes itself when it settles.
|
|
36
|
+
*/
|
|
37
|
+
export class DeviceFlowManager {
|
|
38
|
+
attempts = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Whether a device-flow attempt is running for one provider.
|
|
41
|
+
* @param provider - the provider route.
|
|
42
|
+
* @returns true while an attempt is polling.
|
|
43
|
+
*/
|
|
44
|
+
isBusy(provider) {
|
|
45
|
+
return this.attempts.has(provider);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The pending attempt for one provider, when any.
|
|
49
|
+
* @param provider - the provider route.
|
|
50
|
+
* @returns the in-flight attempt, or `undefined`.
|
|
51
|
+
*/
|
|
52
|
+
pending(provider) {
|
|
53
|
+
return this.attempts.get(provider);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Start a device-flow attempt: request a device code, then poll the token
|
|
57
|
+
* endpoint in the background of `waitToken`.
|
|
58
|
+
* @param provider - the provider route (one attempt at a time).
|
|
59
|
+
* @param spec - static flow facts for this provider.
|
|
60
|
+
* @returns the live attempt; its `waitToken()` settles the login.
|
|
61
|
+
* @throws when an attempt is already running or the device-code request fails.
|
|
62
|
+
*/
|
|
63
|
+
async start(provider, spec) {
|
|
64
|
+
if (this.attempts.has(provider)) {
|
|
65
|
+
throw new Error(`a ${provider} login attempt is already in progress`);
|
|
66
|
+
}
|
|
67
|
+
const fetchFn = spec.fetchFn ?? fetch;
|
|
68
|
+
const response = await fetchFn(spec.deviceCodeUrl, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'accept': 'application/json',
|
|
72
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
73
|
+
},
|
|
74
|
+
body: new URLSearchParams({ client_id: spec.clientId, scope: spec.scope }).toString(),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
throw new Error(`${provider} device-code request failed (HTTP ${String(response.status)})`);
|
|
78
|
+
}
|
|
79
|
+
const wire = await response.json();
|
|
80
|
+
if (typeof wire.device_code !== 'string' || wire.device_code.length === 0
|
|
81
|
+
|| typeof wire.user_code !== 'string' || wire.user_code.length === 0
|
|
82
|
+
|| typeof wire.verification_uri !== 'string' || wire.verification_uri.length === 0) {
|
|
83
|
+
throw new Error(`${provider} device-code response is missing device_code/user_code/verification_uri`);
|
|
84
|
+
}
|
|
85
|
+
const intervalSec = typeof wire.interval === 'number' && wire.interval > 0
|
|
86
|
+
? wire.interval
|
|
87
|
+
: DEFAULT_INTERVAL_SEC;
|
|
88
|
+
const expiresInSec = typeof wire.expires_in === 'number' && wire.expires_in > 0
|
|
89
|
+
? wire.expires_in
|
|
90
|
+
: DEFAULT_EXPIRES_IN_SEC;
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
let resolveToken;
|
|
93
|
+
let rejectToken;
|
|
94
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
95
|
+
resolveToken = resolve;
|
|
96
|
+
rejectToken = reject;
|
|
97
|
+
});
|
|
98
|
+
// The promise settles exactly once, from the poll loop below; an unhandled
|
|
99
|
+
// rejection must not surface if nobody awaited waitToken after a cancel.
|
|
100
|
+
tokenPromise.catch(() => undefined);
|
|
101
|
+
const settle = (error, token) => {
|
|
102
|
+
// Identity check: a late settle from a stale attempt (its poll loop or a
|
|
103
|
+
// cancel arriving after it already settled) must not kill a NEW attempt
|
|
104
|
+
// the user started for the same provider.
|
|
105
|
+
if (this.attempts.get(provider) !== attempt)
|
|
106
|
+
return;
|
|
107
|
+
this.attempts.delete(provider);
|
|
108
|
+
if (error !== undefined)
|
|
109
|
+
rejectToken(error);
|
|
110
|
+
else if (token !== undefined)
|
|
111
|
+
resolveToken(token);
|
|
112
|
+
};
|
|
113
|
+
const poll = async () => {
|
|
114
|
+
let intervalMs = intervalSec * 1000;
|
|
115
|
+
const deadline = Date.now() + expiresInSec * 1000;
|
|
116
|
+
while (true) {
|
|
117
|
+
await sleep(intervalMs, controller.signal);
|
|
118
|
+
if (Date.now() >= deadline) {
|
|
119
|
+
settle(new Error(`login timed out after ${String(Math.round(expiresInSec))}s`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const pollResponse = await fetchFn(spec.tokenUrl, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: {
|
|
125
|
+
'accept': 'application/json',
|
|
126
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
127
|
+
},
|
|
128
|
+
body: new URLSearchParams({
|
|
129
|
+
client_id: spec.clientId,
|
|
130
|
+
device_code: wire.device_code,
|
|
131
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
132
|
+
}).toString(),
|
|
133
|
+
signal: controller.signal,
|
|
134
|
+
});
|
|
135
|
+
const result = await pollResponse.json();
|
|
136
|
+
if (typeof result.access_token === 'string' && result.access_token.length > 0) {
|
|
137
|
+
settle(undefined, result.access_token);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
switch (result.error) {
|
|
141
|
+
case 'authorization_pending':
|
|
142
|
+
break;
|
|
143
|
+
case 'slow_down':
|
|
144
|
+
// RFC 8628 §3.5: add five seconds to the poll interval.
|
|
145
|
+
intervalMs += 5000;
|
|
146
|
+
break;
|
|
147
|
+
case 'access_denied':
|
|
148
|
+
settle(new Error('login declined on the GitHub authorization page'));
|
|
149
|
+
return;
|
|
150
|
+
case 'expired_token':
|
|
151
|
+
settle(new Error('the device code expired before authorization completed'));
|
|
152
|
+
return;
|
|
153
|
+
default:
|
|
154
|
+
settle(new Error(`${provider} device-flow polling failed: ${result.error_description ?? result.error ?? `HTTP ${String(pollResponse.status)}`}`));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const attempt = {
|
|
160
|
+
verificationUrl: wire.verification_uri,
|
|
161
|
+
userCode: wire.user_code,
|
|
162
|
+
waitToken: () => tokenPromise,
|
|
163
|
+
cancel: () => {
|
|
164
|
+
controller.abort(new Error('login cancelled'));
|
|
165
|
+
settle(new Error('login cancelled'));
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
this.attempts.set(provider, attempt);
|
|
169
|
+
void poll().catch((error) => {
|
|
170
|
+
// Aborts land here from sleep/fetch; everything else is a transport or
|
|
171
|
+
// parse failure worth surfacing as the login failure.
|
|
172
|
+
settle(error instanceof Error ? error : new Error(String(error)));
|
|
173
|
+
});
|
|
174
|
+
return attempt;
|
|
175
|
+
}
|
|
176
|
+
}
|
package/lib/auth/oauth-flow.js
CHANGED
package/lib/auth/rpc.d.ts
CHANGED
|
@@ -55,11 +55,13 @@ export interface AuthController {
|
|
|
55
55
|
status(provider: ProviderId): Promise<ProviderStatus>;
|
|
56
56
|
/**
|
|
57
57
|
* Start a background login attempt.
|
|
58
|
-
* @returns the authorize URL for the user's browser
|
|
58
|
+
* @returns the authorize URL for the user's browser; device-flow providers
|
|
59
|
+
* (copilot) also return the `userCode` the user types at that URL.
|
|
59
60
|
* @throws when an attempt is already running for this provider.
|
|
60
61
|
*/
|
|
61
62
|
login(provider: ProviderId): Promise<{
|
|
62
63
|
authorizeUrl: string;
|
|
64
|
+
userCode?: string;
|
|
63
65
|
}>;
|
|
64
66
|
/**
|
|
65
67
|
* Feed a pasted callback URL or bare code into the pending attempt.
|
package/lib/auth/store.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* owns the durable format.
|
|
8
8
|
*/
|
|
9
9
|
/** Provider routes this plugin can serve. */
|
|
10
|
-
export type ProviderId = 'codex' | 'claude' | 'grok';
|
|
10
|
+
export type ProviderId = 'codex' | 'claude' | 'grok' | 'copilot';
|
|
11
11
|
/** Every provider route, in display order. */
|
|
12
12
|
export declare const PROVIDER_IDS: readonly ProviderId[];
|
|
13
13
|
/** Stored ChatGPT/Codex subscription session. */
|
|
@@ -47,14 +47,32 @@ export interface GrokSession {
|
|
|
47
47
|
/** Display account: email, username, or subject claim from the id token. */
|
|
48
48
|
account?: string;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Stored GitHub Copilot subscription session. Two token generations are at
|
|
52
|
+
* play: the long-lived GitHub OAuth token from the device flow is kept in
|
|
53
|
+
* `refreshToken`, and `accessToken` carries the short-lived (~30 minutes)
|
|
54
|
+
* Copilot API token exchanged from it. A "refresh" is therefore a fresh
|
|
55
|
+
* exchange against `copilot_internal/v2/token`, not an OAuth grant.
|
|
56
|
+
*/
|
|
57
|
+
export interface CopilotSession {
|
|
58
|
+
/** Copilot API token; sent as the bearer on api.githubcopilot.com. */
|
|
59
|
+
accessToken: string;
|
|
60
|
+
/** Long-lived GitHub OAuth token from the device flow. */
|
|
61
|
+
refreshToken: string;
|
|
62
|
+
/** Epoch milliseconds at which the Copilot API token expires. */
|
|
63
|
+
expiresAt: number;
|
|
64
|
+
/** GitHub login name, for the status display. */
|
|
65
|
+
account?: string;
|
|
66
|
+
}
|
|
50
67
|
/** The durable store shape: one optional session per provider. */
|
|
51
68
|
export interface SessionMap {
|
|
52
69
|
codex?: CodexSession;
|
|
53
70
|
claude?: ClaudeSession;
|
|
54
71
|
grok?: GrokSession;
|
|
72
|
+
copilot?: CopilotSession;
|
|
55
73
|
}
|
|
56
74
|
/** Any stored session, for provider-agnostic plumbing. */
|
|
57
|
-
export type StoredSession = CodexSession | ClaudeSession | GrokSession;
|
|
75
|
+
export type StoredSession = CodexSession | ClaudeSession | GrokSession | CopilotSession;
|
|
58
76
|
/**
|
|
59
77
|
* Absolute path of the auth store file.
|
|
60
78
|
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
package/lib/auth/store.js
CHANGED
|
@@ -10,7 +10,7 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
|
10
10
|
import { dirname } from 'node:path';
|
|
11
11
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
12
12
|
/** Every provider route, in display order. */
|
|
13
|
-
export const PROVIDER_IDS = ['codex', 'claude', 'grok'];
|
|
13
|
+
export const PROVIDER_IDS = ['codex', 'claude', 'grok', 'copilot'];
|
|
14
14
|
/**
|
|
15
15
|
* Absolute path of the auth store file.
|
|
16
16
|
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
|
@@ -103,6 +103,38 @@ async function writeStore(store, path) {
|
|
|
103
103
|
throw error;
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* One write chain per store path. Every mutation is a read-modify-write of a
|
|
108
|
+
* single JSON file, and the plugin has several independent writers — a login,
|
|
109
|
+
* a logout, and one token refresh per provider adapter, each on its own
|
|
110
|
+
* schedule. Overlapping them unserialized costs whichever provider read the
|
|
111
|
+
* store first its entry.
|
|
112
|
+
*
|
|
113
|
+
* A chain is dropped once nothing is queued behind it, so the map holds an
|
|
114
|
+
* entry only while writes are in flight.
|
|
115
|
+
*/
|
|
116
|
+
const writeChains = new Map();
|
|
117
|
+
/**
|
|
118
|
+
* Run one read-modify-write of a store path after every write already queued
|
|
119
|
+
* for it. Callers join the chain synchronously, so call order is write order.
|
|
120
|
+
* @param path - the store file being mutated.
|
|
121
|
+
* @param action - the read-modify-write to run.
|
|
122
|
+
* @returns whatever `action` returns.
|
|
123
|
+
*/
|
|
124
|
+
async function serialize(path, action) {
|
|
125
|
+
const previous = writeChains.get(path) ?? Promise.resolve();
|
|
126
|
+
// Both handlers: a failed write must not strand everything queued behind it.
|
|
127
|
+
const next = previous.then(action, action);
|
|
128
|
+
const tail = next.then(() => undefined, () => undefined);
|
|
129
|
+
writeChains.set(path, tail);
|
|
130
|
+
try {
|
|
131
|
+
return await next;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
if (writeChains.get(path) === tail)
|
|
135
|
+
writeChains.delete(path);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
106
138
|
/**
|
|
107
139
|
* Read one provider's session.
|
|
108
140
|
* @param provider - the provider route.
|
|
@@ -119,9 +151,11 @@ export async function getSession(provider, path = authFilePath()) {
|
|
|
119
151
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
120
152
|
*/
|
|
121
153
|
export async function saveSession(provider, session, path = authFilePath()) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
154
|
+
return serialize(path, async () => {
|
|
155
|
+
const store = await loadStore(path);
|
|
156
|
+
store[provider] = session;
|
|
157
|
+
await writeStore(store, path);
|
|
158
|
+
});
|
|
125
159
|
}
|
|
126
160
|
/**
|
|
127
161
|
* Delete one provider's session (logout).
|
|
@@ -129,9 +163,11 @@ export async function saveSession(provider, session, path = authFilePath()) {
|
|
|
129
163
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
130
164
|
*/
|
|
131
165
|
export async function deleteSession(provider, path = authFilePath()) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
166
|
+
return serialize(path, async () => {
|
|
167
|
+
const store = await loadStore(path);
|
|
168
|
+
if (store[provider] === undefined)
|
|
169
|
+
return;
|
|
170
|
+
delete store[provider];
|
|
171
|
+
await writeStore(store, path);
|
|
172
|
+
});
|
|
137
173
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
|
|
2
2
|
import type { SubscriptionsKey } from './locales.js';
|
|
3
3
|
/** Subscription provider ids, fixed by the node half's OAuth adapters. */
|
|
4
|
-
export type SubscriptionProvider = 'codex' | 'claude' | 'grok';
|
|
4
|
+
export type SubscriptionProvider = 'codex' | 'claude' | 'grok' | 'copilot';
|
|
5
5
|
/** One provider's login state as answered by the `status` endpoint. */
|
|
6
6
|
export interface ProviderStatus {
|
|
7
7
|
loggedIn: boolean;
|
|
@@ -24,6 +24,7 @@ const PROVIDERS = [
|
|
|
24
24
|
{ id: 'codex', name: 'Codex (ChatGPT)' },
|
|
25
25
|
{ id: 'claude', name: 'Claude' },
|
|
26
26
|
{ id: 'grok', name: 'Grok (X Premium)' },
|
|
27
|
+
{ id: 'copilot', name: 'GitHub Copilot' },
|
|
27
28
|
];
|
|
28
29
|
/** Business error returned by the `/subscriptions-auth` channel (error branch message). */
|
|
29
30
|
class SubscriptionsAuthError extends Error {
|
|
@@ -122,6 +123,15 @@ const styles = {
|
|
|
122
123
|
padding: '0 10px', font: 'inherit', fontSize: 14, lineHeight: '22px',
|
|
123
124
|
background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)',
|
|
124
125
|
},
|
|
126
|
+
deviceCode: {
|
|
127
|
+
marginTop: 4, display: 'flex', flexDirection: 'column', gap: 6,
|
|
128
|
+
border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
|
|
129
|
+
padding: '10px 12px', background: 'var(--dsw-alias-bg-layer-1)',
|
|
130
|
+
},
|
|
131
|
+
deviceCodeText: {
|
|
132
|
+
fontFamily: 'monospace', fontSize: 18, lineHeight: '24px', letterSpacing: 2,
|
|
133
|
+
color: 'var(--dsw-alias-label-primary)', userSelect: 'all',
|
|
134
|
+
},
|
|
125
135
|
};
|
|
126
136
|
/** Status dot color for one provider state. */
|
|
127
137
|
function dotColor(status) {
|
|
@@ -189,8 +199,11 @@ export function SubscriptionsSection(props) {
|
|
|
189
199
|
const [statuses, setStatuses] = useState({});
|
|
190
200
|
const [errors, setErrors] = useState({});
|
|
191
201
|
const [manualDrafts, setManualDrafts] = useState({
|
|
192
|
-
codex: '', claude: '', grok: '',
|
|
202
|
+
codex: '', claude: '', grok: '', copilot: '',
|
|
193
203
|
});
|
|
204
|
+
/** Pending device-flow codes (copilot), shown while the attempt polls. */
|
|
205
|
+
const [deviceCodes, setDeviceCodes] = useState({});
|
|
206
|
+
const [copiedCode, setCopiedCode] = useState(undefined);
|
|
194
207
|
const [usages, setUsages] = useState({});
|
|
195
208
|
const [usageErrors, setUsageErrors] = useState({});
|
|
196
209
|
const [usageLoading, setUsageLoading] = useState({});
|
|
@@ -235,8 +248,17 @@ export function SubscriptionsSection(props) {
|
|
|
235
248
|
setStatuses(response.providers);
|
|
236
249
|
for (const { id } of PROVIDERS) {
|
|
237
250
|
const status = response.providers[id];
|
|
238
|
-
if (status.loggedIn || !status.busy)
|
|
251
|
+
if (status.loggedIn || !status.busy) {
|
|
239
252
|
stopPolling(id);
|
|
253
|
+
// The attempt settled (success, timeout, or cancel): drop the code card.
|
|
254
|
+
setDeviceCodes((prev) => {
|
|
255
|
+
if (prev[id] === undefined)
|
|
256
|
+
return prev;
|
|
257
|
+
const next = { ...prev };
|
|
258
|
+
delete next[id];
|
|
259
|
+
return next;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
240
262
|
}
|
|
241
263
|
}, [rpc, stopPolling]);
|
|
242
264
|
const startPolling = useCallback((provider) => {
|
|
@@ -332,11 +354,18 @@ export function SubscriptionsSection(props) {
|
|
|
332
354
|
if (typeof response.authorizeUrl !== 'string') {
|
|
333
355
|
throw new SubscriptionsAuthError(t('loginMissingUrl'));
|
|
334
356
|
}
|
|
335
|
-
window.open(response.authorizeUrl, '_blank', 'noopener');
|
|
336
357
|
if (!mountedRef.current)
|
|
337
358
|
return;
|
|
338
359
|
// Optimistic busy so Cancel and the manual fallback appear before the first poll tick.
|
|
339
360
|
setStatuses(prev => ({ ...prev, [provider]: { ...prev[provider], busy: true, loggedIn: false } }));
|
|
361
|
+
if (typeof response.userCode === 'string' && response.userCode.length > 0) {
|
|
362
|
+
// Device flow: show the code card instead of opening the page blind —
|
|
363
|
+
// the user copies the code first, then opens the verification page.
|
|
364
|
+
setDeviceCodes(prev => ({ ...prev, [provider]: { userCode: response.userCode, verificationUrl: response.authorizeUrl } }));
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
window.open(response.authorizeUrl, '_blank', 'noopener');
|
|
368
|
+
}
|
|
340
369
|
startPolling(provider);
|
|
341
370
|
}
|
|
342
371
|
catch (error) {
|
|
@@ -386,12 +415,25 @@ export function SubscriptionsSection(props) {
|
|
|
386
415
|
}
|
|
387
416
|
await refresh();
|
|
388
417
|
}, [rpc, t, setProviderError, refresh]);
|
|
418
|
+
const copyDeviceCode = useCallback((provider, userCode) => {
|
|
419
|
+
void navigator.clipboard?.writeText(userCode).then(() => {
|
|
420
|
+
if (!mountedRef.current)
|
|
421
|
+
return;
|
|
422
|
+
setCopiedCode(provider);
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
if (mountedRef.current) {
|
|
425
|
+
setCopiedCode(current => current === provider ? undefined : current);
|
|
426
|
+
}
|
|
427
|
+
}, 1500);
|
|
428
|
+
}).catch(() => undefined);
|
|
429
|
+
}, []);
|
|
389
430
|
if (rpc === undefined) {
|
|
390
431
|
return _jsx("p", { style: styles.intro, children: t('unavailable') });
|
|
391
432
|
}
|
|
392
433
|
return (_jsxs("div", { style: styles.section, children: [_jsx("p", { style: styles.intro, children: t('intro') }), PROVIDERS.map(({ id, name }) => {
|
|
393
434
|
const status = statuses[id];
|
|
394
435
|
const busy = status?.busy === true;
|
|
436
|
+
const deviceCode = deviceCodes[id];
|
|
395
437
|
const usage = usages[id];
|
|
396
438
|
const usageError = usageErrors[id];
|
|
397
439
|
// Providers without a usage endpoint answer supported:false — no block.
|
|
@@ -401,6 +443,6 @@ export function SubscriptionsSection(props) {
|
|
|
401
443
|
const percent = Math.min(100, Math.max(0, window.usedPercent));
|
|
402
444
|
return (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: usageWindowLabel(t, window) }), _jsxs("span", { children: [`${String(Math.round(percent))}%`, window.resetsAt !== undefined
|
|
403
445
|
&& ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx("div", { style: styles.usageTrack, children: _jsx("div", { style: { ...styles.usageFill, width: `${String(percent)}%`, background: usageBarColor(percent) } }) })] }, index));
|
|
404
|
-
})] })), busy && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
|
|
446
|
+
})] })), busy && deviceCode !== undefined && (_jsxs("div", { style: styles.deviceCode, children: [_jsx("span", { style: styles.statusLine, children: t('deviceCodePrompt') }), _jsx("span", { style: styles.deviceCodeText, children: deviceCode.userCode }), _jsxs("div", { style: styles.actions, children: [_jsx("button", { type: "button", style: styles.button, onClick: () => { copyDeviceCode(id, deviceCode.userCode); }, children: copiedCode === id ? t('deviceCodeCopied') : t('deviceCodeCopy') }), _jsx("button", { type: "button", style: styles.button, onClick: () => { window.open(deviceCode.verificationUrl, '_blank', 'noopener'); }, children: t('deviceCodeOpenPage') })] })] })), busy && deviceCode === undefined && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
|
|
405
447
|
})] }));
|
|
406
448
|
}
|