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.
- package/README.md +19 -6
- package/README.zh.md +18 -6
- package/lib/auth/device-flow.d.ts +64 -0
- package/lib/auth/device-flow.js +176 -0
- package/lib/auth/oauth-flow.js +1 -1
- package/lib/auth/rpc.d.ts +21 -2
- package/lib/auth/rpc.js +23 -3
- package/lib/auth/store.d.ts +20 -2
- package/lib/auth/store.js +45 -9
- package/lib/client/ImageGallery.d.ts +54 -0
- package/lib/client/ImageGallery.js +112 -0
- package/lib/client/ImageGenerateToolview.d.ts +1 -1
- package/lib/client/ImageGenerateToolview.js +2 -2
- package/lib/client/SpeedSelect.d.ts +48 -0
- package/lib/client/SpeedSelect.js +173 -0
- package/lib/client/SubscriptionsSection.d.ts +10 -1
- package/lib/client/SubscriptionsSection.js +48 -5
- package/lib/client/index.d.ts +1 -0
- package/lib/client/index.js +42 -0
- package/lib/client/locales.d.ts +22 -0
- package/lib/client/locales.js +22 -0
- package/lib/client.js +679 -77
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.js +1868 -183
- package/lib/providers/catalog-store.js +19 -0
- package/lib/providers/claude.d.ts +20 -1
- package/lib/providers/claude.js +58 -31
- package/lib/providers/codex.d.ts +27 -0
- package/lib/providers/codex.js +117 -27
- package/lib/providers/common.d.ts +34 -1
- package/lib/providers/common.js +48 -1
- package/lib/providers/copilot.d.ts +315 -0
- package/lib/providers/copilot.js +786 -0
- package/lib/providers/grok.d.ts +7 -2
- package/lib/providers/grok.js +46 -18
- package/lib/tools/image-generate.js +3 -10
- package/lib/translate/anthropic.d.ts +47 -6
- package/lib/translate/anthropic.js +135 -20
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +363 -0
- package/lib/translate/responses.d.ts +49 -5
- package/lib/translate/responses.js +40 -7
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# dsh-plugin-subscriptions
|
|
1
|
+
# dsh-plugin-subscriptions [](https://awesome-dsh-plugin.com)
|
|
2
2
|
|
|
3
3
|
English | [中文](README.zh.md)
|
|
4
4
|
|
|
5
|
-
Use your **ChatGPT (Codex)**, **Claude**, and **Grok (X Premium)** subscriptions as LLM providers in [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — no API keys. Codex and Grok log in via OAuth in the dsh web UI (Settings → Subscriptions); Claude imports credentials
|
|
5
|
+
Use your **ChatGPT (Codex)**, **Claude**, and **Grok (X Premium)** subscriptions as LLM providers in [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — no API keys. Codex and Grok log in via OAuth in the dsh web UI (Settings → Subscriptions); Claude imports credentials from an existing Claude Code session when there is one (macOS Keychain or `~/.claude/.credentials.json`) and otherwise falls back to the same browser OAuth flow, so the Claude Code CLI is not required. Tokens live at `~/.dsh/plugins/subscriptions/auth.json` (mode 0600) and refresh automatically.
|
|
6
6
|
|
|
7
7
|
## Demo
|
|
8
8
|
|
|
9
|
-
Settings → **Subscriptions**: per-provider login/logout, no API keys. Claude imports credentials from Claude Code
|
|
9
|
+
Settings → **Subscriptions**: per-provider login/logout, no API keys. Claude imports credentials from Claude Code when available and otherwise uses OAuth, as Codex and Grok always do (account address masked in the screenshot):
|
|
10
10
|
|
|
11
11
|

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

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

|
|
20
20
|
|
|
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
|
+

|
|
24
|
+
|
|
21
25
|
The `image_generate` tool renders its result inline in the conversation:
|
|
22
26
|
|
|
23
27
|

|
|
@@ -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
|
|
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 [](https://awesome-dsh-plugin.com)
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 中文
|
|
4
4
|
|
|
5
|
-
把你的 **ChatGPT(Codex)**、**Claude**、**Grok(X Premium)** 订阅当作 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 LLM provider 使用 —— 不需要 API key。Codex 和 Grok 通过 dsh web 界面 OAuth 登录(设置 → 订阅);Claude
|
|
5
|
+
把你的 **ChatGPT(Codex)**、**Claude**、**Grok(X Premium)** 订阅当作 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 LLM provider 使用 —— 不需要 API key。Codex 和 Grok 通过 dsh web 界面 OAuth 登录(设置 → 订阅);Claude 在存在 Claude Code 会话时直接导入凭据(macOS Keychain 或 `~/.claude/.credentials.json`),否则回退到同样的浏览器 OAuth 流程,因此不要求安装 Claude Code CLI。Token 保存在 `~/.dsh/plugins/subscriptions/auth.json`(权限 0600),过期自动刷新。
|
|
6
6
|
|
|
7
7
|
## 演示
|
|
8
8
|
|
|
9
|
-
设置 → **订阅**:每个 provider 的登录/退出,无需 API key。Claude
|
|
9
|
+
设置 → **订阅**:每个 provider 的登录/退出,无需 API key。Claude 有 Claude Code 会话时导入凭据,否则和 Codex、Grok 一样走 OAuth(截图中账号已打码):
|
|
10
10
|
|
|
11
11
|

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

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

|
|
20
20
|
|
|
21
|
+
目录声明了 fast tier(即 codex CLI 的 fast 模式)的 Codex 模型,会在输入框工具行(模型选择器旁)多出一个**速度**开关 —— 标准 / 快速(`service_tier: priority`),按会话生效。`/fast` 斜杠命令提供同样的弹窗选择;当前模型不支持快速档时会提示原因。
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
21
25
|
`image_generate` 工具生成的图片直接内联显示在对话里:
|
|
22
26
|
|
|
23
27
|

|
|
@@ -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
|
|
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
|
+
}
|
package/lib/auth/oauth-flow.js
CHANGED
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
|
-
|
|
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);
|
package/lib/auth/store.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* owns the durable format.
|
|
8
8
|
*/
|
|
9
9
|
/** Provider routes this plugin can serve. */
|
|
10
|
-
export type ProviderId = 'codex' | 'claude' | 'grok';
|
|
10
|
+
export type ProviderId = 'codex' | 'claude' | 'grok' | 'copilot';
|
|
11
11
|
/** Every provider route, in display order. */
|
|
12
12
|
export declare const PROVIDER_IDS: readonly ProviderId[];
|
|
13
13
|
/** Stored ChatGPT/Codex subscription session. */
|
|
@@ -47,14 +47,32 @@ export interface GrokSession {
|
|
|
47
47
|
/** Display account: email, username, or subject claim from the id token. */
|
|
48
48
|
account?: string;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Stored GitHub Copilot subscription session. Two token generations are at
|
|
52
|
+
* play: the long-lived GitHub OAuth token from the device flow is kept in
|
|
53
|
+
* `refreshToken`, and `accessToken` carries the short-lived (~30 minutes)
|
|
54
|
+
* Copilot API token exchanged from it. A "refresh" is therefore a fresh
|
|
55
|
+
* exchange against `copilot_internal/v2/token`, not an OAuth grant.
|
|
56
|
+
*/
|
|
57
|
+
export interface CopilotSession {
|
|
58
|
+
/** Copilot API token; sent as the bearer on api.githubcopilot.com. */
|
|
59
|
+
accessToken: string;
|
|
60
|
+
/** Long-lived GitHub OAuth token from the device flow. */
|
|
61
|
+
refreshToken: string;
|
|
62
|
+
/** Epoch milliseconds at which the Copilot API token expires. */
|
|
63
|
+
expiresAt: number;
|
|
64
|
+
/** GitHub login name, for the status display. */
|
|
65
|
+
account?: string;
|
|
66
|
+
}
|
|
50
67
|
/** The durable store shape: one optional session per provider. */
|
|
51
68
|
export interface SessionMap {
|
|
52
69
|
codex?: CodexSession;
|
|
53
70
|
claude?: ClaudeSession;
|
|
54
71
|
grok?: GrokSession;
|
|
72
|
+
copilot?: CopilotSession;
|
|
55
73
|
}
|
|
56
74
|
/** Any stored session, for provider-agnostic plumbing. */
|
|
57
|
-
export type StoredSession = CodexSession | ClaudeSession | GrokSession;
|
|
75
|
+
export type StoredSession = CodexSession | ClaudeSession | GrokSession | CopilotSession;
|
|
58
76
|
/**
|
|
59
77
|
* Absolute path of the auth store file.
|
|
60
78
|
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
package/lib/auth/store.js
CHANGED
|
@@ -10,7 +10,7 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
|
10
10
|
import { dirname } from 'node:path';
|
|
11
11
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
12
12
|
/** Every provider route, in display order. */
|
|
13
|
-
export const PROVIDER_IDS = ['codex', 'claude', 'grok'];
|
|
13
|
+
export const PROVIDER_IDS = ['codex', 'claude', 'grok', 'copilot'];
|
|
14
14
|
/**
|
|
15
15
|
* Absolute path of the auth store file.
|
|
16
16
|
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
|
@@ -103,6 +103,38 @@ async function writeStore(store, path) {
|
|
|
103
103
|
throw error;
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* One write chain per store path. Every mutation is a read-modify-write of a
|
|
108
|
+
* single JSON file, and the plugin has several independent writers — a login,
|
|
109
|
+
* a logout, and one token refresh per provider adapter, each on its own
|
|
110
|
+
* schedule. Overlapping them unserialized costs whichever provider read the
|
|
111
|
+
* store first its entry.
|
|
112
|
+
*
|
|
113
|
+
* A chain is dropped once nothing is queued behind it, so the map holds an
|
|
114
|
+
* entry only while writes are in flight.
|
|
115
|
+
*/
|
|
116
|
+
const writeChains = new Map();
|
|
117
|
+
/**
|
|
118
|
+
* Run one read-modify-write of a store path after every write already queued
|
|
119
|
+
* for it. Callers join the chain synchronously, so call order is write order.
|
|
120
|
+
* @param path - the store file being mutated.
|
|
121
|
+
* @param action - the read-modify-write to run.
|
|
122
|
+
* @returns whatever `action` returns.
|
|
123
|
+
*/
|
|
124
|
+
async function serialize(path, action) {
|
|
125
|
+
const previous = writeChains.get(path) ?? Promise.resolve();
|
|
126
|
+
// Both handlers: a failed write must not strand everything queued behind it.
|
|
127
|
+
const next = previous.then(action, action);
|
|
128
|
+
const tail = next.then(() => undefined, () => undefined);
|
|
129
|
+
writeChains.set(path, tail);
|
|
130
|
+
try {
|
|
131
|
+
return await next;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
if (writeChains.get(path) === tail)
|
|
135
|
+
writeChains.delete(path);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
106
138
|
/**
|
|
107
139
|
* Read one provider's session.
|
|
108
140
|
* @param provider - the provider route.
|
|
@@ -119,9 +151,11 @@ export async function getSession(provider, path = authFilePath()) {
|
|
|
119
151
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
120
152
|
*/
|
|
121
153
|
export async function saveSession(provider, session, path = authFilePath()) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
154
|
+
return serialize(path, async () => {
|
|
155
|
+
const store = await loadStore(path);
|
|
156
|
+
store[provider] = session;
|
|
157
|
+
await writeStore(store, path);
|
|
158
|
+
});
|
|
125
159
|
}
|
|
126
160
|
/**
|
|
127
161
|
* Delete one provider's session (logout).
|
|
@@ -129,9 +163,11 @@ export async function saveSession(provider, session, path = authFilePath()) {
|
|
|
129
163
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
130
164
|
*/
|
|
131
165
|
export async function deleteSession(provider, path = authFilePath()) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
166
|
+
return serialize(path, async () => {
|
|
167
|
+
const store = await loadStore(path);
|
|
168
|
+
if (store[provider] === undefined)
|
|
169
|
+
return;
|
|
170
|
+
delete store[provider];
|
|
171
|
+
await writeStore(store, path);
|
|
172
|
+
});
|
|
137
173
|
}
|