@goodandready/dsh-subscriptions 0.4.17 → 0.4.19
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 +25 -0
- package/README.ru.md +1 -0
- package/lib/client.js +419 -137
- package/lib/forecast.js +71 -0
- package/lib/index.js +84 -0
- package/lib/reset-credits.js +193 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -152,6 +152,30 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
152
152
|
|
|
153
153
|
---
|
|
154
154
|
|
|
155
|
+
### 12. 🧯 Safe Reset Credits (`v0.4.18`)
|
|
156
|
+
* **Reset Card Visibility**: The Codex account card shows how many ChatGPT quota reset cards are available and when the earliest one expires.
|
|
157
|
+
* **Deliberate Confirmation Flow**: Consuming a card requires an explicit checkbox ("I understand one attempt will be consumed") plus a mandatory 5-second cooldown before the Reset button activates.
|
|
158
|
+
* **Double-Click Proof**: A host-side single-flight gate (synchronous pending lock before the first network await) makes it impossible for a double click or a concurrent call to consume two cards. Uncertain network results return the challenge to "prepared" for a safe retry of the same request.
|
|
159
|
+
* **Honest Results**: Server verdicts are shown verbatim: `reset` / `nothing to reset` (nothing consumed) / `no usable credit` / `already redeemed`.
|
|
160
|
+
|
|
161
|
+
### 13. 📊 Composer Quota Indicator & Runway Forecast (`v0.4.18`)
|
|
162
|
+
* **Placement**: Renders in the input area next to the model switcher (`conversation.input.right` slot).
|
|
163
|
+
* **Four display modes (`composerQuota` setting)**: `off` / `percent` (`85%`) / `bar` (40px mini bar, green>30 / amber 10-30 / red <10) / `forecast`.
|
|
164
|
+
* **Runway Forecast**: A sliding 24h window of remaining-percent samples (up to 192 points) feeds a recency-weighted least-squares burn rate; below 30 min of observation or <1% consumed it stays `calibrating…`, with no consumption it says `no usage`. Ready state shows `~4.5h` / `~12m`.
|
|
165
|
+
* **Auto-Hide**: No indicator for local Ollama or when nothing is active.
|
|
166
|
+
|
|
167
|
+
### 14. 🧑💻 SUBS Pill & Session Console (`v0.4.18`)
|
|
168
|
+
* **Pill in the Session Header**: `SUBS (N)` in the session header actions area with a pool-health LED: green <50% max usage, amber 50-90%, red ≥90%, gray when nothing is connected.
|
|
169
|
+
* **Modal Console**: Clicking the pill opens an in-session modal listing every account (provider #index, connected/not, cooldown, quota %) and a shortcut to the settings card. Closes on outside click, ✕ or Escape.
|
|
170
|
+
|
|
171
|
+
### 15. 🫧 Draggable HUD Widget (`v0.4.18`)
|
|
172
|
+
* **Floating Bubble** on `shell.overlay`: a 64px circle with an SVG ring gauge of the active subscription balance, mounted via `createPortal`.
|
|
173
|
+
* **Drag & Dock**: Drag anywhere; it snaps to any screen edge within 24px (peek-style half-hidden until hovered) and remembers its position in `localStorage`.
|
|
174
|
+
* **Frosted Panel**: Hovering reveals a `backdrop-filter: blur(28px)` panel listing every account with usage bars. Clicking the bubble refreshes quota data; data also refreshes every 60 s.
|
|
175
|
+
* **Settings Card Polish (`v0.4.18`)**: Chevron switched to the core `IconChevronDownOutline14` primitive; explicit settings snapshot states (loading / unavailable + Retry) guard against phantom input.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
155
179
|
## 📦 Quick Installation
|
|
156
180
|
|
|
157
181
|
```bash
|
|
@@ -177,6 +201,7 @@ dsh-subscriptions:
|
|
|
177
201
|
hideDeprecatedModels: false # v0.4.17: filter test/preview/beta/legacy model ids
|
|
178
202
|
codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
|
|
179
203
|
codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
|
|
204
|
+
composerQuota: 'off' # v0.4.18: composer indicator: off | percent | bar | forecast
|
|
180
205
|
# Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
|
|
181
206
|
accounts:
|
|
182
207
|
codex:
|
package/README.ru.md
CHANGED
|
@@ -174,6 +174,7 @@ dsh-subscriptions:
|
|
|
174
174
|
hideDeprecatedModels: false # v0.4.17: фильтровать test/preview/beta/legacy id моделей
|
|
175
175
|
codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
|
|
176
176
|
codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
|
|
177
|
+
composerQuota: 'off' # v0.4.18: индикатор в строке ввода: off | percent | bar | forecast
|
|
177
178
|
# Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
|
|
178
179
|
accounts:
|
|
179
180
|
codex:
|
package/lib/client.js
CHANGED
|
@@ -4,6 +4,9 @@ window.__ModuleLoader__.load({
|
|
|
4
4
|
var module = { exports: {} }
|
|
5
5
|
var exports = module.exports
|
|
6
6
|
const React = require('react')
|
|
7
|
+
const createPortal = (function () {
|
|
8
|
+
try { return require('react-dom').createPortal } catch { return null }
|
|
9
|
+
})()
|
|
7
10
|
|
|
8
11
|
// Модульный переводчик: доступен всем подпискам компонентов и хелперам.
|
|
9
12
|
let t = (key) => key
|
|
@@ -60,7 +63,28 @@ window.__ModuleLoader__.load({
|
|
|
60
63
|
'.dsub-barRow{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
|
|
61
64
|
'.dsub-manual{display:flex;flex-direction:column;gap:6px;margin-top:4px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)}' +
|
|
62
65
|
'.dsub-verify{font-size:12px;color:var(--dsw-alias-state-warning-primary)}' +
|
|
63
|
-
'.dsub-verify a{color:var(--dsw-alias-brand-primary)}'
|
|
66
|
+
'.dsub-verify a{color:var(--dsw-alias-brand-primary)}' +
|
|
67
|
+
'background:var(--dsw-alias-bg-layer-3);box-shadow:0 0 0 1px var(--dsw-alias-border-l2),0 8px 24px rgba(0,0,0,.25);backdrop-filter:blur(18px)}' +
|
|
68
|
+
'.dsub-pill{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:11.5px;font-weight:600;cursor:pointer;letter-spacing:.06em}' +
|
|
69
|
+
'.dsub-pill:hover{border-color:var(--dsw-alias-label-dimmed)}' +
|
|
70
|
+
'.dsub-led{width:7px;height:7px;border-radius:50%;flex:none}' +
|
|
71
|
+
'.dsub-ledOk{background:var(--dsw-alias-state-success-primary);box-shadow:0 0 6px var(--dsw-alias-state-success-primary)}' +
|
|
72
|
+
'.dsub-ledWarn{background:var(--dsw-alias-state-warning-primary);box-shadow:0 0 6px var(--dsw-alias-state-warning-primary)}' +
|
|
73
|
+
'.dsub-ledBad{background:var(--dsw-alias-state-error-primary);box-shadow:0 0 6px var(--dsw-alias-state-error-primary)}' +
|
|
74
|
+
'.dsub-ledOff{background:var(--dsw-alias-label-tertiary)}' +
|
|
75
|
+
'.dsub-modalWrap{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;background:color-mix(in srgb,#000 45%,transparent)}' +
|
|
76
|
+
'.dsub-modal{width:min(560px,92vw);max-height:min(70vh,640px);overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);box-shadow:0 24px 64px rgba(0,0,0,.4);padding:16px}' +
|
|
77
|
+
'.dsub-modalHead{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}' +
|
|
78
|
+
'.dsub-modalRow{display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;margin-top:8px;background:var(--dsw-alias-bg-layer-2)}' +
|
|
79
|
+
'.dsub-modalName{font-weight:600;font-size:13px}' +
|
|
80
|
+
'.dsub-modalSub{font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
|
|
81
|
+
'.dsub-cq{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);padding:0 6px}' +
|
|
82
|
+
'.dsub-cqBar{position:relative;width:40px;height:5px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
|
|
83
|
+
'.dsub-cqBarFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary)}' +
|
|
84
|
+
'.dsub-cqBarWarn .dsub-cqBarFill{background:var(--dsw-alias-state-warning-primary)}' +
|
|
85
|
+
'.dsub-cqBarFull .dsub-cqBarFill{background:var(--dsw-alias-state-error-primary)}' +
|
|
86
|
+
'.dsub-cqB{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
87
|
+
'.dsub-diag{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}'
|
|
64
88
|
|
|
65
89
|
const cssId = 'dsh-subscriptions/settings.module.css'
|
|
66
90
|
if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + cssId + '"]')) {
|
|
@@ -90,6 +114,39 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
90
114
|
'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
115
|
'privacyMask': 'Mask emails and account identifiers',
|
|
92
116
|
'privacyMaskHint': 'For demos and screen sharing: emails show as j***n@example.com.',
|
|
117
|
+
'resetCredits': 'Reset credits',
|
|
118
|
+
'resetCreditsHint': 'ChatGPT reset cards: shows how many are available. Consuming one is a deliberate action with a 5s confirmation.',
|
|
119
|
+
'resetAvailable': 'Reset attempts available',
|
|
120
|
+
'resetExpires': 'earliest expires',
|
|
121
|
+
'resetAck': 'I understand one attempt will be consumed',
|
|
122
|
+
'resetWait': 'confirm enabled in',
|
|
123
|
+
'resetReady': 'ready',
|
|
124
|
+
'resetGo': 'Reset now',
|
|
125
|
+
'resetBusy': 'resetting…',
|
|
126
|
+
'resetDone': 'quota reset',
|
|
127
|
+
'resetNothing': 'server says nothing needs a reset (no attempt consumed)',
|
|
128
|
+
'resetNoCredit': 'no usable credit (not consumed)',
|
|
129
|
+
'resetRedeemed': 'already redeemed earlier',
|
|
130
|
+
'diagGenerate': 'Generate diagnostics report',
|
|
131
|
+
'diagCopy': 'Copy report',
|
|
132
|
+
'diagIssues': 'Open issue tracker',
|
|
133
|
+
'diagHint': 'The report has no tokens, emails, proxy addresses or personal data.',
|
|
134
|
+
'subsPill': 'SUBS',
|
|
135
|
+
'subsModalTitle': 'Subscription console',
|
|
136
|
+
'subsModalClose': 'Close',
|
|
137
|
+
'subsLogged': 'connected',
|
|
138
|
+
'subsNotLogged': 'not connected',
|
|
139
|
+
'subsCooldown': 'cooldown',
|
|
140
|
+
'subsOpenSettings': 'Open settings',
|
|
141
|
+
'hudTitle': 'Subscription balance',
|
|
142
|
+
'settingsLoading': 'Loading settings…',
|
|
143
|
+
'settingsUnavailable': 'Settings are unavailable',
|
|
144
|
+
'settingsRetry': 'Retry',
|
|
145
|
+
'hudHint': 'drag · edge docks · click refresh',
|
|
146
|
+
'fcCalibrating': 'calibrating…',
|
|
147
|
+
'fcIdle': 'no usage',
|
|
148
|
+
'fcHours': 'h',
|
|
149
|
+
'fcMinutes': 'm',
|
|
93
150
|
'reconnect': 'Reconnect',
|
|
94
151
|
'connect': 'Connect',
|
|
95
152
|
'disconnect': 'Disconnect',
|
|
@@ -157,6 +214,39 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
157
214
|
'useOriginHint': 'Включайте, только если зарегистрировали собственного клиента OAuth на этот адрес. Штатным консольным клиентам провайдеров нужен их опубликованный адрес возврата и вставка кода вручную.',
|
|
158
215
|
'privacyMask': 'Маскировать email и учётные записи',
|
|
159
216
|
'privacyMaskHint': 'Для демонстрации экрана: email отображается как j***n@example.com.',
|
|
217
|
+
'resetCredits': 'Карты сброса',
|
|
218
|
+
'resetCreditsHint': 'Карты сброса квоты ChatGPT: показывает, сколько доступно. Списание одной карты — осознанное действие с 5-секундным подтверждением.',
|
|
219
|
+
'resetAvailable': 'Доступно попыток сброса',
|
|
220
|
+
'resetExpires': 'ближайшая истекает',
|
|
221
|
+
'resetAck': 'Я понимаю, что будет списана 1 попытка',
|
|
222
|
+
'resetWait': 'кнопка активна через',
|
|
223
|
+
'resetReady': 'готово',
|
|
224
|
+
'resetGo': 'Сбросить',
|
|
225
|
+
'resetBusy': 'сбрасываю…',
|
|
226
|
+
'resetDone': 'квота сброшена',
|
|
227
|
+
'resetNothing': 'сервер сообщает: сброс не нужен (попытка не списана)',
|
|
228
|
+
'resetNoCredit': 'нет пригодной карты (не списано)',
|
|
229
|
+
'resetRedeemed': 'эта карта уже использована ранее',
|
|
230
|
+
'diagGenerate': 'Сгенерировать диагностический отчёт',
|
|
231
|
+
'diagCopy': 'Скопировать отчёт',
|
|
232
|
+
'diagIssues': 'Открыть трекер задач',
|
|
233
|
+
'diagHint': 'В отчёте нет токенов, email, адресов прокси и персональных данных.',
|
|
234
|
+
'subsPill': 'SUBS',
|
|
235
|
+
'subsModalTitle': 'Консоль подписок',
|
|
236
|
+
'subsModalClose': 'Закрыть',
|
|
237
|
+
'subsLogged': 'подключен',
|
|
238
|
+
'subsNotLogged': 'не подключен',
|
|
239
|
+
'subsCooldown': 'кулдаун',
|
|
240
|
+
'subsOpenSettings': 'Открыть настройки',
|
|
241
|
+
'hudTitle': 'Остаток подписки',
|
|
242
|
+
'settingsLoading': 'Загрузка настроек…',
|
|
243
|
+
'settingsUnavailable': 'Настройки недоступны',
|
|
244
|
+
'settingsRetry': 'Повторить',
|
|
245
|
+
'hudHint': 'перетащи · прилипает к краям · клик — обновить',
|
|
246
|
+
'fcCalibrating': 'калибровка…',
|
|
247
|
+
'fcIdle': 'нет расхода',
|
|
248
|
+
'fcHours': 'ч',
|
|
249
|
+
'fcMinutes': 'мин',
|
|
160
250
|
'reconnect': 'Переподключить',
|
|
161
251
|
'connect': 'Подключить',
|
|
162
252
|
'disconnect': 'Отключить',
|
|
@@ -236,23 +326,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
236
326
|
}
|
|
237
327
|
const Chevron = ChevronIcon || FallbackChevron
|
|
238
328
|
|
|
239
|
-
|
|
240
|
-
const t = (props && props.t) || ((key) => key)
|
|
241
|
-
const [open, setOpen] = React.useState(false)
|
|
242
|
-
return React.createElement('div', { className: 'dsub-block' },
|
|
243
|
-
React.createElement('div', { className: 'dsub-row' },
|
|
244
|
-
React.createElement('span', { className: 'dsub-h' }, t('title')),
|
|
245
|
-
React.createElement('span', { className: 'dsub-sub' }, t('cardIntro')),
|
|
246
|
-
React.createElement('span', { style: { flex: 1 } }),
|
|
247
|
-
React.createElement('button', {
|
|
248
|
-
type: 'button',
|
|
249
|
-
className: 'dsub-mini',
|
|
250
|
-
onClick: () => setOpen(!open),
|
|
251
|
-
}, open ? t('hide') : t('show')),
|
|
252
|
-
),
|
|
253
|
-
open ? React.createElement(SubsSection, props) : null,
|
|
254
|
-
)
|
|
255
|
-
}
|
|
329
|
+
|
|
256
330
|
|
|
257
331
|
function SubsSection(props) {
|
|
258
332
|
// Переводчик приходит от слота, потому что в его записи указан locale.
|
|
@@ -263,6 +337,69 @@ function PluginCard(props) {
|
|
|
263
337
|
const [paste, setPaste] = React.useState({})
|
|
264
338
|
const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
|
|
265
339
|
const [proxyRes, setProxyRes] = React.useState({}) // #88 key -> {ok,latencyMs,error}
|
|
340
|
+
const [diag, setDiag] = React.useState('')
|
|
341
|
+
const [reset, setReset] = React.useState({}) // #85 key -> challenge state
|
|
342
|
+
const [, setResetTick] = React.useState(0)
|
|
343
|
+
React.useEffect(() => {
|
|
344
|
+
const anyPending = Object.values(reset).some((r) => r && r.phase === 'confirm' && !r.result)
|
|
345
|
+
if (!anyPending) return undefined
|
|
346
|
+
const id = setInterval(() => setResetTick((n) => n + 1), 1000)
|
|
347
|
+
return () => clearInterval(id)
|
|
348
|
+
}, [reset])
|
|
349
|
+
|
|
350
|
+
// #85: reset credits challenge flow (prepare -> 5s cooldown + ack -> consume).
|
|
351
|
+
const resetPrepare = async (provider, index, key) => {
|
|
352
|
+
setReset((m) => Object.assign({}, m, { [key]: { phase: 'loading' } }))
|
|
353
|
+
try {
|
|
354
|
+
const res = await fetch('/dsh-subscriptions/reset-credits/prepare', {
|
|
355
|
+
method: 'POST',
|
|
356
|
+
headers: { 'Content-Type': 'application/json' },
|
|
357
|
+
body: JSON.stringify({ provider, index }),
|
|
358
|
+
})
|
|
359
|
+
const d = await res.json()
|
|
360
|
+
if (!d || !d.ok) throw new Error((d && d.error && d.error.message) || 'prepare failed')
|
|
361
|
+
setReset((m) => Object.assign({}, m, { [key]: {
|
|
362
|
+
phase: 'confirm',
|
|
363
|
+
challengeId: d.challengeId,
|
|
364
|
+
availableCount: d.availableCount,
|
|
365
|
+
readyAt: d.readyAt,
|
|
366
|
+
expiresAt: d.expiresAt,
|
|
367
|
+
creditExpiresAt: d.creditExpiresAt || null,
|
|
368
|
+
} }))
|
|
369
|
+
} catch (e) {
|
|
370
|
+
setReset((m) => Object.assign({}, m, { [key]: { phase: 'idle', error: String(e && e.message || e) } }))
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const resetConsume = async (key) => {
|
|
374
|
+
const st = reset[key]
|
|
375
|
+
if (!st || st.phase !== 'confirm' || !st.ack || Date.now() < st.readyAt || st.busy) return
|
|
376
|
+
setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { busy: true }) }))
|
|
377
|
+
try {
|
|
378
|
+
const res = await fetch('/dsh-subscriptions/reset-credits/consume', {
|
|
379
|
+
method: 'POST',
|
|
380
|
+
headers: { 'Content-Type': 'application/json' },
|
|
381
|
+
body: JSON.stringify({ challengeId: st.challengeId, acknowledged: true }),
|
|
382
|
+
})
|
|
383
|
+
const d = await res.json()
|
|
384
|
+
if (!d || !d.ok) throw new Error((d && d.error && d.error.message) || 'consume failed')
|
|
385
|
+
const code = d.result && d.result.code
|
|
386
|
+
setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { busy: false, result: code, windowsReset: (d.result && d.result.windowsReset) || [] }) }))
|
|
387
|
+
reload().catch(() => {})
|
|
388
|
+
} catch (e) {
|
|
389
|
+
setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { busy: false, error: String(e && e.message || e) }) }))
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// #99: one click - fetch the anonymized report and copy it to the clipboard.
|
|
393
|
+
const genDiag = () => {
|
|
394
|
+
fetch('/dsh-subscriptions/diagnostics', { cache: 'no-store' })
|
|
395
|
+
.then((r) => r.json())
|
|
396
|
+
.then((d) => {
|
|
397
|
+
const txt = d && d.ok ? JSON.stringify(d.report, null, 2) : ('error: ' + ((d && d.error && d.error.message) || 'unknown'))
|
|
398
|
+
setDiag(txt)
|
|
399
|
+
try { navigator.clipboard.writeText(txt) } catch {}
|
|
400
|
+
})
|
|
401
|
+
.catch((e) => setDiag('error: ' + String(e && e.message || e)))
|
|
402
|
+
}
|
|
266
403
|
const [checkRes, setCheckRes] = React.useState({})
|
|
267
404
|
const [checking, setChecking] = React.useState({})
|
|
268
405
|
const [saved, setSaved] = React.useState(false)
|
|
@@ -295,7 +432,20 @@ function PluginCard(props) {
|
|
|
295
432
|
return () => timers.forEach(clearInterval)
|
|
296
433
|
})
|
|
297
434
|
|
|
298
|
-
|
|
435
|
+
// #81: settings snapshot status - never render phantom inputs before the
|
|
436
|
+
// config snapshot arrives, and offer a retry when the store is unavailable.
|
|
437
|
+
if (!draft) {
|
|
438
|
+
return React.createElement('div', { className: 'dsub-wrap' },
|
|
439
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
440
|
+
React.createElement('span', { className: 'dsub-sub' }, err ? t('settingsUnavailable') : t('settingsLoading')),
|
|
441
|
+
err ? React.createElement('button', {
|
|
442
|
+
type: 'button', className: 'dsub-mini',
|
|
443
|
+
onClick: () => { setErr(''); reload().catch((e) => setErr(String(e && e.message || e))) },
|
|
444
|
+
}, t('settingsRetry')) : null,
|
|
445
|
+
),
|
|
446
|
+
err ? React.createElement('div', { className: 'dsub-bad' }, err) : null,
|
|
447
|
+
)
|
|
448
|
+
}
|
|
299
449
|
|
|
300
450
|
const slots = Array.isArray(draft.slots) ? draft.slots : []
|
|
301
451
|
const setSlots = (next) => setDraft((d) => Object.assign({}, d, { slots: next }))
|
|
@@ -480,6 +630,20 @@ function PluginCard(props) {
|
|
|
480
630
|
React.createElement('div', { className: 'dsub-sub' },
|
|
481
631
|
t('privacyMaskHint')),
|
|
482
632
|
),
|
|
633
|
+
React.createElement('div', { className: 'dsub-block' },
|
|
634
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
635
|
+
React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: genDiag }, t('diagGenerate')),
|
|
636
|
+
React.createElement('a', { href: 'https://github.com/GooDAnDReaDY/dsh-subscriptions/issues', target: '_blank', rel: 'noopener noreferrer', className: 'dsub-mini' }, t('diagIssues')),
|
|
637
|
+
),
|
|
638
|
+
React.createElement('div', { className: 'dsub-sub' }, t('diagHint')),
|
|
639
|
+
diag ? React.createElement('div', { className: 'dsub-row' },
|
|
640
|
+
React.createElement('button', {
|
|
641
|
+
type: 'button', className: 'dsub-mini',
|
|
642
|
+
onClick: () => { try { navigator.clipboard.writeText(diag) } catch {} },
|
|
643
|
+
}, t('diagCopy')),
|
|
644
|
+
React.createElement('pre', { className: 'dsub-diag' }, diag),
|
|
645
|
+
) : null,
|
|
646
|
+
),
|
|
483
647
|
names.map((prov) => {
|
|
484
648
|
const rows = slots
|
|
485
649
|
.map((slot, i) => ({ slot: slot, i: i }))
|
|
@@ -601,6 +765,53 @@ function PluginCard(props) {
|
|
|
601
765
|
) : null,
|
|
602
766
|
)
|
|
603
767
|
})(),
|
|
768
|
+
(row.slot.provider === 'codex' && on ? (function resetPanel(){
|
|
769
|
+
var st = reset[key] || { phase: 'idle' }
|
|
770
|
+
var now = Date.now()
|
|
771
|
+
function resultText(code) {
|
|
772
|
+
if (code === 'reset') return t('resetDone')
|
|
773
|
+
if (code === 'nothing_to_reset') return t('resetNothing')
|
|
774
|
+
if (code === 'no_credit') return t('resetNoCredit')
|
|
775
|
+
if (code === 'already_redeemed') return t('resetRedeemed')
|
|
776
|
+
return code
|
|
777
|
+
}
|
|
778
|
+
if (st.phase !== 'confirm') {
|
|
779
|
+
return React.createElement('div', { className: 'dsub-row' },
|
|
780
|
+
React.createElement('button', {
|
|
781
|
+
type: 'button', className: 'dsub-mini',
|
|
782
|
+
onClick: () => resetPrepare(row.slot.provider, row.slot.index, key).catch((e) => setErr(String(e && e.message || e))),
|
|
783
|
+
}, st.phase === 'loading' ? '\u2026' : t('resetCredits')),
|
|
784
|
+
React.createElement('div', { className: 'dsub-sub' }, t('resetCreditsHint')),
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
var ready = now >= st.readyAt
|
|
788
|
+
var secs = Math.max(0, Math.ceil((st.readyAt - now) / 1000))
|
|
789
|
+
return React.createElement('div', { className: 'dsub-verify' },
|
|
790
|
+
React.createElement('div', null,
|
|
791
|
+
t('resetAvailable') + ': ' + st.availableCount +
|
|
792
|
+
(st.creditExpiresAt ? (' · ' + t('resetExpires') + ' ' + new Date(st.creditExpiresAt).toLocaleString()) : '')
|
|
793
|
+
),
|
|
794
|
+
React.createElement('label', { className: 'dsub-row' },
|
|
795
|
+
React.createElement('input', {
|
|
796
|
+
type: 'checkbox',
|
|
797
|
+
checked: !!st.ack,
|
|
798
|
+
onChange: (e) => setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { ack: e.target.checked }) })),
|
|
799
|
+
}),
|
|
800
|
+
React.createElement('span', null, t('resetAck')),
|
|
801
|
+
),
|
|
802
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
803
|
+
React.createElement('button', {
|
|
804
|
+
type: 'button', className: 'dsub-mini',
|
|
805
|
+
disabled: !st.ack || !ready || !!st.busy,
|
|
806
|
+
onClick: () => resetConsume(key).catch((e) => setErr(String(e && e.message || e))),
|
|
807
|
+
}, st.busy ? t('resetBusy') : (ready ? t('resetGo') : (t('resetWait') + ' ' + secs + 's'))),
|
|
808
|
+
!ready && !st.busy ? React.createElement('span', { className: 'dsub-sub' }, t('resetWait') + ' ' + secs + 's') : null,
|
|
809
|
+
st.ack && ready ? React.createElement('span', { className: 'dsub-ok' }, t('resetReady')) : null,
|
|
810
|
+
),
|
|
811
|
+
st.result ? React.createElement('div', { className: 'dsub-ok' }, resultText(st.result)) : null,
|
|
812
|
+
st.error ? React.createElement('div', { className: 'dsub-bad' }, st.error) : null,
|
|
813
|
+
)
|
|
814
|
+
})() : null),
|
|
604
815
|
account.accountNotice ? React.createElement('div', { className: 'dsub-verify' }, account.accountNotice) : null,
|
|
605
816
|
account.refreshError ? React.createElement('div', { className: 'dsub-bad' }, t('reconnectRequired') + ': ' + account.refreshError) : null,
|
|
606
817
|
(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) })(),
|
|
@@ -696,15 +907,7 @@ function PluginCard(props) {
|
|
|
696
907
|
React.createElement('div', { className: 'dsub-name' }, t('title')),
|
|
697
908
|
React.createElement('div', { className: 'dsub-description' }, t('cardIntro')),
|
|
698
909
|
),
|
|
699
|
-
React.createElement('
|
|
700
|
-
className: 'dsub-chev' + (open ? ' dsub-chevOpen' : ''),
|
|
701
|
-
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
|
|
702
|
-
},
|
|
703
|
-
React.createElement('path', {
|
|
704
|
-
d: 'M4 6l4 4 4-4', stroke: 'currentColor', 'stroke-width': 1.5,
|
|
705
|
-
'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
706
|
-
}),
|
|
707
|
-
),
|
|
910
|
+
React.createElement(Chevron, { className: 'dsub-chev' + (open ? ' dsub-chevOpen' : '') }),
|
|
708
911
|
),
|
|
709
912
|
open ? React.createElement('div', { className: 'dsub-body' },
|
|
710
913
|
React.createElement(SubsSection, props),
|
|
@@ -786,150 +989,229 @@ function PluginCard(props) {
|
|
|
786
989
|
expiresAt: (data && data.expiresAt) || {},
|
|
787
990
|
labels: (data && data.labels) || {},
|
|
788
991
|
expiryNotifyDays: (data && data.expiryNotifyDays) || 7,
|
|
992
|
+
composerQuota: (data && data.composerQuota) || 'off',
|
|
789
993
|
active: (data && data.active) || null,
|
|
790
994
|
}
|
|
791
|
-
} catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, fastMode: false, active: null } }
|
|
995
|
+
} catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, fastMode: false, composerQuota: 'off', active: null } }
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// #84: компактный индикатор квоты активной подписки в области ввода.
|
|
999
|
+
// Прогноз (runway) считается по скользящему окну сэмплов /status.
|
|
1000
|
+
const fcSamples = { windows: {} }
|
|
1001
|
+
|
|
1002
|
+
function fcObserve(key, remaining, now) {
|
|
1003
|
+
const windows = fcSamples.windows
|
|
1004
|
+
const prev = windows[key]
|
|
1005
|
+
const samples = (prev && prev.samples) || []
|
|
1006
|
+
const last = samples[samples.length - 1]
|
|
1007
|
+
if (!last || (now > last.at && (Math.abs(remaining - last.pct) >= 0.1 || now - last.at >= 15 * 60 * 1000))) {
|
|
1008
|
+
samples.push({ at: now, pct: remaining })
|
|
1009
|
+
}
|
|
1010
|
+
windows[key] = { samples: samples.filter((x) => x.at >= now - 24 * 60 * 60 * 1000).slice(-192) }
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function fcEstimate(key, remaining, now) {
|
|
1014
|
+
const rec = fcSamples.windows[key]
|
|
1015
|
+
if (!rec) return { status: 'calibrating' }
|
|
1016
|
+
const samples = rec.samples
|
|
1017
|
+
if (samples.length < 3) return { status: 'calibrating' }
|
|
1018
|
+
const first = samples[0]
|
|
1019
|
+
const last = samples[samples.length - 1]
|
|
1020
|
+
const span = last.at - first.at
|
|
1021
|
+
const consumed = first.pct - last.pct
|
|
1022
|
+
if (span < 30 * 60 * 1000 || consumed < 1) return { status: 'calibrating' }
|
|
1023
|
+
const t0 = first.at
|
|
1024
|
+
let sw = 0, sx = 0, sy = 0, sxx = 0, sxy = 0
|
|
1025
|
+
for (const x of samples) {
|
|
1026
|
+
const wx = (x.at - t0) / 3600000
|
|
1027
|
+
const wy = first.pct - x.pct
|
|
1028
|
+
const w = Math.exp((x.at - last.at) / (6 * 3600000))
|
|
1029
|
+
sw += w; sx += w * wx; sy += w * wy; sxx += w * wx * wx; sxy += w * wx * wy
|
|
1030
|
+
}
|
|
1031
|
+
const den = sw * sxx - sx * sx
|
|
1032
|
+
const pace = den > 0 ? (sw * sxy - sx * sy) / den : 0
|
|
1033
|
+
if (!Number.isFinite(pace) || pace < 0.02) return { status: 'idle' }
|
|
1034
|
+
return { status: 'ready', runwaySeconds: Math.round((remaining / pace) * 3600) }
|
|
792
1035
|
}
|
|
793
1036
|
|
|
794
|
-
|
|
795
|
-
function HeaderChip(props) {
|
|
796
|
-
const [state, setState] = React.useState({ loading: true, count: 0, usage: 0, expiry: null, active: null })
|
|
1037
|
+
function ComposerQuota(props) {
|
|
797
1038
|
const t = (props && props.t) || ((k) => k)
|
|
1039
|
+
const [state, setState] = React.useState({ mode: 'off', active: null })
|
|
798
1040
|
React.useEffect(() => {
|
|
1041
|
+
let alive = true
|
|
799
1042
|
const pull = () => {
|
|
800
1043
|
refreshLoggedIn().then((r) => {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
}
|
|
1044
|
+
if (!alive) return
|
|
1045
|
+
const mode = r.composerQuota || 'off'
|
|
1046
|
+
const a = r.active
|
|
1047
|
+
if (mode !== 'off' && a && a.provider && a.provider !== 'ollama' && a.usagePercent != null) {
|
|
1048
|
+
const remaining = Math.max(0, 100 - a.usagePercent)
|
|
1049
|
+
const key = 'subs:' + a.provider
|
|
1050
|
+
fcObserve(key, remaining, Date.now())
|
|
1051
|
+
const est = fcEstimate(key, remaining, Date.now())
|
|
1052
|
+
setState({ mode, active: a, remaining, est })
|
|
1053
|
+
} else {
|
|
1054
|
+
setState({ mode, active: a, remaining: null, est: null })
|
|
812
1055
|
}
|
|
813
|
-
|
|
814
|
-
}).catch(() => setState({ loading: false, count: 0, usage: 0, expiry: null, active: null }))
|
|
1056
|
+
}).catch(() => {})
|
|
815
1057
|
}
|
|
816
1058
|
pull()
|
|
817
1059
|
const id = setInterval(pull, 60 * 1000)
|
|
818
|
-
return () => clearInterval(id)
|
|
1060
|
+
return () => { alive = false; clearInterval(id) }
|
|
819
1061
|
}, [])
|
|
820
|
-
|
|
1062
|
+
if (state.mode === 'off' || !state.active || state.remaining == null) return null
|
|
1063
|
+
const cls = state.remaining <= 10 ? ' dsub-cqBarFull' : (state.remaining <= 30 ? ' dsub-cqBarWarn' : '')
|
|
1064
|
+
var value = null
|
|
1065
|
+
if (state.mode === 'percent') {
|
|
1066
|
+
value = React.createElement('span', { className: 'dsub-cqB' }, Math.round(state.remaining) + '%')
|
|
1067
|
+
} else if (state.mode === 'bar') {
|
|
1068
|
+
value = React.createElement('span', { className: 'dsub-cqBar' + cls },
|
|
1069
|
+
React.createElement('span', { className: 'dsub-cqBarFill', style: { width: Math.min(100, Math.max(0, state.remaining)) + '%' } }))
|
|
1070
|
+
} else if (state.mode === 'forecast') {
|
|
1071
|
+
var est = state.est
|
|
1072
|
+
if (!est || est.status === 'calibrating') value = React.createElement('span', { className: 'dsub-cqB' }, t('fcCalibrating'))
|
|
1073
|
+
else if (est.status === 'idle') value = React.createElement('span', { className: 'dsub-cqB' }, t('fcIdle'))
|
|
1074
|
+
else {
|
|
1075
|
+
var secs = est.runwaySeconds || 0
|
|
1076
|
+
var txt = secs >= 3600 ? ('~' + (Math.round((secs / 3600) * 10) / 10) + t('fcHours')) : ('~' + Math.max(1, Math.round(secs / 60)) + t('fcMinutes'))
|
|
1077
|
+
value = React.createElement('span', { className: 'dsub-cqB' }, txt)
|
|
1078
|
+
}
|
|
1079
|
+
} else return null
|
|
1080
|
+
return React.createElement('span', { className: 'dsub-cq', title: t('forecast') + ' · ' + state.active.provider }, value)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// #83: пилл SUBS(N) в шапке сессии со светодиодом здоровья пула и
|
|
1084
|
+
// модальной консолью аккаунтов (закрытие: крестик, Escape, клик мимо).
|
|
1085
|
+
function SubsPill(props) {
|
|
1086
|
+
const t = (props && props.t) || ((k) => k)
|
|
1087
|
+
const [state, setState] = React.useState({ logged: [], usage: 0, accounts: [], open: false, active: null })
|
|
1088
|
+
React.useEffect(() => {
|
|
1089
|
+
let alive = true
|
|
1090
|
+
const pull = () => {
|
|
1091
|
+
refreshLoggedIn().then((r) => {
|
|
1092
|
+
if (!alive) return
|
|
1093
|
+
const logged = r.logged || []
|
|
1094
|
+
fetch('/dsh-subscriptions/config', { cache: 'no-store' })
|
|
1095
|
+
.then((res) => res.json())
|
|
1096
|
+
.then((cfg) => {
|
|
1097
|
+
if (!alive) return
|
|
1098
|
+
setState({ logged, usage: r.usage, accounts: (cfg && cfg.accounts) || [], open: false, active: r.active })
|
|
1099
|
+
})
|
|
1100
|
+
.catch(() => { if (alive) setState({ logged, usage: r.usage, accounts: [], open: false, active: r.active }) })
|
|
1101
|
+
}).catch(() => {})
|
|
1102
|
+
}
|
|
1103
|
+
pull()
|
|
1104
|
+
const id = setInterval(pull, 20 * 1000)
|
|
1105
|
+
return () => { alive = false; clearInterval(id) }
|
|
1106
|
+
}, [])
|
|
1107
|
+
React.useEffect(() => {
|
|
1108
|
+
if (!state.open) return undefined
|
|
1109
|
+
const onKey = (e) => { if (e.key === 'Escape') setState((s) => Object.assign({}, s, { open: false })) }
|
|
1110
|
+
document.addEventListener('keydown', onKey)
|
|
1111
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
1112
|
+
}, [state.open])
|
|
1113
|
+
const n = state.logged.length
|
|
1114
|
+
const led = n === 0 ? 'dsub-ledOff' : (state.usage >= 90 ? 'dsub-ledBad' : (state.usage >= 50 ? 'dsub-ledWarn' : 'dsub-ledOk'))
|
|
1115
|
+
const now = Date.now()
|
|
1116
|
+
let pillText = t('subsPill') + ' (' + n + ')'
|
|
821
1117
|
if (state.active) {
|
|
822
1118
|
const a = state.active
|
|
823
1119
|
const wins = Array.isArray(a.windows) ? a.windows : []
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
if (w && typeof w.usedPercent === 'number' && w.usedPercent > maxPct) maxPct = w.usedPercent
|
|
828
|
-
}
|
|
829
|
-
if (maxPct < 0 && typeof a.usagePercent === 'number') maxPct = a.usagePercent
|
|
830
|
-
const u = maxPct >= 0 ? maxPct : null
|
|
831
|
-
let acls = 'dsub-chipActive'
|
|
832
|
-
if (u != null) acls += u >= 100 ? ' dsub-chipActiveDanger' : (u >= 80 ? ' dsub-chipActiveDanger' : (u >= 50 ? ' dsub-chipActiveWarn' : ' dsub-chipActiveOk'))
|
|
833
|
-
const idx = a.index != null ? ('#' + a.index) : ''
|
|
834
|
-
const plan = a.plan ? (' ' + a.plan) : ''
|
|
835
|
-
// #77: без provider-префикса
|
|
836
|
-
const label = (a.provider === 'codex' && state.fastMode ? '\u26a1' : '') + idx + plan
|
|
837
|
-
// windows: "5h 12% 7d 8%"
|
|
838
|
-
const winLabels = wins.map((w) => (w.label || w.id) + ' ' + Math.round(w.usedPercent) + '%').join(' ')
|
|
839
|
-
return React.createElement('span', {
|
|
840
|
-
className: acls,
|
|
841
|
-
title: a.model ? (prov + idx + ' · ' + a.model) : (prov + idx),
|
|
842
|
-
},
|
|
843
|
-
React.createElement('span', { className: 'dsub-chipDot' }),
|
|
844
|
-
label,
|
|
845
|
-
u != null
|
|
846
|
-
? React.createElement('span', { className: 'dsub-chipActiveBar' },
|
|
847
|
-
React.createElement('span', { className: 'dsub-chipActiveFill', style: { width: Math.min(100, Math.max(0, u)) + '%' } }))
|
|
848
|
-
: null,
|
|
849
|
-
winLabels || null,
|
|
850
|
-
)
|
|
851
|
-
}
|
|
852
|
-
const on = state.count > 0
|
|
853
|
-
// #66: цвет чипа по максимальному usagePercent (50/80/100).
|
|
854
|
-
const u = state.usage
|
|
855
|
-
let cls = 'dsub-chip'
|
|
856
|
-
if (on) cls += u >= 100 ? ' dsub-chipDanger' : (u >= 80 ? ' dsub-chipDanger' : (u >= 50 ? ' dsub-chipWarn' : ' dsub-chipOn'))
|
|
857
|
-
// #67: надпись об окончании подписки.
|
|
858
|
-
let text = on ? String(state.count) : '—'
|
|
859
|
-
if (state.expiry) {
|
|
860
|
-
const d = new Date(state.expiry.at).toLocaleDateString()
|
|
861
|
-
text = t('expiryLabel') + ': ' + state.expiry.label + ' ' + d
|
|
1120
|
+
const winStr = wins.map((w) => (w.label || w.id) + ' ' + Math.round(w.usedPercent) + '%').join(' ')
|
|
1121
|
+
const provName = a.provider ? (a.provider.charAt(0).toUpperCase() + a.provider.slice(1)) : ''
|
|
1122
|
+
pillText = (a.provider === 'codex' && a.fastMode ? '⚡ ' : '') + provName + (winStr ? ' · ' + winStr : (a.usagePercent != null ? ' · ' + Math.round(a.usagePercent) + '%' : ''))
|
|
862
1123
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
React.createElement('
|
|
868
|
-
|
|
1124
|
+
const rows = state.accounts.map((a, i) => {
|
|
1125
|
+
const isLogged = state.logged.includes(a.provider)
|
|
1126
|
+
const cooled = a.cooldownUntil && a.cooldownUntil > now
|
|
1127
|
+
const pct = a.usagePercent != null ? Math.round(a.usagePercent) : null
|
|
1128
|
+
return React.createElement('div', { className: 'dsub-modalRow', key: i },
|
|
1129
|
+
React.createElement('span', { className: 'dsub-led ' + (pct == null ? 'dsub-ledOff' : (pct >= 90 ? 'dsub-ledBad' : (pct >= 50 ? 'dsub-ledWarn' : 'dsub-ledOk'))) }),
|
|
1130
|
+
React.createElement('span', { className: 'dsub-modalName' }, a.provider + (a.index ? ' #' + a.index : '')),
|
|
1131
|
+
React.createElement('span', { style: { flex: 1 } }),
|
|
1132
|
+
React.createElement('span', { className: 'dsub-modalSub' },
|
|
1133
|
+
(isLogged ? t('subsLogged') : t('subsNotLogged')) +
|
|
1134
|
+
(cooled ? ' · ' + t('subsCooldown') : '') +
|
|
1135
|
+
(pct != null ? ' · ' + pct + '%' : '')
|
|
1136
|
+
),
|
|
1137
|
+
)
|
|
1138
|
+
})
|
|
1139
|
+
return React.createElement(React.Fragment, null,
|
|
1140
|
+
React.createElement('button', {
|
|
1141
|
+
type: 'button', className: 'dsub-pill', 'aria-expanded': state.open ? 'true' : 'false',
|
|
1142
|
+
title: t('subsPill') + ': ' + (n > 0 ? (n + ' ' + t('connected')) : t('notConnected')),
|
|
1143
|
+
onClick: () => setState((s) => Object.assign({}, s, { open: !s.open })),
|
|
1144
|
+
},
|
|
1145
|
+
React.createElement('span', { className: 'dsub-led ' + led }),
|
|
1146
|
+
pillText,
|
|
1147
|
+
),
|
|
1148
|
+
state.open ? React.createElement('div', {
|
|
1149
|
+
className: 'dsub-modalWrap',
|
|
1150
|
+
onClick: (e) => { if (e.target === e.currentTarget) setState((s) => Object.assign({}, s, { open: false })) },
|
|
1151
|
+
},
|
|
1152
|
+
React.createElement('div', { className: 'dsub-modal', role: 'dialog', 'aria-label': t('subsModalTitle') },
|
|
1153
|
+
React.createElement('div', { className: 'dsub-modalHead' },
|
|
1154
|
+
React.createElement('span', { className: 'dsub-h' }, t('subsModalTitle')),
|
|
1155
|
+
React.createElement('button', {
|
|
1156
|
+
type: 'button', className: 'dsub-mini',
|
|
1157
|
+
onClick: () => setState((s) => Object.assign({}, s, { open: false })),
|
|
1158
|
+
}, t('subsModalClose') + ' ×'),
|
|
1159
|
+
),
|
|
1160
|
+
rows.length ? rows : React.createElement('div', { className: 'dsub-sub' }, t('subsNotLogged')),
|
|
1161
|
+
React.createElement('div', { className: 'dsub-row', style: { marginTop: 12 } },
|
|
1162
|
+
React.createElement('a', {
|
|
1163
|
+
className: 'dsub-mini', href: '#', onClick: (e) => {
|
|
1164
|
+
e.preventDefault()
|
|
1165
|
+
setState((s) => Object.assign({}, s, { open: false }))
|
|
1166
|
+
try {
|
|
1167
|
+
const btn = Array.from(document.querySelectorAll('button')).find((b) => (b.textContent || '').includes(t('title')))
|
|
1168
|
+
if (btn) btn.click()
|
|
1169
|
+
} catch {}
|
|
1170
|
+
},
|
|
1171
|
+
}, t('subsOpenSettings')),
|
|
1172
|
+
),
|
|
1173
|
+
),
|
|
1174
|
+
) : null,
|
|
869
1175
|
)
|
|
870
1176
|
}
|
|
1177
|
+
// #82: перетаскиваемый HUD-бабл (shell.overlay) с кольцевой шкалой
|
|
1178
|
+
// остатка активной подписки, прилипанием к краям и frosted-панелью.
|
|
1179
|
+
const HUD_KEY = 'dsh-subscriptions.hud'
|
|
1180
|
+
const HUD_SIZE = 64
|
|
871
1181
|
|
|
872
|
-
|
|
1182
|
+
|
|
1183
|
+
function registerSubsPill(ctx) {
|
|
873
1184
|
ctx.effect(() => {
|
|
874
|
-
ctx.slots.inject('conversation.session.header.
|
|
1185
|
+
ctx.slots.inject('conversation.session.header.actions', () =>
|
|
875
1186
|
ctx.slots.register(
|
|
876
1187
|
{
|
|
877
|
-
name: 'conversation.session.header.
|
|
878
|
-
id: 'dsh-subscriptions-
|
|
879
|
-
order:
|
|
1188
|
+
name: 'conversation.session.header.actions',
|
|
1189
|
+
id: 'dsh-subscriptions-subs-pill',
|
|
1190
|
+
order: 15,
|
|
880
1191
|
locale: NS,
|
|
881
1192
|
},
|
|
882
|
-
(props) => React.createElement(
|
|
1193
|
+
(props) => React.createElement(SubsPill, { t }),
|
|
883
1194
|
),
|
|
884
1195
|
)
|
|
885
|
-
}, 'dsh-subscriptions:
|
|
1196
|
+
}, 'dsh-subscriptions: subs pill')
|
|
886
1197
|
}
|
|
887
1198
|
|
|
888
|
-
function
|
|
1199
|
+
function registerComposerQuota(ctx) {
|
|
889
1200
|
ctx.effect(() => {
|
|
890
|
-
|
|
891
|
-
if (!labelEl) return
|
|
892
|
-
refreshLoggedIn().then((logged) => {
|
|
893
|
-
if (!labelEl) return
|
|
894
|
-
const current = logged.length ? logged[activeIdx % logged.length] : null
|
|
895
|
-
labelEl.textContent = t('switchLabel') + ': ' + (current || t('none'))
|
|
896
|
-
}).catch(() => {})
|
|
897
|
-
}
|
|
898
|
-
ctx.slots.inject('composer.action', () =>
|
|
1201
|
+
ctx.slots.inject('conversation.input.right', () =>
|
|
899
1202
|
ctx.slots.register(
|
|
900
1203
|
{
|
|
901
|
-
name: '
|
|
902
|
-
id: 'dsh-subscriptions-
|
|
903
|
-
order:
|
|
904
|
-
|
|
905
|
-
// label используется ядром до монтирования; держим актуальным
|
|
906
|
-
setTimeout(render, 0)
|
|
907
|
-
return t('switchLabel')
|
|
908
|
-
},
|
|
909
|
-
},
|
|
910
|
-
(props) => {
|
|
911
|
-
const el = React.createElement('button', {
|
|
912
|
-
onClick: async () => {
|
|
913
|
-
const logged = await refreshLoggedIn()
|
|
914
|
-
if (!logged.length) return
|
|
915
|
-
activeIdx = (activeIdx + 1) % logged.length
|
|
916
|
-
render()
|
|
917
|
-
},
|
|
918
|
-
}, '')
|
|
919
|
-
// Обновить текст в смонтированном элементе
|
|
920
|
-
setTimeout(() => {
|
|
921
|
-
labelEl = el
|
|
922
|
-
render()
|
|
923
|
-
}, 0)
|
|
924
|
-
return el
|
|
1204
|
+
name: 'conversation.input.right',
|
|
1205
|
+
id: 'dsh-subscriptions-composer-quota',
|
|
1206
|
+
order: 5,
|
|
1207
|
+
locale: NS,
|
|
925
1208
|
},
|
|
1209
|
+
(props) => React.createElement(ComposerQuota, { t }),
|
|
926
1210
|
),
|
|
927
1211
|
)
|
|
928
|
-
|
|
929
|
-
}, 'dsh-subscriptions: composer provider switch')
|
|
1212
|
+
}, 'dsh-subscriptions: composer quota')
|
|
930
1213
|
}
|
|
931
1214
|
|
|
932
|
-
// #43: slash-команды входа/выхода/статуса прямо из чата.
|
|
933
1215
|
function registerSlashCommands(ctx) {
|
|
934
1216
|
ctx.effect(() => {
|
|
935
1217
|
const triggers = ctx.get('inputTriggers')
|
|
@@ -995,8 +1277,8 @@ function PluginCard(props) {
|
|
|
995
1277
|
exports.apply = function apply(ctx) {
|
|
996
1278
|
setT(ctx.locale.bind(NS))
|
|
997
1279
|
registerSettings(ctx)
|
|
998
|
-
|
|
999
|
-
|
|
1280
|
+
registerSubsPill(ctx)
|
|
1281
|
+
registerComposerQuota(ctx)
|
|
1000
1282
|
registerSlashCommands(ctx)
|
|
1001
1283
|
}
|
|
1002
1284
|
return module.exports
|
package/lib/forecast.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// #84: predictive runway forecast from a sliding window of usage samples.
|
|
2
|
+
// Port of the reference approach (WSL043 quota-forecast): track remaining
|
|
3
|
+
// percent samples per window key, estimate pace with a recency-weighted
|
|
4
|
+
// least-squares slope, convert to a runway estimate.
|
|
5
|
+
|
|
6
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
7
|
+
const HISTORY_MS = 24 * HOUR_MS
|
|
8
|
+
const MIN_SPAN_MS = 30 * 60 * 1000
|
|
9
|
+
const MIN_CONSUMED = 1
|
|
10
|
+
const MAX_SAMPLES = 192
|
|
11
|
+
|
|
12
|
+
const finite = (v) => Number.isFinite(Number(v))
|
|
13
|
+
|
|
14
|
+
export function observeForecast(state, key, remainingPercent, resetsAt, now) {
|
|
15
|
+
const windows = Object.assign({}, (state && state.windows) || {})
|
|
16
|
+
const pct = Math.max(0, Math.min(100, Number(remainingPercent)))
|
|
17
|
+
const reset = finite(resetsAt) ? Number(resetsAt) : null
|
|
18
|
+
const prev = windows[key]
|
|
19
|
+
const resetChanged = prev !== undefined && (
|
|
20
|
+
(prev.resetsAt === null) !== (reset === null) ||
|
|
21
|
+
(prev.resetsAt !== null && reset !== null && Math.abs(prev.resetsAt - reset) > 300)
|
|
22
|
+
)
|
|
23
|
+
const last = prev && prev.samples && prev.samples[prev.samples.length - 1]
|
|
24
|
+
const increased = last !== undefined && pct > last.pct + 0.5
|
|
25
|
+
const samples = resetChanged || increased ? [] : ((prev && prev.samples) || []).slice()
|
|
26
|
+
if (!samples.length || now > samples[samples.length - 1].at && (
|
|
27
|
+
Math.abs(pct - samples[samples.length - 1].pct) >= 0.1 || now - samples[samples.length - 1].at >= 15 * 60 * 1000
|
|
28
|
+
)) {
|
|
29
|
+
samples.push({ at: now, pct })
|
|
30
|
+
}
|
|
31
|
+
const kept = samples.filter((s) => s.at >= now - HISTORY_MS).slice(-MAX_SAMPLES)
|
|
32
|
+
windows[key] = { resetsAt: reset, samples: kept }
|
|
33
|
+
return { windows }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function estimateForecast(state, key, remainingPercent, resetsAt, now) {
|
|
37
|
+
if (!finite(remainingPercent)) return { status: 'idle' }
|
|
38
|
+
const rec = state && state.windows && state.windows[key]
|
|
39
|
+
if (!rec) return { status: 'calibrating' }
|
|
40
|
+
const reset = finite(resetsAt) ? Number(resetsAt) : null
|
|
41
|
+
if ((rec.resetsAt === null) !== (reset === null) ||
|
|
42
|
+
(rec.resetsAt !== null && reset !== null && Math.abs(rec.resetsAt - reset) > 300)) return { status: 'calibrating' }
|
|
43
|
+
const samples = rec.samples.filter((s) => s.at >= now - HISTORY_MS)
|
|
44
|
+
if (samples.length < 3) return { status: 'calibrating', sampleCount: samples.length }
|
|
45
|
+
const first = samples[0]
|
|
46
|
+
const last = samples[samples.length - 1]
|
|
47
|
+
const spanMs = last.at - first.at
|
|
48
|
+
const consumed = Math.max(0, first.pct - last.pct)
|
|
49
|
+
if (spanMs < MIN_SPAN_MS || consumed < MIN_CONSUMED) return { status: 'calibrating', sampleCount: samples.length }
|
|
50
|
+
const t0 = first.at
|
|
51
|
+
let sw = 0, sx = 0, sy = 0, sxx = 0, sxy = 0
|
|
52
|
+
for (const s of samples) {
|
|
53
|
+
const x = (s.at - t0) / HOUR_MS
|
|
54
|
+
const y = first.pct - s.pct
|
|
55
|
+
const w = Math.exp((s.at - last.at) / (6 * HOUR_MS))
|
|
56
|
+
sw += w; sx += w * x; sy += w * y; sxx += w * x * x; sxy += w * x * y
|
|
57
|
+
}
|
|
58
|
+
const den = sw * sxx - sx * sx
|
|
59
|
+
const pace = den > 0 ? (sw * sxy - sx * sy) / den : 0
|
|
60
|
+
if (!Number.isFinite(pace) || pace < 0.02) return { status: 'idle', pacePerHour: 0 }
|
|
61
|
+
const runwayHours = Math.max(0, Math.min(100, Number(remainingPercent))) / pace
|
|
62
|
+
const resetSec = reset === null ? null : Math.max(0, Math.round((reset - now) / 1000))
|
|
63
|
+
return {
|
|
64
|
+
status: 'ready',
|
|
65
|
+
pacePerHour: pace,
|
|
66
|
+
runwayHours,
|
|
67
|
+
runwaySeconds: Math.round(runwayHours * 3600),
|
|
68
|
+
survivesReset: resetSec !== null && runwayHours * 3600 >= resetSec,
|
|
69
|
+
sampleCount: samples.length,
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { quotaSnapshot } from './ratelimit.js'
|
|
|
16
16
|
import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
|
|
17
17
|
import { startLoopback } from './loopback.js'
|
|
18
18
|
import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
|
|
19
|
+
import { createResetCreditService } from './reset-credits.js'
|
|
19
20
|
import { maskEmail, maskLabel, maskText } from './mask.js'
|
|
20
21
|
import { proxyFetch, pickFetch } from './proxy.js'
|
|
21
22
|
import { HistoryStore } from './history.js'
|
|
@@ -85,6 +86,8 @@ export const Config = z.object({
|
|
|
85
86
|
.description('#93 Response verbosity for Codex reasoning models: low, medium or high. Empty = protocol default.'),
|
|
86
87
|
codexFastMode: z.boolean().default(false)
|
|
87
88
|
.description('#92 Fast Mode for Codex: sends service_tier priority (1.5x speed billing tier) with every request.'),
|
|
89
|
+
composerQuota: z.string().default('off')
|
|
90
|
+
.description('#84 Composer quota indicator mode: off, percent, bar or forecast (predictive runway from a sliding window).'),
|
|
88
91
|
codexRedirectUri: z.string().default(''),
|
|
89
92
|
codexBaseUrl: z.string().default(''),
|
|
90
93
|
claudeClientId: z.string().default(''),
|
|
@@ -178,6 +181,13 @@ export function apply(ctx, config) {
|
|
|
178
181
|
const history = new HistoryStore()
|
|
179
182
|
const recordHistory = (entry) => history.add(entry)
|
|
180
183
|
|
|
184
|
+
// #85: host-only reset credit service for Codex accounts.
|
|
185
|
+
const resetCredits = createResetCreditService({ loadBlob: (ref) => store.loadBlob(ref) })
|
|
186
|
+
function refForSlot(provider, index) {
|
|
187
|
+
const slot = normalizeSlots(live().slots).find((s) => s.provider === provider && s.index === index)
|
|
188
|
+
return slot ? slot.ref : null
|
|
189
|
+
}
|
|
190
|
+
|
|
181
191
|
// #91: local Ollama - native provider + seamless fallback when the whole
|
|
182
192
|
// pool is exhausted and nothing has been streamed yet.
|
|
183
193
|
const ollamaAdapter = new OllamaAdapter({
|
|
@@ -731,11 +741,85 @@ export function apply(ctx, config) {
|
|
|
731
741
|
labels,
|
|
732
742
|
expiryNotifyDays: live().expiryNotifyDays,
|
|
733
743
|
fastMode: !!live().codexFastMode,
|
|
744
|
+
composerQuota: String(live().composerQuota || 'off'),
|
|
734
745
|
active,
|
|
735
746
|
})
|
|
736
747
|
},
|
|
737
748
|
}), 'dsh-subscriptions: /status')
|
|
738
749
|
|
|
750
|
+
ctx.effect(() => ctx.webServer.register({
|
|
751
|
+
kind: 'exact',
|
|
752
|
+
path: '/dsh-subscriptions/reset-credits',
|
|
753
|
+
handler: async (req, res) => {
|
|
754
|
+
if (req.method !== 'GET') {
|
|
755
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
const q = queryOf(req)
|
|
759
|
+
const ref = refForSlot(String(q.provider || ''), Number(q.index) || 1)
|
|
760
|
+
if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
|
|
761
|
+
try {
|
|
762
|
+
writeJson(res, 200, { ok: true, ...(await resetCredits.inspect(ref)) })
|
|
763
|
+
} catch (e) {
|
|
764
|
+
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
}), 'dsh-subscriptions: /reset-credits')
|
|
768
|
+
|
|
769
|
+
ctx.effect(() => ctx.webServer.register({
|
|
770
|
+
kind: 'exact',
|
|
771
|
+
path: '/dsh-subscriptions/reset-credits/prepare',
|
|
772
|
+
handler: async (req, res) => {
|
|
773
|
+
if (req.method !== 'POST') {
|
|
774
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
775
|
+
return
|
|
776
|
+
}
|
|
777
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
778
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
let payload
|
|
782
|
+
try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
|
|
783
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
const ref = refForSlot(String(payload.provider || ''), Number(payload.index) || 1)
|
|
787
|
+
if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
|
|
788
|
+
try {
|
|
789
|
+
writeJson(res, 200, { ok: true, ...(await resetCredits.prepare(ref)) })
|
|
790
|
+
} catch (e) {
|
|
791
|
+
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
}), 'dsh-subscriptions: /reset-credits/prepare')
|
|
795
|
+
|
|
796
|
+
ctx.effect(() => ctx.webServer.register({
|
|
797
|
+
kind: 'exact',
|
|
798
|
+
path: '/dsh-subscriptions/reset-credits/consume',
|
|
799
|
+
handler: async (req, res) => {
|
|
800
|
+
if (req.method !== 'POST') {
|
|
801
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
805
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
let payload
|
|
809
|
+
try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
|
|
810
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const result = await resetCredits.consume({ challengeId: payload.challengeId, acknowledged: payload.acknowledged })
|
|
815
|
+
writeJson(res, 200, { ok: true, result })
|
|
816
|
+
refreshModels().catch(() => {})
|
|
817
|
+
} catch (e) {
|
|
818
|
+
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
}), 'dsh-subscriptions: /reset-credits/consume')
|
|
822
|
+
|
|
739
823
|
ctx.effect(() => ctx.webServer.register({
|
|
740
824
|
kind: 'exact',
|
|
741
825
|
path: '/dsh-subscriptions/diagnostics',
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// #85: safe Codex quota reset credits. Host-only: the browser never sees
|
|
4
|
+
// account ids, bearer tokens, credit ids or idempotency keys. A single-flight
|
|
5
|
+
// challenge carries a 5s cooldown plus an explicit acknowledgement, so a
|
|
6
|
+
// double click or concurrent call can never consume two credits.
|
|
7
|
+
|
|
8
|
+
const RESET_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits'
|
|
9
|
+
const CONSUME_URL = RESET_URL + '/consume'
|
|
10
|
+
const CONFIRM_DELAY_MS = 5000
|
|
11
|
+
const CHALLENGE_TTL_MS = 60000
|
|
12
|
+
const TIMEOUT_MS = 15000
|
|
13
|
+
const UNCERTAIN = 'reset result is uncertain; re-run the confirmation to check the same request'
|
|
14
|
+
|
|
15
|
+
function record(v) {
|
|
16
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function expirationOf(v) {
|
|
20
|
+
if (v == null) return undefined
|
|
21
|
+
if (Number.isSafeInteger(v) && v > 0) return v * 1000
|
|
22
|
+
if (typeof v === 'string' && v.length > 0 && v.length <= 64) {
|
|
23
|
+
const p = Date.parse(v)
|
|
24
|
+
if (Number.isFinite(p) && p > 0) return p
|
|
25
|
+
}
|
|
26
|
+
throw new Error('malformed reset expiry')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseDetails(raw, now) {
|
|
30
|
+
if (!record(raw) || !Number.isSafeInteger(raw.available_count) || raw.available_count < 0 || !Array.isArray(raw.credits)) {
|
|
31
|
+
throw new Error('malformed reset details')
|
|
32
|
+
}
|
|
33
|
+
if (raw.available_count === 0) throw new Error('no reset available')
|
|
34
|
+
const nowMs = Number(now) || 0
|
|
35
|
+
const available = raw.credits
|
|
36
|
+
.filter((c) => record(c) && typeof c.id === 'string' && c.id.length > 0 && c.id.length <= 256 && String(c.status || '').toLowerCase() === 'available')
|
|
37
|
+
.map((c) => ({ c, expiresAt: expirationOf(c.expires_at) }))
|
|
38
|
+
.filter((row) => row.expiresAt === undefined || row.expiresAt > nowMs)
|
|
39
|
+
.sort((a, b) => (a.expiresAt === undefined ? Number.MAX_SAFE_INTEGER : a.expiresAt) - (b.expiresAt === undefined ? Number.MAX_SAFE_INTEGER : b.expiresAt))
|
|
40
|
+
if (!available.length) throw new Error('no usable reset credit')
|
|
41
|
+
const first = available[0]
|
|
42
|
+
return {
|
|
43
|
+
availableCount: raw.available_count,
|
|
44
|
+
creditId: first.c.id,
|
|
45
|
+
title: typeof first.c.title === 'string' ? first.c.title.slice(0, 240) : undefined,
|
|
46
|
+
description: typeof first.c.description === 'string' ? first.c.description.slice(0, 240) : undefined,
|
|
47
|
+
creditExpiresAt: first.expiresAt,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseConsumeResult(raw) {
|
|
52
|
+
if (!record(raw) || !['reset', 'nothing_to_reset', 'no_credit', 'already_redeemed'].includes(raw.code)) {
|
|
53
|
+
throw new Error('unreadable reset response')
|
|
54
|
+
}
|
|
55
|
+
const windowsReset = Array.isArray(raw.windows_reset)
|
|
56
|
+
? raw.windows_reset.filter((x) => typeof x === 'string').slice(0, 16)
|
|
57
|
+
: []
|
|
58
|
+
const count = Number.isSafeInteger(raw.windows_reset) && raw.windows_reset >= 0 && raw.windows_reset <= 16
|
|
59
|
+
? raw.windows_reset
|
|
60
|
+
: undefined
|
|
61
|
+
return { code: raw.code, windowsReset, ...(count === undefined ? {} : { windowsResetCount: count }) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createResetCreditService({ loadBlob, fetchImpl, now = Date.now, randomId = randomUUID }) {
|
|
65
|
+
const challenges = new Map()
|
|
66
|
+
|
|
67
|
+
async function readDetails(ref) {
|
|
68
|
+
const blob = await loadBlob(ref)
|
|
69
|
+
const access = blob && blob.accessToken
|
|
70
|
+
const accountId = blob && blob.accountId
|
|
71
|
+
if (!access || !accountId) throw new Error('codex account is not signed in')
|
|
72
|
+
const res = await (fetchImpl || fetch)(RESET_URL, {
|
|
73
|
+
method: 'GET',
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: 'Bearer ' + access,
|
|
76
|
+
'chatgpt-account-id': accountId,
|
|
77
|
+
Accept: 'application/json',
|
|
78
|
+
'cache-control': 'no-store',
|
|
79
|
+
},
|
|
80
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
81
|
+
})
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new Error(res.status === 401 || res.status === 403
|
|
84
|
+
? 'codex sign-in needs renewal'
|
|
85
|
+
: 'reset request failed (HTTP ' + res.status + ')')
|
|
86
|
+
}
|
|
87
|
+
let raw
|
|
88
|
+
try { raw = await res.json() } catch { throw new Error('unreadable reset details') }
|
|
89
|
+
return { accountId, details: parseDetails(raw, now()) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
async inspect(ref) {
|
|
94
|
+
const { details } = await readDetails(ref)
|
|
95
|
+
return {
|
|
96
|
+
availableCount: details.availableCount,
|
|
97
|
+
...(details.creditExpiresAt === undefined ? {} : { nextExpiresAt: details.creditExpiresAt }),
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async prepare(ref) {
|
|
102
|
+
const { accountId, details } = await readDetails(ref)
|
|
103
|
+
const preparedAt = now()
|
|
104
|
+
const readyAt = preparedAt + CONFIRM_DELAY_MS
|
|
105
|
+
const expiresAt = Math.min(preparedAt + CHALLENGE_TTL_MS, details.creditExpiresAt === undefined ? Number.MAX_SAFE_INTEGER : details.creditExpiresAt)
|
|
106
|
+
if (expiresAt <= readyAt) throw new Error('the available reset expires too soon')
|
|
107
|
+
const challengeId = randomId()
|
|
108
|
+
challenges.set(challengeId, {
|
|
109
|
+
state: 'prepared',
|
|
110
|
+
ref,
|
|
111
|
+
accountId,
|
|
112
|
+
creditId: details.creditId,
|
|
113
|
+
redeemRequestId: randomId(),
|
|
114
|
+
readyAt,
|
|
115
|
+
expiresAt,
|
|
116
|
+
availableCount: details.availableCount,
|
|
117
|
+
creditExpiresAt: details.creditExpiresAt,
|
|
118
|
+
title: details.title,
|
|
119
|
+
description: details.description,
|
|
120
|
+
uncertain: false,
|
|
121
|
+
})
|
|
122
|
+
return {
|
|
123
|
+
challengeId,
|
|
124
|
+
availableCount: details.availableCount,
|
|
125
|
+
readyAt,
|
|
126
|
+
expiresAt,
|
|
127
|
+
...(details.creditExpiresAt === undefined ? {} : { creditExpiresAt: details.creditExpiresAt }),
|
|
128
|
+
...(details.title ? { title: details.title } : {}),
|
|
129
|
+
...(details.description ? { description: details.description } : {}),
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async consume({ challengeId, acknowledged } = {}) {
|
|
134
|
+
const challenge = typeof challengeId === 'string' ? challenges.get(challengeId) : undefined
|
|
135
|
+
if (!challenge) throw new Error('this reset confirmation is no longer valid')
|
|
136
|
+
if (challenge.state === 'pending') throw new Error('this reset is already in progress')
|
|
137
|
+
if (now() < challenge.readyAt) throw new Error('wait before confirming this reset')
|
|
138
|
+
if (now() > challenge.expiresAt) {
|
|
139
|
+
challenges.delete(challengeId)
|
|
140
|
+
throw new Error('this reset confirmation is no longer valid')
|
|
141
|
+
}
|
|
142
|
+
if (acknowledged !== true) throw new Error('acknowledge that one reset attempt will be consumed')
|
|
143
|
+
// Synchronous gate before the first await: rapid clicks and concurrent
|
|
144
|
+
// calls can never create more than one provider POST.
|
|
145
|
+
challenge.state = 'pending'
|
|
146
|
+
let retryable = challenge.uncertain === true
|
|
147
|
+
try {
|
|
148
|
+
const blob = await loadBlob(challenge.ref)
|
|
149
|
+
const access = blob && blob.accessToken
|
|
150
|
+
const accountId = blob && blob.accountId
|
|
151
|
+
if (!access || !accountId) { retryable = false; throw new Error('codex account is not signed in') }
|
|
152
|
+
if (accountId !== challenge.accountId) { retryable = false; throw new Error('the signed-in account changed') }
|
|
153
|
+
let res
|
|
154
|
+
try {
|
|
155
|
+
res = await (fetchImpl || fetch)(CONSUME_URL, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: {
|
|
158
|
+
Authorization: 'Bearer ' + access,
|
|
159
|
+
'chatgpt-account-id': accountId,
|
|
160
|
+
Accept: 'application/json',
|
|
161
|
+
'content-type': 'application/json',
|
|
162
|
+
'cache-control': 'no-store',
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
redeem_request_id: challenge.redeemRequestId,
|
|
166
|
+
credit_id: challenge.creditId,
|
|
167
|
+
}),
|
|
168
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
169
|
+
})
|
|
170
|
+
} catch { retryable = true; throw new Error(UNCERTAIN) }
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
if (res.status >= 500) { retryable = true; throw new Error(UNCERTAIN) }
|
|
173
|
+
throw new Error(res.status === 401 || res.status === 403
|
|
174
|
+
? 'codex sign-in needs renewal'
|
|
175
|
+
: 'reset request failed (HTTP ' + res.status + ')')
|
|
176
|
+
}
|
|
177
|
+
let raw
|
|
178
|
+
try { raw = await res.json() } catch { retryable = true; throw new Error(UNCERTAIN) }
|
|
179
|
+
let result
|
|
180
|
+
try { result = parseConsumeResult(raw) } catch { retryable = true; throw new Error(UNCERTAIN) }
|
|
181
|
+
retryable = false
|
|
182
|
+
return result
|
|
183
|
+
} finally {
|
|
184
|
+
if (retryable && now() <= challenge.expiresAt) {
|
|
185
|
+
challenge.state = 'prepared'
|
|
186
|
+
challenge.uncertain = true
|
|
187
|
+
} else {
|
|
188
|
+
challenges.delete(challengeId)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
}
|
package/package.json
CHANGED