dsh-plugin-subscriptions 0.4.2 → 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.
Files changed (44) hide show
  1. package/README.md +19 -6
  2. package/README.zh.md +18 -6
  3. package/lib/auth/device-flow.d.ts +64 -0
  4. package/lib/auth/device-flow.js +176 -0
  5. package/lib/auth/oauth-flow.js +1 -1
  6. package/lib/auth/rpc.d.ts +21 -2
  7. package/lib/auth/rpc.js +23 -3
  8. package/lib/auth/store.d.ts +20 -2
  9. package/lib/auth/store.js +45 -9
  10. package/lib/client/ImageGallery.d.ts +54 -0
  11. package/lib/client/ImageGallery.js +112 -0
  12. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  13. package/lib/client/ImageGenerateToolview.js +2 -2
  14. package/lib/client/SpeedSelect.d.ts +48 -0
  15. package/lib/client/SpeedSelect.js +173 -0
  16. package/lib/client/SubscriptionsSection.d.ts +10 -1
  17. package/lib/client/SubscriptionsSection.js +48 -5
  18. package/lib/client/index.d.ts +1 -0
  19. package/lib/client/index.js +42 -0
  20. package/lib/client/locales.d.ts +22 -0
  21. package/lib/client/locales.js +22 -0
  22. package/lib/client.js +679 -77
  23. package/lib/client.js.map +1 -1
  24. package/lib/index.d.ts +3 -2
  25. package/lib/index.js +1868 -183
  26. package/lib/providers/catalog-store.js +19 -0
  27. package/lib/providers/claude.d.ts +20 -1
  28. package/lib/providers/claude.js +58 -31
  29. package/lib/providers/codex.d.ts +27 -0
  30. package/lib/providers/codex.js +117 -27
  31. package/lib/providers/common.d.ts +34 -1
  32. package/lib/providers/common.js +48 -1
  33. package/lib/providers/copilot.d.ts +315 -0
  34. package/lib/providers/copilot.js +786 -0
  35. package/lib/providers/grok.d.ts +7 -2
  36. package/lib/providers/grok.js +46 -18
  37. package/lib/tools/image-generate.js +3 -10
  38. package/lib/translate/anthropic.d.ts +47 -6
  39. package/lib/translate/anthropic.js +135 -20
  40. package/lib/translate/chat-completions.d.ts +120 -0
  41. package/lib/translate/chat-completions.js +363 -0
  42. package/lib/translate/responses.d.ts +49 -5
  43. package/lib/translate/responses.js +40 -7
  44. package/package.json +6 -3
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,10 +14,14 @@ 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
 
21
+ Codex models whose catalog advertises the fast tier (the codex CLI's fast mode) get a **Speed** toggle in the composer's tool row, next to the model selector — Standard or Fast (`service_tier: priority`), per session. The `/fast` slash command offers the same choice as a popup; it errors with an explanation when the current model has no fast tier.
22
+
23
+ ![Speed toggle with the Standard/Fast menu open](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/speed-toggle.png)
24
+
21
25
  The `image_generate` tool renders its result inline in the conversation:
22
26
 
23
27
  ![image_generate renders the image inline](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/image-generate-inline.png)
@@ -37,10 +41,11 @@ The `video_generate` tool plays the generated clip inline:
37
41
  | `codex` | ChatGPT Plus/Pro | live catalog from `chatgpt.com/backend-api/codex/models` |
38
42
  | `claude` | Claude Pro/Max | all models available in your subscription (Opus, Sonnet, Haiku, Fable — static catalog, updated with the plugin) |
39
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`) |
40
45
 
41
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.
42
47
 
43
- 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.
44
49
 
45
50
  Also included, registered when the matching provider is enabled:
46
51
 
@@ -101,7 +106,7 @@ Either way, restart `dsh web` afterwards so the new version loads.
101
106
  ## Use
102
107
 
103
108
  1. `dsh web`, open the printed URL.
104
- 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.
105
110
  3. In any session, open the model picker (`/model`) and choose a model under **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)**.
106
111
 
107
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.
@@ -117,8 +122,16 @@ Not logged in? The provider stays out of the picker, and requests fail with `MIS
117
122
  models: # override the discovered/built-in catalogs
118
123
  codex:
119
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
120
127
  ```
121
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
+
122
135
  ## Develop
123
136
 
124
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,10 +14,14 @@
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
 
21
+ 目录声明了 fast tier(即 codex CLI 的 fast 模式)的 Codex 模型,会在输入框工具行(模型选择器旁)多出一个**速度**开关 —— 标准 / 快速(`service_tier: priority`),按会话生效。`/fast` 斜杠命令提供同样的弹窗选择;当前模型不支持快速档时会提示原因。
22
+
23
+ ![速度开关及其标准/快速菜单](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/speed-toggle.png)
24
+
21
25
  `image_generate` 工具生成的图片直接内联显示在对话里:
22
26
 
23
27
  ![image_generate 内联显示生成的图片](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/image-generate-inline.png)
@@ -37,10 +41,11 @@
37
41
  | `codex` | ChatGPT Plus/Pro | 从 `chatgpt.com/backend-api/codex/models` 实时获取 |
38
42
  | `claude` | Claude Pro/Max | 订阅内所有可用模型(Opus、Sonnet、Haiku、Fable —— 静态目录,随插件更新) |
39
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` 输入页面显示的验证码) |
40
45
 
41
46
  只有已登录的 provider 才会出现在会话模型选择器里;登录/退出后列表自动刷新。支持视觉的模型会声明 `['text', 'image']` 输入模态,图片内容会被翻译成各 provider 的 wire 格式。
42
47
 
43
- 已登录的卡片还会显示**订阅用量**——按限额窗口(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 没有用量接口,其卡片不显示用量区块。
44
49
 
45
50
  随 provider 启用自动注册的工具:
46
51
 
@@ -101,7 +106,7 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
101
106
  ## 使用
102
107
 
103
108
  1. `dsh web`,打开打印的 URL。
104
- 2. **设置 → 订阅**:点对应 provider 的「连接」。Claude 会即时从 Claude Code 导入凭据(需先运行过 `claude` 并登录)。Codex 和 Grok 在打开的标签页里授权;无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
109
+ 2. **设置 → 订阅**:点对应 provider 的「连接」。若先运行过 `claude` 并登录,Claude 会即时导入凭据;没有凭据时,Claude 也和其他 provider 一样在浏览器里授权。Codex 和 Grok 在打开的标签页里授权;无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
105
110
  3. 在任意会话里打开模型选择器(`/model`),选择 **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)** 下的模型。
106
111
 
107
112
  未登录时:该 provider 不出现在选择器里;直接请求会报 `MISSING_CREDENTIAL` 并提示去设置页登录,不影响其他功能。
@@ -117,8 +122,15 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
117
122
  models: # 覆盖实时发现/内置目录
118
123
  codex:
119
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:强制指定上游协议
120
127
  ```
121
128
 
129
+ `wire`(仅 copilot 条目)把模型固定到 `chat-completions` 或 `responses`。不加该字段手工条目照常
130
+ 工作——它存在的原因是:实时目录不认识的手工模型否则会默认走 `/chat/completions`,而
131
+ responses-only 系列(gpt-5.5/5.6 等)会拒绝该端点。固定为 `chat-completions` 也会退出上文所述
132
+ tools+effort 的自动改道。
133
+
122
134
  ## 开发
123
135
 
124
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
@@ -20,6 +20,22 @@ export interface VideoBytesResult {
20
20
  mediaType: string;
21
21
  dataBase64: string;
22
22
  }
23
+ /** One session's speed choice: standard routing or the fast (priority) tier. */
24
+ export type SpeedTier = 'standard' | 'fast';
25
+ /** `speed` endpoint value: the session's choice plus the visibility list. */
26
+ export interface SpeedState {
27
+ /** The session's current speed tier (default `standard`). */
28
+ tier: SpeedTier;
29
+ /** Codex model ids whose catalog advertises a fast tier. */
30
+ fastModels: string[];
31
+ }
32
+ /** Speed state the RPC handler delegates to (in-memory, per session). */
33
+ export interface SpeedController {
34
+ /** Current speed state: the session's tier and the fast-capable codex models. */
35
+ speed(sessionId: string): Promise<SpeedState>;
36
+ /** Set one session's speed tier. */
37
+ setSpeed(sessionId: string, tier: SpeedTier): Promise<void>;
38
+ }
23
39
  /** Login state of one provider, as rendered by the Settings page. */
24
40
  export interface ProviderStatus {
25
41
  /** Whether a session exists in the store. */
@@ -39,11 +55,13 @@ export interface AuthController {
39
55
  status(provider: ProviderId): Promise<ProviderStatus>;
40
56
  /**
41
57
  * Start a background login attempt.
42
- * @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.
43
60
  * @throws when an attempt is already running for this provider.
44
61
  */
45
62
  login(provider: ProviderId): Promise<{
46
63
  authorizeUrl: string;
64
+ userCode?: string;
47
65
  }>;
48
66
  /**
49
67
  * Feed a pasted callback URL or bare code into the pending attempt.
@@ -83,5 +101,6 @@ export interface AuthController {
83
101
  * Register the `/subscriptions-auth` RPC channel when a host connection exists.
84
102
  * @param ctx - the plugin context (headless profiles have no `connection`).
85
103
  * @param controller - the auth operations backing the endpoints.
104
+ * @param speed - the per-session speed-tier state backing the Speed toggle.
86
105
  */
87
- export declare function registerAuthRpc(ctx: Context, controller: AuthController): void;
106
+ export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController): void;
package/lib/auth/rpc.js CHANGED
@@ -42,6 +42,14 @@ function readString(payload, field) {
42
42
  }
43
43
  return value;
44
44
  }
45
+ /** Validate the `setSpeed` endpoint's tier. */
46
+ function readSpeedTier(payload) {
47
+ const tier = payload.tier;
48
+ if (tier !== 'standard' && tier !== 'fast') {
49
+ throw new BadRequest('payload.tier must be "standard" or "fast"');
50
+ }
51
+ return tier;
52
+ }
45
53
  /** Validate the `image` endpoint's payload into a full attachment reference. */
46
54
  function readImageRef(payload) {
47
55
  if (typeof payload !== 'object' || payload === null)
@@ -88,7 +96,13 @@ function readVideoName(payload) {
88
96
  }
89
97
  return name;
90
98
  }
91
- async function dispatch(controller, endpoint, payload, signal) {
99
+ /** Validate the session id both speed endpoints carry. */
100
+ function readSessionId(payload) {
101
+ if (typeof payload !== 'object' || payload === null)
102
+ throw new BadRequest('payload must be an object');
103
+ return readString(payload, 'sessionId');
104
+ }
105
+ async function dispatch(controller, speed, endpoint, payload, signal) {
92
106
  switch (endpoint) {
93
107
  case 'status': {
94
108
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
@@ -113,6 +127,11 @@ async function dispatch(controller, endpoint, payload, signal) {
113
127
  return ok(await controller.readImage(readImageRef(payload), signal));
114
128
  case 'video':
115
129
  return ok(await controller.readVideo(readVideoName(payload), signal));
130
+ case 'speed':
131
+ return ok(await speed.speed(readSessionId(payload)));
132
+ case 'setSpeed':
133
+ await speed.setSpeed(readSessionId(payload), readSpeedTier(payload));
134
+ return ok({ ok: true });
116
135
  default:
117
136
  throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
118
137
  }
@@ -121,8 +140,9 @@ async function dispatch(controller, endpoint, payload, signal) {
121
140
  * Register the `/subscriptions-auth` RPC channel when a host connection exists.
122
141
  * @param ctx - the plugin context (headless profiles have no `connection`).
123
142
  * @param controller - the auth operations backing the endpoints.
143
+ * @param speed - the per-session speed-tier state backing the Speed toggle.
124
144
  */
125
- export function registerAuthRpc(ctx, controller) {
145
+ export function registerAuthRpc(ctx, controller, speed) {
126
146
  // `connection` is not in this plugin's inject list (headless compositions
127
147
  // lack it), so its startup order is unconstrained: defer registration until
128
148
  // the service exists instead of probing once at apply time.
@@ -130,7 +150,7 @@ export function registerAuthRpc(ctx, controller) {
130
150
  const connection = ctx.get('connection');
131
151
  ctx.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
132
152
  try {
133
- return await dispatch(controller, endpoint, payload, signal);
153
+ return await dispatch(controller, speed, endpoint, payload, signal);
134
154
  }
135
155
  catch (error) {
136
156
  return failure(error);
@@ -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
  }