dsh-plugin-weapp-gateway 0.8.13 → 0.8.23

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.
@@ -0,0 +1,664 @@
1
+ /**
2
+ * CLIProxyAPI gateway quota host core (issue #87).
3
+ *
4
+ * This module owns only the CPA boundary: source policy, write-only management
5
+ * key resolution, strict auth discovery, fixed management paths, bounded
6
+ * concurrency and public-state normalization. Provider payload semantics remain
7
+ * in gateway-quota-adapters.js. No raw auth/token/body is returned or logged.
8
+ */
9
+
10
+ import { createHash } from 'node:crypto'
11
+ import { credentialRef } from './credentials-shim.mjs'
12
+ import { fetchWithRetry, readJsonBounded } from './net.mjs'
13
+ import {
14
+ GATEWAY_PROVIDER_ADAPTERS,
15
+ canonicalGatewayProvider,
16
+ parseWorkBuddyCredits,
17
+ FORBIDDEN_REQUEST_MARKERS,
18
+ ANTIGRAVITY_MODELS_URLS,
19
+ ANTIGRAVITY_REQUEST_HEADERS,
20
+ antigravityProjectId,
21
+ } from './gateway-quota-adapters.mjs'
22
+
23
+ export const GATEWAY_MANAGEMENT_PATHS = Object.freeze({
24
+ authFiles: '/v0/management/auth-files',
25
+ apiCall: '/v0/management/api-call',
26
+ workbuddyCredits: '/v0/management/plugins/workbuddy/credits',
27
+ workBuddyCredits: '/v0/management/plugins/workbuddy/credits',
28
+ })
29
+
30
+ export const GATEWAY_ERROR_CODES = Object.freeze([
31
+ 'CPA_REDIRECT_REFUSED',
32
+ 'CPA_AUTH_FAILED',
33
+ 'CPA_OUTER_HTTP_ERROR',
34
+ 'CPA_INNER_HTTP_ERROR',
35
+ 'CPA_RESPONSE_INVALID',
36
+ 'PROVIDER_PARSE_ERROR',
37
+ 'PROVIDER_CAPABILITY_MISSING',
38
+ 'WORKBUDDY_PLUGIN_UNAVAILABLE',
39
+ 'SOURCE_BLOCKED_BY_POLICY',
40
+ ])
41
+
42
+ const MAX_BODY_BYTES = 262144
43
+ const MAX_ACCOUNTS = 16
44
+ const DEFAULT_CONCURRENCY = 3
45
+ const MAX_AUTH_INDEX_LENGTH = 128
46
+ const MAX_PROVIDER_LENGTH = 64
47
+ const MAX_EMAIL_LENGTH = 256
48
+ const MAX_METADATA_LENGTH = 160
49
+ const MAX_ACCOUNT_ID_LENGTH = 128
50
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
51
+ const SAFE_AUTH_KEYS = new Set([
52
+ 'auth_index', 'authIndex', 'provider', 'type', 'email', 'project_id', 'projectId',
53
+ 'account_type', 'accountType', 'account', 'label', 'status', 'status_message',
54
+ 'statusMessage', 'disabled', 'unavailable', 'plan_type', 'planType',
55
+ 'chatgpt_account_id', 'chatgptAccountId', 'using_api', 'usingApi', 'prefix',
56
+ 'user_id', 'userId', 'team_id', 'teamId',
57
+ ])
58
+
59
+ function errorOf(code, message, extra = {}) {
60
+ const error = new Error(String(message || code))
61
+ error.code = code
62
+ for (const [key, value] of Object.entries(extra)) {
63
+ if (key === 'message' || key === 'stack') continue
64
+ error[key] = value
65
+ }
66
+ return error
67
+ }
68
+
69
+ function asRecord(value) {
70
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
71
+ }
72
+
73
+ function text(value) {
74
+ return typeof value === 'string' ? value.trim() : ''
75
+ }
76
+
77
+ function boundedText(value, max) {
78
+ return text(value).slice(0, max)
79
+ }
80
+
81
+ function safeNumber(value) {
82
+ const n = Number(value)
83
+ return Number.isFinite(n) ? n : null
84
+ }
85
+
86
+ function hostOf(url) {
87
+ try { return new URL(url).host.toLowerCase() } catch { return '' }
88
+ }
89
+
90
+ export function isLoopbackHost(hostname) {
91
+ const host = String(hostname || '').toLowerCase()
92
+ if (LOOPBACK_HOSTS.has(host)) return true
93
+ const match = host.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
94
+ if (match) return match.slice(1).every(part => Number(part) >= 0 && Number(part) <= 255)
95
+ return false
96
+ }
97
+
98
+ const isLoopback = isLoopbackHost
99
+
100
+ function sourceIdOf(source) {
101
+ return boundedText(source?.id, MAX_METADATA_LENGTH)
102
+ }
103
+
104
+ /** Deterministic customVar name; value never appears in config/state. */
105
+ export function managementKeyVarOf(source) {
106
+ const rawId = sourceIdOf(source)
107
+ const id = rawId.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'GATEWAY'
108
+ // Keep the readable prefix while adding an identity suffix so `a-b` and `a_b`
109
+ // cannot address the same credential variable.
110
+ const suffix = createHash('sha256').update(rawId).digest('hex').slice(0, 8).toUpperCase()
111
+ return `CLIPROXYAPI_MANAGEMENT_KEY_${id}_${suffix}`
112
+ }
113
+
114
+ export function gatewaySourceFingerprint(source, keyConfigured = false) {
115
+ const cfg = normalizeGatewaySource(source)
116
+ return JSON.stringify({
117
+ id: cfg.id, type: cfg.type, baseURL: cfg.baseURL, enabled: cfg.enabled,
118
+ display: cfg.display, refreshMinutes: cfg.refreshMinutes,
119
+ includeProviders: cfg.includeProviders, allowedHosts: cfg.allowedHosts,
120
+ allowInsecureHttp: cfg.allowInsecureHttp, antigravityOnlyGemini: cfg.antigravityOnlyGemini,
121
+ keyConfigured: keyConfigured === true,
122
+ })
123
+ }
124
+
125
+ export function normalizeGatewayBaseUrl(raw) {
126
+ const candidate = text(raw).replace(/\/+$/, '')
127
+ let parsed
128
+ try { parsed = new URL(candidate) } catch { return null }
129
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password
130
+ || parsed.search || parsed.hash || parsed.pathname !== '/') return null
131
+ return `${parsed.protocol}//${parsed.host.toLowerCase()}`
132
+ }
133
+
134
+ export function normalizeGatewaySource(raw) {
135
+ const source = asRecord(raw) ?? {}
136
+ const id = sourceIdOf(source)
137
+ const baseURL = normalizeGatewayBaseUrl(source.baseURL) ?? text(source.baseURL).replace(/\/+$/, '')
138
+ const include = Array.isArray(source.includeProviders)
139
+ ? [...new Set(source.includeProviders.map(value => canonicalGatewayProvider(boundedText(value, MAX_PROVIDER_LENGTH))).filter(Boolean))]
140
+ : Object.keys(GATEWAY_PROVIDER_ADAPTERS)
141
+ const allowedHosts = Array.isArray(source.allowedHosts)
142
+ ? [...new Set(source.allowedHosts.map(value => boundedText(value, 255).toLowerCase()).filter(Boolean))].slice(0, 16)
143
+ : []
144
+ const refresh = Math.floor(Number(source.refreshMinutes))
145
+ return {
146
+ id,
147
+ type: boundedText(source.type, MAX_PROVIDER_LENGTH) || 'cliproxyapi',
148
+ label: boundedText(source.label, 80) || id,
149
+ baseURL,
150
+ enabled: source.enabled !== false,
151
+ display: ['sidebar', 'settings', 'both', 'off'].includes(source.display) ? source.display : 'both',
152
+ refreshMinutes: Number.isInteger(refresh) ? Math.min(1440, Math.max(1, refresh)) : 15,
153
+ includeProviders: include,
154
+ allowedHosts,
155
+ allowInsecureHttp: source.allowInsecureHttp === true,
156
+ antigravityOnlyGemini: source.antigravityOnlyGemini === true,
157
+ }
158
+ }
159
+
160
+ export function assertGatewayTransportAllowed(baseUrl, { allowedHosts = [], allowInsecureHttp = false } = {}) {
161
+ let parsed
162
+ try { parsed = new URL(baseUrl) } catch { throw errorOf('SOURCE_BLOCKED_BY_POLICY', 'gateway source baseURL is invalid') }
163
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password
164
+ || parsed.search || parsed.hash || parsed.pathname !== '/') {
165
+ throw errorOf('SOURCE_BLOCKED_BY_POLICY', 'gateway source baseURL must be an origin')
166
+ }
167
+ const host = parsed.host.toLowerCase()
168
+ const loopback = isLoopbackHost(parsed.hostname)
169
+ const allow = new Set((Array.isArray(allowedHosts) ? allowedHosts : []).map(value => text(value).toLowerCase()))
170
+ if (!loopback && !allow.has(host)) throw errorOf('SOURCE_BLOCKED_BY_POLICY', `gateway host ${host} is not in the exact allowlist`)
171
+ if (parsed.protocol === 'http:' && !loopback && allowInsecureHttp !== true) throw errorOf('SOURCE_BLOCKED_BY_POLICY', 'non-loopback HTTP requires allowInsecureHttp')
172
+ return null
173
+ }
174
+
175
+ export function validateGatewaySource(source) {
176
+ const cfg = normalizeGatewaySource(source)
177
+ if (!/^[a-z0-9][a-z0-9_-]{0,47}$/.test(cfg.id)) throw errorOf('SOURCE_BLOCKED_BY_POLICY', 'gateway source id is invalid')
178
+ if (cfg.type !== 'cliproxyapi') throw errorOf('SOURCE_BLOCKED_BY_POLICY', 'gateway source type is unsupported')
179
+ if (normalizeGatewayBaseUrl(cfg.baseURL) === null) throw errorOf('SOURCE_BLOCKED_BY_POLICY', 'gateway source baseURL must be an origin')
180
+ assertGatewayTransportAllowed(cfg.baseURL, cfg)
181
+ if (cfg.includeProviders.length === 0) throw errorOf('SOURCE_BLOCKED_BY_POLICY', 'gateway source has no supported providers')
182
+ return cfg
183
+ }
184
+
185
+ function endpoint(source, path) {
186
+ return `${source.baseURL}${path}`
187
+ }
188
+
189
+ function headerValue(headers, name) {
190
+ if (headers === null || headers === undefined) return ''
191
+ if (typeof headers.get === 'function') return text(headers.get(name))
192
+ for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === name.toLowerCase()) return text(value)
193
+ return ''
194
+ }
195
+
196
+ function safeMessage(code, detail) {
197
+ const suffix = text(detail).replace(/[\r\n]+/g, ' ').replace(/https?:\/\/[^\s]+/gi, '[endpoint]').slice(0, 160)
198
+ return suffix ? `${code}: ${suffix}` : code
199
+ }
200
+
201
+ function responseStatusError(response, phase) {
202
+ const status = Number(response?.status) || 0
203
+ if (status === 401 || status === 403) return errorOf('CPA_AUTH_FAILED', `${phase} management authentication failed`, { status })
204
+ return errorOf('CPA_OUTER_HTTP_ERROR', `${phase} HTTP ${status}`, { status })
205
+ }
206
+
207
+ function retryAfterMs(response) {
208
+ const raw = headerValue(response?.headers, 'retry-after')
209
+ if (!raw) return 0
210
+ const seconds = Number(raw)
211
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(5000, seconds * 1000)
212
+ const date = Date.parse(raw)
213
+ return Number.isFinite(date) ? Math.min(5000, Math.max(0, date - Date.now())) : 0
214
+ }
215
+
216
+ // CPA 是一个本地管理端点加上上游 Provider 的双跳链路。管理端或上游
217
+ // 偶发过载时,只有这些暂态 HTTP 状态才适合重试;认证/策略/解析失败必须立刻报错。
218
+ const RETRYABLE_GATEWAY_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504])
219
+ const GATEWAY_RESPONSE_ATTEMPTS = 2
220
+ const GATEWAY_RETRY_DELAY_MS = 300
221
+
222
+ function isRetryableGatewayStatus(status) {
223
+ return RETRYABLE_GATEWAY_HTTP_STATUSES.has(Number(status))
224
+ }
225
+
226
+ function gatewayRetryDelay(response) {
227
+ return retryAfterMs(response) || GATEWAY_RETRY_DELAY_MS
228
+ }
229
+
230
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
231
+
232
+ async function discardResponse(response) {
233
+ // 重试前释放未消费的错误响应,避免多账号轮询占住连接池。
234
+ try { await response?.body?.cancel() } catch { /* 清理失败不掩盖原始 HTTP 错误。 */ }
235
+ }
236
+
237
+ async function readJsonResponse(response, phase, { maxBytes = MAX_BODY_BYTES } = {}) {
238
+ if (!response || typeof response.ok !== 'boolean') throw errorOf('CPA_RESPONSE_INVALID', `${phase} response is invalid`)
239
+ if (!response.ok) {
240
+ await discardResponse(response)
241
+ throw responseStatusError(response, phase)
242
+ }
243
+ try {
244
+ const length = safeNumber(headerValue(response.headers, 'content-length'))
245
+ if (length !== null && length > maxBytes) {
246
+ await discardResponse(response)
247
+ throw errorOf('CPA_RESPONSE_INVALID', `${phase} response is too large`)
248
+ }
249
+ return await readJsonBounded(response, maxBytes)
250
+ } catch (error) {
251
+ if (error?.code === 'CPA_RESPONSE_INVALID') throw error
252
+ throw errorOf('CPA_RESPONSE_INVALID', error?.code === 'RESPONSE_TOO_LARGE'
253
+ ? `${phase} response is too large` : `${phase} response body is not readable JSON`)
254
+ }
255
+ }
256
+
257
+ export async function cpaManagementFetch(url, key, init = {}, options = {}) {
258
+ const fetchImpl = options.fetchImpl ?? fetch
259
+ const headers = { ...(init.headers ?? {}), 'X-Management-Key': key }
260
+ let response
261
+ try {
262
+ response = await fetchWithRetry(url, { ...init, headers, redirect: 'manual' }, {
263
+ attempts: 2, timeoutMs: init.timeoutMs ?? 15_000, fetchImpl,
264
+ })
265
+ } catch (error) {
266
+ if (error?.code) throw error
267
+ throw errorOf('CPA_OUTER_HTTP_ERROR', 'management request failed', { status: 0 })
268
+ }
269
+ if (response.status >= 300 && response.status < 400) throw errorOf('CPA_REDIRECT_REFUSED', 'CPA redirect refused', { status: response.status })
270
+ return readJsonResponse(response, new URL(url).pathname)
271
+ }
272
+
273
+ async function fetchManagement(source, path, managementKey, options = {}) {
274
+ const fetchImpl = options.fetchImpl ?? fetch
275
+ const url = endpoint(source, path)
276
+ const headers = {
277
+ Accept: 'application/json',
278
+ 'Content-Type': 'application/json',
279
+ 'X-Management-Key': managementKey,
280
+ }
281
+ for (let attempt = 0; attempt < GATEWAY_RESPONSE_ATTEMPTS; attempt++) {
282
+ let response
283
+ try {
284
+ response = await fetchWithRetry(url, { method: 'GET', headers, redirect: 'manual' }, {
285
+ attempts: 2, timeoutMs: 15_000, fetchImpl,
286
+ })
287
+ } catch (error) {
288
+ if (error?.code) throw error
289
+ throw errorOf('CPA_OUTER_HTTP_ERROR', 'management request failed', { status: 0 })
290
+ }
291
+ if (response.status >= 300 && response.status < 400) throw errorOf('CPA_REDIRECT_REFUSED', 'CPA redirect refused', { status: response.status })
292
+ // 管理端临时过载/限流时按 Retry-After 或短退避再试一次。
293
+ if (isRetryableGatewayStatus(response.status) && attempt + 1 < GATEWAY_RESPONSE_ATTEMPTS) {
294
+ await discardResponse(response)
295
+ await sleep(gatewayRetryDelay(response))
296
+ continue
297
+ }
298
+ return { payload: await readJsonResponse(response, path), serverVersion: headerValue(response.headers, 'x-cpa-version') }
299
+ }
300
+ throw errorOf('CPA_OUTER_HTTP_ERROR', 'management request failed', { status: 0 })
301
+ }
302
+
303
+ /** Resolve only the management key value, never returning it to callers' state. */
304
+ export async function resolveManagementKey(ctx, source) {
305
+ const varName = managementKeyVarOf(source)
306
+ const credentials = ctx?.get?.('credentials')
307
+ if (credentials !== undefined) {
308
+ try {
309
+ const hit = await credentials.resolve(credentialRef(varName))
310
+ if (typeof hit?.value === 'string' && hit.value.trim()) return { value: hit.value.trim(), configured: true, source: text(hit.source) || 'store', varName }
311
+ } catch {}
312
+ }
313
+ const env = text(process.env[varName])
314
+ if (env) return { value: env, configured: true, source: 'env', varName }
315
+ return { value: '', configured: false, source: 'none', varName }
316
+ }
317
+
318
+ function authCollection(payload) {
319
+ if (Array.isArray(payload)) return payload
320
+ const object = asRecord(payload)
321
+ for (const key of ['auth_files', 'authFiles', 'files', 'accounts', 'data']) if (Array.isArray(object?.[key])) return object[key]
322
+ return null
323
+ }
324
+
325
+ function strictAuth(raw) {
326
+ const object = asRecord(raw)
327
+ if (!object) return null
328
+ const out = {}
329
+ for (const key of SAFE_AUTH_KEYS) {
330
+ if (!(key in object)) continue
331
+ const value = object[key]
332
+ if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') out[key] = value
333
+ }
334
+ const authIndex = boundedText(out.auth_index || out.authIndex, MAX_AUTH_INDEX_LENGTH)
335
+ if (!authIndex) return null
336
+ out.authIndex = authIndex
337
+ if (typeof out.provider === 'string') out.provider = boundedText(out.provider, MAX_PROVIDER_LENGTH)
338
+ if (typeof out.type === 'string') out.type = boundedText(out.type, MAX_PROVIDER_LENGTH)
339
+ if (typeof out.email === 'string') out.email = boundedText(out.email, MAX_EMAIL_LENGTH)
340
+ for (const key of ['project_id', 'projectId', 'account', 'label', 'status_message', 'statusMessage', 'plan_type', 'planType']) {
341
+ if (typeof out[key] === 'string') out[key] = boundedText(out[key], MAX_METADATA_LENGTH)
342
+ }
343
+ if (!text(out.projectId) && text(out.project_id)) out.projectId = boundedText(out.project_id, MAX_METADATA_LENGTH)
344
+ if (!text(out.chatgptAccountId) && text(out.chatgpt_account_id)) out.chatgptAccountId = boundedText(out.chatgpt_account_id, MAX_ACCOUNT_ID_LENGTH)
345
+ if (!text(out.userId) && text(out.user_id)) out.userId = boundedText(out.user_id, MAX_ACCOUNT_ID_LENGTH)
346
+ return out
347
+ }
348
+
349
+ export const pickAuthFileMetadata = strictAuth
350
+
351
+ export function discoverGatewayAccounts(payload, includeProviders = Object.keys(GATEWAY_PROVIDER_ADAPTERS)) {
352
+ const rows = authCollection(payload)
353
+ if (rows === null) throw errorOf('CPA_RESPONSE_INVALID', 'auth-files response has no account list')
354
+ const allowed = new Set((Array.isArray(includeProviders) ? includeProviders : []).map(value => canonicalGatewayProvider(boundedText(value, MAX_PROVIDER_LENGTH))).filter(Boolean))
355
+ const accounts = []
356
+ const unsupported = new Set()
357
+ for (const raw of rows) {
358
+ const account = strictAuth(raw)
359
+ if (account === null) continue
360
+ const rawProvider = text(account.provider || account.type).toLowerCase()
361
+ const provider = canonicalGatewayProvider(rawProvider)
362
+ if (provider === null) {
363
+ if (rawProvider) unsupported.add(rawProvider)
364
+ continue
365
+ }
366
+ if (!allowed.has(provider)) continue
367
+ accounts.push({ ...account, provider })
368
+ }
369
+ return { accounts: accounts.slice(0, MAX_ACCOUNTS), unsupportedProviders: [...unsupported].sort(), truncated: accounts.length > MAX_ACCOUNTS }
370
+ }
371
+
372
+ export const discoverAccounts = discoverGatewayAccounts
373
+
374
+ function stableAccountId(provider, account) {
375
+ const identity = text(account.email) || text(account.account) || text(account.authIndex)
376
+ return createHash('sha256').update(`${provider}\n${identity}`).digest('hex').slice(0, 16)
377
+ }
378
+
379
+ export function maskAccountLabel(account) {
380
+ const email = boundedText(account?.email, MAX_EMAIL_LENGTH)
381
+ const at = email.lastIndexOf('@')
382
+ if (at > 0 && at < email.length - 1) {
383
+ const local = email.slice(0, at)
384
+ const domain = email.slice(at + 1)
385
+ const visible = local.length <= 1 ? '*' : `${local[0]}***`
386
+ return `${visible}@${domain.slice(0, 80)}`
387
+ }
388
+ const label = boundedText(account?.label || account?.account, MAX_METADATA_LENGTH)
389
+ return label ? `${label.slice(0, 2)}***` : 'account'
390
+ }
391
+
392
+ export const maskLabel = maskAccountLabel
393
+
394
+ function publicAccount(account, patch = {}) {
395
+ const provider = account.provider
396
+ return {
397
+ id: stableAccountId(provider, account),
398
+ provider,
399
+ label: maskAccountLabel(account),
400
+ status: patch.status || 'error',
401
+ message: text(patch.message).slice(0, 160),
402
+ windows: Array.isArray(patch.windows) ? patch.windows : [],
403
+ ...(patch.plan ? { plan: text(patch.plan).slice(0, 40) } : {}),
404
+ ...(patch.credits !== undefined ? { credits: patch.credits } : {}),
405
+ }
406
+ }
407
+
408
+ function normalizeInnerEnvelope(payload, phase) {
409
+ const object = asRecord(payload)
410
+ const status = Number(object?.status_code)
411
+ if (!Number.isInteger(status) || typeof object.body !== 'string') throw errorOf('CPA_RESPONSE_INVALID', `${phase} envelope is invalid`)
412
+ if (status < 200 || status >= 300) throw errorOf('CPA_INNER_HTTP_ERROR', `${phase} upstream HTTP ${status}`, { status })
413
+ if (Buffer.byteLength(object.body, 'utf8') > MAX_BODY_BYTES) throw errorOf('CPA_RESPONSE_INVALID', `${phase} upstream body is too large`)
414
+ try { return JSON.parse(object.body) } catch { throw errorOf('CPA_RESPONSE_INVALID', `${phase} upstream body is not JSON`) }
415
+ }
416
+
417
+ async function apiCall(source, managementKey, account, request, options = {}) {
418
+ const fetchImpl = options.fetchImpl ?? fetch
419
+ // 副作用端点硬闸(FORBIDDEN_REQUEST_MARKERS 的实际执行点):任何请求(URL 或
420
+ // body 序列化)命中消费/补全端点标记即拒绝发出——深度防御,防未来 adapter
421
+ // 改动引入会烧积分或消耗 token 的端点(额度表绝不允许产生计费副作用)。
422
+ const serializedRequest = JSON.stringify([request.method, request.url, request.data ?? ''])
423
+ for (const marker of FORBIDDEN_REQUEST_MARKERS) {
424
+ if (serializedRequest.includes(marker)) {
425
+ throw errorOf('SOURCE_BLOCKED_BY_POLICY', `refusing side-effect endpoint: ${marker}`)
426
+ }
427
+ }
428
+ const body = {
429
+ auth_index: account.authIndex,
430
+ method: request.method,
431
+ url: request.url,
432
+ header: { ...(request.headers ?? {}) },
433
+ ...(request.data !== undefined ? { data: request.data } : {}),
434
+ }
435
+ // Project/account metadata is taken from strict discovery only; it is never public state.
436
+ const projectId = antigravityProjectId(account)
437
+ if (projectId && request.url.includes('cloudcode-pa')) {
438
+ body.data = request.data || JSON.stringify({ project: projectId })
439
+ }
440
+ for (let attempt = 0; attempt < GATEWAY_RESPONSE_ATTEMPTS; attempt++) {
441
+ let response
442
+ try {
443
+ response = await fetchWithRetry(endpoint(source, GATEWAY_MANAGEMENT_PATHS.apiCall), {
444
+ method: 'POST',
445
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Management-Key': managementKey },
446
+ body: JSON.stringify(body),
447
+ redirect: 'manual',
448
+ signal: options.signal,
449
+ }, { attempts: 2, timeoutMs: 30_000, fetchImpl })
450
+ } catch (error) {
451
+ if (error?.code) throw error
452
+ throw errorOf('CPA_OUTER_HTTP_ERROR', 'CPA api-call failed')
453
+ }
454
+ if (response.status >= 300 && response.status < 400) throw errorOf('CPA_REDIRECT_REFUSED', 'CPA redirect refused', { status: response.status })
455
+ // api-call 是副作用闸门已校验的只读额度请求;仅对暂态管理端状态重放一次。
456
+ if (isRetryableGatewayStatus(response.status) && attempt + 1 < GATEWAY_RESPONSE_ATTEMPTS) {
457
+ await discardResponse(response)
458
+ await sleep(gatewayRetryDelay(response))
459
+ continue
460
+ }
461
+ try {
462
+ const envelope = await readJsonResponse(response, 'api-call')
463
+ return normalizeInnerEnvelope(envelope, 'api-call')
464
+ } catch (error) {
465
+ // CPA 把上游 HTTP 状态装进 JSON envelope。429/5xx 同样属于暂态,
466
+ // 但认证、策略与格式错误绝不重试。
467
+ if (error?.code === 'CPA_INNER_HTTP_ERROR' && isRetryableGatewayStatus(error.status)
468
+ && attempt + 1 < GATEWAY_RESPONSE_ATTEMPTS) {
469
+ await sleep(GATEWAY_RETRY_DELAY_MS)
470
+ continue
471
+ }
472
+ throw error
473
+ }
474
+ }
475
+ throw errorOf('CPA_OUTER_HTTP_ERROR', 'CPA api-call retry exhausted', { status: 0 })
476
+ }
477
+
478
+ async function queryApiAccount(source, managementKey, account, options = {}) {
479
+ const adapter = GATEWAY_PROVIDER_ADAPTERS[account.provider]
480
+ if (!adapter || typeof adapter.requests !== 'function') return publicAccount(account, { status: 'unsupported', message: 'provider adapter is unavailable' })
481
+ if (account.provider === 'codex' && !account.authIndex) return publicAccount(account, { status: 'capability_missing', message: 'PROVIDER_CAPABILITY_MISSING: Codex account metadata is missing' })
482
+ if (account.provider === 'antigravity' && !text(account.project_id || account.projectId)) {
483
+ return publicAccount(account, { status: 'capability_missing', message: 'PROVIDER_CAPABILITY_MISSING: Antigravity project metadata is missing' })
484
+ }
485
+ const requests = adapter.requests(account)
486
+ const bodies = new Array(requests.length)
487
+ let lastError = null
488
+ for (let index = 0; index < requests.length; index++) {
489
+ try {
490
+ bodies[index] = await apiCall(source, managementKey, account, requests[index], options)
491
+ if (account.provider !== 'xai') break
492
+ } catch (error) {
493
+ lastError = error
494
+ if (account.provider === 'antigravity' && (error.code === 'CPA_INNER_HTTP_ERROR' || error.status === 404 || error.status === 403)) continue
495
+ if (account.provider === 'xai') continue
496
+ break
497
+ }
498
+ }
499
+ const available = bodies.filter(value => value !== undefined).length
500
+ if (available === 0) {
501
+ const code = lastError?.code || 'CPA_OUTER_HTTP_ERROR'
502
+ return publicAccount(account, { status: 'error', message: safeMessage(code, lastError?.message) })
503
+ }
504
+ try {
505
+ const unpack = value => {
506
+ const object = asRecord(value)
507
+ return object !== null && object.config !== undefined ? object.config : value
508
+ }
509
+ const primaryPayload = bodies.find(value => value !== undefined)
510
+ let extraModels = null
511
+ if (account.provider === 'antigravity') {
512
+ const hasGeminiQuota = payload => {
513
+ try { return adapter.parse(payload, { geminiOnly: true }).windows.length > 0 } catch { return false }
514
+ }
515
+ if (!hasGeminiQuota(primaryPayload)) {
516
+ for (const url of ANTIGRAVITY_MODELS_URLS) {
517
+ options.signal?.throwIfAborted()
518
+ try {
519
+ const res = await apiCall(source, managementKey, account, {
520
+ method: 'POST',
521
+ url,
522
+ headers: ANTIGRAVITY_REQUEST_HEADERS,
523
+ data: '{}',
524
+ }, options)
525
+ options.signal?.throwIfAborted()
526
+ if (res && typeof res === 'object' && hasGeminiQuota({ models: res.models })) {
527
+ extraModels = res.models
528
+ break
529
+ }
530
+ } catch { options.signal?.throwIfAborted() }
531
+ }
532
+ }
533
+ }
534
+ const targetPayload = account.provider === 'xai'
535
+ ? { weekly: unpack(bodies[0]), monthly: unpack(bodies[1]) }
536
+ : (account.provider === 'antigravity' && extraModels
537
+ ? { ...(asRecord(primaryPayload) ?? {}), models: extraModels }
538
+ : primaryPayload)
539
+ const parsed = adapter.parse(
540
+ targetPayload,
541
+ {
542
+ now: options.now,
543
+ // 只有 Antigravity 有"第三方模型池"分组,其余 provider 不吃这个位。
544
+ ...(account.provider === 'antigravity' ? { geminiOnly: source.antigravityOnlyGemini === true } : {}),
545
+ },
546
+ )
547
+ return publicAccount(account, { status: 'ok', windows: parsed.windows, plan: parsed.plan })
548
+ } catch (error) {
549
+ const code = error?.code || 'PROVIDER_PARSE_ERROR'
550
+ return publicAccount(account, { status: 'error', message: safeMessage(code, error?.message) })
551
+ }
552
+ }
553
+
554
+ async function runLimited(items, limit, worker) {
555
+ const results = new Array(items.length)
556
+ let cursor = 0
557
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
558
+ while (true) {
559
+ const index = cursor++
560
+ if (index >= items.length) return
561
+ results[index] = await worker(items[index], index)
562
+ }
563
+ })
564
+ await Promise.all(runners)
565
+ return results
566
+ }
567
+
568
+ async function queryWorkBuddy(source, managementKey, discovered, options = {}) {
569
+ try {
570
+ const response = await fetchManagement(source, GATEWAY_MANAGEMENT_PATHS.workbuddyCredits, managementKey, options)
571
+ const parsed = parseWorkBuddyCredits(response.payload)
572
+ const byIndex = new Map(discovered.map(account => [account.authIndex, account]))
573
+ return parsed.accounts.map(row => {
574
+ const authIndex = text(row.authIndex)
575
+ const account = byIndex.get(authIndex) || { provider: 'workbuddy', authIndex }
576
+ return publicAccount({ ...account, provider: 'workbuddy' }, {
577
+ status: row.status === 'ok' ? 'ok' : row.status,
578
+ message: row.message,
579
+ windows: row.windows,
580
+ credits: row.credits,
581
+ })
582
+ })
583
+ } catch (error) {
584
+ const code = error?.status === 404 ? 'WORKBUDDY_PLUGIN_UNAVAILABLE' : error?.code || 'CPA_OUTER_HTTP_ERROR'
585
+ return discovered.map(account => publicAccount(account, { status: 'error', message: safeMessage(code, error?.message) }))
586
+ }
587
+ }
588
+
589
+ export function emptyGatewayQuota(source = {}) {
590
+ const cfg = normalizeGatewaySource(source)
591
+ return {
592
+ id: cfg.id,
593
+ type: cfg.type,
594
+ label: cfg.label,
595
+ status: cfg.enabled === false || cfg.display === 'off' ? 'off' : 'error',
596
+ message: '',
597
+ fetchedAt: 0,
598
+ attemptedAt: 0,
599
+ serverVersion: '',
600
+ keyConfigured: false,
601
+ keySource: 'none',
602
+ accounts: [],
603
+ unsupportedProviders: [],
604
+ }
605
+ }
606
+
607
+ export const emptyGatewayQuotaSource = emptyGatewayQuota
608
+
609
+ /** Query one source. The returned object is safe for strict public state. */
610
+ export async function queryGatewayQuota(ctx, rawSource, options = {}) {
611
+ const source = validateGatewaySource(rawSource)
612
+ const clock = typeof options.now === 'function' ? options.now : () => typeof options.now === 'number' ? options.now : Date.now()
613
+ const attemptedAt = Number(clock()) || Date.now()
614
+ const key = await resolveManagementKey(ctx, source)
615
+ const base = { ...emptyGatewayQuota(source), attemptedAt, keyConfigured: key.configured, keySource: key.source }
616
+ if (!source.enabled || source.display === 'off') return { ...base, status: 'off' }
617
+ if (!key.configured) return { ...base, status: 'error', message: 'CPA_AUTH_FAILED: management key is not configured' }
618
+ const fetchImpl = options.fetchImpl ?? fetch
619
+ let authPayload
620
+ let version = ''
621
+ try {
622
+ const response = await fetchManagement(source, GATEWAY_MANAGEMENT_PATHS.authFiles, key.value, { ...options, fetchImpl })
623
+ authPayload = response.payload
624
+ version = response.serverVersion
625
+ } catch (error) {
626
+ return { ...base, status: 'error', message: safeMessage(error?.code || 'CPA_OUTER_HTTP_ERROR', error?.message) }
627
+ }
628
+ let discovery
629
+ try {
630
+ discovery = discoverGatewayAccounts(authPayload, source.includeProviders)
631
+ } catch (error) {
632
+ return { ...base, status: 'error', message: safeMessage(error?.code || 'CPA_RESPONSE_INVALID', error?.message) }
633
+ }
634
+ const normal = discovery.accounts.filter(account => account.provider !== 'workbuddy')
635
+ const work = discovery.accounts.filter(account => account.provider === 'workbuddy')
636
+ const apiAccounts = await runLimited(normal, DEFAULT_CONCURRENCY, account => queryApiAccount(source, key.value, account, { ...options, fetchImpl }))
637
+ const workAccounts = work.length > 0 ? await queryWorkBuddy(source, key.value, work, { ...options, fetchImpl }) : []
638
+ const accounts = [...apiAccounts, ...workAccounts]
639
+ const successes = accounts.filter(account => account.status === 'ok').length
640
+ const failures = accounts.filter(account => account.status === 'error' || account.status === 'capability_missing').length
641
+ const status = accounts.length === 0 ? 'ok' : failures === 0 ? 'ok' : successes > 0 ? 'partial' : 'error'
642
+ return {
643
+ ...base,
644
+ status,
645
+ fetchedAt: Number(clock()) || attemptedAt,
646
+ serverVersion: version,
647
+ accounts,
648
+ unsupportedProviders: discovery.unsupportedProviders,
649
+ ...(discovery.truncated ? { message: 'auth-files account list truncated to 16 accounts' } : {}),
650
+ }
651
+ }
652
+
653
+ export function gatewaySourceUsesVar(source, varName) {
654
+ return managementKeyVarOf(source) === varName
655
+ }
656
+
657
+ export const __test = {
658
+ authCollection,
659
+ strictAuth,
660
+ normalizeInnerEnvelope,
661
+ stableAccountId,
662
+ isLoopback,
663
+ apiCall,
664
+ }
@@ -0,0 +1,13 @@
1
+ // Local shim for @deepseek-ai/dsh-home-paths (used by the vendored WorkBuddy
2
+ // adapter). Mirrors the DSH home resolution rules without adding a package
3
+ // dependency to the gateway plugin.
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ export function resolveDshHome() {
8
+ const configured = String(process.env.DSH_HOME || '').trim()
9
+ if (configured === '~') return os.homedir()
10
+ if (configured.startsWith('~/')) return path.join(os.homedir(), configured.slice(2))
11
+ if (configured) return path.resolve(configured)
12
+ return path.join(os.homedir(), '.dsh')
13
+ }