@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 +32 -0
- package/lib/refs.js +52 -12
- package/lib/vendor-factory.js +154 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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 = /^(
|
|
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 (!
|
|
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 (!
|
|
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