@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.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) || 0,
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, doFetch)
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, doFetch)
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
- rememberCooldown(ref, until) { cooldowns.set(ref, until) },
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 models = await getVendor(provider).listModels(blob, cfg, fetchImpl)
46
- if (Array.isArray(models) && models.length) return models
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
- return modelCatalog(provider, cfg.models)
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
- yield* streamWithRotation({
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.fetchImpl || fetch
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,