@goodandready/dsh-subscriptions 0.4.15 → 0.4.17
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 +175 -209
- package/README.ru.md +196 -0
- package/README.zh.md +123 -0
- package/lib/accounts.js +19 -5
- package/lib/adapter.js +28 -7
- package/lib/client.js +153 -3
- package/lib/index.js +352 -12
- package/lib/loopback.js +66 -0
- package/lib/mask.js +21 -0
- package/lib/messages.js +8 -1
- package/lib/ollama.js +70 -0
- package/lib/proxy.js +79 -0
- package/lib/rotate.js +28 -3
- package/lib/stream-rotate.js +4 -3
- package/lib/subscriptions.js +8 -5
- package/lib/vendors/codex.js +46 -1
- package/lib/vendors/grok.js +2 -0
- package/package.json +9 -2
package/README.zh.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# 📦 @goodandready/dsh-subscriptions
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<h3>DeepSeek Harness 个人 AI 订阅桥接、多账号池轮换与零泄漏 OAuth 插件</h3>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/@goodandready/dsh-subscriptions"><img src="https://img.shields.io/npm/v/@goodandready/dsh-subscriptions.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
|
|
10
|
+
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
|
|
11
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://goodandready.app/"><img src="https://img.shields.io/badge/作者全部项目-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="作者全部项目"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="README.md"><b>🇬🇧 English</b></a> •
|
|
20
|
+
<a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
|
|
21
|
+
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ⚡ 插件概览
|
|
29
|
+
|
|
30
|
+
**`dsh-subscriptions`** 将您现有的付费个人 AI 订阅无缝接入 **DeepSeek Harness**,作为第一类模型服务商。
|
|
31
|
+
|
|
32
|
+
无需为日常智能体任务支付昂贵的 API 按量计费。本插件支持标准 OAuth PKCE 鉴权、**单服务商多账号池动态轮换**(遭遇 429 限流时自动秒切可用账号)、**前置配额平滑切换**,并通过**进程内 Cordis 服务 (`ctx.subscriptions`)** 赋能 [`dsh-image-gen`](https://github.com/GooDAnDReaDY/dsh-image-gen) 与 [`dsh-grok-xsearch`](https://github.com/GooDAnDReaDY/dsh-grok-xsearch) 等插件,实现 Token 零网络泄漏。
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
graph LR
|
|
36
|
+
subgraph DSHCore [DeepSeek Harness 对话流]
|
|
37
|
+
Agent[🤖 智能体任务执行] --> Router{服务商路由器}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
subgraph SubscriptionsCore [dsh-subscriptions 调度核心]
|
|
41
|
+
Router --> Pool{多账号负载池}
|
|
42
|
+
Pool -->|账号 1| Acc1[👤 主账号: 活跃中]
|
|
43
|
+
Pool -->|账号 2| Acc2[👤 副账号: 待命中]
|
|
44
|
+
Pool -->|账号 3| Acc3[👤 备用账号: 冷却中]
|
|
45
|
+
Acc1 -->|HTTP 429 / 配额耗尽| Rotate[智能配额与冷却轮换器]
|
|
46
|
+
Rotate -->|流量平滑切换| Acc2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
subgraph VendorBridges [4 大服务商桥接适配]
|
|
50
|
+
Acc1 --> B1[ChatGPT / Codex 后端]
|
|
51
|
+
Acc1 --> B2[Claude Pro / Max 协议]
|
|
52
|
+
Acc1 --> B3[xAI / Grok 订阅]
|
|
53
|
+
Acc1 --> B4[Google Cloud Code Assist / Antigravity]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
subgraph EcosystemBridge [Cordis 进程内共享: ctx.subscriptions]
|
|
57
|
+
Pool --> ImgGen[dsh-image-gen: 订阅零成本生图]
|
|
58
|
+
Pool --> XSearch[dsh-grok-xsearch: X 社交搜索]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
style DSHCore fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
62
|
+
style SubscriptionsCore fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
|
|
63
|
+
style VendorBridges fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
64
|
+
style EcosystemBridge fill:#181825,stroke:#f38ba8,stroke-width:2px,color:#cdd6f4
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## ✨ 核心功能
|
|
70
|
+
|
|
71
|
+
### 支持的内置订阅供应商
|
|
72
|
+
| 供应商 | 订阅级别 | 协议 |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `codex` | ChatGPT Plus / Pro | 流式响应、工具调用、图像生成 |
|
|
75
|
+
| `claude` | Claude Pro / Max | 原生 Messages 协议、用量跟踪 |
|
|
76
|
+
| `grok` | xAI / X Premium | 实时推理、计费检查 |
|
|
77
|
+
| `antigravity` | Google Cloud Code Assist | 流式生成 |
|
|
78
|
+
|
|
79
|
+
### 多账号轮换与限流保护
|
|
80
|
+
* 每个供应商可挂多个账号(如 `CODEX_OAUTH_1`、`CODEX_OAUTH_2`)。
|
|
81
|
+
* 遇到 429/配额耗尽自动切换到下一个健康账号;支持按剩余量提前切换和动态冷却恢复。
|
|
82
|
+
|
|
83
|
+
### 安全与无头登录
|
|
84
|
+
* OAuth 令牌绝不通过 HTTP API 返回,也不在 Web 界面渲染;仅存放于宿主加密凭据存储。
|
|
85
|
+
* **回环自动回调 (`autoLoopback`,`v0.4.9`)**:供应商重定向为回环地址(如 Codex `:1455`)时,插件自动启动临时本地服务器接收回调,无需手动粘贴链接。
|
|
86
|
+
* **设备码登录(Codex,`v0.4.9`)**:完全无浏览器的主机上,点击 **Device login**,在任意设备打开 `https://auth.openai.com/codex/device` 输入短码即可完成 PKCE 授权。
|
|
87
|
+
|
|
88
|
+
### 账号级代理(`v0.4.9`)
|
|
89
|
+
* 每个账号可配置独立代理(`http://`、`https://`、`socks5://`);令牌刷新、供应商检查、模型请求均走该代理。
|
|
90
|
+
* 账号卡片内的 **Check proxy** 按钮一键检测代理延迟。
|
|
91
|
+
|
|
92
|
+
### 本地 Ollama 网关与无缝回退(`v0.4.17`)
|
|
93
|
+
* **原生 provider (`ollama`)**:本地 Ollama 可达时(`ollamaBaseUrl`,默认 `http://127.0.0.1:11434`),自动出现在 DSH 原生模型选择器中(模型来自 `/api/tags`),无需密钥。
|
|
94
|
+
* **无缝配额回退 (`ollamaFallback`,默认开启)**:某供应商的所有账号耗尽或不可达且尚未输出任何内容时,对话自动切换到本地模型(`ollamaFallbackModel` 或 `/api/tags` 第一个模型),并记录到请求历史(`kind: fallback`)。
|
|
95
|
+
* 免费($0)应急通道:无网络、无配额也能用。
|
|
96
|
+
|
|
97
|
+
### Effort、Verbosity 与 Fast Mode(`v0.4.17`)
|
|
98
|
+
* **推理力度 (Effort)**:Codex 模型从实时目录上报支持的力度等级,原生选择器校验后以 `reasoning.effort` 传入 `/responses` 协议;Grok 按自身目录过滤转发。
|
|
99
|
+
* **详略程度 (`codexVerbosity`)**:`low`/`medium`/`high` 以 `text.verbosity` 传给 Codex 推理模型;留空为协议默认。
|
|
100
|
+
* **Fast Mode (`codexFastMode`)**:每次 Codex 请求携带 `service_tier: priority`(1.5x 速度计费档);启用时活动订阅芯片显示 `⚡`。
|
|
101
|
+
|
|
102
|
+
### 按模型家族的冷却与模型过滤(`v0.4.17`)
|
|
103
|
+
* **Reasoning 与 Standard 分离**:推理模型(claude `*thinking*`、grok `*reasoning*`、全部 codex 模型)触发 429 时,只冷却该账号的 reasoning 家族——同账号的 standard 模型立即可用;旧版冷却仍按整账号生效。
|
|
104
|
+
* **隐藏过时模型 (`hideDeprecatedModels`)**:从原生选择器中过滤 `test`/`preview`/`dev`/`alpha`/`beta`/`legacy` 模型 id(同时作用于实时目录与静态回退)。
|
|
105
|
+
|
|
106
|
+
### 隐私模式与诊断报告(`v0.4.9`)
|
|
107
|
+
* **`privacyMask`**:一个开关即可在整个界面隐藏个人数据(邮箱显示为 `j***n@example.com`),服务端遮蔽,适合屏幕共享。
|
|
108
|
+
* **匿名诊断报告**:设置卡片内一键生成(插件/运行时版本、系统、各供应商健康状态、HTTP 状态聚合、最近错误与耗时、非敏感配置)并自动复制到剪贴板;令牌、邮箱、凭据名与代理地址严格排除。
|
|
109
|
+
* 同区块提供问题追踪器链接,便于提交 issue。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 📦 安装指南
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
dsh plugin --profile web add @goodandready/dsh-subscriptions
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 📄 开源协议
|
|
122
|
+
|
|
123
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/lib/accounts.js
CHANGED
|
@@ -42,13 +42,16 @@ export function vendorConfig(provider, cfg) {
|
|
|
42
42
|
originator: pick('Originator', d.originator || ''),
|
|
43
43
|
systemPrefix: pick('SystemPrefix', d.systemPrefix || ''),
|
|
44
44
|
clientVersion: pick('ClientVersion', d.clientVersion || ''),
|
|
45
|
+
// #93/#92: per-vendor request shaping (codex only consumes these today).
|
|
46
|
+
verbosity: pick('Verbosity', ''),
|
|
47
|
+
fastMode: !!(cfg && cfg[`${provider}FastMode`]),
|
|
45
48
|
models: Array.isArray(cfg && cfg[modelsKey]) && cfg[modelsKey].length
|
|
46
49
|
? cfg[modelsKey]
|
|
47
50
|
: (d.models || []),
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitNotice }) {
|
|
54
|
+
export function createAccountStore({ credentials, getConfig, fetchImpl, fetchForRef, onLimitNotice }) {
|
|
52
55
|
const cooldowns = new Map()
|
|
53
56
|
const quotas = new Map()
|
|
54
57
|
const refreshLocks = new Map()
|
|
@@ -60,6 +63,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
|
|
|
60
63
|
const windows = new Map()
|
|
61
64
|
const usageFetched = new Map()
|
|
62
65
|
const doFetch = fetchImpl || fetch
|
|
66
|
+
// #88: per-account proxy fetch for token refresh / usage (falls back to doFetch).
|
|
67
|
+
const fetchFor = (ref) => (typeof fetchForRef === 'function' && fetchForRef(ref)) || doFetch
|
|
63
68
|
|
|
64
69
|
async function resolveRaw(ref) {
|
|
65
70
|
try {
|
|
@@ -122,7 +127,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
|
|
|
122
127
|
}
|
|
123
128
|
return {
|
|
124
129
|
...base,
|
|
125
|
-
cooldownUntil: cooldowns.get(ref)
|
|
130
|
+
cooldownUntil: cooldowns.get(ref) ? cooldowns.get(ref).until : 0,
|
|
131
|
+
cooldownFamilies: cooldowns.get(ref) && cooldowns.get(ref).families ? cooldowns.get(ref).families : null,
|
|
126
132
|
quota: quotas.get(ref) || null,
|
|
127
133
|
usage: windows.get(ref) || base.usage || null,
|
|
128
134
|
health: health.get(ref) || null,
|
|
@@ -168,7 +174,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
|
|
|
168
174
|
const promise = (async () => {
|
|
169
175
|
try {
|
|
170
176
|
const cfg = vendorConfig(provider, getConfig())
|
|
171
|
-
const next = await getVendor(provider).refresh(cfg, blob,
|
|
177
|
+
const next = await getVendor(provider).refresh(cfg, blob, fetchFor(ref))
|
|
172
178
|
const merged = {
|
|
173
179
|
...blob,
|
|
174
180
|
...next,
|
|
@@ -201,7 +207,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
|
|
|
201
207
|
if (!raw) continue
|
|
202
208
|
try {
|
|
203
209
|
const blob = await ensureFresh(provider, parseBlob(raw), slot.ref)
|
|
204
|
-
const snap = await vendor.usage(blob, cfg,
|
|
210
|
+
const snap = await vendor.usage(blob, cfg, fetchFor(slot.ref))
|
|
205
211
|
usageFetched.set(slot.ref, Date.now())
|
|
206
212
|
if (!snap) continue
|
|
207
213
|
if (Number.isFinite(Number(snap.usedPercent))) {
|
|
@@ -270,7 +276,15 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
|
|
|
270
276
|
resolveRaw,
|
|
271
277
|
ensureFresh,
|
|
272
278
|
refreshUsage,
|
|
273
|
-
|
|
279
|
+
// #87: family-scoped cooldowns. families=null blocks the whole account
|
|
280
|
+
// (legacy behavior); a list blocks only those model families.
|
|
281
|
+
rememberCooldown(ref, until, families) {
|
|
282
|
+
if (!families || !families.length) { cooldowns.set(ref, { until: Number(until) || 0, families: null }); return }
|
|
283
|
+
const prev = cooldowns.get(ref)
|
|
284
|
+
const merged = new Set(families)
|
|
285
|
+
if (prev && Array.isArray(prev.families)) for (const f of prev.families) merged.add(f)
|
|
286
|
+
cooldowns.set(ref, { until: Math.max(Number(until) || 0, prev ? prev.until : 0), families: Array.from(merged) })
|
|
287
|
+
},
|
|
274
288
|
rememberQuota(ref, snap) { if (snap) quotas.set(ref, snap); else quotas.delete(ref) },
|
|
275
289
|
shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
|
|
276
290
|
getQuota(ref) { return quotas.get(ref) || null },
|
package/lib/adapter.js
CHANGED
|
@@ -3,6 +3,12 @@ import { displayName } from './refs.js'
|
|
|
3
3
|
import { getVendor } from './vendors/index.js'
|
|
4
4
|
import { modelCatalog } from './messages.js'
|
|
5
5
|
import { streamWithRotation } from './stream-rotate.js'
|
|
6
|
+
import { pickFetch } from './proxy.js'
|
|
7
|
+
|
|
8
|
+
// #94: heuristic for test/preview/beta/legacy model ids.
|
|
9
|
+
function isDeprecatedId(id) {
|
|
10
|
+
return /(^|[-_:])(test|preview|dev|alpha|beta|snapshot|experimental|legacy|deprecated)([-_:]|$)/i.test(id)
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
function asLlmError(err) {
|
|
8
14
|
if (err instanceof LlmError) return err
|
|
@@ -36,16 +42,22 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
36
42
|
if (!accounts.some((a) => a.hasToken)) return []
|
|
37
43
|
const cfg = this.deps.vendorConfig(provider)
|
|
38
44
|
const fetchImpl = this.deps.fetchImpl || fetch
|
|
45
|
+
let models = null
|
|
39
46
|
try {
|
|
40
47
|
const blob = await this.deps.ensureFresh(
|
|
41
48
|
provider,
|
|
42
49
|
await this.deps.loadBlob(accounts.find((a) => a.hasToken).ref),
|
|
43
50
|
accounts.find((a) => a.hasToken).ref,
|
|
44
51
|
)
|
|
45
|
-
const
|
|
46
|
-
if (Array.isArray(
|
|
52
|
+
const live = await getVendor(provider).listModels(blob, cfg, fetchImpl)
|
|
53
|
+
if (Array.isArray(live) && live.length) models = live
|
|
47
54
|
} catch { /* use built-in catalog */ }
|
|
48
|
-
|
|
55
|
+
if (!models) models = modelCatalog(provider, cfg.models)
|
|
56
|
+
// #94: optionally hide test/preview/beta/legacy ids from the picker.
|
|
57
|
+
if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
|
|
58
|
+
models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
|
|
59
|
+
}
|
|
60
|
+
return models
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
async resolveModel(provider, model, _signal) {
|
|
@@ -68,25 +80,26 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
68
80
|
async *stream(options) {
|
|
69
81
|
const provider = options.provider
|
|
70
82
|
const deps = this.deps
|
|
83
|
+
let yieldedAny = false
|
|
71
84
|
try {
|
|
72
85
|
if (typeof deps.refreshUsage === 'function') {
|
|
73
86
|
await deps.refreshUsage(provider)
|
|
74
87
|
}
|
|
75
|
-
|
|
88
|
+
for await (const event of streamWithRotation({
|
|
76
89
|
accounts: await deps.listAccounts(provider),
|
|
77
90
|
nowMs: () => Date.now(),
|
|
78
91
|
cooldownMs: deps.cooldownMs(),
|
|
79
92
|
switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
|
|
80
93
|
options,
|
|
81
94
|
onCooldown: (account) => {
|
|
82
|
-
deps.rememberCooldown(account.ref, account.cooldownUntil)
|
|
95
|
+
deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
|
|
83
96
|
if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
|
|
84
97
|
},
|
|
85
98
|
streamOnce: async function* (account, opts) {
|
|
86
99
|
const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
|
|
87
100
|
const vendor = getVendor(provider)
|
|
88
101
|
// ponytail: capture x-ratelimit headers without touching vendors
|
|
89
|
-
const baseFetch = deps.
|
|
102
|
+
const baseFetch = pickFetch(deps, account.ref)
|
|
90
103
|
const quotaFetch = async (url, init) => {
|
|
91
104
|
const res = await baseFetch(url, init)
|
|
92
105
|
try {
|
|
@@ -131,6 +144,7 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
131
144
|
path: '/responses',
|
|
132
145
|
method: 'POST',
|
|
133
146
|
status: 200,
|
|
147
|
+
ms: Date.now() - t0,
|
|
134
148
|
kind: 'stream',
|
|
135
149
|
})
|
|
136
150
|
} catch {}
|
|
@@ -146,8 +160,15 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
146
160
|
throw err
|
|
147
161
|
}
|
|
148
162
|
},
|
|
149
|
-
})
|
|
163
|
+
})) { yieldedAny = true; yield event }
|
|
164
|
+
return
|
|
150
165
|
} catch (err) {
|
|
166
|
+
// #91: when the whole pool is exhausted and nothing has been streamed
|
|
167
|
+
// yet, continue seamlessly on local Ollama instead of failing the chat.
|
|
168
|
+
if (!yieldedAny && typeof deps.ollamaFallback === 'function') {
|
|
169
|
+
yield* deps.ollamaFallback({ options, provider, err })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
151
172
|
throw asLlmError(err)
|
|
152
173
|
}
|
|
153
174
|
}
|
package/lib/client.js
CHANGED
|
@@ -88,12 +88,24 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
88
88
|
'intro': 'Log in with a consumer subscription. Tokens stay on the host credentials store. The browser never reads them back. After Connect, if the provider lands on localhost or a vendor page, paste the redirected URL or the code here.',
|
|
89
89
|
'useOrigin': 'Use this Web UI origin as OAuth redirect_uri',
|
|
90
90
|
'useOriginHint': 'Leave off unless you registered your own OAuth client for this origin. Vendor CLI clients usually require their published redirect URI plus the paste step.',
|
|
91
|
+
'privacyMask': 'Mask emails and account identifiers',
|
|
92
|
+
'privacyMaskHint': 'For demos and screen sharing: emails show as j***n@example.com.',
|
|
91
93
|
'reconnect': 'Reconnect',
|
|
92
94
|
'connect': 'Connect',
|
|
93
95
|
'disconnect': 'Disconnect',
|
|
94
96
|
'removeSlot': 'Remove slot',
|
|
95
97
|
'pastePlaceholder': 'Paste redirected URL or code',
|
|
96
98
|
'submitCode': 'Submit code',
|
|
99
|
+
'proxyPlaceholder': 'Per-account proxy (http://, https://, socks5://) - optional',
|
|
100
|
+
'proxyCheck': 'Check proxy',
|
|
101
|
+
'proxyOk': 'proxy ok',
|
|
102
|
+
'proxyFail': 'proxy fail',
|
|
103
|
+
'deviceLogin': 'Device login',
|
|
104
|
+
'deviceHint': 'Headless: open the link, enter the code, keep this tab open.',
|
|
105
|
+
'deviceCopy': 'Copy code',
|
|
106
|
+
'devicePending': 'Waiting for confirmation',
|
|
107
|
+
'deviceAuthorized': 'Signed in',
|
|
108
|
+
'deviceExpired': 'Expired - start again',
|
|
97
109
|
'verifyPrefix': 'Google requires one-time account verification. ',
|
|
98
110
|
'verifyLink': 'Open verification link',
|
|
99
111
|
'verifySuffix': ' then reconnect.',
|
|
@@ -143,12 +155,24 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
143
155
|
'intro': 'Вход по обычной пользовательской подписке. Токены остаются в хранилище учётных данных харнесса, браузер их обратно не читает. Если после «Подключить» провайдер увёл на localhost или на свою страницу, вставьте сюда адрес перехода или код.',
|
|
144
156
|
'useOrigin': 'Использовать адрес этого веб-интерфейса как redirect_uri',
|
|
145
157
|
'useOriginHint': 'Включайте, только если зарегистрировали собственного клиента OAuth на этот адрес. Штатным консольным клиентам провайдеров нужен их опубликованный адрес возврата и вставка кода вручную.',
|
|
158
|
+
'privacyMask': 'Маскировать email и учётные записи',
|
|
159
|
+
'privacyMaskHint': 'Для демонстрации экрана: email отображается как j***n@example.com.',
|
|
146
160
|
'reconnect': 'Переподключить',
|
|
147
161
|
'connect': 'Подключить',
|
|
148
162
|
'disconnect': 'Отключить',
|
|
149
163
|
'removeSlot': 'Убрать место',
|
|
150
164
|
'pastePlaceholder': 'Адрес перехода или код',
|
|
151
165
|
'submitCode': 'Отправить код',
|
|
166
|
+
'proxyPlaceholder': 'Прокси аккаунта (http://, https://, socks5://) - необязательно',
|
|
167
|
+
'proxyCheck': 'Проверить прокси',
|
|
168
|
+
'proxyOk': 'прокси ок',
|
|
169
|
+
'proxyFail': 'прокси недоступен',
|
|
170
|
+
'deviceLogin': 'Вход по коду',
|
|
171
|
+
'deviceHint': 'Headless: откройте ссылку, введите код, держите вкладку открытой.',
|
|
172
|
+
'deviceCopy': 'Скопировать код',
|
|
173
|
+
'devicePending': 'Ожидание подтверждения',
|
|
174
|
+
'deviceAuthorized': 'Вход выполнен',
|
|
175
|
+
'deviceExpired': 'Истёк - начните заново',
|
|
152
176
|
'verifyPrefix': 'Google требует однократной проверки аккаунта. ',
|
|
153
177
|
'verifyLink': 'Открыть страницу проверки',
|
|
154
178
|
'verifySuffix': ' затем подключитесь заново.',
|
|
@@ -237,6 +261,8 @@ function PluginCard(props) {
|
|
|
237
261
|
const [accounts, setAccounts] = React.useState([])
|
|
238
262
|
const [providers, setProviders] = React.useState([])
|
|
239
263
|
const [paste, setPaste] = React.useState({})
|
|
264
|
+
const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
|
|
265
|
+
const [proxyRes, setProxyRes] = React.useState({}) // #88 key -> {ok,latencyMs,error}
|
|
240
266
|
const [checkRes, setCheckRes] = React.useState({})
|
|
241
267
|
const [checking, setChecking] = React.useState({})
|
|
242
268
|
const [saved, setSaved] = React.useState(false)
|
|
@@ -259,6 +285,16 @@ function PluginCard(props) {
|
|
|
259
285
|
return () => { alive = false }
|
|
260
286
|
}, [])
|
|
261
287
|
|
|
288
|
+
// #90: poll device login while any slot is in 'pending' state.
|
|
289
|
+
React.useEffect(() => {
|
|
290
|
+
const pendingKeys = Object.entries(device).filter(([, d]) => d && d.status === 'pending')
|
|
291
|
+
if (!pendingKeys.length) return
|
|
292
|
+
const timers = pendingKeys.map(([key, d]) => setInterval(() => {
|
|
293
|
+
devicePollOnce(key.split(':')[0], Number(key.split(':')[1]))
|
|
294
|
+
}, d.intervalMs || 5000))
|
|
295
|
+
return () => timers.forEach(clearInterval)
|
|
296
|
+
})
|
|
297
|
+
|
|
262
298
|
if (!draft) return React.createElement('div', { className: 'dsub-wrap' }, t('loading'))
|
|
263
299
|
|
|
264
300
|
const slots = Array.isArray(draft.slots) ? draft.slots : []
|
|
@@ -286,6 +322,45 @@ function PluginCard(props) {
|
|
|
286
322
|
if (data.url) window.open(data.url, '_blank', 'noopener')
|
|
287
323
|
}
|
|
288
324
|
|
|
325
|
+
// #90: device-code login (headless). Server mints user_code; we show it,
|
|
326
|
+
// open the verification page, and poll until authorized.
|
|
327
|
+
const deviceStartLogin = async (provider, index) => {
|
|
328
|
+
const key = provider + ':' + index
|
|
329
|
+
setErr('')
|
|
330
|
+
const res = await fetch('/dsh-subscriptions/oauth/device/start', {
|
|
331
|
+
method: 'POST',
|
|
332
|
+
headers: { 'Content-Type': 'application/json' },
|
|
333
|
+
body: JSON.stringify({ provider, index }),
|
|
334
|
+
})
|
|
335
|
+
const data = await res.json().catch(() => ({}))
|
|
336
|
+
if (!res.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
|
|
337
|
+
setDevice((m) => Object.assign({}, m, { [key]: { state: data.state, userCode: data.userCode, authUrl: data.authUrl, intervalMs: data.intervalMs || 5000, status: 'pending' } }))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const devicePollOnce = async (provider, index) => {
|
|
341
|
+
const key = provider + ':' + index
|
|
342
|
+
const d = device[key]
|
|
343
|
+
if (!d || !d.state) return
|
|
344
|
+
const res = await fetch('/dsh-subscriptions/oauth/device/poll', {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers: { 'Content-Type': 'application/json' },
|
|
347
|
+
body: JSON.stringify({ state: d.state }),
|
|
348
|
+
})
|
|
349
|
+
const data = await res.json().catch(() => ({}))
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
setDevice((m) => Object.assign({}, m, { [key]: Object.assign({}, d, { status: 'expired' }) }))
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
if (data.status === 'authorized') {
|
|
355
|
+
setDevice((m) => Object.assign({}, m, { [key]: Object.assign({}, d, { status: 'authorized' }) }))
|
|
356
|
+
reload().catch(() => {})
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
if (data.status === 'expired') {
|
|
360
|
+
setDevice((m) => Object.assign({}, m, { [key]: Object.assign({}, d, { status: 'expired' }) }))
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
289
364
|
const complete = async (provider, index) => {
|
|
290
365
|
setErr('')
|
|
291
366
|
const key = provider + ':' + index
|
|
@@ -301,6 +376,24 @@ function PluginCard(props) {
|
|
|
301
376
|
setPaste((p) => Object.assign({}, p, { [key]: '' }))
|
|
302
377
|
}
|
|
303
378
|
|
|
379
|
+
// #88: проверить прокси аккаунта реальным запросом с замером задержки.
|
|
380
|
+
const doProxyCheck = async (provider, index) => {
|
|
381
|
+
const key = provider + ':' + index
|
|
382
|
+
setChecking((c) => Object.assign({}, c, { ['proxy:' + key]: true }))
|
|
383
|
+
setErr('')
|
|
384
|
+
try {
|
|
385
|
+
const res = await fetch('/dsh-subscriptions/proxy-check', {
|
|
386
|
+
method: 'POST',
|
|
387
|
+
headers: { 'Content-Type': 'application/json' },
|
|
388
|
+
body: JSON.stringify({ provider, index }),
|
|
389
|
+
})
|
|
390
|
+
const data = await res.json().catch(() => ({}))
|
|
391
|
+
setProxyRes((m) => Object.assign({}, m, { [key]: data }))
|
|
392
|
+
} catch (e) { setErr(String(e.message || e)) }
|
|
393
|
+
setChecking((c) => Object.assign({}, c, { ['proxy:' + key]: false }))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
304
397
|
const doCheck = async (provider, index) => {
|
|
305
398
|
const key = provider + ':' + index
|
|
306
399
|
setChecking((c) => Object.assign({}, c, { [key]: true }))
|
|
@@ -376,6 +469,16 @@ function PluginCard(props) {
|
|
|
376
469
|
),
|
|
377
470
|
React.createElement('div', { className: 'dsub-sub' },
|
|
378
471
|
t('useOriginHint')),
|
|
472
|
+
React.createElement('label', { className: 'dsub-row' },
|
|
473
|
+
React.createElement('input', {
|
|
474
|
+
type: 'checkbox',
|
|
475
|
+
checked: !!draft.privacyMask,
|
|
476
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { privacyMask: e.target.checked })),
|
|
477
|
+
}),
|
|
478
|
+
React.createElement('span', null, t('privacyMask')),
|
|
479
|
+
),
|
|
480
|
+
React.createElement('div', { className: 'dsub-sub' },
|
|
481
|
+
t('privacyMaskHint')),
|
|
379
482
|
),
|
|
380
483
|
names.map((prov) => {
|
|
381
484
|
const rows = slots
|
|
@@ -404,6 +507,10 @@ function PluginCard(props) {
|
|
|
404
507
|
type: 'button', className: 'dsub-mini',
|
|
405
508
|
onClick: () => connect(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
|
|
406
509
|
}, on ? t('reconnect') : t('connect')),
|
|
510
|
+
(row.slot.provider === 'codex' ? React.createElement('button', {
|
|
511
|
+
type: 'button', className: 'dsub-mini',
|
|
512
|
+
onClick: () => deviceStartLogin(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
|
|
513
|
+
}, t('deviceLogin')) : null),
|
|
407
514
|
React.createElement('button', {
|
|
408
515
|
type: 'button', className: 'dsub-mini',
|
|
409
516
|
disabled: !on,
|
|
@@ -450,7 +557,50 @@ function PluginCard(props) {
|
|
|
450
557
|
onClick: () => importToken(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
|
|
451
558
|
}, t('importToken')),
|
|
452
559
|
),
|
|
560
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
561
|
+
React.createElement('input', {
|
|
562
|
+
className: 'dsub-grow',
|
|
563
|
+
value: row.slot.proxyUrl || '',
|
|
564
|
+
placeholder: t('proxyPlaceholder'),
|
|
565
|
+
onChange: (e) => {
|
|
566
|
+
const next = slots.slice()
|
|
567
|
+
next[row.i] = Object.assign({}, next[row.i], { proxyUrl: e.target.value })
|
|
568
|
+
setSlots(next)
|
|
569
|
+
},
|
|
570
|
+
}),
|
|
571
|
+
React.createElement('button', {
|
|
572
|
+
type: 'button', className: 'dsub-mini',
|
|
573
|
+
disabled: checking['proxy:' + key],
|
|
574
|
+
onClick: () => doProxyCheck(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
|
|
575
|
+
}, checking['proxy:' + key] ? '\u2026' : t('proxyCheck')),
|
|
576
|
+
),
|
|
577
|
+
(function(){
|
|
578
|
+
var r = proxyRes[key]
|
|
579
|
+
if (!r) return null
|
|
580
|
+
var txt = r.ok ? (t('proxyOk') + ' ' + r.latencyMs + 'ms' + (r.viaProxy ? '' : ' (direct)')) : (t('proxyFail') + ': ' + ((r.error && r.error.message) || ''))
|
|
581
|
+
return React.createElement('div', { className: r.ok ? 'dsub-ok' : 'dsub-bad' }, txt)
|
|
582
|
+
})(),
|
|
453
583
|
),
|
|
584
|
+
(function devicePanel(){
|
|
585
|
+
var d = device[key]
|
|
586
|
+
if (!d) return null
|
|
587
|
+
var txt = d.status === 'authorized' ? t('deviceAuthorized') : (d.status === 'expired' ? t('deviceExpired') : t('devicePending'))
|
|
588
|
+
var cls = d.status === 'authorized' ? 'dsub-ok' : (d.status === 'expired' ? 'dsub-bad' : 'dsub-verify')
|
|
589
|
+
return React.createElement('div', { className: 'dsub-verify' },
|
|
590
|
+
React.createElement('div', { className: cls }, txt),
|
|
591
|
+
d.status === 'pending' ? React.createElement(React.Fragment, null,
|
|
592
|
+
React.createElement('div', { style: { fontSize: '2em', fontWeight: 700, letterSpacing: '0.15em', margin: '4px 0' } }, d.userCode),
|
|
593
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
594
|
+
React.createElement('button', {
|
|
595
|
+
type: 'button', className: 'dsub-mini',
|
|
596
|
+
onClick: () => { try { navigator.clipboard.writeText(d.userCode) } catch {} },
|
|
597
|
+
}, t('deviceCopy')),
|
|
598
|
+
React.createElement('a', { href: d.authUrl, target: '_blank', rel: 'noopener noreferrer', className: 'dsub-mini' }, t('verifyLink')),
|
|
599
|
+
),
|
|
600
|
+
React.createElement('div', { className: 'dsub-sub' }, t('deviceHint')),
|
|
601
|
+
) : null,
|
|
602
|
+
)
|
|
603
|
+
})(),
|
|
454
604
|
account.accountNotice ? React.createElement('div', { className: 'dsub-verify' }, account.accountNotice) : null,
|
|
455
605
|
account.refreshError ? React.createElement('div', { className: 'dsub-bad' }, t('reconnectRequired') + ': ' + account.refreshError) : null,
|
|
456
606
|
(function(){ var r=checkRes[key]; if(!r) return null; var txt=r.ok ? ('ok ' + (r.email||'')) : ('fail ' + (r.error && r.error.message || '')); var cls=r.ok ? 'dsub-ok' : 'dsub-bad'; var q=r.quota; if(q && q.remaining!=null) txt += ' quota:'+q.remaining+(q.limit!=null?'/'+q.limit:''); return React.createElement('div', {className: cls}, txt) })(),
|
|
@@ -638,7 +788,7 @@ function PluginCard(props) {
|
|
|
638
788
|
expiryNotifyDays: (data && data.expiryNotifyDays) || 7,
|
|
639
789
|
active: (data && data.active) || null,
|
|
640
790
|
}
|
|
641
|
-
} catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, active: null } }
|
|
791
|
+
} catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, fastMode: false, active: null } }
|
|
642
792
|
}
|
|
643
793
|
|
|
644
794
|
// #52: компактный чип в шапке сессии со статусом подключённых подписок.
|
|
@@ -683,12 +833,12 @@ function PluginCard(props) {
|
|
|
683
833
|
const idx = a.index != null ? ('#' + a.index) : ''
|
|
684
834
|
const plan = a.plan ? (' ' + a.plan) : ''
|
|
685
835
|
// #77: без provider-префикса
|
|
686
|
-
const label = idx + plan
|
|
836
|
+
const label = (a.provider === 'codex' && state.fastMode ? '\u26a1' : '') + idx + plan
|
|
687
837
|
// windows: "5h 12% 7d 8%"
|
|
688
838
|
const winLabels = wins.map((w) => (w.label || w.id) + ' ' + Math.round(w.usedPercent) + '%').join(' ')
|
|
689
839
|
return React.createElement('span', {
|
|
690
840
|
className: acls,
|
|
691
|
-
title: a.model ? (idx + ' · ' + a.model) : idx,
|
|
841
|
+
title: a.model ? (prov + idx + ' · ' + a.model) : (prov + idx),
|
|
692
842
|
},
|
|
693
843
|
React.createElement('span', { className: 'dsub-chipDot' }),
|
|
694
844
|
label,
|