dsh-plugin-subscriptions 0.5.0 → 0.5.2

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 +21 -6
  2. package/README.zh.md +20 -6
  3. package/lib/auth/device-flow.d.ts +55 -0
  4. package/lib/auth/device-flow.js +177 -0
  5. package/lib/auth/oauth-flow.js +1 -1
  6. package/lib/auth/rpc.d.ts +18 -2
  7. package/lib/auth/rpc.js +98 -3
  8. package/lib/auth/store.d.ts +20 -2
  9. package/lib/auth/store.js +45 -9
  10. package/lib/client/SubscriptionsSection.d.ts +18 -1
  11. package/lib/client/SubscriptionsSection.js +216 -6
  12. package/lib/client/index.js +11 -0
  13. package/lib/client/locales.d.ts +72 -0
  14. package/lib/client/locales.js +72 -0
  15. package/lib/client.js +725 -144
  16. package/lib/client.js.map +1 -1
  17. package/lib/http.d.ts +114 -0
  18. package/lib/http.js +402 -0
  19. package/lib/index.d.ts +3 -2
  20. package/lib/index.js +2256 -226
  21. package/lib/providers/antigravity.d.ts +90 -0
  22. package/lib/providers/antigravity.js +392 -0
  23. package/lib/providers/catalog-store.js +15 -0
  24. package/lib/providers/claude.d.ts +20 -1
  25. package/lib/providers/claude.js +51 -33
  26. package/lib/providers/codex.js +58 -13
  27. package/lib/providers/common.d.ts +32 -1
  28. package/lib/providers/common.js +48 -1
  29. package/lib/providers/copilot.d.ts +315 -0
  30. package/lib/providers/copilot.js +787 -0
  31. package/lib/providers/grok.d.ts +7 -2
  32. package/lib/providers/grok.js +53 -24
  33. package/lib/tools/image-generate.js +2 -1
  34. package/lib/tools/video-generate.js +2 -1
  35. package/lib/tools/x-search.js +2 -1
  36. package/lib/translate/anthropic.d.ts +47 -6
  37. package/lib/translate/anthropic.js +135 -20
  38. package/lib/translate/antigravity.d.ts +110 -0
  39. package/lib/translate/antigravity.js +303 -0
  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 +11 -7
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,22 @@ 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
+
135
+ ## Proxy
136
+
137
+ Every subscription request — token exchanges, model-API streams, usage lookups, model discovery, and the `x_search` / `image_generate` / `video_generate` tools — can be routed through an HTTP(S) proxy. Configure it in **Settings → Subscriptions → Proxy → Configure…**: enable the flag, enter the proxy URL (`http://127.0.0.1:7890`), optional username/password, and an optional comma-separated bypass list of hostnames that stay direct (`127.0.0.1`, `localhost`, `*.example.com`). The password is stored in `~/.dsh/plugins/subscriptions/proxy.json` (mode 0600) and is never returned to the browser. A "Test" button probes one endpoint through the current configuration and shows the HTTP status/latency.
138
+
139
+ Changes apply immediately to subsequent requests — no restart needed. The OAuth authorization page opens in your browser and follows the browser/system proxy, not this setting. SOCKS proxies are not supported.
140
+
126
141
  ## Develop
127
142
 
128
143
  ```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,21 @@ 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
+
134
+ ## 代理
135
+
136
+ 所有订阅相关请求 —— token 交换、模型 API 流式调用、用量查询、模型目录发现,以及 `x_search` / `image_generate` / `video_generate` 工具 —— 都可以通过 HTTP(S) 代理发出。在 **设置 → 订阅 → 代理 → 配置…** 中设置:勾选启用,填写代理地址(`http://127.0.0.1:7890`)、可选用户名/密码,以及可选的逗号分隔绕过列表(保持直连的主机名,如 `127.0.0.1`、`localhost`、`*.example.com`)。密码保存在 `~/.dsh/plugins/subscriptions/proxy.json`(权限 0600),不会回传给浏览器;「测试」按钮会用当前配置探测一次端点,显示 HTTP 状态码与耗时。
137
+
138
+ 保存后立即对后续请求生效,无需重启。OAuth 授权页在浏览器中打开,走浏览器/系统自身的代理设置,不受此配置影响;不支持 socks 代理。
139
+
126
140
  ## 开发
127
141
 
128
142
  ```sh
@@ -0,0 +1,55 @@
1
+ /** Static per-provider device-flow facts. */
2
+ export interface DeviceFlowSpec {
3
+ /** OAuth App / GitHub App client id the device code is requested for. */
4
+ clientId: string;
5
+ /** Scope string requested at device-code time. */
6
+ scope: string;
7
+ /** Device-code endpoint (e.g. `https://github.com/login/device/code`). */
8
+ deviceCodeUrl: string;
9
+ /** Token polling endpoint (e.g. `https://github.com/login/oauth/access_token`). */
10
+ tokenUrl: string;
11
+ /** Fetch implementation (injectable for tests). */
12
+ fetchFn?: typeof fetch;
13
+ }
14
+ /** One in-flight device-flow login attempt. */
15
+ export interface DeviceAttempt {
16
+ /** URL the user opens to authorize (e.g. `https://github.com/login/device`). */
17
+ readonly verificationUrl: string;
18
+ /** Short code the user types at the verification URL. */
19
+ readonly userCode: string;
20
+ /**
21
+ * Poll until GitHub releases the access token.
22
+ * @returns the GitHub OAuth access token; rejects on timeout, denial, or cancel.
23
+ */
24
+ waitToken(): Promise<string>;
25
+ /** Abort the attempt; `waitToken` rejects with a cancellation error. */
26
+ cancel(): void;
27
+ }
28
+ /**
29
+ * Own the set of in-flight device-flow attempts, keyed by provider. One
30
+ * attempt per provider at a time; an attempt removes itself when it settles.
31
+ */
32
+ export declare class DeviceFlowManager {
33
+ private attempts;
34
+ /**
35
+ * Whether a device-flow attempt is running for one provider.
36
+ * @param provider - the provider route.
37
+ * @returns true while an attempt is polling.
38
+ */
39
+ isBusy(provider: string): boolean;
40
+ /**
41
+ * The pending attempt for one provider, when any.
42
+ * @param provider - the provider route.
43
+ * @returns the in-flight attempt, or `undefined`.
44
+ */
45
+ pending(provider: string): DeviceAttempt | undefined;
46
+ /**
47
+ * Start a device-flow attempt: request a device code, then poll the token
48
+ * endpoint in the background of `waitToken`.
49
+ * @param provider - the provider route (one attempt at a time).
50
+ * @param spec - static flow facts for this provider.
51
+ * @returns the live attempt; its `waitToken()` settles the login.
52
+ * @throws when an attempt is already running or the device-code request fails.
53
+ */
54
+ start(provider: string, spec: DeviceFlowSpec): Promise<DeviceAttempt>;
55
+ }
@@ -0,0 +1,177 @@
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
+ import { proxiedFetch } from '../http.js';
11
+ /** Default poll interval when the device-code response omits one. */
12
+ const DEFAULT_INTERVAL_SEC = 5;
13
+ /** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
14
+ const DEFAULT_EXPIRES_IN_SEC = 900;
15
+ /** Sleep for `ms`, rejecting early when the signal aborts. */
16
+ function sleep(ms, signal) {
17
+ return new Promise((resolve, reject) => {
18
+ if (signal.aborted) {
19
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
20
+ return;
21
+ }
22
+ const timer = setTimeout(() => {
23
+ signal.removeEventListener('abort', onAbort);
24
+ resolve();
25
+ }, ms);
26
+ timer.unref();
27
+ const onAbort = () => {
28
+ clearTimeout(timer);
29
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
30
+ };
31
+ signal.addEventListener('abort', onAbort, { once: true });
32
+ });
33
+ }
34
+ /**
35
+ * Own the set of in-flight device-flow attempts, keyed by provider. One
36
+ * attempt per provider at a time; an attempt removes itself when it settles.
37
+ */
38
+ export class DeviceFlowManager {
39
+ attempts = new Map();
40
+ /**
41
+ * Whether a device-flow attempt is running for one provider.
42
+ * @param provider - the provider route.
43
+ * @returns true while an attempt is polling.
44
+ */
45
+ isBusy(provider) {
46
+ return this.attempts.has(provider);
47
+ }
48
+ /**
49
+ * The pending attempt for one provider, when any.
50
+ * @param provider - the provider route.
51
+ * @returns the in-flight attempt, or `undefined`.
52
+ */
53
+ pending(provider) {
54
+ return this.attempts.get(provider);
55
+ }
56
+ /**
57
+ * Start a device-flow attempt: request a device code, then poll the token
58
+ * endpoint in the background of `waitToken`.
59
+ * @param provider - the provider route (one attempt at a time).
60
+ * @param spec - static flow facts for this provider.
61
+ * @returns the live attempt; its `waitToken()` settles the login.
62
+ * @throws when an attempt is already running or the device-code request fails.
63
+ */
64
+ async start(provider, spec) {
65
+ if (this.attempts.has(provider)) {
66
+ throw new Error(`a ${provider} login attempt is already in progress`);
67
+ }
68
+ const fetchFn = spec.fetchFn ?? proxiedFetch;
69
+ const response = await fetchFn(spec.deviceCodeUrl, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'accept': 'application/json',
73
+ 'content-type': 'application/x-www-form-urlencoded',
74
+ },
75
+ body: new URLSearchParams({ client_id: spec.clientId, scope: spec.scope }).toString(),
76
+ });
77
+ if (!response.ok) {
78
+ throw new Error(`${provider} device-code request failed (HTTP ${String(response.status)})`);
79
+ }
80
+ const wire = await response.json();
81
+ if (typeof wire.device_code !== 'string' || wire.device_code.length === 0
82
+ || typeof wire.user_code !== 'string' || wire.user_code.length === 0
83
+ || typeof wire.verification_uri !== 'string' || wire.verification_uri.length === 0) {
84
+ throw new Error(`${provider} device-code response is missing device_code/user_code/verification_uri`);
85
+ }
86
+ const intervalSec = typeof wire.interval === 'number' && wire.interval > 0
87
+ ? wire.interval
88
+ : DEFAULT_INTERVAL_SEC;
89
+ const expiresInSec = typeof wire.expires_in === 'number' && wire.expires_in > 0
90
+ ? wire.expires_in
91
+ : DEFAULT_EXPIRES_IN_SEC;
92
+ const controller = new AbortController();
93
+ let resolveToken;
94
+ let rejectToken;
95
+ const tokenPromise = new Promise((resolve, reject) => {
96
+ resolveToken = resolve;
97
+ rejectToken = reject;
98
+ });
99
+ // The promise settles exactly once, from the poll loop below; an unhandled
100
+ // rejection must not surface if nobody awaited waitToken after a cancel.
101
+ tokenPromise.catch(() => undefined);
102
+ const settle = (error, token) => {
103
+ // Identity check: a late settle from a stale attempt (its poll loop or a
104
+ // cancel arriving after it already settled) must not kill a NEW attempt
105
+ // the user started for the same provider.
106
+ if (this.attempts.get(provider) !== attempt)
107
+ return;
108
+ this.attempts.delete(provider);
109
+ if (error !== undefined)
110
+ rejectToken(error);
111
+ else if (token !== undefined)
112
+ resolveToken(token);
113
+ };
114
+ const poll = async () => {
115
+ let intervalMs = intervalSec * 1000;
116
+ const deadline = Date.now() + expiresInSec * 1000;
117
+ while (true) {
118
+ await sleep(intervalMs, controller.signal);
119
+ if (Date.now() >= deadline) {
120
+ settle(new Error(`login timed out after ${String(Math.round(expiresInSec))}s`));
121
+ return;
122
+ }
123
+ const pollResponse = await fetchFn(spec.tokenUrl, {
124
+ method: 'POST',
125
+ headers: {
126
+ 'accept': 'application/json',
127
+ 'content-type': 'application/x-www-form-urlencoded',
128
+ },
129
+ body: new URLSearchParams({
130
+ client_id: spec.clientId,
131
+ device_code: wire.device_code,
132
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
133
+ }).toString(),
134
+ signal: controller.signal,
135
+ });
136
+ const result = await pollResponse.json();
137
+ if (typeof result.access_token === 'string' && result.access_token.length > 0) {
138
+ settle(undefined, result.access_token);
139
+ return;
140
+ }
141
+ switch (result.error) {
142
+ case 'authorization_pending':
143
+ break;
144
+ case 'slow_down':
145
+ // RFC 8628 §3.5: add five seconds to the poll interval.
146
+ intervalMs += 5000;
147
+ break;
148
+ case 'access_denied':
149
+ settle(new Error('login declined on the GitHub authorization page'));
150
+ return;
151
+ case 'expired_token':
152
+ settle(new Error('the device code expired before authorization completed'));
153
+ return;
154
+ default:
155
+ settle(new Error(`${provider} device-flow polling failed: ${result.error_description ?? result.error ?? `HTTP ${String(pollResponse.status)}`}`));
156
+ return;
157
+ }
158
+ }
159
+ };
160
+ const attempt = {
161
+ verificationUrl: wire.verification_uri,
162
+ userCode: wire.user_code,
163
+ waitToken: () => tokenPromise,
164
+ cancel: () => {
165
+ controller.abort(new Error('login cancelled'));
166
+ settle(new Error('login cancelled'));
167
+ },
168
+ };
169
+ this.attempts.set(provider, attempt);
170
+ void poll().catch((error) => {
171
+ // Aborts land here from sleep/fetch; everything else is a transport or
172
+ // parse failure worth surfacing as the login failure.
173
+ settle(error instanceof Error ? error : new Error(String(error)));
174
+ });
175
+ return attempt;
176
+ }
177
+ }
@@ -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
@@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis';
8
8
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
9
9
  import { type ProviderId } from './store.js';
10
10
  import type { ProviderUsage } from '../providers/common.js';
11
+ import type { ProxyConfigView, ProxyDraft, ProxyInput, ProxyTestResult } from '../http.js';
11
12
  /** The RPC channel this plugin registers on the host connection. */
12
13
  export declare const SUBSCRIPTIONS_AUTH_CHANNEL = "/subscriptions-auth";
13
14
  /** Decoded image bytes returned by the `image` endpoint. */
@@ -49,17 +50,31 @@ export interface ProviderStatus {
49
50
  /** Subscription detail (plan) or the last login error. */
50
51
  detail?: string;
51
52
  }
53
+ /** Proxy config operations behind the `proxyGet/proxySet/proxyTest` endpoints. */
54
+ export interface ProxyConfigController {
55
+ /** Current proxy configuration (secrets omitted). */
56
+ get(): Promise<ProxyConfigView>;
57
+ /** Validate, persist, and apply one config. */
58
+ set(input: ProxyInput): Promise<ProxyConfigView>;
59
+ /** Probe one destination through the draft (unsaved) or stored proxy. */
60
+ test(payload: {
61
+ url?: string;
62
+ proxy?: ProxyDraft;
63
+ }): Promise<ProxyTestResult>;
64
+ }
52
65
  /** Provider-agnostic auth operations the RPC handler delegates to. */
53
66
  export interface AuthController {
54
67
  /** Current status of one provider. */
55
68
  status(provider: ProviderId): Promise<ProviderStatus>;
56
69
  /**
57
70
  * Start a background login attempt.
58
- * @returns the authorize URL for the user's browser.
71
+ * @returns the authorize URL for the user's browser; device-flow providers
72
+ * (copilot) also return the `userCode` the user types at that URL.
59
73
  * @throws when an attempt is already running for this provider.
60
74
  */
61
75
  login(provider: ProviderId): Promise<{
62
76
  authorizeUrl: string;
77
+ userCode?: string;
63
78
  }>;
64
79
  /**
65
80
  * Feed a pasted callback URL or bare code into the pending attempt.
@@ -100,5 +115,6 @@ export interface AuthController {
100
115
  * @param ctx - the plugin context (headless profiles have no `connection`).
101
116
  * @param controller - the auth operations backing the endpoints.
102
117
  * @param speed - the per-session speed-tier state backing the Speed toggle.
118
+ * @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
103
119
  */
104
- export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController): void;
120
+ export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController, proxy?: ProxyConfigController | undefined): void;
package/lib/auth/rpc.js CHANGED
@@ -102,7 +102,89 @@ function readSessionId(payload) {
102
102
  throw new BadRequest('payload must be an object');
103
103
  return readString(payload, 'sessionId');
104
104
  }
105
- async function dispatch(controller, speed, endpoint, payload, signal) {
105
+ /** Validate a `proxySet` payload into a shape `ProxyInput` accepts. */
106
+ function readProxyInput(payload) {
107
+ if (typeof payload !== 'object' || payload === null)
108
+ throw new BadRequest('payload must be an object');
109
+ const record = payload;
110
+ if (typeof record.enabled !== 'boolean')
111
+ throw new BadRequest('payload.enabled must be a boolean');
112
+ if (typeof record.url !== 'string')
113
+ throw new BadRequest('payload.url must be a string');
114
+ let username;
115
+ if (record.username !== undefined) {
116
+ if (typeof record.username !== 'string')
117
+ throw new BadRequest('payload.username must be a string when present');
118
+ username = record.username;
119
+ }
120
+ let password;
121
+ if (record.password !== undefined) {
122
+ if (record.password !== null && typeof record.password !== 'string') {
123
+ throw new BadRequest('payload.password must be a string or null when present');
124
+ }
125
+ password = record.password;
126
+ }
127
+ let bypass;
128
+ if (record.bypass !== undefined) {
129
+ if (!Array.isArray(record.bypass) || record.bypass.some(entry => typeof entry !== 'string')) {
130
+ throw new BadRequest('payload.bypass must be an array of strings when present');
131
+ }
132
+ bypass = record.bypass;
133
+ }
134
+ return {
135
+ enabled: record.enabled,
136
+ url: record.url,
137
+ ...username === undefined ? {} : { username },
138
+ ...password === undefined ? {} : { password },
139
+ ...bypass === undefined ? {} : { bypass },
140
+ };
141
+ }
142
+ /** Validate a `proxyTest` payload (the destination URL and an optional draft). */
143
+ function readProxyTestPayload(payload) {
144
+ if (typeof payload !== 'object' || payload === null)
145
+ return {};
146
+ const record = payload;
147
+ const url = record.url;
148
+ if (url === undefined && record.proxy === undefined)
149
+ return {};
150
+ if (url !== undefined && (typeof url !== 'string' || url.length === 0)) {
151
+ throw new BadRequest('payload.url must be a non-empty string when present');
152
+ }
153
+ let proxy;
154
+ if (record.proxy !== undefined) {
155
+ if (typeof record.proxy !== 'object' || record.proxy === null) {
156
+ throw new BadRequest('payload.proxy must be an object when present');
157
+ }
158
+ const draftRecord = record.proxy;
159
+ if (typeof draftRecord.url !== 'string' || draftRecord.url.length === 0) {
160
+ throw new BadRequest('payload.proxy.url must be a non-empty string');
161
+ }
162
+ let username;
163
+ if (draftRecord.username !== undefined) {
164
+ if (typeof draftRecord.username !== 'string') {
165
+ throw new BadRequest('payload.proxy.username must be a string when present');
166
+ }
167
+ username = draftRecord.username;
168
+ }
169
+ let password;
170
+ if (draftRecord.password !== undefined) {
171
+ if (typeof draftRecord.password !== 'string') {
172
+ throw new BadRequest('payload.proxy.password must be a string when present');
173
+ }
174
+ password = draftRecord.password;
175
+ }
176
+ proxy = {
177
+ url: draftRecord.url,
178
+ ...username === undefined ? {} : { username },
179
+ ...password === undefined ? {} : { password },
180
+ };
181
+ }
182
+ return {
183
+ ...url === undefined ? {} : { url },
184
+ ...proxy === undefined ? {} : { proxy },
185
+ };
186
+ }
187
+ async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
106
188
  switch (endpoint) {
107
189
  case 'status': {
108
190
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
@@ -132,6 +214,18 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
132
214
  case 'setSpeed':
133
215
  await speed.setSpeed(readSessionId(payload), readSpeedTier(payload));
134
216
  return ok({ ok: true });
217
+ case 'proxyGet':
218
+ if (proxy === undefined)
219
+ throw new BadRequest('proxy configuration is unavailable');
220
+ return ok(await proxy.get());
221
+ case 'proxySet':
222
+ if (proxy === undefined)
223
+ throw new BadRequest('proxy configuration is unavailable');
224
+ return ok(await proxy.set(readProxyInput(payload)));
225
+ case 'proxyTest':
226
+ if (proxy === undefined)
227
+ throw new BadRequest('proxy configuration is unavailable');
228
+ return ok(await proxy.test(readProxyTestPayload(payload)));
135
229
  default:
136
230
  throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
137
231
  }
@@ -141,8 +235,9 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
141
235
  * @param ctx - the plugin context (headless profiles have no `connection`).
142
236
  * @param controller - the auth operations backing the endpoints.
143
237
  * @param speed - the per-session speed-tier state backing the Speed toggle.
238
+ * @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
144
239
  */
145
- export function registerAuthRpc(ctx, controller, speed) {
240
+ export function registerAuthRpc(ctx, controller, speed, proxy = undefined) {
146
241
  // `connection` is not in this plugin's inject list (headless compositions
147
242
  // lack it), so its startup order is unconstrained: defer registration until
148
243
  // the service exists instead of probing once at apply time.
@@ -150,7 +245,7 @@ export function registerAuthRpc(ctx, controller, speed) {
150
245
  const connection = ctx.get('connection');
151
246
  ctx.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
152
247
  try {
153
- return await dispatch(controller, speed, endpoint, payload, signal);
248
+ return await dispatch(controller, speed, proxy, endpoint, payload, signal);
154
249
  }
155
250
  catch (error) {
156
251
  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')`.