@goodandready/dsh-subscriptions 0.3.1 → 0.4.2

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
@@ -3,10 +3,12 @@ import { PROVIDERS, oauthRef, isProvider, displayName, droppedCredentialRefs } f
3
3
  import { createPkce } from './pkce.js'
4
4
  import { parseCallbackInput, requestOrigin, webCallbackUri } from './oauth.js'
5
5
  import { writeJson, writeHtml, readBody, isTrustedSettingsRequest, queryOf } from './http.js'
6
- import { getVendor } from './vendors/index.js'
6
+ import { getVendor, registerCustomVendor, clearCustomVendors } 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 { registerCustomProviderIds, registerDisplayName } from './refs.js'
10
12
  import { createSubscriptionsService } from './subscriptions.js'
11
13
  import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
12
14
  import { quotaSnapshot } from './ratelimit.js'
@@ -64,6 +66,9 @@ export const Config = z.object({
64
66
  antigravityClientId: z.string().default(''),
65
67
  antigravityClientSecret: z.string().default(''),
66
68
  antigravityRedirectUri: z.string().default(''),
69
+ customVendors: z.array(z.any()).default([])
70
+ .description('Declarative OpenAI-Responses-compatible providers. See README.'),
71
+
67
72
  })
68
73
 
69
74
  function publicConfig(cfg) {
@@ -90,7 +95,32 @@ export function apply(ctx, config) {
90
95
  })
91
96
  })
92
97
 
98
+ // Регистрация кастомных вендоров из Config. Вызывается при старте и при
99
+ // каждой смене конфига (replace) — реестр пересобирается с нуля.
100
+ function syncCustomVendors() {
101
+ let profiles
102
+ try {
103
+ profiles = (live().customVendors || []).map(validateProfile)
104
+ } catch (e) {
105
+ try { ctx.log && ctx.log.warn && ctx.log.warn('[dsh-subscriptions] customVendors: ' + String(e && e.message || e)) } catch {}
106
+ return
107
+ }
108
+ clearCustomVendors()
109
+ for (const profile of profiles) {
110
+ try {
111
+ const vendor = createVendorFromProfile(profile)
112
+ registerCustomVendor(vendor)
113
+ registerCustomProviderIds([profile.id])
114
+ if (profile.displayName) registerDisplayName(profile.id, profile.displayName)
115
+ } catch (e) {
116
+ try { ctx.log && ctx.log.warn && ctx.log.warn('[dsh-subscriptions] customVendors[' + profile.id + ']: ' + String(e && e.message || e)) } catch {}
117
+ }
118
+ }
119
+ }
120
+
93
121
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
122
+ syncCustomVendors()
123
+
94
124
  const store = createAccountStore({
95
125
  credentials: ctx.credentials,
96
126
  getConfig: live,
@@ -429,6 +459,7 @@ export function apply(ctx, config) {
429
459
  const parsed = Config(payload)
430
460
  const dropped = droppedCredentialRefs(live().slots, parsed.slots)
431
461
  await settingsApi.replace(parsed)
462
+ syncCustomVendors()
432
463
  for (const ref of dropped) await store.clearRef(ref)
433
464
  await syncAdapter()
434
465
  writeJson(res, 200, await configResponse())
package/lib/refs.js CHANGED
@@ -1,11 +1,38 @@
1
- export const PROVIDERS = Object.freeze([
1
+ // Встроенные провайдеры + динамические из Config.customVendors.
2
+ export const BUILTIN_PROVIDERS = Object.freeze([
2
3
  'codex',
3
4
  'claude',
4
5
  'grok',
5
6
  'antigravity',
6
7
  ])
7
8
 
8
- const KNOWN = new Set(PROVIDERS)
9
+ const dynamicIds = new Set()
10
+ const displayExtra = {}
11
+
12
+ export let PROVIDERS = [...BUILTIN_PROVIDERS]
13
+
14
+ export function registerCustomProviderIds(ids) {
15
+ let changed = false
16
+ for (const id of ids || []) {
17
+ if (typeof id === 'string' && /^[a-z][a-z0-9_]*$/.test(id) && !dynamicIds.has(id)) {
18
+ dynamicIds.add(id)
19
+ changed = true
20
+ }
21
+ }
22
+ if (changed) PROVIDERS = listProviders()
23
+ }
24
+
25
+ export function listProviders() {
26
+ return [...BUILTIN_PROVIDERS, ...dynamicIds]
27
+ }
28
+
29
+ export function registerDisplayName(id, name) {
30
+ if (id && name) displayExtra[id] = name
31
+ }
32
+
33
+ export function isProvider(value) {
34
+ return BUILTIN_PROVIDERS.includes(value) || dynamicIds.has(value)
35
+ }
9
36
 
10
37
  const DISPLAY = {
11
38
  codex: 'ChatGPT Codex',
@@ -14,24 +41,19 @@ const DISPLAY = {
14
41
  antigravity: 'Antigravity',
15
42
  }
16
43
 
17
- export function isProvider(value) {
18
- return KNOWN.has(value)
19
- }
20
-
21
44
  export function displayName(provider) {
22
- return DISPLAY[provider] || provider
45
+ return DISPLAY[provider] || displayExtra[provider] || provider
23
46
  }
24
47
 
25
48
  export function oauthRef(provider, index) {
26
- if (!KNOWN.has(provider)) throw new Error(`unknown provider: ${provider}`)
27
49
  const n = Number(index)
28
50
  if (!Number.isInteger(n) || n < 1) throw new Error('account index must be an integer >= 1')
29
- return `${provider.toUpperCase()}_OAUTH_${n}`
51
+ return `${String(provider).toUpperCase()}_OAUTH_${n}`
30
52
  }
31
53
 
32
54
  export function parseOauthRef(ref) {
33
55
  if (typeof ref !== 'string') return null
34
- const match = /^(CODEX|CLAUDE|GROK|ANTIGRAVITY)_OAUTH_([1-9][0-9]*)$/.exec(ref)
56
+ const match = /^([A-Z][A-Z0-9_]*)_OAUTH_([1-9][0-9]*)$/.exec(ref)
35
57
  if (!match) return null
36
58
  return { provider: match[1].toLowerCase(), index: Number(match[2]) }
37
59
  }
@@ -39,7 +61,7 @@ export function parseOauthRef(ref) {
39
61
  export function droppedCredentialRefs(previousSlots, nextSlots) {
40
62
  const keep = new Set()
41
63
  for (const slot of nextSlots || []) {
42
- if (!isProvider(slot.provider)) continue
64
+ if (!slot || !slot.provider) continue
43
65
  const index = Number(slot.index)
44
66
  if (!Number.isInteger(index) || index < 1) continue
45
67
  keep.add(oauthRef(slot.provider, index))
@@ -47,7 +69,7 @@ export function droppedCredentialRefs(previousSlots, nextSlots) {
47
69
  const out = []
48
70
  const seen = new Set()
49
71
  for (const slot of previousSlots || []) {
50
- if (!isProvider(slot.provider)) continue
72
+ if (!slot || !slot.provider) continue
51
73
  const index = Number(slot.index)
52
74
  if (!Number.isInteger(index) || index < 1) continue
53
75
  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
+ }
@@ -2,12 +2,39 @@ import * as codex from './codex.js'
2
2
  import * as claude from './claude.js'
3
3
  import * as grok from './grok.js'
4
4
  import * as antigravity from './antigravity.js'
5
+ import { createVendorFromProfile } from '../vendor-factory.js'
5
6
 
6
- export const vendors = {
7
- codex,
8
- claude,
9
- grok,
10
- antigravity,
7
+ const builtins = { codex, claude, grok, antigravity }
8
+
9
+ const customVendors = new Map()
10
+
11
+ export function registerCustomVendor(vendor) {
12
+ if (!vendor || !vendor.id) throw new Error('custom vendor needs id')
13
+ customVendors.set(vendor.id, vendor)
14
+ }
15
+
16
+ export function clearCustomVendors() {
17
+ customVendors.clear()
18
+ }
19
+
20
+ const vendors = new Proxy({}, {
21
+ get(_t, prop) {
22
+ if (prop in builtins) return builtins[prop]
23
+ return customVendors.get(prop)
24
+ },
25
+ has(_t, prop) {
26
+ return prop in builtins || customVendors.has(prop)
27
+ },
28
+ ownKeys() {
29
+ return [...Reflect.ownKeys(builtins), ...customVendors.keys()]
30
+ },
31
+ getOwnPropertyDescriptor() {
32
+ return { enumerable: true, configurable: true }
33
+ },
34
+ })
35
+
36
+ export function isProvider(value) {
37
+ return Boolean(vendors[value])
11
38
  }
12
39
 
13
40
  export function getVendor(provider) {
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.2",
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",