@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 +32 -1
- package/lib/refs.js +34 -12
- package/lib/vendor-factory.js +154 -0
- package/lib/vendors/index.js +32 -5
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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 = /^(
|
|
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 (!
|
|
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 (!
|
|
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
|
+
}
|
package/lib/vendors/index.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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