@goodandready/dsh-subscriptions 0.5.21 → 0.5.24

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/lib/accounts.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
2
2
  import { oauthRef, isProvider } from './refs.js'
3
3
  import { parseBlob, serializeBlob } from './blob.js'
4
+ import { recordSwitch, recordExhaust, recordBroken } from './health.js'
5
+ import { executeWithRetry } from './backoff.js'
4
6
  import { getVendor } from './vendors/index.js'
5
7
 
6
8
  const SKEW_MS = 60 * 1000
@@ -54,6 +56,8 @@ export function vendorConfig(provider, cfg) {
54
56
  export function createAccountStore({ credentials, getConfig, fetchImpl, fetchForRef, onLimitNotice }) {
55
57
  const cooldowns = new Map()
56
58
  const quotas = new Map()
59
+ const quarantines = new Map()
60
+ const writeQueues = new Map()
57
61
  const refreshLocks = new Map()
58
62
  const refreshFailures = new Map()
59
63
  const usage = new Map()
@@ -86,13 +90,20 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
86
90
  }
87
91
 
88
92
  async function saveBlob(ref, blob) {
89
- await credentials.set(credentialRef(ref), serializeBlob(blob))
93
+ const prev = writeQueues.get(ref) || Promise.resolve()
94
+ const next = prev.catch(() => {}).then(async () => {
95
+ await credentials.set(credentialRef(ref), serializeBlob(blob))
96
+ })
97
+ writeQueues.set(ref, next)
98
+ await next
90
99
  }
91
100
 
92
101
  async function clearRef(ref) {
93
102
  await credentials.unset(credentialRef(ref))
94
103
  cooldowns.delete(ref)
95
104
  quotas.delete(ref)
105
+ quarantines.delete(ref)
106
+ writeQueues.delete(ref)
96
107
  for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
97
108
  for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
98
109
  refreshFailures.delete(ref)
@@ -128,6 +139,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
128
139
  return {
129
140
  ...base,
130
141
  cooldownUntil: cooldowns.get(ref) ? cooldowns.get(ref).until : 0,
142
+ quarantineUntil: quarantines.get(ref) ? quarantines.get(ref).until : 0,
143
+ quarantineReason: quarantines.get(ref) ? quarantines.get(ref).reason : null,
131
144
  cooldownFamilies: cooldowns.get(ref) && cooldowns.get(ref).families ? cooldowns.get(ref).families : null,
132
145
  quota: quotas.get(ref) || null,
133
146
  usage: windows.get(ref) || base.usage || null,
@@ -144,14 +157,20 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
144
157
  async function listAccounts(provider) {
145
158
  const slots = normalizeSlots(getConfig().slots).filter((s) => s.provider === provider)
146
159
  const out = []
160
+ const now = Date.now()
147
161
  for (const slot of slots) {
148
162
  const info = await describeRef(slot.ref)
163
+ const q = quarantines.get(slot.ref)
164
+ const inQ = q && Number(q.until) > now
149
165
  out.push({
150
166
  ref: slot.ref,
151
167
  hasToken: !!info.configured,
152
168
  usagePercent: info.usagePercent,
153
169
  cooldownUntil: info.cooldownUntil,
170
+ cooldownFamilies: info.cooldownFamilies || null,
154
171
  quota: info.quota || null,
172
+ quarantineUntil: inQ ? q.until : 0,
173
+ quarantineReason: inQ ? q.reason : null,
155
174
  label: slot.label || info.label,
156
175
  })
157
176
  }
@@ -174,7 +193,19 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
174
193
  const promise = (async () => {
175
194
  try {
176
195
  const cfg = vendorConfig(provider, getConfig())
177
- const next = await getVendor(provider).refresh(cfg, blob, fetchFor(ref))
196
+ const next = await executeWithRetry(
197
+ () => getVendor(provider).refresh(cfg, blob, fetchFor(ref)),
198
+ {
199
+ maxRetries: 2,
200
+ initialDelayMs: 300,
201
+ maxDelayMs: 2000,
202
+ isRetryable: (err) => {
203
+ const status = Number(err && (err.status || err.statusCode) || 0)
204
+ if (status === 400 || status === 401 || status === 403) return false
205
+ return true
206
+ },
207
+ }
208
+ )
178
209
  const merged = {
179
210
  ...blob,
180
211
  ...next,
@@ -297,8 +328,25 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
297
328
  recordSuccess(ref) {
298
329
  // успешный запрос сбрасывает кулдаун и возвращает аккаунт в строй
299
330
  cooldowns.delete(ref)
331
+ quarantines.delete(ref)
300
332
  if (health.has(ref)) health.set(ref, null)
301
333
  },
334
+ rememberQuarantine(ref, reason, until) {
335
+ if (!ref) return
336
+ quarantines.set(ref, { reason: reason || 'RATE_LIMIT', until: Number(until) || (Date.now() + 3600000) })
337
+ },
338
+ getQuarantine(ref) {
339
+ const q = quarantines.get(ref)
340
+ if (!q) return null
341
+ if (Number(q.until) <= Date.now()) {
342
+ quarantines.delete(ref)
343
+ return null
344
+ }
345
+ return q
346
+ },
347
+ releaseQuarantine(ref) {
348
+ quarantines.delete(ref)
349
+ },
302
350
  rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
303
351
  getRequestCount(ref) { return requestCounts.get(ref) || 0 },
304
352
  }
package/lib/adapter.js CHANGED
@@ -4,6 +4,7 @@ import { getVendor } from './vendors/index.js'
4
4
  import { modelCatalog } from './messages.js'
5
5
  import { streamWithRotation } from './stream-rotate.js'
6
6
  import { pickFetch } from './proxy.js'
7
+ import { quotaSnapshot, parseBody } from './ratelimit.js'
7
8
 
8
9
  // #94: heuristic for test/preview/beta/legacy model ids.
9
10
  function isDeprecatedId(id) {
@@ -57,6 +58,8 @@ export class SubscriptionAdapter extends LlmAdapter {
57
58
  if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
58
59
  models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
59
60
  }
61
+ // Defensive: ensure every model has the provider field set
62
+ models = models.map((m) => (m && !m.provider ? { ...m, provider } : m))
60
63
  return models
61
64
  }
62
65
 
@@ -107,6 +110,7 @@ export class SubscriptionAdapter extends LlmAdapter {
107
110
  if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
108
111
  },
109
112
  streamOnce: async function* (account, opts) {
113
+ const t0 = Date.now()
110
114
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
111
115
  const vendor = getVendor(provider)
112
116
  // ponytail: capture x-ratelimit headers without touching vendors
package/lib/client.js CHANGED
@@ -78,6 +78,24 @@ window.__ModuleLoader__.load({
78
78
  '.dsub-barLabelRow{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:3px}' +
79
79
  '.dsub-barTrack{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
80
80
  '.dsub-barFillGrad{height:100%;border-radius:3px;transition:width .3s ease}' +
81
+ '.dsub-panel-box{margin-top:10px;padding:12px 14px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:8px}' +
82
+ '.dsub-panel-warn{border-color:rgba(245,158,11,0.4);background:rgba(245,158,11,0.05)}' +
83
+ '.dsub-panel-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
84
+ '.dsub-panel-desc{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}' +
85
+ '.dsub-usage-grid{display:flex;flex-direction:column;gap:8px;margin-top:4px}' +
86
+ '.dsub-usage-card{display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}' +
87
+ '.dsub-usage-head{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary)}' +
88
+ '.dsub-usage-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);overflow:hidden}' +
89
+ '.dsub-usage-fill{height:100%;border-radius:999px;transition:width .3s ease}' +
90
+ '.dsub-usage-meta{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}' +
91
+ '.dsub-tag-ok{border-color:rgba(16,185,129,0.3);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
92
+ '.dsub-tag-warn{border-color:rgba(245,158,11,0.4);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
93
+ '.dsub-tag-bad{border-color:rgba(239,68,68,0.4);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
94
+ '.dsub-slot-meta-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2);font-size:12px}' +
95
+ '.dsub-ref-tag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
96
+ '.dsub-ref-tag code{padding:2px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-family:ui-monospace,monospace;color:var(--dsw-alias-label-primary);font-size:11px}' +
97
+ '.dsub-btn-danger{border-color:rgba(239,68,68,0.4)!important;color:var(--dsw-alias-state-error-primary)!important}' +
98
+ '.dsub-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12)!important}' +
81
99
  '.dsub-poolTitle{margin-top:16px;margin-bottom:8px;font-size:11.5px;font-weight:700;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.05em}' +
82
100
  '.dsub-accountCard{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);margin-top:6px;transition:border-color .15s,background .15s}' +
83
101
  '.dsub-accountCard:hover{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-label-tertiary)}' +
@@ -261,7 +279,10 @@ const cssId = 'dsh-subscriptions/settings.module.css'
261
279
  'privacyMask': 'Mask emails and account identifiers',
262
280
  'privacyMaskHint': 'For demos and screen sharing: emails show as j***n@example.com.',
263
281
  'resetCredits': 'Reset credits',
264
- 'resetCreditsHint': 'ChatGPT reset cards: shows how many are available. Consuming one is a deliberate action with a 5s confirmation.',
282
+ 'resetCreditsHint': 'ChatGPT reset cards: emergency reset for the 5-hour window. Requires a 5s confirmation.',
283
+ 'resetCreditsAction': 'Reset ChatGPT Limit',
284
+ 'usageLimitsTitle': 'Usage Limits',
285
+ 'slotDetailsTitle': 'Slot Details',
265
286
  'resetAvailable': 'Reset attempts available',
266
287
  'resetExpires': 'earliest expires',
267
288
  'resetAck': 'I understand one attempt will be consumed',
@@ -335,6 +356,24 @@ const cssId = 'dsh-subscriptions/settings.module.css'
335
356
  'slashStatus': 'Subscription status',
336
357
  'none': 'none',
337
358
  'forecast': '≈',
359
+ 'resetCredits': 'Сброс лимитов ChatGPT',
360
+ 'resetCreditsHint': 'Экстренный сброс 5-часового окна ChatGPT Plus/Pro. Требует 5 секунд подтверждения перед списанием.',
361
+ 'resetCreditsAction': 'Сбросить лимит ChatGPT',
362
+ 'resetAvailable': 'Доступно сбросов',
363
+ 'resetExpires': 'срок действия',
364
+ 'resetAck': 'Я понимаю, что одна попытка сброса будет списана',
365
+ 'resetWait': 'подтверждение через',
366
+ 'resetReady': 'готово к сбросу',
367
+ 'resetGo': 'Сбросить лимит сейчас',
368
+ 'resetBusy': 'Сброс…',
369
+ 'resetDone': 'Лимит успешно сброшен',
370
+ 'resetNothing': 'Лимит не требует сброса',
371
+ 'resetNoCredit': 'Нет доступных попыток сброса',
372
+ 'resetRedeemed': 'Попытка уже использована',
373
+ 'windowPrimary': 'Окно 5 часов',
374
+ 'windowSecondary': 'Окно 7 дней',
375
+ 'usageLimitsTitle': 'Лимиты использования',
376
+ 'slotDetailsTitle': 'Параметры слота',
338
377
  'resetLabel': 'reset',
339
378
  'check': 'Check',
340
379
  'checking': 'Checking…',
@@ -457,6 +496,24 @@ const cssId = 'dsh-subscriptions/settings.module.css'
457
496
  'slashStatus': 'Статус подписок',
458
497
  'none': 'нет',
459
498
  'forecast': '≈',
499
+ 'resetCredits': 'Сброс лимитов ChatGPT',
500
+ 'resetCreditsHint': 'Экстренный сброс 5-часового окна ChatGPT Plus/Pro. Требует 5 секунд подтверждения перед списанием.',
501
+ 'resetCreditsAction': 'Сбросить лимит ChatGPT',
502
+ 'resetAvailable': 'Доступно сбросов',
503
+ 'resetExpires': 'срок действия',
504
+ 'resetAck': 'Я понимаю, что одна попытка сброса будет списана',
505
+ 'resetWait': 'подтверждение через',
506
+ 'resetReady': 'готово к сбросу',
507
+ 'resetGo': 'Сбросить лимит сейчас',
508
+ 'resetBusy': 'Сброс…',
509
+ 'resetDone': 'Лимит успешно сброшен',
510
+ 'resetNothing': 'Лимит не требует сброса',
511
+ 'resetNoCredit': 'Нет доступных попыток сброса',
512
+ 'resetRedeemed': 'Попытка уже использована',
513
+ 'windowPrimary': 'Окно 5 часов',
514
+ 'windowSecondary': 'Окно 7 дней',
515
+ 'usageLimitsTitle': 'Лимиты использования',
516
+ 'slotDetailsTitle': 'Параметры слота',
460
517
  }
461
518
 
462
519
  // #51: live countdown to the quota window reset (account.quota.resetAt).
@@ -1082,39 +1139,54 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1082
1139
  return code
1083
1140
  }
1084
1141
  if (st.phase !== 'confirm') {
1085
- return React.createElement('div', { className: 'dsub-row' },
1086
- React.createElement('button', {
1087
- type: 'button', className: 'dsub-mini',
1088
- onClick: () => resetPrepare(row.slot.provider, row.slot.index, key).catch((e) => setErr(String(e && e.message || e))),
1089
- }, st.phase === 'loading' ? '\u2026' : t('resetCredits')),
1090
- React.createElement('div', { className: 'dsub-sub' }, t('resetCreditsHint')),
1142
+ return React.createElement('div', { className: 'dsub-panel-box' },
1143
+ React.createElement('div', { className: 'dsub-panel-title' },
1144
+ React.createElement('span', null, '' + t('resetCredits')),
1145
+ React.createElement('button', {
1146
+ type: 'button',
1147
+ className: 'dsub-mini',
1148
+ disabled: st.phase === 'loading',
1149
+ onClick: () => resetPrepare(row.slot.provider, row.slot.index, key).catch((e) => setErr(String(e && e.message || e))),
1150
+ }, st.phase === 'loading' ? '…' : t('resetCreditsAction'))
1151
+ ),
1152
+ React.createElement('div', { className: 'dsub-panel-desc' }, t('resetCreditsHint'))
1091
1153
  )
1092
1154
  }
1093
1155
  var ready = now >= st.readyAt
1094
1156
  var secs = Math.max(0, Math.ceil((st.readyAt - now) / 1000))
1095
- return React.createElement('div', { className: 'dsub-verify' },
1096
- React.createElement('div', null,
1097
- t('resetAvailable') + ': ' + st.availableCount +
1098
- (st.creditExpiresAt ? (' · ' + t('resetExpires') + ' ' + new Date(st.creditExpiresAt).toLocaleString()) : '')
1157
+ return React.createElement('div', { className: 'dsub-panel-box dsub-panel-warn' },
1158
+ React.createElement('div', { className: 'dsub-panel-title' },
1159
+ React.createElement('span', null, '⚠️ ' + t('resetCredits')),
1160
+ React.createElement('span', { className: 'dsub-tag-ok' }, t('resetAvailable') + ': ' + st.availableCount)
1099
1161
  ),
1100
- React.createElement('label', { className: 'dsub-row' },
1162
+ st.creditExpiresAt ? React.createElement('div', { className: 'dsub-panel-desc' },
1163
+ t('resetExpires') + ': ' + new Date(st.creditExpiresAt).toLocaleString()
1164
+ ) : null,
1165
+ React.createElement('label', { className: 'dsub-row', style: { cursor: 'pointer', margin: '4px 0' } },
1101
1166
  React.createElement('input', {
1102
1167
  type: 'checkbox',
1103
1168
  checked: !!st.ack,
1104
1169
  onChange: (e) => setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { ack: e.target.checked }) })),
1105
1170
  }),
1106
- React.createElement('span', null, t('resetAck')),
1171
+ React.createElement('span', { style: { fontSize: '12px', fontWeight: 500 } }, t('resetAck')),
1107
1172
  ),
1108
- React.createElement('div', { className: 'dsub-row' },
1173
+ React.createElement('div', { className: 'dsub-row', style: { gap: '8px' } },
1109
1174
  React.createElement('button', {
1110
- type: 'button', className: 'dsub-mini',
1175
+ type: 'button',
1176
+ className: 'dsub-mini dsub-btn-danger',
1111
1177
  disabled: !st.ack || !ready || !!st.busy,
1112
1178
  onClick: () => resetConsume(key).catch((e) => setErr(String(e && e.message || e))),
1113
1179
  }, st.busy ? t('resetBusy') : (ready ? t('resetGo') : (t('resetWait') + ' ' + secs + 's'))),
1114
1180
  !ready && !st.busy ? React.createElement('span', { className: 'dsub-sub' }, t('resetWait') + ' ' + secs + 's') : null,
1115
- st.ack && ready ? React.createElement('span', { className: 'dsub-ok' }, t('resetReady')) : null,
1181
+ st.ack && ready ? React.createElement('span', { className: 'dsub-ok', style: { fontSize: '12px', fontWeight: 600 } }, '✓ ' + t('resetReady')) : null,
1182
+ React.createElement('button', {
1183
+ type: 'button',
1184
+ className: 'dsub-mini',
1185
+ style: { marginLeft: 'auto' },
1186
+ onClick: () => setReset((m) => Object.assign({}, m, { [key]: { phase: 'idle' } })),
1187
+ }, t('cancel'))
1116
1188
  ),
1117
- st.result ? React.createElement('div', { className: 'dsub-ok' }, resultText(st.result)) : null,
1189
+ st.result ? React.createElement('div', { className: 'dsub-ok', style: { fontWeight: 600 } }, resultText(st.result)) : null,
1118
1190
  st.error ? React.createElement('div', { className: 'dsub-bad' }, st.error) : null,
1119
1191
  )
1120
1192
  })() : null),
@@ -1126,56 +1198,89 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1126
1198
  React.createElement('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink')),
1127
1199
  t('verifySuffix'),
1128
1200
  ) : null,
1129
- (function(){
1130
- var parts=[]
1131
- var wins=account.usage
1132
- function bar(label,pct){
1133
- var cls='dsub-bar'+(pct>=100?' dsub-barFull':(pct>=70?' dsub-barWarn':''))
1134
- return React.createElement('div',{className:'dsub-barRow',key:label},
1135
- React.createElement('span',null,label),
1136
- React.createElement('div',{className:cls},
1137
- React.createElement('div',{className:'dsub-barFill',style:{width:Math.min(100,Math.max(0,pct))+'%'}})
1201
+ (function usageLimitsBlock(){
1202
+ var wins = account.usage
1203
+ var q = account.quota
1204
+ var hasWins = Array.isArray(wins) && wins.length > 0
1205
+ var hasQuota = q && q.usedPercent != null
1206
+ var hasUsagePct = !hasWins && !hasQuota && account.usagePercent != null
1207
+
1208
+ if (!hasWins && !hasQuota && !hasUsagePct) return null
1209
+
1210
+ function windowLabel(w){
1211
+ var id = String((w && w.id) || '')
1212
+ var given = w && (w.ru || w.en)
1213
+ if (given && given !== id) return given
1214
+ if (id === 'primary_window') return t('windowPrimary')
1215
+ if (id === 'secondary_window') return t('windowSecondary')
1216
+ if (!id) return t('quota')
1217
+ return id.replace(/_/g, ' ')
1218
+ }
1219
+
1220
+ function renderItem(label, pct, detail){
1221
+ var p = Math.min(100, Math.max(0, Math.round(pct)))
1222
+ var tagCls = p >= 90 ? 'dsub-tag-bad' : (p >= 70 ? 'dsub-tag-warn' : 'dsub-tag-ok')
1223
+ var fillBg = p >= 90 ? 'var(--dsw-alias-state-error-primary)' : (p >= 70 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)')
1224
+ return React.createElement('div', { key: label, className: 'dsub-usage-card' },
1225
+ React.createElement('div', { className: 'dsub-usage-head' },
1226
+ React.createElement('span', null, label),
1227
+ React.createElement('span', { className: tagCls }, p + '%')
1228
+ ),
1229
+ React.createElement('div', { className: 'dsub-usage-track' },
1230
+ React.createElement('div', {
1231
+ className: 'dsub-usage-fill',
1232
+ style: { width: p + '%', background: fillBg }
1233
+ })
1138
1234
  ),
1139
- React.createElement('span',null,Math.round(pct)+'%'),
1235
+ detail ? React.createElement('div', { className: 'dsub-usage-meta' }, detail) : null
1140
1236
  )
1141
1237
  }
1142
- // Имя окна приходит от провайдера внутренним: primary_window.
1143
- // Известные переводим, незнакомое хотя бы причёсываем, чтобы
1144
- // в карточке не оставалось подчёркиваний из чужого протокола.
1145
- function windowLabel(w){
1146
- var id=String((w&&w.id)||'')
1147
- var given=w&&(w.ru||w.en)
1148
- if(given&&given!==id)return given
1149
- if(id==='primary_window')return t('windowPrimary')
1150
- if(id==='secondary_window')return t('windowSecondary')
1151
- if(!id)return t('quota')
1152
- return id.replace(/_/g,' ')
1153
- }
1154
- if(Array.isArray(wins)&&wins.length){
1155
- wins.forEach(function(w){
1156
- if(w&&w.usedPercent!=null)parts.push(bar(windowLabel(w),w.usedPercent))
1238
+
1239
+ var items = []
1240
+ if (hasWins) {
1241
+ wins.forEach(function(w, idx){
1242
+ if (w && w.usedPercent != null) {
1243
+ var extra = null
1244
+ if (idx === 0 && account.requests && w.usedPercent < 100) {
1245
+ var rem = 100 - w.usedPercent
1246
+ var est = Math.floor(rem / (w.usedPercent / account.requests))
1247
+ if (est > 0) extra = React.createElement('span', null, t('forecast') + ' ' + est)
1248
+ }
1249
+ items.push(renderItem(windowLabel(w), w.usedPercent, extra))
1250
+ }
1157
1251
  })
1158
1252
  }
1159
- var q=account.quota
1160
- if(q&&q.usedPercent!=null)parts.push(bar(t('quota'),q.usedPercent))
1161
- if(q&&q.resetAt)parts.push(React.createElement(ResetCountdown,{key:'reset',resetAt:q.resetAt}))
1162
- if(!wins&&!q&&account.usagePercent!=null)parts.push(bar(t('quota'),account.usagePercent))
1163
- var at=(q&&q.measuredAt)||(account.usageAt||0)
1164
- 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'))}
1165
- if(Array.isArray(wins)&&wins.length&&account.requests){
1166
- var w0=wins[0]
1167
- if(w0&&w0.usedPercent!=null&&w0.usedPercent<100){
1168
- var remaining=100-w0.usedPercent
1169
- var est=Math.floor(remaining/(w0.usedPercent/account.requests))
1170
- if(est>0)parts.push(React.createElement('span',{className:'dsub-sub',key:'fc'},t('forecast')+' '+est+' ('+windowLabel(w0)+')'))
1171
- }
1253
+ if (hasQuota) {
1254
+ items.push(renderItem(t('quota'), q.usedPercent, q.resetAt ? React.createElement(ResetCountdown, { key: 'reset', resetAt: q.resetAt }) : null))
1255
+ } else if (hasUsagePct) {
1256
+ items.push(renderItem(t('quota'), account.usagePercent, null))
1257
+ }
1258
+
1259
+ var at = (q && q.measuredAt) || (account.usageAt || 0)
1260
+ var agoStr = null
1261
+ if (at) {
1262
+ var m = Math.round((Date.now() - at) / 60000)
1263
+ if (m >= 1) agoStr = m + 'm ' + (t('lang') === 'ru' || t('refresh') === 'Обновить' ? 'назад' : 'ago')
1172
1264
  }
1173
- if(!parts.length)return null
1174
- return React.createElement('div',{className:'dsub-block'},parts)
1175
- })(),
1176
1265
 
1177
- account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, t('plan') + ': ' + account.paidTierName) : null,
1178
- account.ref ? React.createElement('span', { className: 'dsub-sub' }, t('storedAs') + ' ' + account.ref) : null,
1266
+ return React.createElement('div', { className: 'dsub-panel-box' },
1267
+ React.createElement('div', { className: 'dsub-panel-title' },
1268
+ React.createElement('span', null, '📊 ' + t('usageLimitsTitle')),
1269
+ agoStr ? React.createElement('span', { className: 'dsub-sub' }, agoStr) : null
1270
+ ),
1271
+ React.createElement('div', { className: 'dsub-usage-grid' }, items)
1272
+ )
1273
+ })(),
1274
+ (account.paidTierName || account.ref ? React.createElement('div', { className: 'dsub-slot-meta-row' },
1275
+ account.paidTierName ? React.createElement('span', { className: 'dsub-sub' },
1276
+ React.createElement('strong', null, t('plan') + ': '),
1277
+ account.paidTierName
1278
+ ) : null,
1279
+ account.ref ? React.createElement('span', { className: 'dsub-ref-tag' },
1280
+ React.createElement('span', null, t('storedAs')),
1281
+ React.createElement('code', null, account.ref)
1282
+ ) : null
1283
+ ) : null),
1179
1284
  )
1180
1285
  }),
1181
1286
  React.createElement('button', {
@@ -128,11 +128,13 @@ export async function discoverProject(fetchImpl, token, metadata, extraHeaders)
128
128
  }
129
129
  const projectId = projectFrom(op) || account.projectId
130
130
  const after = accountFromLoad(op && op.response ? op.response : load)
131
+ const notice = noticeFromLoadCodeAssist(op && op.response ? op.response : load)
131
132
  return {
132
133
  projectId,
133
134
  tierId: after.paidTierId || tierId,
134
135
  paidTierId: after.paidTierId,
135
136
  paidTierName: after.paidTierName,
137
+ accountNotice: notice ? notice.message : '',
136
138
  }
137
139
  }
138
140
 
@@ -231,6 +233,7 @@ export async function inspectGoogleAccount(fetchImpl, token, { metadata, extraHe
231
233
  return {
232
234
  validation: null,
233
235
  notice,
236
+ accountNotice: notice ? notice.message : '',
234
237
  projectId: resolvedProject,
235
238
  paidTierId: account.paidTierId,
236
239
  paidTierName: account.paidTierName,
@@ -115,3 +115,13 @@ export function googleRateLimitMessage(status, bodyText) {
115
115
  }
116
116
  return ''
117
117
  }
118
+
119
+ export function googleLicenseMessage(status, bodyText) {
120
+ if (status !== 403) return ''
121
+ const api = parseGoogleApiError(bodyText)
122
+ const msg = String(api?.message || bodyText || '')
123
+ if (/do not have a valid license|#3501|PERMISSION_DENIED/i.test(msg)) {
124
+ return 'У аккаунта Google нет активной лицензии Gemini Code Assist / Antigravity (#3501), либо не привязан проект Google Cloud. Проверьте подписку на https://one.google.com или подключите другой Google аккаунт.'
125
+ }
126
+ return ''
127
+ }
package/lib/http.js CHANGED
@@ -42,3 +42,25 @@ export function queryOf(req) {
42
42
  return new URLSearchParams()
43
43
  }
44
44
  }
45
+
46
+ /**
47
+ * Fetch with connect/overall timeout using AbortSignal.any.
48
+ */
49
+ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs = 30000 } = {}) {
50
+ const impl = fetchImpl || fetch
51
+ if (!timeoutMs || timeoutMs <= 0) return impl(url, init)
52
+ const controller = new AbortController()
53
+ const timer = setTimeout(() => {
54
+ controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
55
+ }, timeoutMs)
56
+
57
+ const signal = init.signal
58
+ ? AbortSignal.any([init.signal, controller.signal])
59
+ : controller.signal
60
+
61
+ try {
62
+ return await impl(url, { ...init, signal })
63
+ } finally {
64
+ clearTimeout(timer)
65
+ }
66
+ }
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { createResetCreditService } from './reset-credits.js'
22
22
  import { maskEmail, maskLabel, maskText } from './mask.js'
23
23
  import { proxyFetch, pickFetch } from './proxy.js'
24
24
  import { HistoryStore } from './history.js'
25
+ import { ProactiveTokenRefreshDaemon } from './proactive-refresh.js'
25
26
  import {
26
27
  inspectGoogleAccount,
27
28
  antigravityMetadata,
@@ -183,6 +184,43 @@ export function apply(ctx, config) {
183
184
  const history = new HistoryStore()
184
185
  const recordHistory = (entry) => history.add(entry)
185
186
 
187
+ // Proactive token refresh daemon: runs periodically and refreshes tokens nearing expiration
188
+ const refreshDaemon = new ProactiveTokenRefreshDaemon({
189
+ refreshLeadMs: 15 * 60 * 1000,
190
+ checkIntervalMs: 60 * 1000,
191
+ })
192
+ refreshDaemon.start(
193
+ async () => {
194
+ const slots = normalizeSlots(live().slots)
195
+ const accounts = []
196
+ for (const slot of slots) {
197
+ try {
198
+ const raw = await store.resolveRaw(slot.ref)
199
+ if (!raw) continue
200
+ const blob = parseBlob(raw)
201
+ if (blob && blob.refreshToken && blob.expiresAt) {
202
+ accounts.push({
203
+ ref: slot.ref,
204
+ provider: slot.provider,
205
+ refreshToken: blob.refreshToken,
206
+ expiresAt: blob.expiresAt,
207
+ blob,
208
+ })
209
+ }
210
+ } catch {}
211
+ }
212
+ return accounts
213
+ },
214
+ async (acc) => {
215
+ try {
216
+ await store.ensureFresh(acc.provider, acc.blob, acc.ref)
217
+ } catch (e) {
218
+ try { ctx.log && ctx.log.warn && ctx.log.warn('[dsh-subscriptions] proactive refresh failed for ' + acc.ref + ': ' + String(e && e.message || e)) } catch {}
219
+ }
220
+ }
221
+ )
222
+ ctx.effect(() => () => refreshDaemon.stop(), 'dsh-subscriptions: proactive token refresh daemon')
223
+
186
224
  // #85: host-only reset credit service for Codex accounts.
187
225
  const resetCredits = createResetCreditService({ loadBlob: (ref) => store.loadBlob(ref) })
188
226
  function refForSlot(provider, index) {
@@ -236,6 +274,7 @@ export function apply(ctx, config) {
236
274
  cooldownMs: () => live().cooldownMs,
237
275
  switchAtRemaining: () => live().switchAtRemaining,
238
276
  rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
277
+ rememberQuarantine: (ref, reason, until) => store.rememberQuarantine(ref, reason, until),
239
278
  recordSuccess: (ref) => store.recordSuccess(ref),
240
279
  getHealth: (ref) => store.getHealth(ref),
241
280
  recordSwitch: (ref) => store.recordSwitch(ref),
@@ -301,6 +340,7 @@ export function apply(ctx, config) {
301
340
  cooldownMs: () => live().cooldownMs,
302
341
  switchAtRemaining: () => live().switchAtRemaining,
303
342
  rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
343
+ rememberQuarantine: (ref, reason, until) => store.rememberQuarantine(ref, reason, until),
304
344
  rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
305
345
  getQuota: (ref) => store.getQuota(ref),
306
346
  refreshUsage: (provider) => store.refreshUsage(provider),
package/lib/rotate.js CHANGED
@@ -5,6 +5,7 @@ const SWITCH_CODES = new Set([
5
5
  'RATE_LIMIT',
6
6
  'QUOTA',
7
7
  'QUOTA_EXCEEDED',
8
+ 'LICENSE_REQUIRED',
8
9
  ])
9
10
 
10
11
  export function isSwitchableError(err) {
@@ -12,7 +13,7 @@ export function isSwitchableError(err) {
12
13
  const code = err.code || (err.failure && err.failure.code)
13
14
  if (SWITCH_CODES.has(code)) return true
14
15
  const status = err.status || err.statusCode
15
- return status === 429 || status === 500 || status === 502 || status === 503
16
+ return status === 401 || status === 403 || status === 429 || status === 500 || status === 502 || status === 503
16
17
  }
17
18
 
18
19
  export function modelFamily(provider, model) {
package/lib/sse.js CHANGED
@@ -1,4 +1,4 @@
1
- export async function* iterateSse(body) {
1
+ export async function* iterateSse(body, { idleTimeoutMs = 60000 } = {}) {
2
2
  const reader = body && typeof body.getReader === 'function' ? body.getReader() : null
3
3
  const decoder = new TextDecoder()
4
4
  let buffer = ''
@@ -18,9 +18,27 @@ export async function* iterateSse(body) {
18
18
  yield data
19
19
  }
20
20
  }
21
+
22
+ async function readWithTimeout(r) {
23
+ if (!idleTimeoutMs || idleTimeoutMs <= 0) return r.read()
24
+ let timer
25
+ const timeoutPromise = new Promise((_, reject) => {
26
+ timer = setTimeout(() => {
27
+ const err = new Error(`stream idle timeout: no data received for ${Math.round(idleTimeoutMs / 1000)}s`)
28
+ err.code = 'TIMEOUT'
29
+ reject(err)
30
+ }, idleTimeoutMs)
31
+ })
32
+ try {
33
+ return await Promise.race([r.read(), timeoutPromise])
34
+ } finally {
35
+ clearTimeout(timer)
36
+ }
37
+ }
38
+
21
39
  if (reader) {
22
40
  while (true) {
23
- const { done, value } = await reader.read()
41
+ const { done, value } = await readWithTimeout(reader)
24
42
  if (done) break
25
43
  yield* fromText(decoder.decode(value, { stream: true }))
26
44
  }
@@ -1,5 +1,5 @@
1
1
  import { pickAccount, markCooldown, isSwitchableError, modelFamily } from './rotate.js'
2
- import { putInQuarantine, REASON_RATE_LIMIT } from './quarantine.js'
2
+ import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
3
3
 
4
4
  export async function* streamWithRotation({
5
5
  accounts,
@@ -38,9 +38,8 @@ export async function* streamWithRotation({
38
38
 
39
39
  tried.add(account.ref || account.id)
40
40
 
41
+ let firstChunkDelivered = false
41
42
  try {
42
- // In-flight seamless failover (#166): stream generator execution
43
- let firstChunkDelivered = false
44
43
  for await (const chunk of streamOnce(account, options)) {
45
44
  firstChunkDelivered = true
46
45
  yield chunk
@@ -48,6 +47,9 @@ export async function* streamWithRotation({
48
47
  return
49
48
  } catch (err) {
50
49
  lastError = err
50
+ // If chunks were already yielded to the caller, never rotate mid-stream
51
+ // as that would repeat or scramble generated tokens.
52
+ if (firstChunkDelivered) throw err
51
53
  if (!isSwitchableError(err)) throw err
52
54
 
53
55
  // Move slot to cooldown and quarantine (#172)
@@ -55,7 +57,15 @@ export async function* streamWithRotation({
55
57
  account.cooldownUntil = cooled.cooldownUntil
56
58
  if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
57
59
 
58
- const quarantined = putInQuarantine(account, REASON_RATE_LIMIT, nowMs())
60
+ const status = Number(err && (err.status || err.statusCode) || 0)
61
+ const code = String(err && err.code || '')
62
+ const reason = (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
63
+ ? REASON_REVOKED
64
+ : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
65
+ ? REASON_HARD_LIMIT
66
+ : REASON_RATE_LIMIT
67
+
68
+ const quarantined = putInQuarantine(account, reason, nowMs())
59
69
  account.quarantineUntil = quarantined.quarantineUntil
60
70
  account.quarantineReason = quarantined.quarantineReason
61
71
 
@@ -32,8 +32,8 @@ export function providerInfo() {
32
32
 
33
33
  export function defaults() {
34
34
  return {
35
- clientId: '884354919052-36trc1jjb3tguiac32ov6cod268c5blh.apps.googleusercontent.com',
36
- clientSecret: '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',
35
+ clientId: '',
36
+ clientSecret: '',
37
37
  redirectUri: 'http://localhost:8085/oauth/callback',
38
38
  models: [
39
39
  'gemini-3.8-flash-medium',
@@ -84,6 +84,7 @@ async function withProject(blob, fetchImpl, saveBlob) {
84
84
  projectId: found.projectId || blob.projectId || '',
85
85
  paidTierId: found.paidTierId || blob.paidTierId || '',
86
86
  paidTierName: found.paidTierName || blob.paidTierName || '',
87
+ accountNotice: found.accountNotice || blob.accountNotice || '',
87
88
  sessionId: blob.sessionId || randomUUID(),
88
89
  }
89
90
  if (saveBlob) await saveBlob(next)
@@ -1,5 +1,5 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
- import { openaiMessages, openaiTools } from '../messages.js'
2
+ import { openaiMessages, openaiTools, modelCatalog } from '../messages.js'
3
3
  import { openaiChatStream, readJson, httpError } from '../wire.js'
4
4
  import { asUsageSnapshot } from '../usage.js'
5
5
 
@@ -11,6 +11,13 @@ export const CURSOR_AGENT_URL = 'https://agentn.us.api5.cursor.sh/agent.v1.Agent
11
11
  export const CURSOR_CLIENT_VERSION = 'cli-2026.05.01-eea359f'
12
12
 
13
13
  export const CURSOR_MODELS = [
14
+ {
15
+ id: 'composer-2.5',
16
+ name: 'Composer 2.5 Fast',
17
+ contextWindow: 200000,
18
+ maxTokens: 64000,
19
+ inputModalities: ['text', 'image'],
20
+ },
14
21
  {
15
22
  id: 'composer-2',
16
23
  name: 'Composer 2',
@@ -27,24 +34,89 @@ export const CURSOR_MODELS = [
27
34
  inputModalities: ['text', 'image'],
28
35
  },
29
36
  {
30
- id: 'claude-sonnet-5',
31
- name: 'Claude Sonnet 5 (Cursor)',
37
+ id: 'claude-3.7-sonnet',
38
+ name: 'Claude 3.7 Sonnet (Cursor)',
32
39
  contextWindow: 200000,
33
40
  maxTokens: 64000,
34
41
  inputModalities: ['text', 'image'],
35
42
  },
36
43
  {
37
- id: 'gpt-5.5',
38
- name: 'GPT-5.5 (Cursor)',
44
+ id: 'claude-3.7-sonnet-thinking',
45
+ name: 'Claude 3.7 Sonnet Thinking (Cursor)',
39
46
  contextWindow: 200000,
40
- maxTokens: 128000,
41
- inputModalities: ['text'],
47
+ maxTokens: 64000,
48
+ inputModalities: ['text', 'image'],
49
+ reasoning: { efforts: [{ id: 'low', name: 'Low' }, { id: 'medium', name: 'Medium' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }] },
42
50
  },
43
51
  {
44
- id: 'grok-4.5',
45
- name: 'Grok 4.5 (Cursor)',
52
+ id: 'claude-3.5-sonnet',
53
+ name: 'Claude 3.5 Sonnet (Cursor)',
46
54
  contextWindow: 200000,
47
55
  maxTokens: 64000,
56
+ inputModalities: ['text', 'image'],
57
+ },
58
+ {
59
+ id: 'claude-3.5-haiku',
60
+ name: 'Claude 3.5 Haiku (Cursor)',
61
+ contextWindow: 200000,
62
+ maxTokens: 8192,
63
+ inputModalities: ['text'],
64
+ },
65
+ {
66
+ id: 'gpt-4o',
67
+ name: 'GPT-4o (Cursor)',
68
+ contextWindow: 128000,
69
+ maxTokens: 16384,
70
+ inputModalities: ['text', 'image'],
71
+ },
72
+ {
73
+ id: 'gpt-4o-mini',
74
+ name: 'GPT-4o Mini (Cursor)',
75
+ contextWindow: 128000,
76
+ maxTokens: 16384,
77
+ inputModalities: ['text'],
78
+ },
79
+ {
80
+ id: 'o3-mini',
81
+ name: 'o3-mini (Cursor)',
82
+ contextWindow: 200000,
83
+ maxTokens: 100000,
84
+ inputModalities: ['text'],
85
+ reasoning: { efforts: [{ id: 'low', name: 'Low' }, { id: 'medium', name: 'Medium' }, { id: 'high', name: 'High' }] },
86
+ },
87
+ {
88
+ id: 'o1',
89
+ name: 'o1 (Cursor)',
90
+ contextWindow: 200000,
91
+ maxTokens: 100000,
92
+ inputModalities: ['text', 'image'],
93
+ },
94
+ {
95
+ id: 'deepseek-r1',
96
+ name: 'DeepSeek R1 (Cursor)',
97
+ contextWindow: 64000,
98
+ maxTokens: 8192,
99
+ inputModalities: ['text'],
100
+ },
101
+ {
102
+ id: 'deepseek-v3',
103
+ name: 'DeepSeek V3 (Cursor)',
104
+ contextWindow: 64000,
105
+ maxTokens: 8192,
106
+ inputModalities: ['text'],
107
+ },
108
+ {
109
+ id: 'gemini-2.0-flash',
110
+ name: 'Gemini 2.0 Flash (Cursor)',
111
+ contextWindow: 1000000,
112
+ maxTokens: 8192,
113
+ inputModalities: ['text', 'image'],
114
+ },
115
+ {
116
+ id: 'cursor-small',
117
+ name: 'Cursor Small',
118
+ contextWindow: 32000,
119
+ maxTokens: 4096,
48
120
  inputModalities: ['text'],
49
121
  },
50
122
  ]
@@ -64,8 +136,8 @@ export function authorizeUrl() {
64
136
  return 'https://cursor.com/settings'
65
137
  }
66
138
 
67
- export async function listModels() {
68
- return CURSOR_MODELS
139
+ export async function listModels(_blob, cfg) {
140
+ return modelCatalog(id, cfg && cfg.models ? cfg.models.map((m) => typeof m === "string" ? CURSOR_MODELS.find((c) => c.id === m) || m : m) : CURSOR_MODELS)
69
141
  }
70
142
 
71
143
  export async function usage(blob, config, fetchImpl) {
@@ -1,5 +1,5 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
- import { openaiMessages, openaiTools } from '../messages.js'
2
+ import { openaiMessages, openaiTools, modelCatalog } from '../messages.js'
3
3
  import { openaiChatStream, readJson, httpError } from '../wire.js'
4
4
  import { asUsageSnapshot } from '../usage.js'
5
5
 
@@ -50,8 +50,8 @@ export function authorizeUrl() {
50
50
  return 'https://open.bigmodel.cn/usercenter/apikeys'
51
51
  }
52
52
 
53
- export async function listModels() {
54
- return GLM_MODELS
53
+ export async function listModels(_blob, cfg) {
54
+ return modelCatalog(id, cfg && cfg.models ? cfg.models : GLM_MODELS)
55
55
  }
56
56
 
57
57
  export async function usage(blob, config, fetchImpl) {
@@ -1,5 +1,5 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
- import { openaiMessages, openaiTools } from '../messages.js'
2
+ import { openaiMessages, openaiTools, modelCatalog } from '../messages.js'
3
3
  import { openaiChatStream, readJson, httpError } from '../wire.js'
4
4
  import { asUsageSnapshot } from '../usage.js'
5
5
 
@@ -53,8 +53,8 @@ export function authorizeUrl() {
53
53
  return 'https://platform.moonshot.cn/console/api-keys'
54
54
  }
55
55
 
56
- export async function listModels() {
57
- return KIMI_MODELS
56
+ export async function listModels(_blob, cfg) {
57
+ return modelCatalog(id, cfg && cfg.models ? cfg.models : KIMI_MODELS)
58
58
  }
59
59
 
60
60
  export async function usage(blob, config, fetchImpl) {
@@ -1,5 +1,5 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
- import { openaiMessages, openaiTools } from '../messages.js'
2
+ import { openaiMessages, openaiTools, modelCatalog } from '../messages.js'
3
3
  import { openaiChatStream, readJson, httpError } from '../wire.js'
4
4
  import { asUsageSnapshot } from '../usage.js'
5
5
 
@@ -54,8 +54,8 @@ export function authorizeUrl() {
54
54
  return `${KIRO_PORTAL_URL}/oauth/authorize`
55
55
  }
56
56
 
57
- export async function listModels() {
58
- return KIRO_MODELS
57
+ export async function listModels(_blob, cfg) {
58
+ return modelCatalog(id, cfg && cfg.models ? cfg.models : KIRO_MODELS)
59
59
  }
60
60
 
61
61
  export async function usage(blob, config, fetchImpl) {
package/lib/wire.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { iterateSse, jsonSse } from './sse.js'
2
- import { validationFromHttpError, validationRequiredError, googleRateLimitMessage } from './google-validation.js'
2
+ import { validationFromHttpError, validationRequiredError, googleRateLimitMessage, googleLicenseMessage } from './google-validation.js'
3
3
 
4
4
  export function httpError(status, bodyText, code) {
5
5
  const snippet = String(bodyText || '').slice(0, 400)
@@ -21,6 +21,12 @@ export function httpError(status, bodyText, code) {
21
21
  export function throwHttpError(status, bodyText) {
22
22
  const validation = validationFromHttpError(status, bodyText)
23
23
  if (validation) throw validationRequiredError(validation)
24
+ const license = googleLicenseMessage(status, bodyText)
25
+ if (license) {
26
+ const err = httpError(status, bodyText, 'LICENSE_REQUIRED')
27
+ err.message = `${license} (${err.message})`
28
+ throw err
29
+ }
24
30
  const hint = googleRateLimitMessage(status, bodyText)
25
31
  if (hint) {
26
32
  const err = httpError(status, bodyText, status === 429 ? 'RATE_LIMIT' : undefined)
@@ -181,12 +187,14 @@ export async function* googleStream(body) {
181
187
  yield { type: 'finish', reason: { kind: 'stop' } }
182
188
  }
183
189
 
184
- export async function formTokenRequest(url, params, fetchImpl, headers) {
190
+ export async function formTokenRequest(url, params, fetchImpl, headers, { timeoutMs = 25000 } = {}) {
185
191
  const impl = fetchImpl || fetch
192
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
186
193
  const res = await impl(url, {
187
194
  method: 'POST',
188
195
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', ...(headers || {}) },
189
196
  body: new URLSearchParams(params),
197
+ signal: timeoutSignal,
190
198
  })
191
199
  return readJson(res)
192
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.5.21",
3
+ "version": "0.5.24",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, and Antigravity subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  "url": "https://github.com/GooDAnDReaDY/dsh-subscriptions/issues"
36
36
  },
37
37
  "scripts": {
38
- "test": "node --test test/*.test.mjs"
38
+ "test": "eslint lib/ && node --test test/*.test.mjs"
39
39
  },
40
40
  "dsh": {
41
41
  "bundle": {