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.
- package/PROTOCOL.md +168 -1
- package/README.md +63 -14
- package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -0
- package/bin/setup-ip.mjs +0 -0
- package/lib/account-bridge.mjs +57 -0
- package/lib/client.js +5 -5
- package/lib/context-bridge.mjs +1715 -0
- package/lib/cost-meter-bridge.mjs +794 -0
- package/lib/cost-meter-dashboard-bridge.mjs +1449 -0
- package/lib/dsh-host-adapter.mjs +1 -1
- package/lib/index.mjs +111 -15
- package/lib/plugin-updates.mjs +103 -10
- package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -0
- package/lib/vendor/cost-meter/coding-plans.mjs +1668 -0
- package/lib/vendor/cost-meter/credentials-shim.mjs +8 -0
- package/lib/vendor/cost-meter/custom-balance.mjs +281 -0
- package/lib/vendor/cost-meter/factory-quota.mjs +69 -0
- package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -0
- package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -0
- package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -0
- package/lib/vendor/cost-meter/net.mjs +183 -0
- package/lib/vendor/cost-meter/opendesign.mjs +204 -0
- package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -0
- package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -0
- package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -0
- package/lib/vendor/cost-meter/workbuddy.mjs +356 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -54
|
@@ -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
|
+
}
|