@goodandready/dsh-subscriptions 0.4.16 → 0.4.18

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.
@@ -0,0 +1,71 @@
1
+ // #84: predictive runway forecast from a sliding window of usage samples.
2
+ // Port of the reference approach (WSL043 quota-forecast): track remaining
3
+ // percent samples per window key, estimate pace with a recency-weighted
4
+ // least-squares slope, convert to a runway estimate.
5
+
6
+ const HOUR_MS = 60 * 60 * 1000
7
+ const HISTORY_MS = 24 * HOUR_MS
8
+ const MIN_SPAN_MS = 30 * 60 * 1000
9
+ const MIN_CONSUMED = 1
10
+ const MAX_SAMPLES = 192
11
+
12
+ const finite = (v) => Number.isFinite(Number(v))
13
+
14
+ export function observeForecast(state, key, remainingPercent, resetsAt, now) {
15
+ const windows = Object.assign({}, (state && state.windows) || {})
16
+ const pct = Math.max(0, Math.min(100, Number(remainingPercent)))
17
+ const reset = finite(resetsAt) ? Number(resetsAt) : null
18
+ const prev = windows[key]
19
+ const resetChanged = prev !== undefined && (
20
+ (prev.resetsAt === null) !== (reset === null) ||
21
+ (prev.resetsAt !== null && reset !== null && Math.abs(prev.resetsAt - reset) > 300)
22
+ )
23
+ const last = prev && prev.samples && prev.samples[prev.samples.length - 1]
24
+ const increased = last !== undefined && pct > last.pct + 0.5
25
+ const samples = resetChanged || increased ? [] : ((prev && prev.samples) || []).slice()
26
+ if (!samples.length || now > samples[samples.length - 1].at && (
27
+ Math.abs(pct - samples[samples.length - 1].pct) >= 0.1 || now - samples[samples.length - 1].at >= 15 * 60 * 1000
28
+ )) {
29
+ samples.push({ at: now, pct })
30
+ }
31
+ const kept = samples.filter((s) => s.at >= now - HISTORY_MS).slice(-MAX_SAMPLES)
32
+ windows[key] = { resetsAt: reset, samples: kept }
33
+ return { windows }
34
+ }
35
+
36
+ export function estimateForecast(state, key, remainingPercent, resetsAt, now) {
37
+ if (!finite(remainingPercent)) return { status: 'idle' }
38
+ const rec = state && state.windows && state.windows[key]
39
+ if (!rec) return { status: 'calibrating' }
40
+ const reset = finite(resetsAt) ? Number(resetsAt) : null
41
+ if ((rec.resetsAt === null) !== (reset === null) ||
42
+ (rec.resetsAt !== null && reset !== null && Math.abs(rec.resetsAt - reset) > 300)) return { status: 'calibrating' }
43
+ const samples = rec.samples.filter((s) => s.at >= now - HISTORY_MS)
44
+ if (samples.length < 3) return { status: 'calibrating', sampleCount: samples.length }
45
+ const first = samples[0]
46
+ const last = samples[samples.length - 1]
47
+ const spanMs = last.at - first.at
48
+ const consumed = Math.max(0, first.pct - last.pct)
49
+ if (spanMs < MIN_SPAN_MS || consumed < MIN_CONSUMED) return { status: 'calibrating', sampleCount: samples.length }
50
+ const t0 = first.at
51
+ let sw = 0, sx = 0, sy = 0, sxx = 0, sxy = 0
52
+ for (const s of samples) {
53
+ const x = (s.at - t0) / HOUR_MS
54
+ const y = first.pct - s.pct
55
+ const w = Math.exp((s.at - last.at) / (6 * HOUR_MS))
56
+ sw += w; sx += w * x; sy += w * y; sxx += w * x * x; sxy += w * x * y
57
+ }
58
+ const den = sw * sxx - sx * sx
59
+ const pace = den > 0 ? (sw * sxy - sx * sy) / den : 0
60
+ if (!Number.isFinite(pace) || pace < 0.02) return { status: 'idle', pacePerHour: 0 }
61
+ const runwayHours = Math.max(0, Math.min(100, Number(remainingPercent))) / pace
62
+ const resetSec = reset === null ? null : Math.max(0, Math.round((reset - now) / 1000))
63
+ return {
64
+ status: 'ready',
65
+ pacePerHour: pace,
66
+ runwayHours,
67
+ runwaySeconds: Math.round(runwayHours * 3600),
68
+ survivesReset: resetSec !== null && runwayHours * 3600 >= resetSec,
69
+ sampleCount: samples.length,
70
+ }
71
+ }
package/lib/index.js CHANGED
@@ -15,6 +15,8 @@ import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
15
15
  import { quotaSnapshot } from './ratelimit.js'
16
16
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
17
17
  import { startLoopback } from './loopback.js'
18
+ import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
19
+ import { createResetCreditService } from './reset-credits.js'
18
20
  import { maskEmail, maskLabel, maskText } from './mask.js'
19
21
  import { proxyFetch, pickFetch } from './proxy.js'
20
22
  import { HistoryStore } from './history.js'
@@ -71,7 +73,21 @@ export const Config = z.object({
71
73
  .description('When on, redirect_uri is this Web UI origin + /dsh-subscriptions/oauth/callback. When off, the vendor CLI registered redirect is used and you paste the redirected URL.'),
72
74
  autoLoopback: z.boolean().default(true)
73
75
  .description('#89 When on and the vendor redirect_uri is a loopback address (codex :1455, grok :56121), a temporary local server catches the OAuth callback automatically - no paste needed. Paste fallback stays available.'),
76
+ ollamaBaseUrl: z.string().default('http://127.0.0.1:11434')
77
+ .description('#91 Local Ollama base URL. Served as the ollama provider in the native model picker when reachable.'),
78
+ ollamaFallback: z.boolean().default(true)
79
+ .description('#91 When every account of a provider is exhausted, continue the chat on local Ollama instead of failing.'),
80
+ ollamaFallbackModel: z.string().default('')
81
+ .description('#91 Ollama model used for the fallback (for example qwen2.5-coder). Empty = first model from /api/tags.'),
82
+ hideDeprecatedModels: z.boolean().default(false)
83
+ .description('#94 Hide test/preview/beta/legacy model ids from the native model picker.'),
74
84
  codexClientId: z.string().default(''),
85
+ codexVerbosity: z.string().default('')
86
+ .description('#93 Response verbosity for Codex reasoning models: low, medium or high. Empty = protocol default.'),
87
+ codexFastMode: z.boolean().default(false)
88
+ .description('#92 Fast Mode for Codex: sends service_tier priority (1.5x speed billing tier) with every request.'),
89
+ composerQuota: z.string().default('off')
90
+ .description('#84 Composer quota indicator mode: off, percent, bar or forecast (predictive runway from a sliding window).'),
75
91
  codexRedirectUri: z.string().default(''),
76
92
  codexBaseUrl: z.string().default(''),
77
93
  claudeClientId: z.string().default(''),
@@ -165,6 +181,51 @@ export function apply(ctx, config) {
165
181
  const history = new HistoryStore()
166
182
  const recordHistory = (entry) => history.add(entry)
167
183
 
184
+ // #85: host-only reset credit service for Codex accounts.
185
+ const resetCredits = createResetCreditService({ loadBlob: (ref) => store.loadBlob(ref) })
186
+ function refForSlot(provider, index) {
187
+ const slot = normalizeSlots(live().slots).find((s) => s.provider === provider && s.index === index)
188
+ return slot ? slot.ref : null
189
+ }
190
+
191
+ // #91: local Ollama - native provider + seamless fallback when the whole
192
+ // pool is exhausted and nothing has been streamed yet.
193
+ const ollamaAdapter = new OllamaAdapter({
194
+ baseUrl: () => ollamaBase(live()),
195
+ fallbackModel: () => live().ollamaFallbackModel || '',
196
+ })
197
+ let ollamaHandle
198
+ async function syncOllama() {
199
+ const cfg = live()
200
+ const alive = !!cfg.ollamaFallback && await ollamaAlive(ollamaBase(cfg), fetch)
201
+ if (alive && !ollamaHandle) {
202
+ try { ollamaHandle = ctx.llm.registerAdapter(['ollama'], ollamaAdapter) } catch { /* already registered elsewhere */ }
203
+ } else if (!alive && ollamaHandle) {
204
+ try { ollamaHandle() } catch { /* already gone */ }
205
+ ollamaHandle = undefined
206
+ }
207
+ }
208
+ async function* ollamaFallbackStream({ options, provider, err }) {
209
+ const cfg = live()
210
+ const models = await ollamaModels(ollamaBase(cfg), fetch).catch(() => [])
211
+ if (!cfg.ollamaFallback || !models.length) throw err
212
+ const model = cfg.ollamaFallbackModel || models[0].id
213
+ try { ctx.emit && ctx.emit('subscriptions.ollama-fallback', { provider, model, reason: err && err.code || 'EXHAUSTED' }) } catch {}
214
+ try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: все аккаунты исчерпаны (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
215
+ try {
216
+ recordHistory({
217
+ provider: 'ollama',
218
+ ref: 'OLLAMA_FALLBACK',
219
+ model,
220
+ path: '/v1/chat/completions',
221
+ method: 'POST',
222
+ status: 200,
223
+ kind: 'fallback',
224
+ })
225
+ } catch {}
226
+ yield* ollamaAdapter.stream({ ...options, provider: 'ollama', model })
227
+ }
228
+
168
229
  const subscriptions = createSubscriptionsService({
169
230
  listAccounts: (provider) => store.listAccounts(provider),
170
231
  loadBlob: (ref) => store.loadBlob(ref),
@@ -172,7 +233,7 @@ export function apply(ctx, config) {
172
233
  vendorConfig: (provider) => vendorConfig(provider, live()),
173
234
  cooldownMs: () => live().cooldownMs,
174
235
  switchAtRemaining: () => live().switchAtRemaining,
175
- rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
236
+ rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
176
237
  recordSuccess: (ref) => store.recordSuccess(ref),
177
238
  getHealth: (ref) => store.getHealth(ref),
178
239
  recordSwitch: (ref) => store.recordSwitch(ref),
@@ -185,6 +246,8 @@ export function apply(ctx, config) {
185
246
  recordHistory,
186
247
  fetchImpl: fetch,
187
248
  fetchForRef,
249
+ ollamaFallback: ollamaFallbackStream,
250
+ hideDeprecatedModels: () => !!live().hideDeprecatedModels,
188
251
  })
189
252
 
190
253
  // Служба генерации картинок на подписке.
@@ -235,7 +298,7 @@ export function apply(ctx, config) {
235
298
  vendorConfig: (provider) => vendorConfig(provider, live()),
236
299
  cooldownMs: () => live().cooldownMs,
237
300
  switchAtRemaining: () => live().switchAtRemaining,
238
- rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
301
+ rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
239
302
  rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
240
303
  getQuota: (ref) => store.getQuota(ref),
241
304
  refreshUsage: (provider) => store.refreshUsage(provider),
@@ -470,6 +533,7 @@ export function apply(ctx, config) {
470
533
 
471
534
  ctx.effect(() => {
472
535
  syncAdapter().catch(() => { /* first paint */ })
536
+ syncOllama().catch(() => { /* first paint */ })
473
537
  // #75: eager refresh usage на старте, чтобы windows (5h/7d) появились в blob сразу
474
538
  // и активная подписка в чипе сразу показывала 5h/7d/..., а не ждала probeInterval.
475
539
  const eager = async () => {
@@ -560,7 +624,7 @@ export function apply(ctx, config) {
560
624
  await tick()
561
625
  }
562
626
  wrapped().catch(() => {})
563
- const timer = setInterval(() => { wrapped().catch(() => {}) }, 60 * 1000)
627
+ const timer = setInterval(() => { wrapped().catch(() => {}); syncOllama().catch(() => {}) }, 60 * 1000)
564
628
  return () => clearInterval(timer)
565
629
  }, 'dsh-subscriptions: probe loop')
566
630
 
@@ -676,11 +740,86 @@ export function apply(ctx, config) {
676
740
  expiresAt: expires,
677
741
  labels,
678
742
  expiryNotifyDays: live().expiryNotifyDays,
743
+ fastMode: !!live().codexFastMode,
744
+ composerQuota: String(live().composerQuota || 'off'),
679
745
  active,
680
746
  })
681
747
  },
682
748
  }), 'dsh-subscriptions: /status')
683
749
 
750
+ ctx.effect(() => ctx.webServer.register({
751
+ kind: 'exact',
752
+ path: '/dsh-subscriptions/reset-credits',
753
+ handler: async (req, res) => {
754
+ if (req.method !== 'GET') {
755
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
756
+ return
757
+ }
758
+ const q = queryOf(req)
759
+ const ref = refForSlot(String(q.provider || ''), Number(q.index) || 1)
760
+ if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
761
+ try {
762
+ writeJson(res, 200, { ok: true, ...(await resetCredits.inspect(ref)) })
763
+ } catch (e) {
764
+ writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
765
+ }
766
+ },
767
+ }), 'dsh-subscriptions: /reset-credits')
768
+
769
+ ctx.effect(() => ctx.webServer.register({
770
+ kind: 'exact',
771
+ path: '/dsh-subscriptions/reset-credits/prepare',
772
+ handler: async (req, res) => {
773
+ if (req.method !== 'POST') {
774
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
775
+ return
776
+ }
777
+ if (!isTrustedSettingsRequest(req)) {
778
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
779
+ return
780
+ }
781
+ let payload
782
+ try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
783
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
784
+ return
785
+ }
786
+ const ref = refForSlot(String(payload.provider || ''), Number(payload.index) || 1)
787
+ if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
788
+ try {
789
+ writeJson(res, 200, { ok: true, ...(await resetCredits.prepare(ref)) })
790
+ } catch (e) {
791
+ writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
792
+ }
793
+ },
794
+ }), 'dsh-subscriptions: /reset-credits/prepare')
795
+
796
+ ctx.effect(() => ctx.webServer.register({
797
+ kind: 'exact',
798
+ path: '/dsh-subscriptions/reset-credits/consume',
799
+ handler: async (req, res) => {
800
+ if (req.method !== 'POST') {
801
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
802
+ return
803
+ }
804
+ if (!isTrustedSettingsRequest(req)) {
805
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
806
+ return
807
+ }
808
+ let payload
809
+ try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
810
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
811
+ return
812
+ }
813
+ try {
814
+ const result = await resetCredits.consume({ challengeId: payload.challengeId, acknowledged: payload.acknowledged })
815
+ writeJson(res, 200, { ok: true, result })
816
+ refreshModels().catch(() => {})
817
+ } catch (e) {
818
+ writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
819
+ }
820
+ },
821
+ }), 'dsh-subscriptions: /reset-credits/consume')
822
+
684
823
  ctx.effect(() => ctx.webServer.register({
685
824
  kind: 'exact',
686
825
  path: '/dsh-subscriptions/diagnostics',
package/lib/messages.js CHANGED
@@ -65,7 +65,7 @@ export function openaiTools(options) {
65
65
  }))
66
66
  }
67
67
 
68
- export function codexResponsesBody(options, fallbackInstructions) {
68
+ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
69
69
  const systemParts = []
70
70
  if (options.system) systemParts.push(options.system)
71
71
  const input = []
@@ -134,6 +134,13 @@ export function codexResponsesBody(options, fallbackInstructions) {
134
134
  instructions,
135
135
  input,
136
136
  ...(responsesTools && responsesTools.length ? { tools: responsesTools } : {}),
137
+ // #93: reasoning effort chosen in the native picker flows to the protocol.
138
+ ...(options.reasoningEffort ? { reasoning: { effort: String(options.reasoningEffort) } } : {}),
139
+ // #93: verbosity comes from the codexVerbosity setting (low/medium/high).
140
+ ...(vendorCfg && /^(low|medium|high)$/.test(String(vendorCfg.verbosity || ''))
141
+ ? { text: { verbosity: String(vendorCfg.verbosity) } } : {}),
142
+ // #92: Fast Mode = 1.5x speed billing tier on the Codex backend.
143
+ ...(vendorCfg && vendorCfg.fastMode ? { service_tier: 'priority' } : {}),
137
144
  ...(options.maxTokens != null ? { max_output_tokens: options.maxTokens } : {}),
138
145
  ...(options.temperature != null ? { temperature: options.temperature } : {}),
139
146
  }
package/lib/ollama.js ADDED
@@ -0,0 +1,70 @@
1
+ import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
2
+ import { openaiMessages, openaiTools } from './messages.js'
3
+ import { openaiChatStream } from './wire.js'
4
+
5
+ // #91: local Ollama gateway - a $0 emergency provider and seamless quota
6
+ // fallback. Speaks the OpenAI-compatible subset Ollama serves at /v1.
7
+
8
+ export function ollamaBase(cfg) {
9
+ return String(cfg.ollamaBaseUrl || 'http://127.0.0.1:11434').replace(/\/$/, '')
10
+ }
11
+
12
+ export async function ollamaAlive(baseUrl, fetchImpl, timeoutMs = 2000) {
13
+ try {
14
+ const res = await fetchImpl(baseUrl + '/api/tags', { signal: AbortSignal.timeout(timeoutMs) })
15
+ return !!res && res.ok
16
+ } catch { return false }
17
+ }
18
+
19
+ export async function ollamaModels(baseUrl, fetchImpl) {
20
+ const res = await fetchImpl(baseUrl + '/api/tags', { signal: AbortSignal.timeout(3000) })
21
+ if (!res || !res.ok) throw new LlmError('ollama /api/tags http ' + (res && res.status), 'VENDOR', { status: res && res.status })
22
+ const j = await res.json()
23
+ return (Array.isArray(j.models) ? j.models : []).map((m) => ({
24
+ id: m.name,
25
+ name: m.name,
26
+ ...(m.details && m.details.parameter_size ? { description: m.details.parameter_size } : {}),
27
+ }))
28
+ }
29
+
30
+ export class OllamaAdapter extends LlmAdapter {
31
+ constructor(deps) {
32
+ super()
33
+ this.deps = deps
34
+ }
35
+
36
+ providerInfo(provider) {
37
+ return { id: provider, name: 'Ollama (local)' }
38
+ }
39
+
40
+ providerRetryPolicy(_provider) {
41
+ return undefined
42
+ }
43
+
44
+ async listModels(provider) {
45
+ return ollamaModels(this.deps.baseUrl(), this.deps.fetchImpl || fetch)
46
+ }
47
+
48
+ async resolveModel(provider, model, _signal) {
49
+ return { provider, id: model, name: model }
50
+ }
51
+
52
+ async *stream(options) {
53
+ const base = this.deps.baseUrl()
54
+ const model = options.model || this.deps.fallbackModel() || ''
55
+ const body = { model, messages: openaiMessages(options), stream: true }
56
+ const tools = openaiTools(options)
57
+ if (tools) body.tools = tools
58
+ const res = await (this.deps.fetchImpl || fetch)(base + '/v1/chat/completions', {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json', ...attributionHeaders() },
61
+ body: JSON.stringify(body),
62
+ signal: options.signal,
63
+ })
64
+ if (!res || !res.ok) {
65
+ const txt = res && res.text ? await res.text().catch(() => '') : ''
66
+ throw new LlmError('ollama http ' + (res && res.status) + (txt ? ': ' + txt.slice(0, 200) : ''), 'VENDOR', { status: res && res.status })
67
+ }
68
+ yield* openaiChatStream(res.body)
69
+ }
70
+ }
@@ -0,0 +1,193 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ // #85: safe Codex quota reset credits. Host-only: the browser never sees
4
+ // account ids, bearer tokens, credit ids or idempotency keys. A single-flight
5
+ // challenge carries a 5s cooldown plus an explicit acknowledgement, so a
6
+ // double click or concurrent call can never consume two credits.
7
+
8
+ const RESET_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits'
9
+ const CONSUME_URL = RESET_URL + '/consume'
10
+ const CONFIRM_DELAY_MS = 5000
11
+ const CHALLENGE_TTL_MS = 60000
12
+ const TIMEOUT_MS = 15000
13
+ const UNCERTAIN = 'reset result is uncertain; re-run the confirmation to check the same request'
14
+
15
+ function record(v) {
16
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
17
+ }
18
+
19
+ function expirationOf(v) {
20
+ if (v == null) return undefined
21
+ if (Number.isSafeInteger(v) && v > 0) return v * 1000
22
+ if (typeof v === 'string' && v.length > 0 && v.length <= 64) {
23
+ const p = Date.parse(v)
24
+ if (Number.isFinite(p) && p > 0) return p
25
+ }
26
+ throw new Error('malformed reset expiry')
27
+ }
28
+
29
+ export function parseDetails(raw, now) {
30
+ if (!record(raw) || !Number.isSafeInteger(raw.available_count) || raw.available_count < 0 || !Array.isArray(raw.credits)) {
31
+ throw new Error('malformed reset details')
32
+ }
33
+ if (raw.available_count === 0) throw new Error('no reset available')
34
+ const nowMs = Number(now) || 0
35
+ const available = raw.credits
36
+ .filter((c) => record(c) && typeof c.id === 'string' && c.id.length > 0 && c.id.length <= 256 && String(c.status || '').toLowerCase() === 'available')
37
+ .map((c) => ({ c, expiresAt: expirationOf(c.expires_at) }))
38
+ .filter((row) => row.expiresAt === undefined || row.expiresAt > nowMs)
39
+ .sort((a, b) => (a.expiresAt === undefined ? Number.MAX_SAFE_INTEGER : a.expiresAt) - (b.expiresAt === undefined ? Number.MAX_SAFE_INTEGER : b.expiresAt))
40
+ if (!available.length) throw new Error('no usable reset credit')
41
+ const first = available[0]
42
+ return {
43
+ availableCount: raw.available_count,
44
+ creditId: first.c.id,
45
+ title: typeof first.c.title === 'string' ? first.c.title.slice(0, 240) : undefined,
46
+ description: typeof first.c.description === 'string' ? first.c.description.slice(0, 240) : undefined,
47
+ creditExpiresAt: first.expiresAt,
48
+ }
49
+ }
50
+
51
+ export function parseConsumeResult(raw) {
52
+ if (!record(raw) || !['reset', 'nothing_to_reset', 'no_credit', 'already_redeemed'].includes(raw.code)) {
53
+ throw new Error('unreadable reset response')
54
+ }
55
+ const windowsReset = Array.isArray(raw.windows_reset)
56
+ ? raw.windows_reset.filter((x) => typeof x === 'string').slice(0, 16)
57
+ : []
58
+ const count = Number.isSafeInteger(raw.windows_reset) && raw.windows_reset >= 0 && raw.windows_reset <= 16
59
+ ? raw.windows_reset
60
+ : undefined
61
+ return { code: raw.code, windowsReset, ...(count === undefined ? {} : { windowsResetCount: count }) }
62
+ }
63
+
64
+ export function createResetCreditService({ loadBlob, fetchImpl, now = Date.now, randomId = randomUUID }) {
65
+ const challenges = new Map()
66
+
67
+ async function readDetails(ref) {
68
+ const blob = await loadBlob(ref)
69
+ const access = blob && blob.accessToken
70
+ const accountId = blob && blob.accountId
71
+ if (!access || !accountId) throw new Error('codex account is not signed in')
72
+ const res = await (fetchImpl || fetch)(RESET_URL, {
73
+ method: 'GET',
74
+ headers: {
75
+ Authorization: 'Bearer ' + access,
76
+ 'chatgpt-account-id': accountId,
77
+ Accept: 'application/json',
78
+ 'cache-control': 'no-store',
79
+ },
80
+ signal: AbortSignal.timeout(TIMEOUT_MS),
81
+ })
82
+ if (!res.ok) {
83
+ throw new Error(res.status === 401 || res.status === 403
84
+ ? 'codex sign-in needs renewal'
85
+ : 'reset request failed (HTTP ' + res.status + ')')
86
+ }
87
+ let raw
88
+ try { raw = await res.json() } catch { throw new Error('unreadable reset details') }
89
+ return { accountId, details: parseDetails(raw, now()) }
90
+ }
91
+
92
+ return {
93
+ async inspect(ref) {
94
+ const { details } = await readDetails(ref)
95
+ return {
96
+ availableCount: details.availableCount,
97
+ ...(details.creditExpiresAt === undefined ? {} : { nextExpiresAt: details.creditExpiresAt }),
98
+ }
99
+ },
100
+
101
+ async prepare(ref) {
102
+ const { accountId, details } = await readDetails(ref)
103
+ const preparedAt = now()
104
+ const readyAt = preparedAt + CONFIRM_DELAY_MS
105
+ const expiresAt = Math.min(preparedAt + CHALLENGE_TTL_MS, details.creditExpiresAt === undefined ? Number.MAX_SAFE_INTEGER : details.creditExpiresAt)
106
+ if (expiresAt <= readyAt) throw new Error('the available reset expires too soon')
107
+ const challengeId = randomId()
108
+ challenges.set(challengeId, {
109
+ state: 'prepared',
110
+ ref,
111
+ accountId,
112
+ creditId: details.creditId,
113
+ redeemRequestId: randomId(),
114
+ readyAt,
115
+ expiresAt,
116
+ availableCount: details.availableCount,
117
+ creditExpiresAt: details.creditExpiresAt,
118
+ title: details.title,
119
+ description: details.description,
120
+ uncertain: false,
121
+ })
122
+ return {
123
+ challengeId,
124
+ availableCount: details.availableCount,
125
+ readyAt,
126
+ expiresAt,
127
+ ...(details.creditExpiresAt === undefined ? {} : { creditExpiresAt: details.creditExpiresAt }),
128
+ ...(details.title ? { title: details.title } : {}),
129
+ ...(details.description ? { description: details.description } : {}),
130
+ }
131
+ },
132
+
133
+ async consume({ challengeId, acknowledged } = {}) {
134
+ const challenge = typeof challengeId === 'string' ? challenges.get(challengeId) : undefined
135
+ if (!challenge) throw new Error('this reset confirmation is no longer valid')
136
+ if (challenge.state === 'pending') throw new Error('this reset is already in progress')
137
+ if (now() < challenge.readyAt) throw new Error('wait before confirming this reset')
138
+ if (now() > challenge.expiresAt) {
139
+ challenges.delete(challengeId)
140
+ throw new Error('this reset confirmation is no longer valid')
141
+ }
142
+ if (acknowledged !== true) throw new Error('acknowledge that one reset attempt will be consumed')
143
+ // Synchronous gate before the first await: rapid clicks and concurrent
144
+ // calls can never create more than one provider POST.
145
+ challenge.state = 'pending'
146
+ let retryable = challenge.uncertain === true
147
+ try {
148
+ const blob = await loadBlob(challenge.ref)
149
+ const access = blob && blob.accessToken
150
+ const accountId = blob && blob.accountId
151
+ if (!access || !accountId) { retryable = false; throw new Error('codex account is not signed in') }
152
+ if (accountId !== challenge.accountId) { retryable = false; throw new Error('the signed-in account changed') }
153
+ let res
154
+ try {
155
+ res = await (fetchImpl || fetch)(CONSUME_URL, {
156
+ method: 'POST',
157
+ headers: {
158
+ Authorization: 'Bearer ' + access,
159
+ 'chatgpt-account-id': accountId,
160
+ Accept: 'application/json',
161
+ 'content-type': 'application/json',
162
+ 'cache-control': 'no-store',
163
+ },
164
+ body: JSON.stringify({
165
+ redeem_request_id: challenge.redeemRequestId,
166
+ credit_id: challenge.creditId,
167
+ }),
168
+ signal: AbortSignal.timeout(TIMEOUT_MS),
169
+ })
170
+ } catch { retryable = true; throw new Error(UNCERTAIN) }
171
+ if (!res.ok) {
172
+ if (res.status >= 500) { retryable = true; throw new Error(UNCERTAIN) }
173
+ throw new Error(res.status === 401 || res.status === 403
174
+ ? 'codex sign-in needs renewal'
175
+ : 'reset request failed (HTTP ' + res.status + ')')
176
+ }
177
+ let raw
178
+ try { raw = await res.json() } catch { retryable = true; throw new Error(UNCERTAIN) }
179
+ let result
180
+ try { result = parseConsumeResult(raw) } catch { retryable = true; throw new Error(UNCERTAIN) }
181
+ retryable = false
182
+ return result
183
+ } finally {
184
+ if (retryable && now() <= challenge.expiresAt) {
185
+ challenge.state = 'prepared'
186
+ challenge.uncertain = true
187
+ } else {
188
+ challenges.delete(challengeId)
189
+ }
190
+ }
191
+ },
192
+ }
193
+ }
package/lib/rotate.js CHANGED
@@ -12,6 +12,25 @@ export function isSwitchableError(err) {
12
12
  return status === 429
13
13
  }
14
14
 
15
+ // #87: family classification. Cooldowns are scoped to the model family that
16
+ // hit the limit, so a reasoning 429 does not block standard models.
17
+ export function modelFamily(provider, model) {
18
+ const id = String(model || '')
19
+ if (provider === 'claude') return /thinking/i.test(id) ? 'reasoning' : 'standard'
20
+ if (provider === 'grok') return /reasoning/i.test(id) ? 'reasoning' : 'standard'
21
+ if (provider === 'codex') return 'reasoning'
22
+ return 'standard'
23
+ }
24
+
25
+ // Legacy cooldowns (no family list) block everything; scoped ones block only
26
+ // their own family. An unknown family never blocks a differently-scoped cooldown.
27
+ export function cooldownBlocks(acc, family) {
28
+ const fams = acc && acc.cooldownFamilies
29
+ if (!Array.isArray(fams) || !fams.length) return true
30
+ if (!family) return true
31
+ return fams.includes(family)
32
+ }
33
+
15
34
  export function pickAccount(accounts, nowMs, opts) {
16
35
  const list = Array.isArray(accounts) ? accounts : []
17
36
  const now = Number(nowMs) || 0
@@ -47,7 +66,7 @@ export function pickAccount(accounts, nowMs, opts) {
47
66
  const fallback = []
48
67
  for (const acc of list) {
49
68
  if (!acc || !acc.hasToken) continue
50
- const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now
69
+ const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
51
70
  const exhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
52
71
  if (isCooldown || exhausted) {
53
72
  tiers[2].push(acc)
@@ -68,8 +87,14 @@ export function pickAccount(accounts, nowMs, opts) {
68
87
  return null
69
88
  }
70
89
 
71
- export function markCooldown(account, nowMs, cooldownMs) {
90
+ export function markCooldown(account, nowMs, cooldownMs, family) {
72
91
  const wait = Number(cooldownMs)
73
92
  const ms = Number.isFinite(wait) && wait > 0 ? wait : 30 * 60 * 1000
74
- return { ...account, cooldownUntil: (Number(nowMs) || 0) + ms }
93
+ const prev = Array.isArray(account.cooldownFamilies) ? account.cooldownFamilies.slice() : []
94
+ const fams = family ? (prev.includes(family) ? prev : prev.concat(family)) : prev
95
+ return {
96
+ ...account,
97
+ cooldownUntil: (Number(nowMs) || 0) + ms,
98
+ ...(fams.length ? { cooldownFamilies: fams } : {}),
99
+ }
75
100
  }
@@ -1,4 +1,4 @@
1
- import { pickAccount, markCooldown, isSwitchableError } from './rotate.js'
1
+ import { pickAccount, markCooldown, isSwitchableError, modelFamily } from './rotate.js'
2
2
 
3
3
  export async function* streamWithRotation({
4
4
  accounts,
@@ -13,7 +13,7 @@ export async function* streamWithRotation({
13
13
  let lastError = null
14
14
  const tried = new Set()
15
15
  while (true) {
16
- const account = pickAccount(pool, nowMs(), { switchAtRemaining })
16
+ const account = pickAccount(pool, nowMs(), { switchAtRemaining, family: modelFamily(options && options.provider, options && options.model) })
17
17
  if (!account) {
18
18
  if (lastError) throw lastError
19
19
  const err = new Error('no usable subscription account for this provider')
@@ -33,8 +33,9 @@ export async function* streamWithRotation({
33
33
  } catch (err) {
34
34
  lastError = err
35
35
  if (!isSwitchableError(err)) throw err
36
- const cooled = markCooldown(account, nowMs(), cooldownMs)
36
+ const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
37
37
  account.cooldownUntil = cooled.cooldownUntil
38
+ if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
38
39
  if (onCooldown) onCooldown(account)
39
40
  }
40
41
  }