@goodandready/dsh-subscriptions 0.4.15 → 0.4.17
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 +175 -209
- package/README.ru.md +196 -0
- package/README.zh.md +123 -0
- package/lib/accounts.js +19 -5
- package/lib/adapter.js +28 -7
- package/lib/client.js +153 -3
- package/lib/index.js +352 -12
- package/lib/loopback.js +66 -0
- package/lib/mask.js +21 -0
- package/lib/messages.js +8 -1
- package/lib/ollama.js +70 -0
- package/lib/proxy.js +79 -0
- package/lib/rotate.js +28 -3
- package/lib/stream-rotate.js +4 -3
- package/lib/subscriptions.js +8 -5
- package/lib/vendors/codex.js +46 -1
- package/lib/vendors/grok.js +2 -0
- package/package.json +9 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
1
2
|
import z from '@deepseek-ai/schemastery'
|
|
2
|
-
import { PROVIDERS, oauthRef, isProvider, displayName, droppedCredentialRefs } from './refs.js'
|
|
3
|
+
import { PROVIDERS, oauthRef, parseOauthRef, isProvider, displayName, droppedCredentialRefs } from './refs.js'
|
|
3
4
|
import { createPkce } from './pkce.js'
|
|
4
5
|
import { parseCallbackInput, requestOrigin, webCallbackUri } from './oauth.js'
|
|
5
6
|
import { writeJson, writeHtml, readBody, isTrustedSettingsRequest, queryOf } from './http.js'
|
|
@@ -13,6 +14,10 @@ import { createSubscriptionsService } from './subscriptions.js'
|
|
|
13
14
|
import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
|
|
14
15
|
import { quotaSnapshot } from './ratelimit.js'
|
|
15
16
|
import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
|
|
17
|
+
import { startLoopback } from './loopback.js'
|
|
18
|
+
import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
|
|
19
|
+
import { maskEmail, maskLabel, maskText } from './mask.js'
|
|
20
|
+
import { proxyFetch, pickFetch } from './proxy.js'
|
|
16
21
|
import { HistoryStore } from './history.js'
|
|
17
22
|
import {
|
|
18
23
|
inspectGoogleAccount,
|
|
@@ -24,6 +29,9 @@ export const name = 'dsh-subscriptions'
|
|
|
24
29
|
export const inject = ['llm', 'credentials', 'webServer', 'settings']
|
|
25
30
|
|
|
26
31
|
const NS = 'dsh-subscriptions'
|
|
32
|
+
|
|
33
|
+
let pkgVersion = ''
|
|
34
|
+
try { pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '' } catch {}
|
|
27
35
|
const PENDING_TTL_MS = 15 * 60 * 1000
|
|
28
36
|
|
|
29
37
|
const Slot = z.object({
|
|
@@ -35,6 +43,8 @@ const Slot = z.object({
|
|
|
35
43
|
.description('Optional display label. Empty uses the account email after login.'),
|
|
36
44
|
expiresAt: z.number().default(0)
|
|
37
45
|
.description('#67 Optional subscription expiry timestamp (ms). When set and within expiryNotifyDays, the header chip shows the account and expiry date.'),
|
|
46
|
+
proxyUrl: z.string().default('')
|
|
47
|
+
.description('#88 Per-account proxy URL (http://, https://, socks5://). All requests for this account route through it. Empty = direct connection.'),
|
|
38
48
|
})
|
|
39
49
|
|
|
40
50
|
const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
|
|
@@ -54,11 +64,27 @@ export const Config = z.object({
|
|
|
54
64
|
.description('Emit log notices when usage crosses 70/90/100% of a window.'),
|
|
55
65
|
expiryNotifyDays: z.number().default(7)
|
|
56
66
|
.description('#67 Warn in the header chip this many days before a subscription expiry (expiresAt). 0 disables.'),
|
|
67
|
+
privacyMask: z.boolean().default(false)
|
|
68
|
+
.description('#98 Hide personal data in the UI: emails show as j***n@example.com.'),
|
|
57
69
|
slots: z.array(Slot).default(defaultSlots)
|
|
58
70
|
.description('Account slots. Secrets are not stored here; only the credential ref names.'),
|
|
59
71
|
useWebCallback: z.boolean().default(false)
|
|
60
72
|
.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.'),
|
|
73
|
+
autoLoopback: z.boolean().default(true)
|
|
74
|
+
.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.'),
|
|
75
|
+
ollamaBaseUrl: z.string().default('http://127.0.0.1:11434')
|
|
76
|
+
.description('#91 Local Ollama base URL. Served as the ollama provider in the native model picker when reachable.'),
|
|
77
|
+
ollamaFallback: z.boolean().default(true)
|
|
78
|
+
.description('#91 When every account of a provider is exhausted, continue the chat on local Ollama instead of failing.'),
|
|
79
|
+
ollamaFallbackModel: z.string().default('')
|
|
80
|
+
.description('#91 Ollama model used for the fallback (for example qwen2.5-coder). Empty = first model from /api/tags.'),
|
|
81
|
+
hideDeprecatedModels: z.boolean().default(false)
|
|
82
|
+
.description('#94 Hide test/preview/beta/legacy model ids from the native model picker.'),
|
|
61
83
|
codexClientId: z.string().default(''),
|
|
84
|
+
codexVerbosity: z.string().default('')
|
|
85
|
+
.description('#93 Response verbosity for Codex reasoning models: low, medium or high. Empty = protocol default.'),
|
|
86
|
+
codexFastMode: z.boolean().default(false)
|
|
87
|
+
.description('#92 Fast Mode for Codex: sends service_tier priority (1.5x speed billing tier) with every request.'),
|
|
62
88
|
codexRedirectUri: z.string().default(''),
|
|
63
89
|
codexBaseUrl: z.string().default(''),
|
|
64
90
|
claudeClientId: z.string().default(''),
|
|
@@ -87,6 +113,8 @@ function redirectFor(provider, cfg, origin) {
|
|
|
87
113
|
return overlay.redirectUri || webCallbackUri(origin)
|
|
88
114
|
}
|
|
89
115
|
|
|
116
|
+
const OK_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>'
|
|
117
|
+
|
|
90
118
|
export function apply(ctx, config) {
|
|
91
119
|
let getConfig = () => config
|
|
92
120
|
let settingsApi
|
|
@@ -126,10 +154,20 @@ export function apply(ctx, config) {
|
|
|
126
154
|
const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
|
|
127
155
|
syncCustomVendors()
|
|
128
156
|
|
|
157
|
+
// #88: fetch bound to the per-account proxy (slot.proxyUrl), or null for direct.
|
|
158
|
+
const fetchForRef = (ref) => {
|
|
159
|
+
const parsed = parseOauthRef(ref)
|
|
160
|
+
if (!parsed) return null
|
|
161
|
+
const slot = normalizeSlots(live().slots).find((s) => s.provider === parsed.provider && s.index === parsed.index)
|
|
162
|
+
if (!slot || !slot.proxyUrl) return null
|
|
163
|
+
return proxyFetch(slot.proxyUrl)
|
|
164
|
+
}
|
|
165
|
+
|
|
129
166
|
const store = createAccountStore({
|
|
130
167
|
credentials: ctx.credentials,
|
|
131
168
|
getConfig: live,
|
|
132
169
|
fetchImpl: fetch,
|
|
170
|
+
fetchForRef,
|
|
133
171
|
onLimitNotice: (provider, ref, win, threshold) => {
|
|
134
172
|
if (!live().notifyLimits) return
|
|
135
173
|
const label = win.ru || win.en || win.id
|
|
@@ -140,6 +178,44 @@ export function apply(ctx, config) {
|
|
|
140
178
|
const history = new HistoryStore()
|
|
141
179
|
const recordHistory = (entry) => history.add(entry)
|
|
142
180
|
|
|
181
|
+
// #91: local Ollama - native provider + seamless fallback when the whole
|
|
182
|
+
// pool is exhausted and nothing has been streamed yet.
|
|
183
|
+
const ollamaAdapter = new OllamaAdapter({
|
|
184
|
+
baseUrl: () => ollamaBase(live()),
|
|
185
|
+
fallbackModel: () => live().ollamaFallbackModel || '',
|
|
186
|
+
})
|
|
187
|
+
let ollamaHandle
|
|
188
|
+
async function syncOllama() {
|
|
189
|
+
const cfg = live()
|
|
190
|
+
const alive = !!cfg.ollamaFallback && await ollamaAlive(ollamaBase(cfg), fetch)
|
|
191
|
+
if (alive && !ollamaHandle) {
|
|
192
|
+
try { ollamaHandle = ctx.llm.registerAdapter(['ollama'], ollamaAdapter) } catch { /* already registered elsewhere */ }
|
|
193
|
+
} else if (!alive && ollamaHandle) {
|
|
194
|
+
try { ollamaHandle() } catch { /* already gone */ }
|
|
195
|
+
ollamaHandle = undefined
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function* ollamaFallbackStream({ options, provider, err }) {
|
|
199
|
+
const cfg = live()
|
|
200
|
+
const models = await ollamaModels(ollamaBase(cfg), fetch).catch(() => [])
|
|
201
|
+
if (!cfg.ollamaFallback || !models.length) throw err
|
|
202
|
+
const model = cfg.ollamaFallbackModel || models[0].id
|
|
203
|
+
try { ctx.emit && ctx.emit('subscriptions.ollama-fallback', { provider, model, reason: err && err.code || 'EXHAUSTED' }) } catch {}
|
|
204
|
+
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: все аккаунты исчерпаны (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
|
|
205
|
+
try {
|
|
206
|
+
recordHistory({
|
|
207
|
+
provider: 'ollama',
|
|
208
|
+
ref: 'OLLAMA_FALLBACK',
|
|
209
|
+
model,
|
|
210
|
+
path: '/v1/chat/completions',
|
|
211
|
+
method: 'POST',
|
|
212
|
+
status: 200,
|
|
213
|
+
kind: 'fallback',
|
|
214
|
+
})
|
|
215
|
+
} catch {}
|
|
216
|
+
yield* ollamaAdapter.stream({ ...options, provider: 'ollama', model })
|
|
217
|
+
}
|
|
218
|
+
|
|
143
219
|
const subscriptions = createSubscriptionsService({
|
|
144
220
|
listAccounts: (provider) => store.listAccounts(provider),
|
|
145
221
|
loadBlob: (ref) => store.loadBlob(ref),
|
|
@@ -147,7 +223,7 @@ export function apply(ctx, config) {
|
|
|
147
223
|
vendorConfig: (provider) => vendorConfig(provider, live()),
|
|
148
224
|
cooldownMs: () => live().cooldownMs,
|
|
149
225
|
switchAtRemaining: () => live().switchAtRemaining,
|
|
150
|
-
rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
|
|
226
|
+
rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
|
|
151
227
|
recordSuccess: (ref) => store.recordSuccess(ref),
|
|
152
228
|
getHealth: (ref) => store.getHealth(ref),
|
|
153
229
|
recordSwitch: (ref) => store.recordSwitch(ref),
|
|
@@ -159,6 +235,9 @@ export function apply(ctx, config) {
|
|
|
159
235
|
getRequestCount: (ref) => store.getRequestCount(ref),
|
|
160
236
|
recordHistory,
|
|
161
237
|
fetchImpl: fetch,
|
|
238
|
+
fetchForRef,
|
|
239
|
+
ollamaFallback: ollamaFallbackStream,
|
|
240
|
+
hideDeprecatedModels: () => !!live().hideDeprecatedModels,
|
|
162
241
|
})
|
|
163
242
|
|
|
164
243
|
// Служба генерации картинок на подписке.
|
|
@@ -209,13 +288,14 @@ export function apply(ctx, config) {
|
|
|
209
288
|
vendorConfig: (provider) => vendorConfig(provider, live()),
|
|
210
289
|
cooldownMs: () => live().cooldownMs,
|
|
211
290
|
switchAtRemaining: () => live().switchAtRemaining,
|
|
212
|
-
rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
|
|
291
|
+
rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
|
|
213
292
|
rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
|
|
214
293
|
getQuota: (ref) => store.getQuota(ref),
|
|
215
294
|
refreshUsage: (provider) => store.refreshUsage(provider),
|
|
216
295
|
saveBlob: (ref, blob) => store.saveBlob(ref, blob),
|
|
217
296
|
recordHistory,
|
|
218
297
|
fetchImpl: fetch,
|
|
298
|
+
fetchForRef,
|
|
219
299
|
})
|
|
220
300
|
|
|
221
301
|
let handle
|
|
@@ -291,7 +371,7 @@ export function apply(ctx, config) {
|
|
|
291
371
|
pending.delete(row.state)
|
|
292
372
|
await syncAdapter()
|
|
293
373
|
refreshModels().catch(() => {})
|
|
294
|
-
return { ref, label: blob.label || blob.email || displayName(provider) }
|
|
374
|
+
return { ref, label: pmL(blob.label || blob.email) || displayName(provider) }
|
|
295
375
|
}
|
|
296
376
|
|
|
297
377
|
async function enrichAntigravityAccount(slot, info) {
|
|
@@ -343,6 +423,69 @@ export function apply(ctx, config) {
|
|
|
343
423
|
return (slots || []).filter((slot) => isProvider(slot.provider))
|
|
344
424
|
}
|
|
345
425
|
|
|
426
|
+
// #98: privacy masking. pmE for raw emails, pmL for display labels (only email-looking ones masked).
|
|
427
|
+
const privacyOn = () => !!live().privacyMask
|
|
428
|
+
const pmE = (s) => privacyOn() ? maskEmail(s) : String(s || '')
|
|
429
|
+
const pmL = (s) => privacyOn() ? maskLabel(s) : String(s || '')
|
|
430
|
+
|
|
431
|
+
// #99: anonymized diagnostics report. No tokens, emails, refs, proxy URLs.
|
|
432
|
+
function scrubReport(v) {
|
|
433
|
+
if (typeof v === 'string') return maskText(v)
|
|
434
|
+
if (Array.isArray(v)) return v.map(scrubReport)
|
|
435
|
+
if (v && typeof v === 'object') {
|
|
436
|
+
const o = {}
|
|
437
|
+
for (const k of Object.keys(v)) o[k] = scrubReport(v[k])
|
|
438
|
+
return o
|
|
439
|
+
}
|
|
440
|
+
return v
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function diagnosticsReport() {
|
|
444
|
+
const cfg = live()
|
|
445
|
+
const slots = normalizeSlots(cfg.slots)
|
|
446
|
+
const mk = () => ({ loggedIn: false, slots: 0, configured: 0, cooldown: 0, proxy: 0, maxUsagePercent: null })
|
|
447
|
+
const providers = {}
|
|
448
|
+
for (const p of PROVIDERS) providers[p] = mk()
|
|
449
|
+
const logged = await store.loggedInProviders()
|
|
450
|
+
for (const slot of slots) {
|
|
451
|
+
const pv = providers[slot.provider] || (providers[slot.provider] = mk())
|
|
452
|
+
pv.slots++
|
|
453
|
+
if (slot.proxyUrl) pv.proxy++
|
|
454
|
+
try {
|
|
455
|
+
const info = await store.describeRef(slot.ref)
|
|
456
|
+
if (info.configured) pv.configured++
|
|
457
|
+
if (info.cooldownUntil && info.cooldownUntil > Date.now()) pv.cooldown++
|
|
458
|
+
if (info.usagePercent != null) pv.maxUsagePercent = Math.max(pv.maxUsagePercent || 0, Math.round(info.usagePercent))
|
|
459
|
+
} catch {}
|
|
460
|
+
}
|
|
461
|
+
for (const p of Object.keys(providers)) providers[p].loggedIn = logged.includes(p)
|
|
462
|
+
const rows = history.all()
|
|
463
|
+
const byStatus = {}
|
|
464
|
+
for (const r of rows) {
|
|
465
|
+
const k = (r.provider || '?') + ':' + (r.status || '?')
|
|
466
|
+
byStatus[k] = (byStatus[k] || 0) + 1
|
|
467
|
+
}
|
|
468
|
+
const lastErrors = rows.filter((r) => r.status && r.status >= 400).slice(0, 10)
|
|
469
|
+
.map((r) => ({ ts: r.ts, provider: r.provider, status: r.status, kind: r.kind || 'request', ms: r.ms || null }))
|
|
470
|
+
return scrubReport({
|
|
471
|
+
generatedAt: new Date().toISOString(),
|
|
472
|
+
plugin: NS + (pkgVersion ? ' v' + pkgVersion : ''),
|
|
473
|
+
runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
|
|
474
|
+
providers,
|
|
475
|
+
requests: { total: rows.length, byStatus, lastErrors },
|
|
476
|
+
settings: {
|
|
477
|
+
cooldownMs: cfg.cooldownMs,
|
|
478
|
+
switchAtRemaining: cfg.switchAtRemaining,
|
|
479
|
+
probeIntervalMin: cfg.probeIntervalMin,
|
|
480
|
+
useWebCallback: !!cfg.useWebCallback,
|
|
481
|
+
autoLoopback: !!cfg.autoLoopback,
|
|
482
|
+
privacyMask: !!cfg.privacyMask,
|
|
483
|
+
customVendors: Array.isArray(cfg.customVendors) ? cfg.customVendors.length : 0,
|
|
484
|
+
proxySlots: slots.filter((s) => s.proxyUrl).length,
|
|
485
|
+
},
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
346
489
|
async function accountsView() {
|
|
347
490
|
const out = []
|
|
348
491
|
for (const slot of normalizeSlots(live().slots)) {
|
|
@@ -351,7 +494,7 @@ export function apply(ctx, config) {
|
|
|
351
494
|
provider: slot.provider,
|
|
352
495
|
index: slot.index,
|
|
353
496
|
ref: slot.ref,
|
|
354
|
-
label: slot.label || info.label,
|
|
497
|
+
label: pmL(slot.label || info.label),
|
|
355
498
|
configured: info.configured,
|
|
356
499
|
writable: info.writable,
|
|
357
500
|
cooldownUntil: info.cooldownUntil,
|
|
@@ -380,6 +523,7 @@ export function apply(ctx, config) {
|
|
|
380
523
|
|
|
381
524
|
ctx.effect(() => {
|
|
382
525
|
syncAdapter().catch(() => { /* first paint */ })
|
|
526
|
+
syncOllama().catch(() => { /* first paint */ })
|
|
383
527
|
// #75: eager refresh usage на старте, чтобы windows (5h/7d) появились в blob сразу
|
|
384
528
|
// и активная подписка в чипе сразу показывала 5h/7d/..., а не ждала probeInterval.
|
|
385
529
|
const eager = async () => {
|
|
@@ -470,7 +614,7 @@ export function apply(ctx, config) {
|
|
|
470
614
|
await tick()
|
|
471
615
|
}
|
|
472
616
|
wrapped().catch(() => {})
|
|
473
|
-
const timer = setInterval(() => { wrapped().catch(() => {}) }, 60 * 1000)
|
|
617
|
+
const timer = setInterval(() => { wrapped().catch(() => {}); syncOllama().catch(() => {}) }, 60 * 1000)
|
|
474
618
|
return () => clearInterval(timer)
|
|
475
619
|
}, 'dsh-subscriptions: probe loop')
|
|
476
620
|
|
|
@@ -541,7 +685,7 @@ export function apply(ctx, config) {
|
|
|
541
685
|
// #67: дата окончания подписки берётся из слота (вводится в настройках).
|
|
542
686
|
if (slot.expiresAt) {
|
|
543
687
|
expires[slot.provider] = Math.max(expires[slot.provider] || 0, slot.expiresAt)
|
|
544
|
-
labels[slot.provider] = slot.label || info.label || slot.provider
|
|
688
|
+
labels[slot.provider] = pmL(slot.label || info.label || slot.provider)
|
|
545
689
|
}
|
|
546
690
|
} catch {}
|
|
547
691
|
}
|
|
@@ -586,11 +730,24 @@ export function apply(ctx, config) {
|
|
|
586
730
|
expiresAt: expires,
|
|
587
731
|
labels,
|
|
588
732
|
expiryNotifyDays: live().expiryNotifyDays,
|
|
733
|
+
fastMode: !!live().codexFastMode,
|
|
589
734
|
active,
|
|
590
735
|
})
|
|
591
736
|
},
|
|
592
737
|
}), 'dsh-subscriptions: /status')
|
|
593
738
|
|
|
739
|
+
ctx.effect(() => ctx.webServer.register({
|
|
740
|
+
kind: 'exact',
|
|
741
|
+
path: '/dsh-subscriptions/diagnostics',
|
|
742
|
+
handler: async (req, res) => {
|
|
743
|
+
if (req.method !== 'GET') {
|
|
744
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
writeJson(res, 200, { ok: true, report: await diagnosticsReport() })
|
|
748
|
+
},
|
|
749
|
+
}), 'dsh-subscriptions: /diagnostics')
|
|
750
|
+
|
|
594
751
|
ctx.effect(() => ctx.webServer.register({
|
|
595
752
|
kind: 'exact',
|
|
596
753
|
path: '/dsh-subscriptions/oauth/start',
|
|
@@ -620,10 +777,135 @@ export function apply(ctx, config) {
|
|
|
620
777
|
})
|
|
621
778
|
const cfg = { ...vendorConfig(provider, live()), redirectUri }
|
|
622
779
|
const url = getVendor(provider).authorizeUrl(cfg, pkce)
|
|
623
|
-
|
|
780
|
+
// #89: если redirect_uri loopback — поднять временный сервер перехвата.
|
|
781
|
+
let autoCatch = false
|
|
782
|
+
if (live().autoLoopback) {
|
|
783
|
+
try {
|
|
784
|
+
const cb = new URL(redirectUri)
|
|
785
|
+
if (cb.hostname === 'localhost' || cb.hostname === '127.0.0.1') {
|
|
786
|
+
autoCatch = true
|
|
787
|
+
startLoopback({
|
|
788
|
+
redirectUri,
|
|
789
|
+
onCode: async (params) => {
|
|
790
|
+
const code = params.get('code') || ''
|
|
791
|
+
const state = params.get('state') || ''
|
|
792
|
+
if (!code) throw new Error('no code')
|
|
793
|
+
await completeOAuth({ provider, index, code, state })
|
|
794
|
+
return OK_HTML
|
|
795
|
+
},
|
|
796
|
+
}).catch(() => {})
|
|
797
|
+
}
|
|
798
|
+
} catch { autoCatch = false }
|
|
799
|
+
}
|
|
800
|
+
writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch })
|
|
624
801
|
},
|
|
625
802
|
}), 'dsh-subscriptions: /oauth/start')
|
|
626
803
|
|
|
804
|
+
// #90: Device-code login (headless/VPS). Codex-only: auth.openai.com mints an
|
|
805
|
+
// authorization_code + code_verifier server-side; we finish with the normal
|
|
806
|
+
// PKCE exchange using the device redirect URI. Device code stays server-side
|
|
807
|
+
// in the pending map (same lifetime as PKCE pending rows).
|
|
808
|
+
ctx.effect(() => ctx.webServer.register({
|
|
809
|
+
kind: 'exact',
|
|
810
|
+
path: '/dsh-subscriptions/oauth/device/start',
|
|
811
|
+
handler: async (req, res) => {
|
|
812
|
+
if (req.method !== 'POST') {
|
|
813
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
817
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
let body
|
|
821
|
+
try {
|
|
822
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
823
|
+
} catch {
|
|
824
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
const provider = String(body.provider || '')
|
|
828
|
+
const index = Number(body.index || 1)
|
|
829
|
+
if (!isProvider(provider)) {
|
|
830
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
const vendor = getVendor(provider)
|
|
834
|
+
if (typeof vendor.deviceStart !== 'function') {
|
|
835
|
+
writeJson(res, 400, { ok: false, error: { code: 'device', message: 'device login not supported for ' + provider } })
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
const cfg = vendorConfig(provider, live())
|
|
840
|
+
const start = await vendor.deviceStart(cfg, fetch)
|
|
841
|
+
const state = 'dev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
|
|
842
|
+
sweepPending(Date.now())
|
|
843
|
+
pending.set(state, {
|
|
844
|
+
kind: 'device',
|
|
845
|
+
provider,
|
|
846
|
+
index,
|
|
847
|
+
ref: oauthRef(provider, index),
|
|
848
|
+
deviceAuthId: start.deviceAuthId,
|
|
849
|
+
userCode: start.userCode,
|
|
850
|
+
intervalMs: start.intervalMs,
|
|
851
|
+
createdAt: Date.now(),
|
|
852
|
+
})
|
|
853
|
+
writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs })
|
|
854
|
+
} catch (e) {
|
|
855
|
+
writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
|
|
856
|
+
}
|
|
857
|
+
},
|
|
858
|
+
}), 'dsh-subscriptions: /oauth/device/start')
|
|
859
|
+
|
|
860
|
+
ctx.effect(() => ctx.webServer.register({
|
|
861
|
+
kind: 'exact',
|
|
862
|
+
path: '/dsh-subscriptions/oauth/device/poll',
|
|
863
|
+
handler: async (req, res) => {
|
|
864
|
+
if (req.method !== 'POST') {
|
|
865
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
869
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
let body
|
|
873
|
+
try {
|
|
874
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
875
|
+
} catch {
|
|
876
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
const state = String(body.state || '')
|
|
880
|
+
sweepPending(Date.now())
|
|
881
|
+
const row = pending.get(state)
|
|
882
|
+
if (!row || row.kind !== 'device') {
|
|
883
|
+
writeJson(res, 404, { ok: false, error: { code: 'expired', message: 'device login session expired; start again' } })
|
|
884
|
+
return
|
|
885
|
+
}
|
|
886
|
+
const vendor = getVendor(row.provider)
|
|
887
|
+
try {
|
|
888
|
+
const cfg = vendorConfig(row.provider, live())
|
|
889
|
+
const out = await vendor.devicePoll(cfg, { deviceAuthId: row.deviceAuthId, userCode: row.userCode }, fetch)
|
|
890
|
+
if (out.status !== 'authorized') {
|
|
891
|
+
writeJson(res, 200, { ok: true, status: out.status })
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
const slots = normalizeSlots(live().slots)
|
|
895
|
+
const slot = slots.find((s) => s.ref === row.ref)
|
|
896
|
+
const blob = out.blob
|
|
897
|
+
if (slot && slot.label) blob.label = slot.label
|
|
898
|
+
await store.saveBlob(row.ref, blob)
|
|
899
|
+
pending.delete(state)
|
|
900
|
+
await syncAdapter()
|
|
901
|
+
refreshModels().catch(() => {})
|
|
902
|
+
writeJson(res, 200, { ok: true, status: 'authorized', ref: row.ref, label: pmL(blob.label || blob.email) || displayName(row.provider) })
|
|
903
|
+
} catch (e) {
|
|
904
|
+
writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
}), 'dsh-subscriptions: /oauth/device/poll')
|
|
908
|
+
|
|
627
909
|
ctx.effect(() => ctx.webServer.register({
|
|
628
910
|
kind: 'exact',
|
|
629
911
|
path: '/dsh-subscriptions/oauth/callback',
|
|
@@ -721,7 +1003,7 @@ export function apply(ctx, config) {
|
|
|
721
1003
|
try { blob = await store.ensureFresh(provider, blob, ref) } catch (e) {
|
|
722
1004
|
writeJson(res, 200, {
|
|
723
1005
|
ok: false, provider, index: payload.index, ref,
|
|
724
|
-
email: blob.email
|
|
1006
|
+
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
725
1007
|
quota: info.quota || null,
|
|
726
1008
|
error: { code: 'refresh', message: String(e && e.message || e) },
|
|
727
1009
|
})
|
|
@@ -729,7 +1011,7 @@ export function apply(ctx, config) {
|
|
|
729
1011
|
}
|
|
730
1012
|
const vendor = getVendor(provider)
|
|
731
1013
|
if (typeof vendor.check !== 'function') {
|
|
732
|
-
writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: blob.email
|
|
1014
|
+
writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: info.quota || null })
|
|
733
1015
|
return
|
|
734
1016
|
}
|
|
735
1017
|
let capturedQuota = null
|
|
@@ -743,11 +1025,11 @@ export function apply(ctx, config) {
|
|
|
743
1025
|
}
|
|
744
1026
|
try {
|
|
745
1027
|
await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
|
|
746
|
-
writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: blob.email
|
|
1028
|
+
writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
|
|
747
1029
|
} catch (e) {
|
|
748
1030
|
writeJson(res, 200, {
|
|
749
1031
|
ok: false, provider, index: payload.index, ref,
|
|
750
|
-
email: blob.email
|
|
1032
|
+
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
751
1033
|
quota: capturedQuota || info.quota || null,
|
|
752
1034
|
error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
|
|
753
1035
|
})
|
|
@@ -810,6 +1092,64 @@ export function apply(ctx, config) {
|
|
|
810
1092
|
},
|
|
811
1093
|
}), 'dsh-subscriptions: proxy')
|
|
812
1094
|
|
|
1095
|
+
// #88: проверка прокси аккаунта — реальный запрос к эндпоинту провайдера с замером задержки.
|
|
1096
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1097
|
+
kind: 'exact',
|
|
1098
|
+
path: '/dsh-subscriptions/proxy-check',
|
|
1099
|
+
handler: async (req, res) => {
|
|
1100
|
+
if (req.method !== 'POST') {
|
|
1101
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1102
|
+
return
|
|
1103
|
+
}
|
|
1104
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
1105
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1106
|
+
return
|
|
1107
|
+
}
|
|
1108
|
+
let body
|
|
1109
|
+
try {
|
|
1110
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
1111
|
+
} catch {
|
|
1112
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
const provider = String(body.provider || '')
|
|
1116
|
+
const index = Number(body.index || 1)
|
|
1117
|
+
if (!isProvider(provider)) {
|
|
1118
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
const slots = normalizeSlots(live().slots)
|
|
1122
|
+
const slot = slots.find((s) => s.provider === provider && s.index === index)
|
|
1123
|
+
const proxyUrl = (slot && slot.proxyUrl) || ''
|
|
1124
|
+
const DEFAULT_BASE = {
|
|
1125
|
+
codex: 'https://chatgpt.com/backend-api/codex',
|
|
1126
|
+
claude: 'https://api.anthropic.com',
|
|
1127
|
+
grok: 'https://api.x.ai/v1',
|
|
1128
|
+
antigravity: 'https://cloudcode-pa.googleapis.com',
|
|
1129
|
+
}
|
|
1130
|
+
const base = String((vendorConfig(provider, live()) || {}).baseUrl || DEFAULT_BASE[provider] || '').replace(/\/$/, '')
|
|
1131
|
+
const started = Date.now()
|
|
1132
|
+
try {
|
|
1133
|
+
const impl = (proxyUrl && proxyFetch(proxyUrl)) || fetch
|
|
1134
|
+
if (proxyUrl && impl === fetch) throw new Error('invalid proxy URL')
|
|
1135
|
+
const out = await impl(base + '/models', {
|
|
1136
|
+
method: 'GET',
|
|
1137
|
+
headers: { Accept: 'application/json' },
|
|
1138
|
+
signal: AbortSignal.timeout(10000),
|
|
1139
|
+
})
|
|
1140
|
+
// Любой HTTP-ответ (включая 401/403) = прокси и эндпоинт доступны.
|
|
1141
|
+
writeJson(res, 200, { ok: true, status: out.status, latencyMs: Date.now() - started, viaProxy: !!proxyUrl })
|
|
1142
|
+
} catch (e) {
|
|
1143
|
+
writeJson(res, 200, {
|
|
1144
|
+
ok: false,
|
|
1145
|
+
latencyMs: Date.now() - started,
|
|
1146
|
+
viaProxy: !!proxyUrl,
|
|
1147
|
+
error: { code: (e && e.code) || 'NETWORK', message: String((e && e.message) || e).slice(0, 200) },
|
|
1148
|
+
})
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
}), 'dsh-subscriptions: proxy-check')
|
|
1152
|
+
|
|
813
1153
|
// Экспорт зашифрованного бандла токенов. Токены не логгируются.
|
|
814
1154
|
ctx.effect(() => ctx.webServer.register({
|
|
815
1155
|
kind: 'exact',
|
package/lib/loopback.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
|
|
3
|
+
// #89: временный loopback-сервер для перехвата OAuth callback.
|
|
4
|
+
// Слушает на зарегистрированном у провайдера redirect_uri (порт фиксирован
|
|
5
|
+
// вендором: codex localhost:1455, grok 127.0.0.1:56121). Принимает GET с
|
|
6
|
+
// code/state, отдаёт HTML-страницу и завершается после первого запроса.
|
|
7
|
+
|
|
8
|
+
const OK_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>'
|
|
9
|
+
const ERR_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Login failed. Return to Settings and paste the redirected URL.</p>'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Поднять loopback-сервер и дождаться callback.
|
|
13
|
+
* @param {object} opts
|
|
14
|
+
* @param {string} opts.redirectUri зарегистрированный redirect_uri (localhost/127.0.0.1)
|
|
15
|
+
* @param {number} opts.timeoutMs время жизни сервера
|
|
16
|
+
* @param {(query: URLSearchParams) => Promise<string>} opts.onCode
|
|
17
|
+
* вызывается с query callback-запроса; возвращает HTML для браузера.
|
|
18
|
+
* Бросок исключения = ошибка авторизации (отдаётся ERR_HTML).
|
|
19
|
+
* @returns {Promise<{url: string, close: () => void}>}
|
|
20
|
+
*/
|
|
21
|
+
export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode }) {
|
|
22
|
+
const parsed = new URL(redirectUri)
|
|
23
|
+
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
|
24
|
+
throw new Error(`loopback redirect requires localhost, got ${parsed.hostname}`)
|
|
25
|
+
}
|
|
26
|
+
const port = Number(parsed.port) || 80
|
|
27
|
+
const path = parsed.pathname
|
|
28
|
+
|
|
29
|
+
const state = { server: null, timer: null, done: false }
|
|
30
|
+
const promise = new Promise((resolve, reject) => {
|
|
31
|
+
const server = http.createServer((req, res) => {
|
|
32
|
+
const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
|
|
33
|
+
// Провайдер может редиректить на путь с suffix (например /auth/callback/extra)
|
|
34
|
+
if (!url.pathname.startsWith(path)) {
|
|
35
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
36
|
+
res.end(ERR_HTML)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (state.done) return
|
|
40
|
+
state.done = true
|
|
41
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
42
|
+
Promise.resolve(onCode(url.searchParams))
|
|
43
|
+
.then((html) => res.end(html || OK_HTML))
|
|
44
|
+
.catch(() => res.end(ERR_HTML))
|
|
45
|
+
.finally(() => {
|
|
46
|
+
clearTimeout(state.timer)
|
|
47
|
+
server.close()
|
|
48
|
+
resolve({ ok: true })
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
server.on('error', (err) => {
|
|
52
|
+
state.done = true
|
|
53
|
+
clearTimeout(state.timer)
|
|
54
|
+
reject(err)
|
|
55
|
+
})
|
|
56
|
+
state.server = server
|
|
57
|
+
server.listen(port, parsed.hostname, () => {})
|
|
58
|
+
state.timer = setTimeout(() => {
|
|
59
|
+
if (state.done) return
|
|
60
|
+
state.done = true
|
|
61
|
+
server.close()
|
|
62
|
+
reject(new Error('loopback timeout: no callback received'))
|
|
63
|
+
}, timeoutMs)
|
|
64
|
+
})
|
|
65
|
+
return promise
|
|
66
|
+
}
|
package/lib/mask.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// #98: privacy masking. Emails -> j***n@example.com. Non-email labels pass through.
|
|
2
|
+
export function maskEmail(value) {
|
|
3
|
+
const s = String(value || '')
|
|
4
|
+
const at = s.indexOf('@')
|
|
5
|
+
if (at <= 0) return s
|
|
6
|
+
const local = s.slice(0, at)
|
|
7
|
+
const tail = local.length > 2 ? local[local.length - 1] : ''
|
|
8
|
+
return local[0] + '***' + tail + s.slice(at)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function maskLabel(value) {
|
|
12
|
+
const s = String(value || '')
|
|
13
|
+
return s.includes('@') ? maskEmail(s) : s
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
|
|
17
|
+
|
|
18
|
+
// #99: scrub free-text (error messages, notes): mask embedded emails only.
|
|
19
|
+
export function maskText(value) {
|
|
20
|
+
return String(value || '').replace(EMAIL_RE, (m) => maskEmail(m))
|
|
21
|
+
}
|
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
|
}
|