@goodandready/dsh-subscriptions 0.3.1 → 0.4.0

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
@@ -7,6 +7,9 @@ import { getVendor } from './vendors/index.js'
7
7
  import { SubscriptionAdapter } from './adapter.js'
8
8
  import { generateOnce, SIZES as IMAGE_SIZES } from './images.js'
9
9
  import { parseBlob } from './blob.js'
10
+ import { createVendorFromProfile, validateProfile } from './vendor-factory.js'
11
+ import { registerCustomVendor } from './vendors/index.js'
12
+ import { registerCustomProviderIds, registerDisplayName } from './refs.js'
10
13
  import { createSubscriptionsService } from './subscriptions.js'
11
14
  import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
12
15
  import { quotaSnapshot } from './ratelimit.js'
@@ -64,6 +67,9 @@ export const Config = z.object({
64
67
  antigravityClientId: z.string().default(''),
65
68
  antigravityClientSecret: z.string().default(''),
66
69
  antigravityRedirectUri: z.string().default(''),
70
+ customVendors: z.array(z.any()).default([])
71
+ .description('Declarative OpenAI-Responses-compatible providers. See README.'),
72
+
67
73
  })
68
74
 
69
75
  function publicConfig(cfg) {
@@ -90,7 +96,32 @@ export function apply(ctx, config) {
90
96
  })
91
97
  })
92
98
 
99
+ // Регистрация кастомных вендоров из Config. Вызывается при старте и при
100
+ // каждой смене конфига (replace) — реестр пересобирается с нуля.
101
+ function syncCustomVendors() {
102
+ let profiles
103
+ try {
104
+ profiles = (live().customVendors || []).map(validateProfile)
105
+ } catch (e) {
106
+ try { ctx.log && ctx.log.warn && ctx.log.warn('[dsh-subscriptions] customVendors: ' + String(e && e.message || e)) } catch {}
107
+ return
108
+ }
109
+ clearCustomProviders()
110
+ for (const profile of profiles) {
111
+ try {
112
+ const vendor = createVendorFromProfile(profile)
113
+ registerCustomVendor(vendor)
114
+ registerCustomProviderIds([profile.id])
115
+ if (profile.displayName) registerDisplayName(profile.id, profile.displayName)
116
+ } catch (e) {
117
+ try { ctx.log && ctx.log.warn && ctx.log.warn('[dsh-subscriptions] customVendors[' + profile.id + ']: ' + String(e && e.message || e)) } catch {}
118
+ }
119
+ }
120
+ }
121
+
93
122
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
123
+ syncCustomVendors()
124
+
94
125
  const store = createAccountStore({
95
126
  credentials: ctx.credentials,
96
127
  getConfig: live,
@@ -429,6 +460,7 @@ export function apply(ctx, config) {
429
460
  const parsed = Config(payload)
430
461
  const dropped = droppedCredentialRefs(live().slots, parsed.slots)
431
462
  await settingsApi.replace(parsed)
463
+ syncCustomVendors()
432
464
  for (const ref of dropped) await store.clearRef(ref)
433
465
  await syncAdapter()
434
466
  writeJson(res, 200, await configResponse())
package/lib/refs.js CHANGED
@@ -1,11 +1,56 @@
1
- export const PROVIDERS = Object.freeze([
1
+ // Встроенные провайдеры. Кастомные из Config добавляются динамически через
2
+ // registerCustomProviderIds и попадают в тот же список.
3
+ export const BUILTIN_PROVIDERS = Object.freeze([
2
4
  'codex',
3
5
  'claude',
4
6
  'grok',
5
7
  'antigravity',
6
8
  ])
7
9
 
8
- const KNOWN = new Set(PROVIDERS)
10
+ const dynamic = new Set()
11
+
12
+ export function registerCustomProviderIds(ids) {
13
+ for (const id of ids || []) {
14
+ if (typeof id === 'string' && /^[a-z][a-z0-9_]*$/.test(id)) dynamic.add(id)
15
+ }
16
+ }
17
+
18
+ export function listProviders() {
19
+ return [...BUILTIN_PROVIDERS, ...dynamic]
20
+ }
21
+
22
+ // Обратно-совместимый доступ: массив читается на момент вызова.
23
+ export const PROVIDERS = new Proxy([], {
24
+ get(_t, prop) {
25
+ if (prop === 'length') return listProviders().length
26
+ if (prop === Symbol.iterator) return listProviders()[Symbol.iterator]
27
+ const i = Number(prop)
28
+ return Number.isInteger(i) ? listProviders()[i] : undefined
29
+ },
30
+ has() { return true },
31
+ })
32
+
33
+ const DISPLAY_EXTRA = {}
34
+
35
+ export function registerDisplayName(id, name) {
36
+ if (id && name) DISPLAY_EXTRA[id] = name
37
+ }
38
+
39
+ const KNOWN_CACHE = new Map()
40
+
41
+ export function isProvider(value) {
42
+ if (KNOWN_CACHE.has(value)) return KNOWN_CACHE.get(value)
43
+ let result
44
+ try {
45
+ const { isProvider: check } = require('./vendors/index.js')
46
+ result = Boolean(check(value))
47
+ } catch {
48
+ // require недоступен в этом контексте — используем списки
49
+ result = BUILTIN_PROVIDERS.includes(value) || dynamic.has(value)
50
+ }
51
+ KNOWN_CACHE.set(value, result)
52
+ return result
53
+ }
9
54
 
10
55
  const DISPLAY = {
11
56
  codex: 'ChatGPT Codex',
@@ -14,24 +59,19 @@ const DISPLAY = {
14
59
  antigravity: 'Antigravity',
15
60
  }
16
61
 
17
- export function isProvider(value) {
18
- return KNOWN.has(value)
19
- }
20
-
21
62
  export function displayName(provider) {
22
- return DISPLAY[provider] || provider
63
+ return DISPLAY[provider] || DISPLAY_EXTRA[provider] || provider
23
64
  }
24
65
 
25
66
  export function oauthRef(provider, index) {
26
- if (!KNOWN.has(provider)) throw new Error(`unknown provider: ${provider}`)
27
67
  const n = Number(index)
28
68
  if (!Number.isInteger(n) || n < 1) throw new Error('account index must be an integer >= 1')
29
- return `${provider.toUpperCase()}_OAUTH_${n}`
69
+ return `${String(provider).toUpperCase()}_OAUTH_${n}`
30
70
  }
31
71
 
32
72
  export function parseOauthRef(ref) {
33
73
  if (typeof ref !== 'string') return null
34
- const match = /^(CODEX|CLAUDE|GROK|ANTIGRAVITY)_OAUTH_([1-9][0-9]*)$/.exec(ref)
74
+ const match = /^([A-Z][A-Z0-9_]*)_OAUTH_([1-9][0-9]*)$/.exec(ref)
35
75
  if (!match) return null
36
76
  return { provider: match[1].toLowerCase(), index: Number(match[2]) }
37
77
  }
@@ -39,7 +79,7 @@ export function parseOauthRef(ref) {
39
79
  export function droppedCredentialRefs(previousSlots, nextSlots) {
40
80
  const keep = new Set()
41
81
  for (const slot of nextSlots || []) {
42
- if (!isProvider(slot.provider)) continue
82
+ if (!slot || !slot.provider) continue
43
83
  const index = Number(slot.index)
44
84
  if (!Number.isInteger(index) || index < 1) continue
45
85
  keep.add(oauthRef(slot.provider, index))
@@ -47,7 +87,7 @@ export function droppedCredentialRefs(previousSlots, nextSlots) {
47
87
  const out = []
48
88
  const seen = new Set()
49
89
  for (const slot of previousSlots || []) {
50
- if (!isProvider(slot.provider)) continue
90
+ if (!slot || !slot.provider) continue
51
91
  const index = Number(slot.index)
52
92
  if (!Number.isInteger(index) || index < 1) continue
53
93
  const ref = oauthRef(slot.provider, index)
@@ -0,0 +1,154 @@
1
+ // Фабрика вендоров из декларативного профиля.
2
+ //
3
+ // Покрывает семейство OpenAI-Responses-совместимых подписок: авторизация PKCE,
4
+ // form- или json-токен-эндпоинт, каталог моделей, usage, стрим через /responses.
5
+ // Нестандартные протоколы (Anthropic messages, Cloud Code Assist) фабрикой не
6
+ // описываются — для них есть рукописные модули.
7
+ //
8
+ // Новый провайдер = запись customVendors в Config, не код.
9
+
10
+ import { buildAuthorizeUrl } from "./oauth.js"
11
+ import { codexResponsesBody, modelCatalog } from "./messages.js"
12
+ import { formTokenRequest, jsonTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError } from "./wire.js"
13
+ import { emailFromToken } from "./jwt.js"
14
+ import { asUsageSnapshot } from "./usage.js"
15
+
16
+ function defaultModels(models) {
17
+ return modelCatalog("custom", (models || []).map((row) => (typeof row === "string" ? { id: row, name: row } : row)))
18
+ }
19
+
20
+ /**
21
+ * Собирает объект вендора из профиля. Профиль — данные из Config:
22
+ * { id, displayName, authUrl, tokenUrl, baseUrl, scope, tokenStyle,
23
+ * clientId, redirectUri, modelsPath, models, usagePath, headers }
24
+ */
25
+ export function createVendorFromProfile(profile) {
26
+ const id = String(profile.id)
27
+ const name = String(profile.displayName || profile.id)
28
+ const base = String(profile.baseUrl || "").replace(/\/$/, "")
29
+ const tokenStyle = profile.tokenStyle === "json" ? "json" : "form"
30
+
31
+ function identityHeaders(blob, extra) {
32
+ return {
33
+ Authorization: `Bearer ${blob.accessToken}`,
34
+ ...(profile.headers || {}),
35
+ ...(extra || {}),
36
+ }
37
+ }
38
+
39
+ return {
40
+ id,
41
+ providerInfo() {
42
+ return { id, name }
43
+ },
44
+ defaults() {
45
+ return {
46
+ clientId: profile.clientId || "",
47
+ redirectUri: profile.redirectUri || "",
48
+ baseUrl: base,
49
+ scope: profile.scope || "openid profile offline_access",
50
+ models: profile.models || [],
51
+ }
52
+ },
53
+ authorizeUrl(cfg, pkce) {
54
+ return buildAuthorizeUrl({
55
+ authUrl: profile.authUrl,
56
+ clientId: cfg.clientId,
57
+ redirectUri: cfg.redirectUri,
58
+ challenge: pkce.challenge,
59
+ state: pkce.state,
60
+ scope: cfg.scope || profile.scope || "openid profile offline_access",
61
+ })
62
+ },
63
+ async exchangeCode(cfg, pkce, code, fetchImpl) {
64
+ const params = {
65
+ grant_type: "authorization_code",
66
+ client_id: cfg.clientId,
67
+ code,
68
+ redirect_uri: cfg.redirectUri,
69
+ code_verifier: pkce.verifier,
70
+ }
71
+ const req = tokenStyle === "json" ? jsonTokenRequest : formTokenRequest
72
+ const json = await req(profile.tokenUrl, params, fetchImpl)
73
+ const blob = tokenBlobFromOAuth(json)
74
+ return { ...blob, email: blob.email || emailFromToken(blob.accessToken), label: blob.label || name }
75
+ },
76
+ async refresh(cfg, blob, fetchImpl) {
77
+ const params = {
78
+ grant_type: "refresh_token",
79
+ client_id: cfg.clientId,
80
+ refresh_token: blob.refreshToken,
81
+ }
82
+ const req = tokenStyle === "json" ? jsonTokenRequest : formTokenRequest
83
+ const json = await req(profile.tokenUrl, params, fetchImpl)
84
+ return tokenBlobFromOAuth(json, { label: blob.label, email: blob.email })
85
+ },
86
+ async listModels(blob, cfg, fetchImpl) {
87
+ const catalog = defaultModels(cfg.models || profile.models || [])
88
+ if (!profile.modelsPath) return catalog
89
+ try {
90
+ const res = await (fetchImpl || fetch)(`${base}${profile.modelsPath}`, {
91
+ headers: identityHeaders(blob, { Accept: "application/json" }),
92
+ })
93
+ const json = await readJson(res)
94
+ const rows = []
95
+ for (const entry of json.data || json.models || []) {
96
+ const slug = entry && (entry.slug || entry.id)
97
+ if (!slug) continue
98
+ rows.push({ id: slug, name: entry.display_name || entry.name || slug })
99
+ }
100
+ if (!rows.length) throw new Error("empty catalog")
101
+ return modelCatalog(id, rows)
102
+ } catch {
103
+ return catalog
104
+ }
105
+ },
106
+ async usage() {
107
+ // Универсального usage-эндпоинта у произвольных сервисов нет — квота
108
+ // наполняется из ratelimit-заголовков через adapter quotaFetch.
109
+ return null
110
+ },
111
+ check: undefined,
112
+ async* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
113
+ const body = codexResponsesBody(options, "")
114
+ if (!body.instructions) delete body.instructions
115
+ const res = await (fetchImpl || fetch)(`${base}/responses`, {
116
+ method: "POST",
117
+ headers: {
118
+ ...headers,
119
+ ...identityHeaders(blob, config),
120
+ "Content-Type": "application/json",
121
+ Accept: "text/event-stream",
122
+ },
123
+ body: JSON.stringify(body),
124
+ signal,
125
+ })
126
+ if (!res.ok) throw httpError(res.status, await res.text())
127
+ yield* codexResponsesStream(res.body)
128
+ },
129
+ }
130
+ }
131
+
132
+ /** Валидирует профиль из Config; бросает при невалидных полях. */
133
+ export function validateProfile(raw) {
134
+ const p = raw && typeof raw === "object" ? raw : {}
135
+ for (const key of ["id", "authUrl", "tokenUrl", "baseUrl", "clientId"]) {
136
+ if (!p[key] || typeof p[key] !== "string") throw new Error(`customVendors[${p.id || "?"}]: поле ${key} обязательно`)
137
+ }
138
+ if (!/^[a-z][a-z0-9_]*$/.test(p.id)) throw new Error(`customVendors: id "${p.id}" должен быть [a-z][a-z0-9_]*`)
139
+ if (p.tokenStyle && !["form", "json"].includes(p.tokenStyle)) throw new Error(`customVendors[${p.id}]: tokenStyle должен быть form|json`)
140
+ return {
141
+ id: p.id,
142
+ displayName: p.displayName || p.id,
143
+ authUrl: p.authUrl,
144
+ tokenUrl: p.tokenUrl,
145
+ baseUrl: p.baseUrl,
146
+ scope: p.scope || "",
147
+ tokenStyle: p.tokenStyle || "form",
148
+ clientId: p.clientId,
149
+ redirectUri: p.redirectUri || "",
150
+ modelsPath: p.modelsPath || "",
151
+ models: Array.isArray(p.models) ? p.models : [],
152
+ headers: p.headers && typeof p.headers === "object" ? p.headers : {},
153
+ }
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, and Antigravity subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",