@goodandready/dsh-subscriptions 0.4.0 → 0.4.3
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 +150 -19
- package/lib/accounts.js +12 -0
- package/lib/adapter.js +6 -1
- package/lib/client.js +166 -1
- package/lib/health.js +38 -0
- package/lib/index.js +135 -3
- package/lib/refs.js +15 -33
- package/lib/rotate.js +7 -1
- package/lib/vendors/index.js +32 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# dsh-subscriptions
|
|
2
2
|
|
|
3
|
-
Use **ChatGPT Codex**, **Claude**, **Grok**, **Antigravity**
|
|
3
|
+
Use **ChatGPT Codex**, **Claude**, **Grok**, and **Antigravity**
|
|
4
4
|
subscriptions as DeepSeek Harness LLM providers. Log in from
|
|
5
|
-
**Settings →
|
|
6
|
-
inside this plugin.
|
|
5
|
+
**Settings → Plugins → Plugin settings → Subscriptions**.
|
|
6
|
+
Several accounts per provider rotate on quota inside this plugin.
|
|
7
7
|
|
|
8
8
|
This package is original software. It speaks vendor-public OAuth and LLM HTTP
|
|
9
9
|
contracts. It does not ship tools (`x_search`, image, or video).
|
|
@@ -25,19 +25,145 @@ After a `file:` install, remove then add again if you added new files under
|
|
|
25
25
|
|
|
26
26
|
## Settings
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
Settings live on a collapsible card in **Settings → Plugins → Plugin settings**
|
|
29
|
+
(not a sidebar entry). Click **Show** to expand it.
|
|
30
|
+
|
|
31
|
+
1. Pick a provider block and click **Connect**. Complete sign-in in the browser.
|
|
32
|
+
2. **+ Add account** adds another account slot for the same provider; every
|
|
33
|
+
account participates in rotation.
|
|
34
|
+
3. **Disconnect** removes that account's token; **Reconnect** repeats OAuth on
|
|
35
|
+
the same slot. The × button disconnects and drops the slot.
|
|
31
36
|
4. If the provider redirects to a localhost or vendor URL that this host cannot
|
|
32
|
-
receive, paste the full redirected URL (or the `code` value) into the
|
|
33
|
-
row and click **Submit code**.
|
|
37
|
+
receive, paste the full redirected URL (or the `code` value) into the
|
|
38
|
+
account row and click **Submit code**.
|
|
34
39
|
5. Logged-in providers appear in the session model picker.
|
|
35
40
|
|
|
36
41
|
Leave **Use this Web UI origin as OAuth redirect_uri** off unless you registered
|
|
37
42
|
your own OAuth client for this origin. Vendor CLI clients typically require
|
|
38
43
|
their published redirect URI plus the paste step.
|
|
39
44
|
|
|
40
|
-
|
|
45
|
+
## Accounts and rotation
|
|
46
|
+
|
|
47
|
+
Each provider holds any number of account slots (`CODEX_OAUTH_1`,
|
|
48
|
+
`CODEX_OAUTH_2`, …). On `RATE_LIMIT`, `QUOTA`, or HTTP 429 the plugin cools the
|
|
49
|
+
account down (`cooldownMs`, default 30 minutes) and retries the same request on
|
|
50
|
+
the next account of the same provider — never a different provider.
|
|
51
|
+
|
|
52
|
+
Beyond error-driven rotation, accounts are proactively skipped when:
|
|
53
|
+
|
|
54
|
+
- usage is at 100% for the current window (vendor-reported), or
|
|
55
|
+
- remaining quota fraction is at or below `switchAtRemaining`
|
|
56
|
+
(default `0.01` = 1%), or
|
|
57
|
+
- the window resets within one minute (no point spending the tail).
|
|
58
|
+
|
|
59
|
+
When every account is below threshold, the request still goes out on the first
|
|
60
|
+
exhausted account — a refusal beats silence.
|
|
61
|
+
|
|
62
|
+
## Quota visibility
|
|
63
|
+
|
|
64
|
+
Vendors that report usage expose named windows (Claude `5h`/`7d apps`,
|
|
65
|
+
Codex primary/secondary, Grok credits). Each window renders as a progress bar
|
|
66
|
+
with percent; colors follow theme variables (normal / warning ≥70% / exhausted
|
|
67
|
+
100%). The last known snapshot persists across harness restarts and refreshes
|
|
68
|
+
on the next successful request.
|
|
69
|
+
|
|
70
|
+
The plugin also estimates remaining requests for the primary window
|
|
71
|
+
(`≈ N (5h)`) once enough request history exists — hidden when data is thin.
|
|
72
|
+
|
|
73
|
+
### Limit notifications
|
|
74
|
+
|
|
75
|
+
When a window crosses 70%, 90%, or 100%, the plugin logs a warning and emits a
|
|
76
|
+
`subscriptions.limit-notice` event (provider, ref, window id, usedPercent,
|
|
77
|
+
threshold). Each threshold fires once per window until it resets. Toggle with
|
|
78
|
+
`notifyLimits` in Config.
|
|
79
|
+
|
|
80
|
+
## Background maintenance
|
|
81
|
+
|
|
82
|
+
Two timers run while the plugin is loaded:
|
|
83
|
+
|
|
84
|
+
- **Token refresh ahead**: expiring tokens are refreshed before they are needed
|
|
85
|
+
(`refreshAheadMs`, default 5 min). Failures back off via `refreshRetryMs`
|
|
86
|
+
(default 10 min) and mark the card as *reconnect required*.
|
|
87
|
+
- **Health probe loop**: every `probeIntervalMin` minutes (default 15, 0
|
|
88
|
+
disables) each connected account gets a cheap vendor check. Dead accounts
|
|
89
|
+
surface in the card; probes never set cooldown.
|
|
90
|
+
|
|
91
|
+
Both timers clean up on plugin dispose.
|
|
92
|
+
|
|
93
|
+
## HTTP proxy
|
|
94
|
+
|
|
95
|
+
`POST|GET /dsh-subscriptions/proxy/{provider}/{path…}` forwards to the vendor
|
|
96
|
+
API on behalf of a logged-in account — through the same allowlist, rotation,
|
|
97
|
+
and quota accounting as model traffic. Same-origin only; no token ever appears
|
|
98
|
+
in a response or log. Paths outside the per-provider allowlist get 403.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
curl -X POST https://<host>:3080/dsh-subscriptions/proxy/codex/models \
|
|
104
|
+
-H 'Content-Type: application/json' -d '{}'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Export / import
|
|
108
|
+
|
|
109
|
+
Tokens live in the host credentials store and normally tie the installation to
|
|
110
|
+
one machine. To move them:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# export (returns an encrypted DSHE1 payload)
|
|
114
|
+
curl -X POST https://<host>:3080/dsh-subscriptions/export \
|
|
115
|
+
-H 'Content-Type: application/json' -d '{"passphrase":"strong-passphrase"}'
|
|
116
|
+
|
|
117
|
+
# import
|
|
118
|
+
curl -X POST https://<host>:3080/dsh-subscriptions/import \
|
|
119
|
+
-H 'Content-Type: application/json' \
|
|
120
|
+
-d '{"passphrase":"strong-passphrase","payload":"DSHE1:…"}'
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Bundles are AES-256-GCM encrypted with a key derived from your passphrase
|
|
124
|
+
(scrypt). Without the passphrase nothing decrypts; wrong passphrase fails with
|
|
125
|
+
a clear error. Tokens are never logged.
|
|
126
|
+
|
|
127
|
+
## Composer provider switcher
|
|
128
|
+
|
|
129
|
+
A small widget in the composer bar shows the active subscription provider;
|
|
130
|
+
clicking cycles among logged-in providers without opening Settings. It does not
|
|
131
|
+
change the session model picker state.
|
|
132
|
+
|
|
133
|
+
## Session header chip
|
|
134
|
+
|
|
135
|
+
A compact chip in the conversation header shows how many subscriptions are
|
|
136
|
+
connected. It polls `/dsh-subscriptions/status` every minute and turns green
|
|
137
|
+
when at least one account is active.
|
|
138
|
+
|
|
139
|
+
## Quota reset countdown
|
|
140
|
+
|
|
141
|
+
When a vendor reports a quota window reset timestamp, the account card shows a
|
|
142
|
+
live `reset HH:MM:SS` countdown next to the quota bar, updating every second.
|
|
143
|
+
|
|
144
|
+
## Slash commands
|
|
145
|
+
|
|
146
|
+
From the chat, without opening Settings:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
/login <provider> # start OAuth for codex|claude|grok|antigravity (opens the vendor page)
|
|
150
|
+
/login status # insert the connected providers into the composer
|
|
151
|
+
/logout <provider> # disconnect that provider
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## /subscriptions page
|
|
155
|
+
|
|
156
|
+
A localhost-only summary page at `/subscriptions` lists every account slot,
|
|
157
|
+
connection status, usage percent, remaining quota and reset time across all
|
|
158
|
+
providers. Requests from non-loopback hosts get 403.
|
|
159
|
+
|
|
160
|
+
## Import a token directly
|
|
161
|
+
|
|
162
|
+
In any account card, paste an existing refresh token (or API key) and click
|
|
163
|
+
**Import token** to sign in without the browser OAuth round trip. The token is
|
|
164
|
+
written straight to the host credentials store via
|
|
165
|
+
`POST /dsh-subscriptions/import-token { provider, index, refreshToken }`.
|
|
166
|
+
|
|
41
167
|
|
|
42
168
|
## Credential names
|
|
43
169
|
|
|
@@ -68,8 +194,9 @@ Live requests use the vendor subscription surfaces, not API-key hosts:
|
|
|
68
194
|
Codex `chatgpt.com/backend-api/codex/responses`, Claude Messages with the
|
|
69
195
|
OAuth beta header, Grok `cli-chat-proxy.grok.com` with CLI identity headers,
|
|
70
196
|
and Antigravity Cloud Code Assist (`loadCodeAssist` then
|
|
71
|
-
`streamGenerateContent`). Usage endpoints, when they answer, feed the
|
|
72
|
-
|
|
197
|
+
`streamGenerateContent`). Usage endpoints, when they answer, feed the skip
|
|
198
|
+
logic above. If a live model list fails, the built-in catalog is used.
|
|
199
|
+
|
|
73
200
|
Antigravity uses a confidential Google OAuth client: set `antigravityClientId`
|
|
74
201
|
and `antigravityClientSecret` in plugin Config (Settings) — nothing is baked
|
|
75
202
|
into the repository.
|
|
@@ -78,14 +205,18 @@ Default model catalogs are built-in lists you can replace with
|
|
|
78
205
|
`codexModels`, `claudeModels`, `grokModels`, `antigravityModels`
|
|
79
206
|
in the plugin Config.
|
|
80
207
|
|
|
81
|
-
|
|
208
|
+
### Config reference
|
|
82
209
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
210
|
+
| Key | Default | Meaning |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| `cooldownMs` | 1800000 | Cooldown after RATE_LIMIT/QUOTA/429 |
|
|
213
|
+
| `switchAtRemaining` | 0.01 | Skip account when remaining ≤ this (fraction <1 or absolute ≥1); 0 disables |
|
|
214
|
+
| `refreshAheadMs` | 300000 | Refresh tokens expiring within this window |
|
|
215
|
+
| `refreshRetryMs` | 600000 | Backoff after a failed background refresh |
|
|
216
|
+
| `probeIntervalMin` | 15 | Account health-check interval, minutes; 0 disables |
|
|
217
|
+
| `notifyLimits` | true | Emit notices when usage crosses 70/90/100% |
|
|
218
|
+
| `useWebCallback` | false | Use Web UI origin as OAuth redirect_uri |
|
|
219
|
+
| `<vendor>Models` | built-in | Replace default model catalog per vendor |
|
|
89
220
|
|
|
90
221
|
## Identity
|
|
91
222
|
|
|
@@ -99,4 +230,4 @@ These three names must match:
|
|
|
99
230
|
|
|
100
231
|
## License
|
|
101
232
|
|
|
102
|
-
MIT
|
|
233
|
+
MIT
|
package/lib/accounts.js
CHANGED
|
@@ -55,6 +55,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
|
|
|
55
55
|
const refreshFailures = new Map()
|
|
56
56
|
const usage = new Map()
|
|
57
57
|
const notifyThresholds = new Map()
|
|
58
|
+
const health = new Map()
|
|
58
59
|
const requestCounts = new Map()
|
|
59
60
|
const windows = new Map()
|
|
60
61
|
const usageFetched = new Map()
|
|
@@ -124,6 +125,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
|
|
|
124
125
|
cooldownUntil: cooldowns.get(ref) || 0,
|
|
125
126
|
quota: quotas.get(ref) || null,
|
|
126
127
|
usage: windows.get(ref) || base.usage || null,
|
|
128
|
+
health: health.get(ref) || null,
|
|
127
129
|
usagePercent: usage.has(ref) ? usage.get(ref) : null,
|
|
128
130
|
refreshError: refreshFailures.get(ref)?.error || '',
|
|
129
131
|
validationUrl: base.validationUrl || '',
|
|
@@ -273,6 +275,16 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitN
|
|
|
273
275
|
shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
|
|
274
276
|
getQuota(ref) { return quotas.get(ref) || null },
|
|
275
277
|
rememberUsage(ref, percent) { usage.set(ref, percent) },
|
|
278
|
+
getHealth(ref) { return health.get(ref) || null },
|
|
279
|
+
setHealth(ref, h) { health.set(ref, h) },
|
|
280
|
+
recordSwitch(ref) { health.set(ref, recordSwitch(health.get(ref))) },
|
|
281
|
+
recordExhaust(ref) { health.set(ref, recordExhaust(health.get(ref))) },
|
|
282
|
+
recordBroken(ref) { health.set(ref, recordBroken(health.get(ref))) },
|
|
283
|
+
recordSuccess(ref) {
|
|
284
|
+
// успешный запрос сбрасывает кулдаун и возвращает аккаунт в строй
|
|
285
|
+
cooldowns.delete(ref)
|
|
286
|
+
if (health.has(ref)) health.set(ref, null)
|
|
287
|
+
},
|
|
276
288
|
rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
|
|
277
289
|
getRequestCount(ref) { return requestCounts.get(ref) || 0 },
|
|
278
290
|
}
|
package/lib/adapter.js
CHANGED
|
@@ -78,7 +78,10 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
78
78
|
cooldownMs: deps.cooldownMs(),
|
|
79
79
|
switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
|
|
80
80
|
options,
|
|
81
|
-
onCooldown: (account) =>
|
|
81
|
+
onCooldown: (account) => {
|
|
82
|
+
deps.rememberCooldown(account.ref, account.cooldownUntil)
|
|
83
|
+
if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
|
|
84
|
+
},
|
|
82
85
|
streamOnce: async function* (account, opts) {
|
|
83
86
|
const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
|
|
84
87
|
const vendor = getVendor(provider)
|
|
@@ -116,6 +119,8 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
116
119
|
signal: opts.signal,
|
|
117
120
|
saveBlob: (next) => deps.saveBlob(account.ref, next),
|
|
118
121
|
})
|
|
122
|
+
// поток завершился успешно: снять кулдаун и здоровье-штрафы
|
|
123
|
+
if (typeof deps.recordSuccess === 'function') deps.recordSuccess(account.ref)
|
|
119
124
|
} catch (err) {
|
|
120
125
|
if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
|
|
121
126
|
await deps.saveBlob(account.ref, {
|
package/lib/client.js
CHANGED
|
@@ -36,6 +36,10 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
'.dsub-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}' +
|
|
37
37
|
'.dsub-badge-on{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
|
|
38
38
|
'.dsub-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
|
|
39
|
+
'.dsub-chip{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 10px;border-radius:999px;font-size:12px;font-weight:600;line-height:1;white-space:nowrap;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2)}' +
|
|
40
|
+
'.dsub-chipOn{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
|
|
41
|
+
'.dsub-chipDot{width:6px;height:6px;border-radius:999px;background:currentColor;flex:none}' +
|
|
42
|
+
|
|
39
43
|
'.dsub-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}' +
|
|
40
44
|
'.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}' +
|
|
41
45
|
'.dsub-barFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary);transition:width .2s}' +
|
|
@@ -88,8 +92,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
88
92
|
'plan': 'Plan',
|
|
89
93
|
'storedAs': 'Stored as',
|
|
90
94
|
'switchLabel': 'Provider',
|
|
95
|
+
'chipActive': 'Subscriptions',
|
|
96
|
+
'slashLogin': 'Login to a subscription provider',
|
|
97
|
+
'slashLogout': 'Log out a subscription provider',
|
|
98
|
+
'slashStatus': 'Subscription status',
|
|
91
99
|
'none': 'none',
|
|
92
100
|
'forecast': '≈',
|
|
101
|
+
'resetLabel': 'сброс',
|
|
102
|
+
'resetLabel': 'reset',
|
|
93
103
|
}
|
|
94
104
|
const ru = {
|
|
95
105
|
'notConnected': 'Не подключено',
|
|
@@ -121,10 +131,32 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
121
131
|
'plan': 'Тариф',
|
|
122
132
|
'storedAs': 'Хранится как',
|
|
123
133
|
'switchLabel': 'Провайдер',
|
|
134
|
+
'chipActive': 'Подписки',
|
|
135
|
+
'slashLogin': 'Вход в провайдера подписки',
|
|
136
|
+
'slashLogout': 'Выйти из провайдера подписки',
|
|
137
|
+
'slashStatus': 'Статус подписок',
|
|
124
138
|
'none': 'нет',
|
|
125
139
|
'forecast': '≈',
|
|
126
140
|
}
|
|
127
141
|
|
|
142
|
+
// #51: live countdown to the quota window reset (account.quota.resetAt).
|
|
143
|
+
function ResetCountdown(props) {
|
|
144
|
+
const [now, setNow] = React.useState(Date.now())
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
147
|
+
return () => clearInterval(id)
|
|
148
|
+
}, [])
|
|
149
|
+
const ms = (props.resetAt || 0) - now
|
|
150
|
+
if (ms <= 0) return null
|
|
151
|
+
const total = Math.floor(ms / 1000)
|
|
152
|
+
const h = Math.floor(total / 3600)
|
|
153
|
+
const m = Math.floor((total % 3600) / 60)
|
|
154
|
+
const sec = total % 60
|
|
155
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
156
|
+
return React.createElement('span', { className: 'dsub-sub', style: { fontVariantNumeric: 'tabular-nums' } },
|
|
157
|
+
t('resetLabel') + ' ' + pad(h) + ':' + pad(m) + ':' + pad(sec))
|
|
158
|
+
}
|
|
159
|
+
|
|
128
160
|
function badgeFor(account, t) {
|
|
129
161
|
if (!account || !account.configured) return t('notConnected')
|
|
130
162
|
if (account.validationUrl) return t('verifyAccount')
|
|
@@ -178,6 +210,7 @@ function PluginCard(props) {
|
|
|
178
210
|
const [checkRes, setCheckRes] = React.useState({})
|
|
179
211
|
const [checking, setChecking] = React.useState({})
|
|
180
212
|
const [saved, setSaved] = React.useState(false)
|
|
213
|
+
const [tokenDraft, setTokenDraft] = React.useState({})
|
|
181
214
|
const [err, setErr] = React.useState('')
|
|
182
215
|
|
|
183
216
|
const applyPayload = (data) => {
|
|
@@ -269,6 +302,21 @@ function PluginCard(props) {
|
|
|
269
302
|
setAccounts(data.accounts || [])
|
|
270
303
|
}
|
|
271
304
|
|
|
305
|
+
const importToken = async (provider, index) => {
|
|
306
|
+
setErr('')
|
|
307
|
+
const tok = (tokenDraft[provider + ':' + index] || '').trim()
|
|
308
|
+
if (!tok) return
|
|
309
|
+
const res = await fetch('/dsh-subscriptions/import-token', {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: { 'Content-Type': 'application/json' },
|
|
312
|
+
body: JSON.stringify({ provider, index, refreshToken: tok }),
|
|
313
|
+
})
|
|
314
|
+
const data = await res.json().catch(() => ({}))
|
|
315
|
+
if (!res.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
|
|
316
|
+
setAccounts(data.accounts || [])
|
|
317
|
+
setTokenDraft((d) => Object.assign({}, d, { [provider + ':' + index]: '' }))
|
|
318
|
+
}
|
|
319
|
+
|
|
272
320
|
const addSlot = (provider) => {
|
|
273
321
|
const used = slots.filter((s) => s.provider === provider).map((s) => s.index)
|
|
274
322
|
let index = 1
|
|
@@ -358,6 +406,18 @@ function PluginCard(props) {
|
|
|
358
406
|
onClick: () => complete(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
|
|
359
407
|
}, t('submitCode')),
|
|
360
408
|
),
|
|
409
|
+
React.createElement('div', { className: 'dsub-row' },
|
|
410
|
+
React.createElement('input', {
|
|
411
|
+
className: 'dsub-grow',
|
|
412
|
+
value: tokenDraft[key] || '',
|
|
413
|
+
placeholder: t('importTokenPlace'),
|
|
414
|
+
onChange: (e) => setTokenDraft((d) => Object.assign({}, d, { [key]: e.target.value })),
|
|
415
|
+
}),
|
|
416
|
+
React.createElement('button', {
|
|
417
|
+
type: 'button', className: 'dsub-mini',
|
|
418
|
+
onClick: () => importToken(row.slot.provider, row.slot.index).catch((e) => setErr(String(e.message || e))),
|
|
419
|
+
}, t('importToken')),
|
|
420
|
+
),
|
|
361
421
|
account.accountNotice ? React.createElement('div', { className: 'dsub-verify' }, account.accountNotice) : null,
|
|
362
422
|
account.refreshError ? React.createElement('div', { className: 'dsub-bad' }, 'reconnect required: ' + account.refreshError) : null,
|
|
363
423
|
(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) })(),
|
|
@@ -386,6 +446,7 @@ function PluginCard(props) {
|
|
|
386
446
|
}
|
|
387
447
|
var q=account.quota
|
|
388
448
|
if(q&&q.usedPercent!=null)parts.push(bar(t('quota'),q.usedPercent))
|
|
449
|
+
if(q&&q.resetAt)parts.push(React.createElement(ResetCountdown,{key:'reset',resetAt:q.resetAt}))
|
|
389
450
|
if(!wins&&!q&&account.usagePercent!=null)parts.push(bar(t('quota'),account.usagePercent))
|
|
390
451
|
var at=(q&&q.measuredAt)||(account.usageAt||0)
|
|
391
452
|
if(at){var m=Math.round((Date.now()-at)/60000);if(m>=1)parts.push(React.createElement('span',{className:'dsub-sub',key:'ago'},m+'m'))}
|
|
@@ -525,6 +586,46 @@ function PluginCard(props) {
|
|
|
525
586
|
} catch { return [] }
|
|
526
587
|
}
|
|
527
588
|
|
|
589
|
+
// #52: компактный чип в шапке сессии со статусом подключённых подписок.
|
|
590
|
+
function HeaderChip(props) {
|
|
591
|
+
const [state, setState] = React.useState({ loading: true, count: 0 })
|
|
592
|
+
const t = (props && props.t) || ((k) => k)
|
|
593
|
+
React.useEffect(() => {
|
|
594
|
+
const pull = () => {
|
|
595
|
+
refreshLoggedIn().then((logged) => {
|
|
596
|
+
setState({ loading: false, count: logged.length })
|
|
597
|
+
}).catch(() => setState({ loading: false, count: 0 }))
|
|
598
|
+
}
|
|
599
|
+
pull()
|
|
600
|
+
const id = setInterval(pull, 60 * 1000)
|
|
601
|
+
return () => clearInterval(id)
|
|
602
|
+
}, [])
|
|
603
|
+
const on = state.count > 0
|
|
604
|
+
return React.createElement('span', {
|
|
605
|
+
className: 'dsub-chip' + (on ? ' dsub-chipOn' : ''),
|
|
606
|
+
title: on ? t('chipActive') + ': ' + state.count : t('none'),
|
|
607
|
+
},
|
|
608
|
+
React.createElement('span', { className: 'dsub-chipDot' }),
|
|
609
|
+
on ? String(state.count) : '—',
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function registerHeaderChip(ctx) {
|
|
614
|
+
ctx.effect(() => {
|
|
615
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
616
|
+
ctx.slots.register(
|
|
617
|
+
{
|
|
618
|
+
name: 'conversation.session.header.utilities',
|
|
619
|
+
id: 'dsh-subscriptions-header-chip',
|
|
620
|
+
order: 5,
|
|
621
|
+
locale: NS,
|
|
622
|
+
},
|
|
623
|
+
(props) => React.createElement(HeaderChip, { t }),
|
|
624
|
+
),
|
|
625
|
+
)
|
|
626
|
+
}, 'dsh-subscriptions: header chip')
|
|
627
|
+
}
|
|
628
|
+
|
|
528
629
|
function registerComposerSwitch(ctx) {
|
|
529
630
|
ctx.effect(() => {
|
|
530
631
|
const render = () => {
|
|
@@ -569,10 +670,74 @@ function PluginCard(props) {
|
|
|
569
670
|
}, 'dsh-subscriptions: composer provider switch')
|
|
570
671
|
}
|
|
571
672
|
|
|
572
|
-
|
|
673
|
+
// #43: slash-команды входа/выхода/статуса прямо из чата.
|
|
674
|
+
function registerSlashCommands(ctx) {
|
|
675
|
+
ctx.effect(() => {
|
|
676
|
+
const triggers = ctx.get('inputTriggers')
|
|
677
|
+
if (!triggers) return () => {}
|
|
678
|
+
const providerMatch = new RegExp('^(' + ORDER.join('|') + ')$')
|
|
679
|
+
const run = (cmd, line) => {
|
|
680
|
+
const args = (line.trim().replace(/^\/\S+\s*/, '')).split(/\s+/).filter(Boolean)
|
|
681
|
+
const prov = args[0]
|
|
682
|
+
if (cmd === 'logout') {
|
|
683
|
+
if (!prov || !providerMatch.test(prov)) return
|
|
684
|
+
fetch('/dsh-subscriptions/logout', {
|
|
685
|
+
method: 'POST',
|
|
686
|
+
headers: { 'Content-Type': 'application/json' },
|
|
687
|
+
body: JSON.stringify({ provider: prov, index: 1 }),
|
|
688
|
+
}).catch(() => {})
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
if (cmd === 'status' || (cmd === 'login' && prov === 'status')) { refreshLoggedIn().catch(() => {}); return }
|
|
692
|
+
// login <provider>
|
|
693
|
+
if (!prov || !providerMatch.test(prov)) return
|
|
694
|
+
fetch('/dsh-subscriptions/oauth/start?provider=' + encodeURIComponent(prov) + '&index=1', { cache: 'no-store' })
|
|
695
|
+
.then((r) => r.json()).then((data) => {
|
|
696
|
+
if (data && data.url) window.open(data.url, '_blank', 'noopener')
|
|
697
|
+
}).catch(() => {})
|
|
698
|
+
}
|
|
699
|
+
const line = (line) => line.trim()
|
|
700
|
+
const sources = [
|
|
701
|
+
{ name: 'login', description: t('slashLogin') },
|
|
702
|
+
{ name: 'logout', description: t('slashLogout') },
|
|
703
|
+
]
|
|
704
|
+
const disposers = sources.map((src) =>
|
|
705
|
+
triggers.registerSource({
|
|
706
|
+
trigger: '/',
|
|
707
|
+
name: src.name,
|
|
708
|
+
order: 40,
|
|
709
|
+
description: src.description,
|
|
710
|
+
candidates: (_s, req) => {
|
|
711
|
+
if (req.position !== 'leading') return Promise.resolve([])
|
|
712
|
+
const q = req.query.trim().toLowerCase()
|
|
713
|
+
const name = src.name
|
|
714
|
+
if (q !== '' && !name.startsWith(q)) return Promise.resolve([])
|
|
715
|
+
return Promise.resolve([{ name, description: src.description }])
|
|
716
|
+
},
|
|
717
|
+
matchEnter: (_session, l) => {
|
|
718
|
+
const t2 = line(l)
|
|
719
|
+
const tok = t2.split(/\s+/)[0]
|
|
720
|
+
if (tok !== '/' + src.name) return Promise.resolve(undefined)
|
|
721
|
+
// '/login status' surfaces the connected providers as composer text.
|
|
722
|
+
if (src.name === 'login' && /\bstatus\b/.test(t2)) {
|
|
723
|
+
return refreshLoggedIn().then((logged) =>
|
|
724
|
+
({ text: 'Connected: ' + (logged.length ? logged.join(', ') : 'none') }))
|
|
725
|
+
}
|
|
726
|
+
run(src.name, t2)
|
|
727
|
+
return Promise.resolve('handled')
|
|
728
|
+
},
|
|
729
|
+
}),
|
|
730
|
+
)
|
|
731
|
+
return () => { for (const off of disposers) off() }
|
|
732
|
+
}, 'dsh-subscriptions: slash commands')
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
exports.inject = ['slots', 'locale', 'sessions']
|
|
573
736
|
exports.apply = function apply(ctx) {
|
|
574
737
|
registerSettings(ctx)
|
|
738
|
+
registerHeaderChip(ctx)
|
|
575
739
|
registerComposerSwitch(ctx)
|
|
740
|
+
registerSlashCommands(ctx)
|
|
576
741
|
}
|
|
577
742
|
return module.exports
|
|
578
743
|
},
|
package/lib/health.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Health-счёт аккаунта для умной ротации.
|
|
2
|
+
// Числа как в dsh-key-rotation pool.js: свитч 5, исчерпание 10, битый ключ 15.
|
|
3
|
+
const PENALTY = { switch: 5, exhaust: 10, broken: 15 }
|
|
4
|
+
|
|
5
|
+
export function emptyHealth() {
|
|
6
|
+
return { switches: 0, exhaustions: 0, broken: 0 }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function recordSwitch(h) {
|
|
10
|
+
const cur = h || emptyHealth()
|
|
11
|
+
return { ...cur, switches: (cur.switches || 0) + 1 }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function recordExhaust(h) {
|
|
15
|
+
const cur = h || emptyHealth()
|
|
16
|
+
return { ...cur, exhaustions: (cur.exhaustions || 0) + 1 }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function recordBroken(h) {
|
|
20
|
+
const cur = h || emptyHealth()
|
|
21
|
+
return { ...cur, broken: (cur.broken || 0) + 1 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function computeHealthScore(h) {
|
|
25
|
+
const H = h || emptyHealth()
|
|
26
|
+
const score = 100
|
|
27
|
+
- (H.switches || 0) * PENALTY.switch
|
|
28
|
+
- (H.exhaustions || 0) * PENALTY.exhaust
|
|
29
|
+
- (H.broken || 0) * PENALTY.broken
|
|
30
|
+
return Math.max(0, Math.min(100, score))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function healthBadge(score) {
|
|
34
|
+
if (score == null) return 'unknown'
|
|
35
|
+
if (score >= 80) return 'healthy'
|
|
36
|
+
if (score >= 50) return 'tired'
|
|
37
|
+
return 'broken'
|
|
38
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -3,12 +3,11 @@ import { PROVIDERS, oauthRef, isProvider, displayName, droppedCredentialRefs } f
|
|
|
3
3
|
import { createPkce } from './pkce.js'
|
|
4
4
|
import { parseCallbackInput, requestOrigin, webCallbackUri } from './oauth.js'
|
|
5
5
|
import { writeJson, writeHtml, readBody, isTrustedSettingsRequest, queryOf } from './http.js'
|
|
6
|
-
import { getVendor } from './vendors/index.js'
|
|
6
|
+
import { getVendor, registerCustomVendor, clearCustomVendors } from './vendors/index.js'
|
|
7
7
|
import { SubscriptionAdapter } from './adapter.js'
|
|
8
8
|
import { generateOnce, SIZES as IMAGE_SIZES } from './images.js'
|
|
9
9
|
import { parseBlob } from './blob.js'
|
|
10
10
|
import { createVendorFromProfile, validateProfile } from './vendor-factory.js'
|
|
11
|
-
import { registerCustomVendor } from './vendors/index.js'
|
|
12
11
|
import { registerCustomProviderIds, registerDisplayName } from './refs.js'
|
|
13
12
|
import { createSubscriptionsService } from './subscriptions.js'
|
|
14
13
|
import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
|
|
@@ -106,7 +105,7 @@ export function apply(ctx, config) {
|
|
|
106
105
|
try { ctx.log && ctx.log.warn && ctx.log.warn('[dsh-subscriptions] customVendors: ' + String(e && e.message || e)) } catch {}
|
|
107
106
|
return
|
|
108
107
|
}
|
|
109
|
-
|
|
108
|
+
clearCustomVendors()
|
|
110
109
|
for (const profile of profiles) {
|
|
111
110
|
try {
|
|
112
111
|
const vendor = createVendorFromProfile(profile)
|
|
@@ -141,6 +140,12 @@ export function apply(ctx, config) {
|
|
|
141
140
|
cooldownMs: () => live().cooldownMs,
|
|
142
141
|
switchAtRemaining: () => live().switchAtRemaining,
|
|
143
142
|
rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
|
|
143
|
+
recordSuccess: (ref) => store.recordSuccess(ref),
|
|
144
|
+
getHealth: (ref) => store.getHealth(ref),
|
|
145
|
+
recordSwitch: (ref) => store.recordSwitch(ref),
|
|
146
|
+
recordExhaust: (ref) => store.recordExhaust(ref),
|
|
147
|
+
recordBroken: (ref) => store.recordBroken(ref),
|
|
148
|
+
setHealth: (ref, h) => store.setHealth(ref, h),
|
|
144
149
|
rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
|
|
145
150
|
rememberRequest: (ref) => store.rememberRequest(ref),
|
|
146
151
|
getRequestCount: (ref) => store.getRequestCount(ref),
|
|
@@ -224,6 +229,26 @@ export function apply(ctx, config) {
|
|
|
224
229
|
}
|
|
225
230
|
}
|
|
226
231
|
|
|
232
|
+
// #46: after a login lands, eagerly refresh the live model catalog so the
|
|
233
|
+
// model picker shows the new provider within ~1 min instead of waiting.
|
|
234
|
+
let lastModelRefresh = 0
|
|
235
|
+
async function refreshModels() {
|
|
236
|
+
if (Date.now() - lastModelRefresh < 60 * 1000) return
|
|
237
|
+
lastModelRefresh = Date.now()
|
|
238
|
+
const providers = await store.loggedInProviders()
|
|
239
|
+
for (const provider of providers) {
|
|
240
|
+
try {
|
|
241
|
+
const slot = normalizeSlots(live().slots).find((x) => x.provider === provider)
|
|
242
|
+
if (!slot) continue
|
|
243
|
+
const blob = await store.loadBlob(slot.ref).catch(() => null)
|
|
244
|
+
if (!blob) continue
|
|
245
|
+
const fresh = await store.ensureFresh(provider, blob, slot.ref)
|
|
246
|
+
const cfg = vendorConfig(provider, live())
|
|
247
|
+
await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => {})
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
227
252
|
function sweepPending(now) {
|
|
228
253
|
for (const [state, row] of pending) {
|
|
229
254
|
if (now - row.createdAt > PENDING_TTL_MS) pending.delete(state)
|
|
@@ -255,6 +280,7 @@ export function apply(ctx, config) {
|
|
|
255
280
|
await store.saveBlob(ref, blob)
|
|
256
281
|
pending.delete(row.state)
|
|
257
282
|
await syncAdapter()
|
|
283
|
+
refreshModels().catch(() => {})
|
|
258
284
|
return { ref, label: blob.label || blob.email || displayName(provider) }
|
|
259
285
|
}
|
|
260
286
|
|
|
@@ -798,6 +824,111 @@ export function apply(ctx, config) {
|
|
|
798
824
|
},
|
|
799
825
|
}), 'dsh-subscriptions: /import')
|
|
800
826
|
|
|
827
|
+
// #45: импорт существующего refresh token / API key без OAuth-флоу.
|
|
828
|
+
ctx.effect(() => ctx.webServer.register({
|
|
829
|
+
kind: 'exact',
|
|
830
|
+
path: '/dsh-subscriptions/import-token',
|
|
831
|
+
handler: async (req, res) => {
|
|
832
|
+
if (req.method !== 'POST') {
|
|
833
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
837
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
let payload
|
|
841
|
+
try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch {
|
|
842
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
const provider = payload.provider
|
|
846
|
+
const index = Number(payload.index || '1')
|
|
847
|
+
const refreshToken = payload.refreshToken
|
|
848
|
+
const apiKey = payload.apiKey
|
|
849
|
+
if (!isProvider(provider)) {
|
|
850
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
if (!refreshToken && !apiKey) {
|
|
854
|
+
writeJson(res, 400, { ok: false, error: { code: 'token', message: 'нужен refreshToken или apiKey' } })
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
const ref = oauthRef(provider, index)
|
|
859
|
+
const blob = { accessToken: apiKey || refreshToken, refreshToken: refreshToken || apiKey }
|
|
860
|
+
if (apiKey) { blob.apiKey = apiKey; blob.apiKeyOnly = true }
|
|
861
|
+
await store.saveBlob(ref, blob)
|
|
862
|
+
await syncAdapter()
|
|
863
|
+
refreshModels().catch(() => {})
|
|
864
|
+
writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
|
|
865
|
+
} catch (e) {
|
|
866
|
+
writeJson(res, 400, { ok: false, error: { code: 'import', message: String(e && e.message || e) } })
|
|
867
|
+
}
|
|
868
|
+
},
|
|
869
|
+
}), 'dsh-subscriptions: /import-token')
|
|
870
|
+
|
|
871
|
+
// #50: сводная страница /subscriptions (localhost-only).
|
|
872
|
+
ctx.effect(() => ctx.webServer.register({
|
|
873
|
+
kind: 'exact',
|
|
874
|
+
path: '/subscriptions',
|
|
875
|
+
handler: async (req, res) => {
|
|
876
|
+
if (req.method !== 'GET') {
|
|
877
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
878
|
+
return
|
|
879
|
+
}
|
|
880
|
+
const host = (req.headers.host || '').split(':')[0]
|
|
881
|
+
if (host !== 'localhost' && host !== '127.0.0.1') {
|
|
882
|
+
writeHtml(res, 403, '<!doctype html><meta charset="utf-8"><p>Subscriptions overview is localhost-only.</p>')
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
let cfg, accounts
|
|
886
|
+
try {
|
|
887
|
+
const out = await configResponse()
|
|
888
|
+
cfg = out.config
|
|
889
|
+
accounts = out.accounts || []
|
|
890
|
+
} catch (e) {
|
|
891
|
+
writeHtml(res, 500, '<!doctype html><meta charset="utf-8"><p>Failed to load: ' + escapeHtml(String(e && e.message || e)) + '</p>')
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
const rows = accounts.map((a) => {
|
|
895
|
+
const pct = a.usagePercent != null ? a.usagePercent : (a.quota && a.quota.usedPercent) || null
|
|
896
|
+
const rem = a.quota && a.quota.remaining != null ? a.quota.remaining : null
|
|
897
|
+
const lim = a.quota && a.quota.limit != null ? a.quota.limit : null
|
|
898
|
+
const reset = a.quota && a.quota.resetAt ? new Date(a.quota.resetAt).toLocaleString() : ''
|
|
899
|
+
const status = a.validationUrl ? 'verify' : (a.cooldownUntil && a.cooldownUntil > Date.now() ? 'cooldown' : (a.configured ? 'ok' : 'none'))
|
|
900
|
+
return '<tr><td>' + escapeHtml(a.provider) + '</td><td>' + (a.index||1) + '</td>' +
|
|
901
|
+
'<td>' + esc(a.label || a.email || '') + '</td><td>' + status + '</td>' +
|
|
902
|
+
'<td>' + (pct != null ? Math.round(pct) + '%' : '—') + '</td>' +
|
|
903
|
+
'<td>' + (rem != null ? (rem + (lim != null ? '/' + lim : '')) : '—') + '</td>' +
|
|
904
|
+
'<td>' + esc(reset) + '</td><td>' + esc(a.refreshError || '') + '</td></div>'
|
|
905
|
+
})
|
|
906
|
+
const body = rows.length
|
|
907
|
+
? '<div class="grid">' + rows.join('') + '</div>'
|
|
908
|
+
: '<p class="empty">No accounts connected yet.</p>'
|
|
909
|
+
const slots = cfg.slots || []
|
|
910
|
+
const connected = accounts.filter((a) => a.configured).length
|
|
911
|
+
writeHtml(res, 200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Subscriptions</title>
|
|
912
|
+
<style>
|
|
913
|
+
body{font-family:system-ui,sans-serif;margin:0;padding:24px;background:#0d1117;color:#e6edf3}
|
|
914
|
+
h1{font-size:20px} .dim{color:#8b949e;font-size:13px}
|
|
915
|
+
.stats{display:flex;gap:24px;margin:16px 0;font-size:13px}
|
|
916
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}
|
|
917
|
+
.card{border:1px solid #30363d;border-radius:10px;padding:14px;background:#161b22}
|
|
918
|
+
.card b{display:block;margin-bottom:4px}
|
|
919
|
+
.status{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid #30363d}
|
|
920
|
+
.status.ok{color:#3fb950;border-color:#238636} .status.none{color:#8b949e}
|
|
921
|
+
.status.cooldown{color:#d29922;border-color:#9e6a03} .status.verify{color:#d29922;border-color:#9e6a03}
|
|
922
|
+
.meta{color:#8b949e;font-size:12px}
|
|
923
|
+
</style></head><body>
|
|
924
|
+
<h1>Subscriptions</h1>
|
|
925
|
+
<div class="dim">/subscriptions — localhost only</div>
|
|
926
|
+
<div class="stats"><span><b>${connected}</b> connected</span><span><b>${accounts.length}</b> accounts</span><span><b>${slots.length}</b> slots</span></div>
|
|
927
|
+
${body}
|
|
928
|
+
</body></html>`)
|
|
929
|
+
},
|
|
930
|
+
}), 'dsh-subscriptions: /subscriptions')
|
|
931
|
+
|
|
801
932
|
ctx.effect(() => ctx.webServer.register({
|
|
802
933
|
kind: 'exact',
|
|
803
934
|
path: '/dsh-subscriptions/logout',
|
|
@@ -819,6 +950,7 @@ export function apply(ctx, config) {
|
|
|
819
950
|
const ref = oauthRef(payload.provider, payload.index)
|
|
820
951
|
await store.clearRef(ref)
|
|
821
952
|
await syncAdapter()
|
|
953
|
+
refreshModels().catch(() => {})
|
|
822
954
|
writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
|
|
823
955
|
} catch (e) {
|
|
824
956
|
writeJson(res, 400, { ok: false, error: { code: 'logout', message: String(e && e.message || e) } })
|
package/lib/refs.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Встроенные
|
|
2
|
-
// registerCustomProviderIds и попадают в тот же список.
|
|
1
|
+
// Встроенные провайдеры + динамические из Config.customVendors.
|
|
3
2
|
export const BUILTIN_PROVIDERS = Object.freeze([
|
|
4
3
|
'codex',
|
|
5
4
|
'claude',
|
|
@@ -7,49 +6,32 @@ export const BUILTIN_PROVIDERS = Object.freeze([
|
|
|
7
6
|
'antigravity',
|
|
8
7
|
])
|
|
9
8
|
|
|
10
|
-
const
|
|
9
|
+
const dynamicIds = new Set()
|
|
10
|
+
const displayExtra = {}
|
|
11
|
+
|
|
12
|
+
export let PROVIDERS = [...BUILTIN_PROVIDERS]
|
|
11
13
|
|
|
12
14
|
export function registerCustomProviderIds(ids) {
|
|
15
|
+
let changed = false
|
|
13
16
|
for (const id of ids || []) {
|
|
14
|
-
if (typeof id === 'string' && /^[a-z][a-z0-9_]*$/.test(id)
|
|
17
|
+
if (typeof id === 'string' && /^[a-z][a-z0-9_]*$/.test(id) && !dynamicIds.has(id)) {
|
|
18
|
+
dynamicIds.add(id)
|
|
19
|
+
changed = true
|
|
20
|
+
}
|
|
15
21
|
}
|
|
22
|
+
if (changed) PROVIDERS = listProviders()
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function listProviders() {
|
|
19
|
-
return [...BUILTIN_PROVIDERS, ...
|
|
26
|
+
return [...BUILTIN_PROVIDERS, ...dynamicIds]
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
// Обратно-совместимый доступ: массив читается на момент вызова.
|
|
23
|
-
export const PROVIDERS = new Proxy([], {
|
|
24
|
-
get(_t, prop) {
|
|
25
|
-
if (prop === 'length') return listProviders().length
|
|
26
|
-
if (prop === Symbol.iterator) return listProviders()[Symbol.iterator]
|
|
27
|
-
const i = Number(prop)
|
|
28
|
-
return Number.isInteger(i) ? listProviders()[i] : undefined
|
|
29
|
-
},
|
|
30
|
-
has() { return true },
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
const DISPLAY_EXTRA = {}
|
|
34
|
-
|
|
35
29
|
export function registerDisplayName(id, name) {
|
|
36
|
-
if (id && name)
|
|
30
|
+
if (id && name) displayExtra[id] = name
|
|
37
31
|
}
|
|
38
32
|
|
|
39
|
-
const KNOWN_CACHE = new Map()
|
|
40
|
-
|
|
41
33
|
export function isProvider(value) {
|
|
42
|
-
|
|
43
|
-
let result
|
|
44
|
-
try {
|
|
45
|
-
const { isProvider: check } = require('./vendors/index.js')
|
|
46
|
-
result = Boolean(check(value))
|
|
47
|
-
} catch {
|
|
48
|
-
// require недоступен в этом контексте — используем списки
|
|
49
|
-
result = BUILTIN_PROVIDERS.includes(value) || dynamic.has(value)
|
|
50
|
-
}
|
|
51
|
-
KNOWN_CACHE.set(value, result)
|
|
52
|
-
return result
|
|
34
|
+
return BUILTIN_PROVIDERS.includes(value) || dynamicIds.has(value)
|
|
53
35
|
}
|
|
54
36
|
|
|
55
37
|
const DISPLAY = {
|
|
@@ -60,7 +42,7 @@ const DISPLAY = {
|
|
|
60
42
|
}
|
|
61
43
|
|
|
62
44
|
export function displayName(provider) {
|
|
63
|
-
return DISPLAY[provider] ||
|
|
45
|
+
return DISPLAY[provider] || displayExtra[provider] || provider
|
|
64
46
|
}
|
|
65
47
|
|
|
66
48
|
export function oauthRef(provider, index) {
|
package/lib/rotate.js
CHANGED
|
@@ -57,7 +57,13 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
57
57
|
if (!acc.quota) tiers[1].push(acc)
|
|
58
58
|
else tiers[0].push(acc)
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
// Внутри здоровых тиров предпочитаем аккаунт с более высоким health-счётом
|
|
61
|
+
for (const tier of tiers) {
|
|
62
|
+
if (tier.length) {
|
|
63
|
+
tier.sort((a, b) => (b.healthScore || 100) - (a.healthScore || 100))
|
|
64
|
+
return tier[0]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
61
67
|
if (fallback.length) return fallback[0]
|
|
62
68
|
return null
|
|
63
69
|
}
|
package/lib/vendors/index.js
CHANGED
|
@@ -2,12 +2,39 @@ import * as codex from './codex.js'
|
|
|
2
2
|
import * as claude from './claude.js'
|
|
3
3
|
import * as grok from './grok.js'
|
|
4
4
|
import * as antigravity from './antigravity.js'
|
|
5
|
+
import { createVendorFromProfile } from '../vendor-factory.js'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const builtins = { codex, claude, grok, antigravity }
|
|
8
|
+
|
|
9
|
+
const customVendors = new Map()
|
|
10
|
+
|
|
11
|
+
export function registerCustomVendor(vendor) {
|
|
12
|
+
if (!vendor || !vendor.id) throw new Error('custom vendor needs id')
|
|
13
|
+
customVendors.set(vendor.id, vendor)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clearCustomVendors() {
|
|
17
|
+
customVendors.clear()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const vendors = new Proxy({}, {
|
|
21
|
+
get(_t, prop) {
|
|
22
|
+
if (prop in builtins) return builtins[prop]
|
|
23
|
+
return customVendors.get(prop)
|
|
24
|
+
},
|
|
25
|
+
has(_t, prop) {
|
|
26
|
+
return prop in builtins || customVendors.has(prop)
|
|
27
|
+
},
|
|
28
|
+
ownKeys() {
|
|
29
|
+
return [...Reflect.ownKeys(builtins), ...customVendors.keys()]
|
|
30
|
+
},
|
|
31
|
+
getOwnPropertyDescriptor() {
|
|
32
|
+
return { enumerable: true, configurable: true }
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export function isProvider(value) {
|
|
37
|
+
return Boolean(vendors[value])
|
|
11
38
|
}
|
|
12
39
|
|
|
13
40
|
export function getVendor(provider) {
|
package/package.json
CHANGED