@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/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
- writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri })
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 || '', label: blob.label || '', expiresAt: blob.expiresAt || null,
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 || '', label: blob.label || '', expiresAt: blob.expiresAt || null, quota: info.quota || null })
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 || '', label: blob.label || '', expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
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 || '', label: blob.label || '', expiresAt: blob.expiresAt || null,
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',
@@ -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
  }