@goodandready/dsh-subscriptions 0.6.72 → 0.6.83
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 +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/accounts-view.js +97 -5
- package/lib/accounts.js +137 -11
- package/lib/adapter.js +3 -0
- package/lib/alerts.js +35 -0
- package/lib/analytics.js +31 -2
- package/lib/billing.js +99 -0
- package/lib/blob.js +22 -0
- package/lib/capabilities.js +208 -0
- package/lib/client.js +344 -17
- package/lib/config-schema.js +50 -4
- package/lib/diagnostic-wizard.js +2 -5
- package/lib/diagnostics.js +2 -2
- package/lib/error-buffer.js +2 -2
- package/lib/forecast.js +56 -0
- package/lib/headless-auth.js +392 -0
- package/lib/history.js +36 -0
- package/lib/http.js +67 -4
- package/lib/import-auth.js +3 -6
- package/lib/index.js +99 -11
- package/lib/jwt.js +84 -17
- package/lib/loopback.js +3 -2
- package/lib/mask.js +16 -0
- package/lib/oauth-pending.js +12 -0
- package/lib/pricing.js +89 -0
- package/lib/refs.js +2 -0
- package/lib/reset-clock.js +55 -0
- package/lib/rotate.js +18 -4
- package/lib/routes/accounts.js +142 -7
- package/lib/routes/oauth.js +51 -2
- package/lib/routes/proxy.js +14 -0
- package/lib/scope-inspector.js +51 -0
- package/lib/stream-rotate.js +31 -10
- package/lib/subscriptions.js +24 -1
- package/lib/updater.js +21 -0
- package/lib/vault-sync.js +414 -0
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -2
- package/lib/vendors/antigravity.js +13 -4
- package/lib/vendors/claude.js +56 -8
- package/lib/vendors/codex.js +34 -6
- package/lib/vendors/copilot-cli.js +36 -5
- package/lib/vendors/ernie.js +9 -2
- package/lib/vendors/grok.js +66 -6
- package/lib/vendors/jetbrains.js +136 -13
- package/lib/vendors/perplexity.js +151 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -305,6 +305,14 @@ dsh-subscriptions:
|
|
|
305
305
|
|
|
306
306
|
---
|
|
307
307
|
|
|
308
|
+
### Custom Providers and Host Trust
|
|
309
|
+
|
|
310
|
+
- `customVendors.authUrl`, `tokenUrl` and `baseUrl` must use `https://`. Plain `http://` is accepted only for loopback hosts (`localhost`, `127.0.0.1`, `::1`).
|
|
311
|
+
- Grok `baseUrl` overrides must point to `api.x.ai`, `grok.com` / `*.grok.com`, or a loopback host; other hosts are rejected with `GROK_UNTRUSTED_HOST` so bearer tokens never leave trusted xAI infrastructure.
|
|
312
|
+
- Antigravity CLI imports must configure `antigravityClientSecret`; the embedded default was removed.
|
|
313
|
+
- The error buffer and diagnostics reports redact bearer tokens, API keys, JWTs and `key=value` credentials before storing or returning them.
|
|
314
|
+
|
|
315
|
+
|
|
308
316
|
## 🧠 Claude Adaptive Thinking & Effort (Added in v0.6.7)
|
|
309
317
|
|
|
310
318
|
Supports Anthropic's adaptive thinking (`thinking: { type: "adaptive" }`) and reasoning effort steering (`output_config: { effort }`):
|
package/README.ru.md
CHANGED
|
@@ -278,6 +278,14 @@ dsh-subscriptions:
|
|
|
278
278
|
|
|
279
279
|
---
|
|
280
280
|
|
|
281
|
+
### Кастомные провайдеры и доверие хостам
|
|
282
|
+
|
|
283
|
+
- `customVendors.authUrl`, `tokenUrl` и `baseUrl` обязаны использовать `https://`. Открытый `http://` допускается только для loopback-хостов (`localhost`, `127.0.0.1`, `::1`).
|
|
284
|
+
- Переопределение Grok `baseUrl` должно указывать на `api.x.ai`, `grok.com` / `*.grok.com` или loopback-хост; остальные хосты отклоняются с `GROK_UNTRUSTED_HOST`, чтобы bearer-токен не покидал доверенную инфраструктуру xAI.
|
|
285
|
+
- Импорт Antigravity CLI требует явного `antigravityClientSecret`; встроенный default удалён.
|
|
286
|
+
- Error buffer и диагностические отчёты редактируют bearer-токены, API-ключи, JWT и `key=value`-credentials до сохранения и выдачи.
|
|
287
|
+
|
|
288
|
+
|
|
281
289
|
## 🧠 Адаптивное мышление Claude и уровни усилия (Добавлено в v0.6.7)
|
|
282
290
|
|
|
283
291
|
Поддержка адаптивного мышления Anthropic (`thinking: { type: "adaptive" }`) и управления глубиной рассуждений (`output_config: { effort }`):
|
package/README.zh.md
CHANGED
|
@@ -184,6 +184,14 @@ dsh plugin --profile web add @goodandready/dsh-subscriptions
|
|
|
184
184
|
|
|
185
185
|
---
|
|
186
186
|
|
|
187
|
+
### 自定义供应商与主机信任
|
|
188
|
+
|
|
189
|
+
- `customVendors.authUrl`、`tokenUrl` 和 `baseUrl` 必须使用 `https://`。明文 `http://` 仅允许用于回环主机(`localhost`、`127.0.0.1`、`::1`)。
|
|
190
|
+
- Grok `baseUrl` 覆盖只能指向 `api.x.ai`、`grok.com` / `*.grok.com` 或回环主机;其他主机会以 `GROK_UNTRUSTED_HOST` 拒绝,避免 bearer 令牌离开可信的 xAI 基础设施。
|
|
191
|
+
- Antigravity CLI 导入必须配置 `antigravityClientSecret`;内置默认值已移除。
|
|
192
|
+
- 错误缓冲区和诊断报告会在存储或返回前脱敏 bearer 令牌、API 密钥、JWT 和 `key=value` 凭据。
|
|
193
|
+
|
|
194
|
+
|
|
187
195
|
## 🧠 Claude 自适应思考与推理深度控制 (v0.6.7 新增)
|
|
188
196
|
|
|
189
197
|
支持 Anthropic 自适应思考 (`thinking: { type: "adaptive" }`) 及推理深度调节 (`output_config: { effort }`):
|
package/lib/accounts-view.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
import { inspectAccountCapabilities } from './capabilities.js'
|
|
2
|
+
import { extractBillingInfo } from './billing.js'
|
|
3
|
+
import { calculatePacingRunway } from './forecast.js'
|
|
4
|
+
import { calculateValueDelivered } from './pricing.js'
|
|
5
|
+
import { notifyBillingAlert } from './alerts.js'
|
|
6
|
+
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
1
7
|
import { normalizeSlots } from './accounts.js'
|
|
2
8
|
import {
|
|
3
9
|
inspectGoogleAccount,
|
|
4
10
|
antigravityMetadata,
|
|
5
11
|
antigravityIdentityHeaders,
|
|
6
12
|
} from './code-assist.js'
|
|
13
|
+
import {
|
|
14
|
+
computeEffectiveResetAt,
|
|
15
|
+
formatRollingCountdown,
|
|
16
|
+
VENDOR_WINDOW_MS,
|
|
17
|
+
} from './reset-clock.js'
|
|
7
18
|
|
|
8
19
|
async function enrichAntigravityAccount(slot, info, store, fetch) {
|
|
9
20
|
if (!info.configured || slot.provider !== 'antigravity') return info
|
|
@@ -15,6 +26,7 @@ async function enrichAntigravityAccount(slot, info, store, fetch) {
|
|
|
15
26
|
validationUrl: blob.validationUrl,
|
|
16
27
|
validationMessage: blob.validationMessage || info.validationMessage || '',
|
|
17
28
|
paidTierName: blob.paidTierName || info.paidTierName || '',
|
|
29
|
+
plan: blob.plan || info.plan || blob.paidTierName || info.paidTierName || '',
|
|
18
30
|
}
|
|
19
31
|
}
|
|
20
32
|
try {
|
|
@@ -37,28 +49,96 @@ async function enrichAntigravityAccount(slot, info, store, fetch) {
|
|
|
37
49
|
validationUrl: next.validationUrl,
|
|
38
50
|
validationMessage: next.validationMessage,
|
|
39
51
|
paidTierName: next.paidTierName || '',
|
|
52
|
+
plan: info.plan || next.paidTierName || '',
|
|
40
53
|
}
|
|
41
54
|
}
|
|
42
55
|
if (probe.notice?.message) {
|
|
43
56
|
next.accountNotice = probe.notice.message
|
|
44
57
|
await store.saveBlob(slot.ref, next)
|
|
45
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
...info,
|
|
60
|
+
accountNotice: next.accountNotice,
|
|
61
|
+
paidTierName: next.paidTierName || '',
|
|
62
|
+
plan: info.plan || next.paidTierName || '',
|
|
63
|
+
}
|
|
46
64
|
}
|
|
47
65
|
if (probe.projectId || probe.paidTierId) await store.saveBlob(slot.ref, next)
|
|
48
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
...info,
|
|
68
|
+
paidTierName: next.paidTierName || info.paidTierName || '',
|
|
69
|
+
plan: info.plan || next.paidTierName || '',
|
|
70
|
+
}
|
|
49
71
|
} catch { /* keep settings responsive */ }
|
|
50
72
|
return info
|
|
51
73
|
}
|
|
52
74
|
|
|
53
|
-
export async function createAccountsView({ live, store, fetch, pmL }) {
|
|
75
|
+
export async function createAccountsView({ live, store, fetch, pmL, pmE, concurrencyQueue = defaultConcurrencyQueue, history = null }) {
|
|
54
76
|
const out = []
|
|
77
|
+
const qStats = (concurrencyQueue && typeof concurrencyQueue.stats === 'function') ? concurrencyQueue.stats() : null
|
|
78
|
+
const now = Date.now()
|
|
55
79
|
for (const slot of normalizeSlots(live().slots)) {
|
|
56
|
-
const
|
|
80
|
+
const rawInfo = await store.describeRef(slot.ref)
|
|
81
|
+
const info = await enrichAntigravityAccount(slot, rawInfo, store, fetch)
|
|
82
|
+
const effectiveResetAt = computeEffectiveResetAt(info, slot.provider)
|
|
83
|
+
const resetCountdown = effectiveResetAt ? formatRollingCountdown(effectiveResetAt, 'en') : ''
|
|
84
|
+
const slotQ = (qStats && qStats.byKey && qStats.byKey[slot.ref]) || { active: 0, queued: 0 }
|
|
85
|
+
|
|
86
|
+
let blob = null
|
|
87
|
+
try { blob = await store.loadBlob(slot.ref) } catch { /* best effort */ }
|
|
88
|
+
|
|
89
|
+
// #556: Capability matrix
|
|
90
|
+
const capabilities = inspectAccountCapabilities(slot.provider, blob, info)
|
|
91
|
+
|
|
92
|
+
// #558: Billing cycle & payment status
|
|
93
|
+
const billing = extractBillingInfo(blob, slot, now)
|
|
94
|
+
if (billing.paymentFailed || billing.isOverdue) {
|
|
95
|
+
try {
|
|
96
|
+
notifyBillingAlert({
|
|
97
|
+
ref: slot.ref,
|
|
98
|
+
provider: slot.provider,
|
|
99
|
+
status: billing.status,
|
|
100
|
+
renewsAt: billing.renewsAt,
|
|
101
|
+
webhookUrl: live()?.webhookUrl,
|
|
102
|
+
fetchImpl: fetch,
|
|
103
|
+
nowMs: now,
|
|
104
|
+
})
|
|
105
|
+
} catch { /* best-effort alert */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// #559: Quota pacing runway
|
|
109
|
+
const remPct = info.quota?.remainingPercent != null
|
|
110
|
+
? Number(info.quota.remainingPercent)
|
|
111
|
+
: (info.usagePercent != null ? Math.max(0, 100 - Number(info.usagePercent)) : null)
|
|
112
|
+
const runway = calculatePacingRunway(remPct, info.pacePerHour, effectiveResetAt, now)
|
|
113
|
+
|
|
114
|
+
// #561: Value delivered & equivalent API cost
|
|
115
|
+
const accTelem = history && typeof history.accountTelemetry === 'function' ? history.accountTelemetry(slot.ref) : null
|
|
116
|
+
const valueDelivered = calculateValueDelivered({
|
|
117
|
+
promptTokens: accTelem?.totalPromptTokens || 0,
|
|
118
|
+
completionTokens: accTelem?.totalOutputTokens || 0,
|
|
119
|
+
cacheReadTokens: accTelem?.totalCacheReadTokens || 0,
|
|
120
|
+
subscriptionCost: slot.monthlyCost || 20.0,
|
|
121
|
+
})
|
|
122
|
+
|
|
57
123
|
out.push({
|
|
58
124
|
provider: slot.provider,
|
|
59
125
|
index: slot.index,
|
|
60
126
|
ref: slot.ref,
|
|
61
|
-
|
|
127
|
+
activeStreams: slotQ.active || 0,
|
|
128
|
+
queuedStreams: slotQ.queued || 0,
|
|
129
|
+
maxConcurrency: slot.maxConcurrency !== undefined ? slot.maxConcurrency : ((live() && live().accountMaxConcurrency) || 1),
|
|
130
|
+
label: pmL ? pmL(slot.label || info.label) : (slot.label || info.label),
|
|
131
|
+
email: typeof pmE === 'function' ? pmE(info.email || '') : String(info.email || ''),
|
|
132
|
+
plan: info.plan || info.paidTierName || '',
|
|
133
|
+
avatarUrl: info.avatarUrl || '',
|
|
134
|
+
scopes: Array.isArray(info.scopes) ? info.scopes : [],
|
|
135
|
+
scopeWarning: info.scopeWarning || '',
|
|
136
|
+
selectedOrgId: info.selectedOrgId || '',
|
|
137
|
+
selectedOrgName: info.selectedOrgName || '',
|
|
138
|
+
organizations: Array.isArray(info.organizations) ? info.organizations : [],
|
|
139
|
+
isEncryptedAtRest: Boolean(info.isEncryptedAtRest),
|
|
140
|
+
isRevoked: Boolean(info.isRevoked),
|
|
141
|
+
authWarning: info.authWarning || '',
|
|
62
142
|
configured: info.configured,
|
|
63
143
|
writable: info.writable,
|
|
64
144
|
cooldownUntil: info.cooldownUntil,
|
|
@@ -72,6 +152,9 @@ export async function createAccountsView({ live, store, fetch, pmL }) {
|
|
|
72
152
|
usagePercent: info.usagePercent,
|
|
73
153
|
quota: info.quota || null,
|
|
74
154
|
usage: info.usage || null,
|
|
155
|
+
resetAt: effectiveResetAt || (info.quota && info.quota.resetAt) || null,
|
|
156
|
+
resetCountdown: resetCountdown || '',
|
|
157
|
+
resetWindowMs: VENDOR_WINDOW_MS[slot.provider] || 0,
|
|
75
158
|
requests: store.getRequestCount(slot.ref) || null,
|
|
76
159
|
refreshError: info.refreshError || '',
|
|
77
160
|
validationUrl: info.validationUrl || '',
|
|
@@ -82,6 +165,15 @@ export async function createAccountsView({ live, store, fetch, pmL }) {
|
|
|
82
165
|
latencyGrade: info.latencyGrade ?? null,
|
|
83
166
|
lastDoctorVerdict: info.lastDoctorVerdict || null,
|
|
84
167
|
availableModels: info.availableModels || null,
|
|
168
|
+
capabilities,
|
|
169
|
+
billing,
|
|
170
|
+
renewsAt: billing.renewsAt,
|
|
171
|
+
renewalCountdown: billing.renewalCountdown,
|
|
172
|
+
billingWarning: billing.warning,
|
|
173
|
+
runwayMs: runway.runwayMs,
|
|
174
|
+
runwayText: runway.runwayText,
|
|
175
|
+
pacingStatus: runway.pacingStatus,
|
|
176
|
+
valueDelivered,
|
|
85
177
|
})
|
|
86
178
|
}
|
|
87
179
|
return out
|
package/lib/accounts.js
CHANGED
|
@@ -45,6 +45,7 @@ import { fetchWithTimeout, isTimeoutError } from "./http.js"
|
|
|
45
45
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
46
46
|
import { oauthRef, isProvider } from './refs.js'
|
|
47
47
|
import { parseBlob, serializeBlob } from './blob.js'
|
|
48
|
+
import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
|
|
48
49
|
import { recordSwitch, recordExhaust, recordBroken, computeHealthScore, healthBadge } from './health.js'
|
|
49
50
|
import { executeWithRetry } from './backoff.js'
|
|
50
51
|
import { getVendor } from './vendors/index.js'
|
|
@@ -71,6 +72,7 @@ export function normalizeSlots(slots) {
|
|
|
71
72
|
ref,
|
|
72
73
|
...(slot.proxyUrl ? { proxyUrl: String(slot.proxyUrl) } : {}),
|
|
73
74
|
...(slot.expiresAt ? { expiresAt: Number(slot.expiresAt) } : {}),
|
|
75
|
+
...(slot.maxConcurrency !== undefined ? { maxConcurrency: Number(slot.maxConcurrency) } : {}),
|
|
74
76
|
})
|
|
75
77
|
}
|
|
76
78
|
return out
|
|
@@ -122,15 +124,74 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
122
124
|
const doctorVerdicts = new Map()
|
|
123
125
|
const dynamicModels = new Map()
|
|
124
126
|
const doFetch = fetchImpl || fetch
|
|
127
|
+
const clockDrifts = new Map()
|
|
128
|
+
|
|
129
|
+
function getClockSkew(provider) {
|
|
130
|
+
if (!provider) return 0
|
|
131
|
+
return clockDrifts.get(String(provider).toLowerCase()) || 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function recordClockSkew(provider, vendorDateMsOrHeader) {
|
|
135
|
+
if (!provider) return
|
|
136
|
+
const key = String(provider).toLowerCase()
|
|
137
|
+
if (typeof vendorDateMsOrHeader === 'number') {
|
|
138
|
+
clockDrifts.set(key, vendorDateMsOrHeader - Date.now())
|
|
139
|
+
} else if (typeof vendorDateMsOrHeader === 'string') {
|
|
140
|
+
const parsed = Date.parse(vendorDateMsOrHeader)
|
|
141
|
+
if (!isNaN(parsed)) {
|
|
142
|
+
clockDrifts.set(key, parsed - Date.now())
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
125
147
|
// #88: per-account proxy fetch for token refresh / usage (falls back to doFetch).
|
|
126
148
|
const rawFetchFor = (ref) => (typeof fetchForRef === 'function' && fetchForRef(ref)) || doFetch
|
|
127
|
-
const fetchFor = (ref, defaultTimeoutMs = 25000
|
|
149
|
+
const fetchFor = (ref, defaultTimeoutMs = 25000, providerHint = null) => async (url, init, opts) => {
|
|
150
|
+
const res = await fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs: defaultTimeoutMs, ...(opts || {}) })
|
|
151
|
+
try {
|
|
152
|
+
const dateHeader = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('date') : null
|
|
153
|
+
if (dateHeader) {
|
|
154
|
+
const prov = providerHint || (ref ? String(ref).split('_')[0].toLowerCase() : null)
|
|
155
|
+
if (prov) recordClockSkew(prov, dateHeader)
|
|
156
|
+
}
|
|
157
|
+
} catch { /* best-effort drift extraction */ }
|
|
158
|
+
return res
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveMasterKey(passedCfg = null) {
|
|
162
|
+
const envKey = process.env.DSH_SUBSCRIPTIONS_MASTER_KEY
|
|
163
|
+
if (typeof envKey === 'string' && envKey.trim()) return envKey.trim()
|
|
164
|
+
if (passedCfg && typeof passedCfg.masterKey === 'string' && passedCfg.masterKey.trim()) {
|
|
165
|
+
return passedCfg.masterKey.trim()
|
|
166
|
+
}
|
|
167
|
+
return ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function currentMasterKey() {
|
|
171
|
+
let cfg = null
|
|
172
|
+
try {
|
|
173
|
+
cfg = typeof getConfig === 'function' ? getConfig() : null
|
|
174
|
+
} catch { /* keep env-only fallback */ }
|
|
175
|
+
return resolveMasterKey(cfg)
|
|
176
|
+
}
|
|
128
177
|
|
|
129
178
|
async function resolveRaw(ref) {
|
|
130
179
|
try {
|
|
131
180
|
const resolved = await credentials.resolve(credentialRef(ref))
|
|
132
|
-
|
|
133
|
-
|
|
181
|
+
const raw = resolved && resolved.value ? String(resolved.value) : ''
|
|
182
|
+
if (!raw) return ''
|
|
183
|
+
if (raw.startsWith('DSHE1:') || raw.startsWith('DSHE2:') || raw.startsWith('DSHE3:')) {
|
|
184
|
+
const masterKey = currentMasterKey()
|
|
185
|
+
if (!masterKey) {
|
|
186
|
+
const err = /** @type {Error & { code?: string }} */ (new Error(`Credentials for ${ref} are encrypted at rest. Configure masterKey in the plugin settings or DSH_SUBSCRIPTIONS_MASTER_KEY to decrypt.`))
|
|
187
|
+
err.code = 'ENCRYPTED_AT_REST'
|
|
188
|
+
throw err
|
|
189
|
+
}
|
|
190
|
+
return decryptWithPassphrase(raw, masterKey)
|
|
191
|
+
}
|
|
192
|
+
return raw
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err && err.code === 'ENCRYPTED_AT_REST') throw err
|
|
134
195
|
return ''
|
|
135
196
|
}
|
|
136
197
|
}
|
|
@@ -158,11 +219,16 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
158
219
|
return
|
|
159
220
|
}
|
|
160
221
|
try {
|
|
161
|
-
|
|
222
|
+
const serialized = serializeBlob(blob)
|
|
223
|
+
const masterKey = currentMasterKey()
|
|
224
|
+
const toStore = masterKey ? encryptWithPassphrase(serialized, masterKey) : serialized
|
|
225
|
+
await credentials.set(credentialRef(ref), toStore)
|
|
162
226
|
if (clearedRefs.has(ref)) {
|
|
163
227
|
try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
|
|
164
228
|
} else if (credentialVersions.get(ref) > version && latestBlobs.has(ref)) {
|
|
165
|
-
|
|
229
|
+
const nextSerialized = serializeBlob(latestBlobs.get(ref))
|
|
230
|
+
const nextStore = masterKey ? encryptWithPassphrase(nextSerialized, masterKey) : nextSerialized
|
|
231
|
+
try { await credentials.set(credentialRef(ref), nextStore) } catch { /* best-effort */ }
|
|
166
232
|
}
|
|
167
233
|
} catch (err) {
|
|
168
234
|
if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
|
|
@@ -204,7 +270,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
204
270
|
windows.delete(ref)
|
|
205
271
|
}
|
|
206
272
|
|
|
207
|
-
async function describeRef(ref) {
|
|
273
|
+
async function describeRef(ref, cfg = null) {
|
|
208
274
|
const base = { ref, configured: false, writable: true, label: '', email: '' }
|
|
209
275
|
try {
|
|
210
276
|
if (typeof credentials.describe === 'function') {
|
|
@@ -217,16 +283,34 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
217
283
|
} catch {
|
|
218
284
|
return base
|
|
219
285
|
}
|
|
286
|
+
let isEncryptedAtRest = false
|
|
287
|
+
try {
|
|
288
|
+
const resolved = await credentials.resolve(credentialRef(ref))
|
|
289
|
+
const raw = resolved && resolved.value ? String(resolved.value) : ''
|
|
290
|
+
if (raw.startsWith('DSHE1:') || raw.startsWith('DSHE2:') || raw.startsWith('DSHE3:')) {
|
|
291
|
+
isEncryptedAtRest = true
|
|
292
|
+
} else if (Boolean(resolveMasterKey(cfg))) {
|
|
293
|
+
isEncryptedAtRest = true
|
|
294
|
+
}
|
|
295
|
+
} catch { /* best-effort */ }
|
|
296
|
+
|
|
220
297
|
if (base.configured) {
|
|
221
298
|
try {
|
|
222
299
|
const blob = parseBlob(await resolveRaw(ref))
|
|
223
300
|
base.label = blob.label || blob.email || ''
|
|
224
301
|
base.email = blob.email || ''
|
|
302
|
+
base.plan = blob.plan || blob.paidTierName || ''
|
|
303
|
+
base.avatarUrl = blob.avatarUrl || ''
|
|
304
|
+
base.scopes = Array.isArray(blob.scopes) ? blob.scopes : []
|
|
305
|
+
base.scopeWarning = blob.scopeWarning || ''
|
|
225
306
|
if (Array.isArray(blob.usage)) { base.usage = blob.usage; base.usageAt = blob.usageAt || 0 }
|
|
226
307
|
if (blob.validationUrl) base.validationUrl = blob.validationUrl
|
|
227
308
|
if (blob.validationMessage) base.validationMessage = blob.validationMessage
|
|
228
309
|
if (blob.accountNotice) base.accountNotice = blob.accountNotice
|
|
229
310
|
if (blob.paidTierName) base.paidTierName = blob.paidTierName
|
|
311
|
+
base.selectedOrgId = blob.selectedOrgId || blob.accountId || blob.projectId || ''
|
|
312
|
+
base.selectedOrgName = blob.selectedOrgName || base.selectedOrgId || ''
|
|
313
|
+
base.organizations = Array.isArray(blob.organizations) ? blob.organizations : []
|
|
230
314
|
} catch { /* ignore parse */ }
|
|
231
315
|
}
|
|
232
316
|
return {
|
|
@@ -245,6 +329,24 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
245
329
|
usagePercent: usage.has(ref) ? usage.get(ref) : null,
|
|
246
330
|
refreshError: refreshFailures.get(ref)?.error || '',
|
|
247
331
|
isTimeout: !!refreshFailures.get(ref)?.isTimeout,
|
|
332
|
+
plan: base.plan || '',
|
|
333
|
+
avatarUrl: base.avatarUrl || '',
|
|
334
|
+
scopes: base.scopes || [],
|
|
335
|
+
scopeWarning: base.scopeWarning || '',
|
|
336
|
+
selectedOrgId: base.selectedOrgId || '',
|
|
337
|
+
selectedOrgName: base.selectedOrgName || '',
|
|
338
|
+
organizations: base.organizations || [],
|
|
339
|
+
isEncryptedAtRest,
|
|
340
|
+
isRevoked: Boolean(
|
|
341
|
+
(quarantines.get(ref)?.reason === 'REVOKED' || quarantines.get(ref)?.reason === 'SESSION_REVOKED') ||
|
|
342
|
+
(doctorVerdicts.get(ref)?.code === 'TOKEN_EXPIRED' || doctorVerdicts.get(ref)?.code === 'TOKEN_REVOKED') ||
|
|
343
|
+
(/invalid_grant|token_revoked|revoked|session expired/i.test(refreshFailures.get(ref)?.error || ''))
|
|
344
|
+
),
|
|
345
|
+
authWarning: (
|
|
346
|
+
(quarantines.get(ref)?.reason === 'REVOKED' || quarantines.get(ref)?.reason === 'SESSION_REVOKED') ||
|
|
347
|
+
(doctorVerdicts.get(ref)?.code === 'TOKEN_EXPIRED' || doctorVerdicts.get(ref)?.code === 'TOKEN_REVOKED') ||
|
|
348
|
+
(/invalid_grant|token_revoked|revoked|session expired/i.test(refreshFailures.get(ref)?.error || ''))
|
|
349
|
+
) ? 'Session expired / revoked — re-authorization required' : '',
|
|
248
350
|
validationUrl: base.validationUrl || '',
|
|
249
351
|
validationMessage: base.validationMessage || '',
|
|
250
352
|
accountNotice: base.accountNotice || '',
|
|
@@ -261,7 +363,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
261
363
|
const slots = normalizeSlots(curConfig.slots).filter((s) => s.provider === provider)
|
|
262
364
|
const out = []
|
|
263
365
|
for (const slot of slots) {
|
|
264
|
-
const info = await describeRef(slot.ref)
|
|
366
|
+
const info = await describeRef(slot.ref, curConfig)
|
|
265
367
|
const q = quarantines.get(slot.ref)
|
|
266
368
|
out.push({
|
|
267
369
|
ref: slot.ref,
|
|
@@ -276,6 +378,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
276
378
|
quarantineReason: q ? q.reason : null,
|
|
277
379
|
label: slot.label || info.label,
|
|
278
380
|
pacePerHour: info.pacePerHour || 0,
|
|
381
|
+
maxConcurrency: slot.maxConcurrency !== undefined ? slot.maxConcurrency : (curConfig.accountMaxConcurrency || 1),
|
|
279
382
|
})
|
|
280
383
|
}
|
|
281
384
|
return out
|
|
@@ -286,7 +389,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
286
389
|
const slots = normalizeSlots(curConfig.slots)
|
|
287
390
|
const found = new Set()
|
|
288
391
|
for (const slot of slots) {
|
|
289
|
-
const info = await describeRef(slot.ref)
|
|
392
|
+
const info = await describeRef(slot.ref, curConfig)
|
|
290
393
|
if (info.configured) found.add(slot.provider)
|
|
291
394
|
}
|
|
292
395
|
return [...found]
|
|
@@ -296,15 +399,34 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
296
399
|
if (!blob || typeof blob !== 'object') return blob
|
|
297
400
|
if (blob.apiKeyOnly || (blob.apiKey && (!blob.refreshToken || blob.refreshToken === blob.apiKey))) return blob
|
|
298
401
|
if (!blob.refreshToken) return blob
|
|
299
|
-
|
|
402
|
+
|
|
403
|
+
const curConfig = getConfig()
|
|
404
|
+
const safetyMargin = Math.max(SKEW_MS, Number(curConfig?.refreshAheadMs) || (5 * 60 * 1000))
|
|
405
|
+
const clockSkew = getClockSkew(provider)
|
|
406
|
+
const adjustedNow = Date.now() + clockSkew
|
|
407
|
+
|
|
408
|
+
if (blob.expiresAt && blob.expiresAt - safetyMargin > adjustedNow) return blob
|
|
409
|
+
|
|
410
|
+
if (ref && refreshFailures.has(ref)) {
|
|
411
|
+
const failInfo = refreshFailures.get(ref)
|
|
412
|
+
const retryMs = Math.min(Number(curConfig?.refreshRetryMs) || 10000, 60000)
|
|
413
|
+
if (Date.now() - (failInfo.at || 0) < retryMs) {
|
|
414
|
+
if (blob.expiresAt && blob.expiresAt > adjustedNow && blob.accessToken) {
|
|
415
|
+
return blob
|
|
416
|
+
}
|
|
417
|
+
const err = /** @type {Error & { code?: string }} */ (new Error(`Token refresh in backoff window after failure: ${failInfo.error || 'transient failure'}`))
|
|
418
|
+
err.code = 'TOKEN'
|
|
419
|
+
throw err
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
300
423
|
const lockKey = ref || (provider + ":" + (blob.accountId || blob.email || (blob.refreshToken && blob.refreshToken.slice(-16)) || "anon"))
|
|
301
424
|
if (refreshLocks.has(lockKey)) return refreshLocks.get(lockKey)
|
|
302
425
|
const promise = (async () => {
|
|
303
426
|
try {
|
|
304
|
-
const curConfig = getConfig()
|
|
305
427
|
const cfg = vendorConfig(provider, curConfig)
|
|
306
428
|
const next = await executeWithRetry(
|
|
307
|
-
() => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000)),
|
|
429
|
+
() => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000, provider)),
|
|
308
430
|
{
|
|
309
431
|
maxRetries: 2,
|
|
310
432
|
initialDelayMs: 300,
|
|
@@ -446,6 +568,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
446
568
|
resolveRaw,
|
|
447
569
|
ensureFresh,
|
|
448
570
|
refreshUsage,
|
|
571
|
+
getClockSkew,
|
|
572
|
+
recordClockSkew,
|
|
449
573
|
// #87: family-scoped cooldowns. families=null blocks the whole account
|
|
450
574
|
// (legacy behavior); a list blocks only those model families.
|
|
451
575
|
rememberCooldown(ref, until, families) {
|
|
@@ -486,6 +610,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
486
610
|
quotaSamples.delete(ref)
|
|
487
611
|
}
|
|
488
612
|
},
|
|
613
|
+
clearRefreshFailure(ref) { if (ref) refreshFailures.delete(ref) },
|
|
614
|
+
clearCooldown(ref) { if (ref) cooldowns.delete(ref) },
|
|
489
615
|
shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
|
|
490
616
|
getQuota(ref) { return quotas.get(ref) || null },
|
|
491
617
|
rememberUsage(ref, percent) { usage.set(ref, percent) },
|
package/lib/adapter.js
CHANGED
|
@@ -180,6 +180,9 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
180
180
|
deps.rememberQuarantine(account.ref, account.quarantineReason || 'RATE_LIMIT', account.quarantineUntil || account.cooldownUntil, account.quarantineAttempts || 1)
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
if (account.quarantineReason === 'REVOKED' || account.isRevoked) {
|
|
184
|
+
if (typeof deps.recordBroken === 'function') deps.recordBroken(account.ref)
|
|
185
|
+
}
|
|
183
186
|
if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
|
|
184
187
|
},
|
|
185
188
|
streamOnce: async function* (account, opts) {
|
package/lib/alerts.js
CHANGED
|
@@ -209,3 +209,38 @@ export function notifyQuarantineReleased({
|
|
|
209
209
|
}
|
|
210
210
|
return alert
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* #558: Fires an alert when a subscription experiences a billing failure or overdue renewal.
|
|
215
|
+
*/
|
|
216
|
+
export function notifyBillingAlert({
|
|
217
|
+
ref,
|
|
218
|
+
provider,
|
|
219
|
+
status,
|
|
220
|
+
renewsAt,
|
|
221
|
+
message,
|
|
222
|
+
webhookUrl = '',
|
|
223
|
+
fetchImpl = fetch,
|
|
224
|
+
nowMs = Date.now(),
|
|
225
|
+
}) {
|
|
226
|
+
const isFailed = status === 'past_due' || status === 'unpaid' || status === 'payment_failed'
|
|
227
|
+
const severity = isFailed ? 'error' : 'warn'
|
|
228
|
+
const defaultMsg = isFailed
|
|
229
|
+
? `Billing alert: ${provider || 'Account'} (${ref || 'slot'}) has payment issue (${status}).`
|
|
230
|
+
: `Subscription renewal alert: ${provider || 'Account'} (${ref || 'slot'}) renewal is overdue.`
|
|
231
|
+
|
|
232
|
+
const alert = recordAlert({
|
|
233
|
+
type: 'billing_status',
|
|
234
|
+
ref,
|
|
235
|
+
provider,
|
|
236
|
+
message: message || defaultMsg,
|
|
237
|
+
severity,
|
|
238
|
+
details: { status, renewsAt },
|
|
239
|
+
nowMs,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
if (alert && webhookUrl) {
|
|
243
|
+
dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
|
|
244
|
+
}
|
|
245
|
+
return alert
|
|
246
|
+
}
|
package/lib/analytics.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { calculateValueDelivered } from './pricing.js'
|
|
2
|
+
import { calculatePacingRunway } from './forecast.js'
|
|
1
3
|
// Subscription and cache analytics, performance aggregates, and quota forecasting.
|
|
2
4
|
// Enforces 0 Cyrillic characters.
|
|
3
5
|
|
|
@@ -240,6 +242,11 @@ export function calculateAccountBreakdown(entries = [], accounts = []) {
|
|
|
240
242
|
avgTtftMs: acc.ttftCount > 0 ? Math.round(acc.ttftSum / acc.ttftCount) : null,
|
|
241
243
|
avgTps: acc.tpsCount > 0 ? Math.round((acc.tpsSum / acc.tpsCount) * 10) / 10 : null,
|
|
242
244
|
avgLatencyMs: acc.totalRequests > 0 ? Math.round(acc.latencySum / acc.totalRequests) : 0,
|
|
245
|
+
valueDelivered: calculateValueDelivered({
|
|
246
|
+
promptTokens: acc.promptTokens,
|
|
247
|
+
completionTokens: acc.completionTokens,
|
|
248
|
+
cacheReadTokens: acc.cacheReadTokens,
|
|
249
|
+
}),
|
|
243
250
|
}
|
|
244
251
|
}
|
|
245
252
|
return result
|
|
@@ -307,18 +314,28 @@ export function calculateQuotaForecast(accounts = [], entries = [], nowMs = Date
|
|
|
307
314
|
}
|
|
308
315
|
}
|
|
309
316
|
|
|
317
|
+
const qPct = quota && typeof quota.percentage === 'number'
|
|
318
|
+
? quota.percentage
|
|
319
|
+
: (acc.usagePercent != null ? Math.max(0, 100 - Number(acc.usagePercent)) : null)
|
|
320
|
+
const qReset = quota && typeof quota.resetAt === 'number' ? quota.resetAt : (acc.resetAt || null)
|
|
321
|
+
const runway = calculatePacingRunway(qPct, acc.pacePerHour || 0, qReset, nowMs)
|
|
322
|
+
|
|
310
323
|
forecast.push({
|
|
311
324
|
ref,
|
|
312
325
|
provider: acc.provider,
|
|
313
326
|
label: acc.label || ref,
|
|
314
|
-
quotaPercent:
|
|
315
|
-
resetAt:
|
|
327
|
+
quotaPercent: qPct,
|
|
328
|
+
resetAt: qReset,
|
|
316
329
|
burnRateTokensPerHour,
|
|
317
330
|
requests24h,
|
|
318
331
|
tokens24h,
|
|
319
332
|
hoursToExhaustion,
|
|
320
333
|
willExhaustBeforeReset,
|
|
321
334
|
projectedExhaustionAt,
|
|
335
|
+
runwayHours: runway.runwayHours,
|
|
336
|
+
runwayMs: runway.runwayMs,
|
|
337
|
+
runwayText: runway.runwayText,
|
|
338
|
+
pacingStatus: runway.pacingStatus,
|
|
322
339
|
})
|
|
323
340
|
}
|
|
324
341
|
|
|
@@ -326,6 +343,17 @@ export function calculateQuotaForecast(accounts = [], entries = [], nowMs = Date
|
|
|
326
343
|
}
|
|
327
344
|
|
|
328
345
|
export function calculateAnalytics(entries = [], accounts = [], nowMs = Date.now()) {
|
|
346
|
+
let totalPrompt = 0, totalCompletion = 0, totalCacheRead = 0
|
|
347
|
+
for (const r of entries) {
|
|
348
|
+
totalPrompt += Number(r.inputTokens || (r.tokens && r.tokens.prompt) || 0)
|
|
349
|
+
totalCompletion += Number(r.outputTokens || (r.tokens && r.tokens.completion) || 0)
|
|
350
|
+
totalCacheRead += Number(r.cacheReadTokens || (r.tokens && r.tokens.cacheRead) || 0)
|
|
351
|
+
}
|
|
352
|
+
const valueDelivered = calculateValueDelivered({
|
|
353
|
+
promptTokens: totalPrompt,
|
|
354
|
+
completionTokens: totalCompletion,
|
|
355
|
+
cacheReadTokens: totalCacheRead,
|
|
356
|
+
})
|
|
329
357
|
return {
|
|
330
358
|
generatedAt: new Date(nowMs).toISOString(),
|
|
331
359
|
totalRequests: entries.length,
|
|
@@ -334,5 +362,6 @@ export function calculateAnalytics(entries = [], accounts = [], nowMs = Date.now
|
|
|
334
362
|
hourly: calculateHourlyBuckets(entries, nowMs),
|
|
335
363
|
accounts: calculateAccountBreakdown(entries, accounts),
|
|
336
364
|
forecast: calculateQuotaForecast(accounts, entries, nowMs),
|
|
365
|
+
valueDelivered,
|
|
337
366
|
}
|
|
338
367
|
}
|