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 CHANGED
@@ -1,12 +1,12 @@
1
- # dsh-plugin-subscriptions
1
+ # dsh-plugin-subscriptions [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](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 directly from an existing Claude Code session (macOS Keychain or `~/.claude/.credentials.json`). Tokens live at `~/.dsh/plugins/subscriptions/auth.json` (mode 0600) and refresh automatically.
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; Codex and Grok use OAuth (account address masked in the screenshot):
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
  ![Subscriptions settings page](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/subscriptions.png)
12
12
 
@@ -14,7 +14,7 @@ Logged-in providers join the session model picker with their live model catalogs
14
14
 
15
15
  ![Model picker with subscription models](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-picker.png)
16
16
 
17
- Models that advertise reasoning levels get an **Effort** selector in the same menu — Codex models, and Grok 4.6 / 4.5 (levels and defaults come from each provider's live catalog, not a hardcoded list):
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
  ![Reasoning effort selector](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-effort.png)
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 from Claude Code (you must have run `claude` and logged in at least once). 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
+ 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 [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](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 直接从已有的 Claude Code 会话导入凭据(macOS Keychain 或 `~/.claude/.credentials.json`)。Token 保存在 `~/.dsh/plugins/subscriptions/auth.json`(权限 0600),过期自动刷新。
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 Claude Code 导入凭据;CodexGrok 使用 OAuth(截图中账号已打码):
9
+ 设置 → **订阅**:每个 provider 的登录/退出,无需 API key。Claude Claude Code 会话时导入凭据,否则和 CodexGrok 一样走 OAuth(截图中账号已打码):
10
10
 
11
11
  ![订阅设置页](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/subscriptions.png)
12
12
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  ![模型选择器中的订阅模型](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-picker.png)
16
16
 
17
- 声明了推理等级的模型会在同一菜单里多出**推理等级**选择 —— Codex 系列模型,以及 Grok 4.6 / 4.5(档位和默认值来自各 provider 的实时目录,不是硬编码列表):
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
  ![推理等级选择器](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-effort.png)
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 的「连接」。Claude 会即时从 Claude Code 导入凭据(需先运行过 `claude` 并登录)。Codex 和 Grok 在打开的标签页里授权;无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
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
+ }
@@ -117,7 +117,7 @@ export class OAuthFlowManager {
117
117
  }
118
118
  const input = {
119
119
  redirectUri: '',
120
- state: randomToken(16),
120
+ state: randomToken(32),
121
121
  pkce: createPkce(),
122
122
  nonce: randomHex(8),
123
123
  };
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.
@@ -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
- const store = await loadStore(path);
123
- store[provider] = session;
124
- await writeStore(store, path);
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
- const store = await loadStore(path);
133
- if (store[provider] === undefined)
134
- return;
135
- delete store[provider];
136
- await writeStore(store, path);
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
  }