@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.
- package/README.md +47 -0
- package/README.ru.md +23 -0
- package/README.zh.md +14 -0
- package/lib/accounts.js +14 -2
- package/lib/adapter.js +25 -6
- package/lib/client.js +616 -13
- package/lib/forecast.js +71 -0
- package/lib/index.js +142 -3
- package/lib/messages.js +8 -1
- package/lib/ollama.js +70 -0
- package/lib/reset-credits.js +193 -0
- package/lib/rotate.js +28 -3
- package/lib/stream-rotate.js +4 -3
- package/lib/subscriptions.js +4 -4
- package/lib/vendors/codex.js +1 -1
- package/lib/vendors/grok.js +2 -0
- package/package.json +4 -1
package/lib/forecast.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// #84: predictive runway forecast from a sliding window of usage samples.
|
|
2
|
+
// Port of the reference approach (WSL043 quota-forecast): track remaining
|
|
3
|
+
// percent samples per window key, estimate pace with a recency-weighted
|
|
4
|
+
// least-squares slope, convert to a runway estimate.
|
|
5
|
+
|
|
6
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
7
|
+
const HISTORY_MS = 24 * HOUR_MS
|
|
8
|
+
const MIN_SPAN_MS = 30 * 60 * 1000
|
|
9
|
+
const MIN_CONSUMED = 1
|
|
10
|
+
const MAX_SAMPLES = 192
|
|
11
|
+
|
|
12
|
+
const finite = (v) => Number.isFinite(Number(v))
|
|
13
|
+
|
|
14
|
+
export function observeForecast(state, key, remainingPercent, resetsAt, now) {
|
|
15
|
+
const windows = Object.assign({}, (state && state.windows) || {})
|
|
16
|
+
const pct = Math.max(0, Math.min(100, Number(remainingPercent)))
|
|
17
|
+
const reset = finite(resetsAt) ? Number(resetsAt) : null
|
|
18
|
+
const prev = windows[key]
|
|
19
|
+
const resetChanged = prev !== undefined && (
|
|
20
|
+
(prev.resetsAt === null) !== (reset === null) ||
|
|
21
|
+
(prev.resetsAt !== null && reset !== null && Math.abs(prev.resetsAt - reset) > 300)
|
|
22
|
+
)
|
|
23
|
+
const last = prev && prev.samples && prev.samples[prev.samples.length - 1]
|
|
24
|
+
const increased = last !== undefined && pct > last.pct + 0.5
|
|
25
|
+
const samples = resetChanged || increased ? [] : ((prev && prev.samples) || []).slice()
|
|
26
|
+
if (!samples.length || now > samples[samples.length - 1].at && (
|
|
27
|
+
Math.abs(pct - samples[samples.length - 1].pct) >= 0.1 || now - samples[samples.length - 1].at >= 15 * 60 * 1000
|
|
28
|
+
)) {
|
|
29
|
+
samples.push({ at: now, pct })
|
|
30
|
+
}
|
|
31
|
+
const kept = samples.filter((s) => s.at >= now - HISTORY_MS).slice(-MAX_SAMPLES)
|
|
32
|
+
windows[key] = { resetsAt: reset, samples: kept }
|
|
33
|
+
return { windows }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function estimateForecast(state, key, remainingPercent, resetsAt, now) {
|
|
37
|
+
if (!finite(remainingPercent)) return { status: 'idle' }
|
|
38
|
+
const rec = state && state.windows && state.windows[key]
|
|
39
|
+
if (!rec) return { status: 'calibrating' }
|
|
40
|
+
const reset = finite(resetsAt) ? Number(resetsAt) : null
|
|
41
|
+
if ((rec.resetsAt === null) !== (reset === null) ||
|
|
42
|
+
(rec.resetsAt !== null && reset !== null && Math.abs(rec.resetsAt - reset) > 300)) return { status: 'calibrating' }
|
|
43
|
+
const samples = rec.samples.filter((s) => s.at >= now - HISTORY_MS)
|
|
44
|
+
if (samples.length < 3) return { status: 'calibrating', sampleCount: samples.length }
|
|
45
|
+
const first = samples[0]
|
|
46
|
+
const last = samples[samples.length - 1]
|
|
47
|
+
const spanMs = last.at - first.at
|
|
48
|
+
const consumed = Math.max(0, first.pct - last.pct)
|
|
49
|
+
if (spanMs < MIN_SPAN_MS || consumed < MIN_CONSUMED) return { status: 'calibrating', sampleCount: samples.length }
|
|
50
|
+
const t0 = first.at
|
|
51
|
+
let sw = 0, sx = 0, sy = 0, sxx = 0, sxy = 0
|
|
52
|
+
for (const s of samples) {
|
|
53
|
+
const x = (s.at - t0) / HOUR_MS
|
|
54
|
+
const y = first.pct - s.pct
|
|
55
|
+
const w = Math.exp((s.at - last.at) / (6 * HOUR_MS))
|
|
56
|
+
sw += w; sx += w * x; sy += w * y; sxx += w * x * x; sxy += w * x * y
|
|
57
|
+
}
|
|
58
|
+
const den = sw * sxx - sx * sx
|
|
59
|
+
const pace = den > 0 ? (sw * sxy - sx * sy) / den : 0
|
|
60
|
+
if (!Number.isFinite(pace) || pace < 0.02) return { status: 'idle', pacePerHour: 0 }
|
|
61
|
+
const runwayHours = Math.max(0, Math.min(100, Number(remainingPercent))) / pace
|
|
62
|
+
const resetSec = reset === null ? null : Math.max(0, Math.round((reset - now) / 1000))
|
|
63
|
+
return {
|
|
64
|
+
status: 'ready',
|
|
65
|
+
pacePerHour: pace,
|
|
66
|
+
runwayHours,
|
|
67
|
+
runwaySeconds: Math.round(runwayHours * 3600),
|
|
68
|
+
survivesReset: resetSec !== null && runwayHours * 3600 >= resetSec,
|
|
69
|
+
sampleCount: samples.length,
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/stream-rotate.js
CHANGED
|
@@ -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
|
}
|