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,8 @@
|
|
|
1
|
+
// Local shim for @deepseek-ai/dsh-credentials' credentialRef branding helper.
|
|
2
|
+
// The vendored quota adapters only need name validation; secrets are resolved
|
|
3
|
+
// through the gateway host context, never stored here.
|
|
4
|
+
export function credentialRef(value) {
|
|
5
|
+
const ref = String(value || '')
|
|
6
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) throw new TypeError(`credential ref "${ref}" must be an environment variable name`)
|
|
7
|
+
return ref
|
|
8
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自定义 Provider 余额查询 adapter(用户可配置 HTTP 端点 + 声明式 extract)。
|
|
3
|
+
* 与 coding-plans.js 固定端点 adapter 互补:共用 index.js 侧的 refresh/cache 模式。
|
|
4
|
+
*/
|
|
5
|
+
import { credentialRef } from './credentials-shim.mjs'
|
|
6
|
+
// 密钥头判定自 net.js 直接导入(v1.7.9):此前从 store.js 导入会在
|
|
7
|
+
// coding-plans → custom-balance → store → plan-billing → coding-plans 的
|
|
8
|
+
// ESM 环上再加一条边,DSH Desktop 的加载顺序下爆发 TDZ。net.js 是零本地
|
|
9
|
+
// 依赖底层,从它导入即断环。
|
|
10
|
+
import { fetchWithRetry, looksLikeSecretHeaderValue, readJsonBounded } from './net.mjs'
|
|
11
|
+
import { ALIYUN_BALANCE_CREDENTIAL_VARS, queryAliyunBalance } from './aliyun-balance.mjs'
|
|
12
|
+
import { TOKENRHYTHM_BALANCE_CREDENTIAL_VARS, queryTokenRhythmBalance } from './tokenrhythm-balance.mjs'
|
|
13
|
+
// 复用网关层的 loopback 判定(issue #87 v1.7.25+):本机回环端点(如
|
|
14
|
+
// dsh-workbuddy-connect 的 /plugins/.../status)只监听 127.0.0.1,不提供 TLS,
|
|
15
|
+
// 与 gatewayQuotas 同口径放行明文 http;非回环主机仍强制 https。
|
|
16
|
+
// gateway-quotas → gateway-quota-adapters 均不反向依赖本模块,无 ESM 环。
|
|
17
|
+
import { isLoopbackHost } from './gateway-quotas.mjs'
|
|
18
|
+
|
|
19
|
+
export const CUSTOM_BALANCE_ADAPTER_ID = 'custom'
|
|
20
|
+
|
|
21
|
+
/** A root-relative route follows the running host, never a persisted port or browser input. */
|
|
22
|
+
export const isHostRelativeBalanceUrl = value => typeof value === 'string' && value.startsWith('/')
|
|
23
|
+
export function customBalanceHostOrigin(ctx) {
|
|
24
|
+
try {
|
|
25
|
+
const server = ctx?.get?.('webServer'), port = server?.port
|
|
26
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
|
|
27
|
+
const host = server.host === '::' || server.host === '::1' || server.host === '[::1]' ? '[::1]' : '127.0.0.1'
|
|
28
|
+
if (server.host !== undefined && !['0.0.0.0', '::'].includes(server.host) && !isLoopbackHost(server.host)) return null
|
|
29
|
+
return `http://${host}:${port}`
|
|
30
|
+
} catch { return null }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveCustomBalanceUrl(value, ctx) {
|
|
34
|
+
if (typeof value !== 'string' || /[\\\s\u0000-\u001f\u007f]/.test(value)) throw new Error('customBalance.request.url is not a valid URL')
|
|
35
|
+
let parsed
|
|
36
|
+
if (isHostRelativeBalanceUrl(value)) {
|
|
37
|
+
if (value.startsWith('//')) throw new Error('customBalance.request.url must be a single-slash host-relative path')
|
|
38
|
+
const origin = customBalanceHostOrigin(ctx)
|
|
39
|
+
if (!origin) throw new Error('DSH web server is not listening; retry the host-relative balance query after startup')
|
|
40
|
+
parsed = new URL(value, origin)
|
|
41
|
+
if (parsed.origin !== origin) throw new Error('customBalance.request.url must stay on the current DSH host')
|
|
42
|
+
} else {
|
|
43
|
+
try { parsed = new URL(value) } catch { throw new Error('customBalance.request.url is not a valid URL') }
|
|
44
|
+
}
|
|
45
|
+
if (parsed.username || parsed.password || parsed.hash) throw new Error('customBalance.request.url must not contain credentials or a fragment')
|
|
46
|
+
return parsed
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function customBalanceCredentialVars(entry) {
|
|
50
|
+
if (entry?.adapter === 'aliyun') return [...ALIYUN_BALANCE_CREDENTIAL_VARS]
|
|
51
|
+
if (entry?.adapter === 'tokenrhythm') return [...TOKENRHYTHM_BALANCE_CREDENTIAL_VARS]
|
|
52
|
+
return [...new Set(Object.values(entry?.request?.headers ?? {}).flatMap(value =>
|
|
53
|
+
typeof value === 'string' ? [...value.matchAll(/\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g)].map(match => match[1]) : []))]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 按点路径取响应 JSON 的字段(v1.6.8 加自有属性守卫)。
|
|
58
|
+
* 只在**自有属性**上取值:`current[segment]` 会沿原型链上溯,`__proto__`/`constructor`
|
|
59
|
+
* 等段能读到 Object.prototype 上的继承属性——恶意/畸形响应可能借此把继承值伪装成
|
|
60
|
+
* 业务字段。改为 hasOwn 后路径解析严格限定在响应对象自身结构内。
|
|
61
|
+
* @param {unknown} root
|
|
62
|
+
* @param {string} path
|
|
63
|
+
*/
|
|
64
|
+
function getPath(root, path) {
|
|
65
|
+
if (typeof path !== 'string' || path.length === 0) return undefined
|
|
66
|
+
let current = root
|
|
67
|
+
for (const segment of path.split('.')) {
|
|
68
|
+
if (current === null || current === undefined || typeof current !== 'object') return undefined
|
|
69
|
+
if (!Object.hasOwn(current, segment)) return undefined
|
|
70
|
+
current = current[segment]
|
|
71
|
+
}
|
|
72
|
+
return current
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 严格取数(v1.6.1 B-3 残留变体):Number(null)/Number('')/Number(false) 都
|
|
77
|
+
* 是 0,会蒙混过 isFinite 守卫把「提取失败」伪造成 remaining:0。这里只接受
|
|
78
|
+
* 数字与非空数值字面量字符串;其余一律 NaN(fail-loud)。
|
|
79
|
+
* @param {unknown} value
|
|
80
|
+
* @returns {number} 有限数值,或 NaN 表示不可信。
|
|
81
|
+
*/
|
|
82
|
+
function toStrictNumber(value) {
|
|
83
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : NaN
|
|
84
|
+
if (typeof value === 'string') {
|
|
85
|
+
const text = value.trim()
|
|
86
|
+
if (text.length > 0 && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(text)) return Number(text)
|
|
87
|
+
return NaN
|
|
88
|
+
}
|
|
89
|
+
return NaN
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {unknown} data
|
|
94
|
+
* @param {unknown} rule
|
|
95
|
+
*/
|
|
96
|
+
export function extractByRule(data, rule) {
|
|
97
|
+
if (rule === null || rule === undefined) return null
|
|
98
|
+
if (typeof rule === 'number' && Number.isFinite(rule)) return rule
|
|
99
|
+
if (typeof rule === 'string') {
|
|
100
|
+
const value = getPath(data, rule)
|
|
101
|
+
const num = toStrictNumber(value)
|
|
102
|
+
// 非数值字符串原样交回外层 fail-loud(千分位/带货币符号等由 queryCustomBalance 抛错),
|
|
103
|
+
// 而 null/布尔/对象等一律 null,不再被 Number() 强转成 0。
|
|
104
|
+
if (Number.isFinite(num)) return num
|
|
105
|
+
return typeof value === 'string' ? value : null
|
|
106
|
+
}
|
|
107
|
+
if (typeof rule === 'object' && !Array.isArray(rule)) {
|
|
108
|
+
const op = rule.op
|
|
109
|
+
if (op === 'subtract' && Array.isArray(rule.paths)) {
|
|
110
|
+
if (rule.paths.length === 0) return null // 空 paths 防 Reduce of empty array 报错(与 add 的空数组返 0 区分:减法无中性初值)
|
|
111
|
+
const values = rule.paths.map(path => toStrictNumber(getPath(data, path)))
|
|
112
|
+
if (!values.every(Number.isFinite)) return null
|
|
113
|
+
return values.reduce((acc, value) => acc - value)
|
|
114
|
+
}
|
|
115
|
+
if (op === 'add' && Array.isArray(rule.paths)) {
|
|
116
|
+
const values = rule.paths.map(path => toStrictNumber(getPath(data, path)))
|
|
117
|
+
if (!values.every(Number.isFinite)) return null
|
|
118
|
+
return values.reduce((acc, value) => acc + value, 0)
|
|
119
|
+
}
|
|
120
|
+
if (op === 'divide' && typeof rule.path === 'string') {
|
|
121
|
+
const value = toStrictNumber(getPath(data, rule.path))
|
|
122
|
+
const by = toStrictNumber(rule.by)
|
|
123
|
+
if (!Number.isFinite(value) || !Number.isFinite(by) || by === 0) return null
|
|
124
|
+
return value / by
|
|
125
|
+
}
|
|
126
|
+
if (typeof rule.path === 'string') return extractByRule(data, rule.path)
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {string} value
|
|
133
|
+
* @param {import('cordis').Context} ctx
|
|
134
|
+
*/
|
|
135
|
+
async function resolveTemplateString(value, ctx) {
|
|
136
|
+
const pattern = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g
|
|
137
|
+
let out = value
|
|
138
|
+
const names = [...value.matchAll(pattern)].map(match => match[1])
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
let resolved = ''
|
|
141
|
+
const credentials = ctx.get('credentials')
|
|
142
|
+
if (credentials !== undefined) {
|
|
143
|
+
try {
|
|
144
|
+
const hit = await credentials.resolve(credentialRef(name))
|
|
145
|
+
if (typeof hit?.value === 'string' && hit.value.length > 0) resolved = hit.value
|
|
146
|
+
} catch {
|
|
147
|
+
// fall through to env
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (resolved.length === 0) resolved = String(process.env[name] ?? '').trim()
|
|
151
|
+
// 函数替换而非字符串替换:resolved 是凭据/密钥,若含 $$、$&、$' 等
|
|
152
|
+
// String.replace 特殊模式会被错误展开,密钥内容被破坏。
|
|
153
|
+
out = out.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, 'g'), () => resolved)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {Record<string, string>} headers
|
|
160
|
+
* @param {import('cordis').Context} ctx
|
|
161
|
+
*/
|
|
162
|
+
async function resolveHeaders(headers, ctx) {
|
|
163
|
+
const out = {}
|
|
164
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
165
|
+
if (typeof value !== 'string') continue
|
|
166
|
+
out[key] = await resolveTemplateString(value, ctx)
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 判定字符串是否携带凭据占位符( {{VAR}} 形式,解析自 DSH 凭据库或环境变量)。 */
|
|
172
|
+
const hasCredentialPlaceholder = value => typeof value === 'string' && /\{\{\s*[A-Za-z_][A-Za-z0-9_]*\s*\}\}/.test(value)
|
|
173
|
+
|
|
174
|
+
/** 已警告过的主机(进程内去重,避免每次轮询刷屏)。 */
|
|
175
|
+
const warnedHosts = new Set()
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {import('cordis').Context} ctx
|
|
179
|
+
* @param {Record<string, unknown>} config
|
|
180
|
+
*/
|
|
181
|
+
export async function queryCustomBalance(ctx, config, { signal } = {}) {
|
|
182
|
+
signal?.throwIfAborted()
|
|
183
|
+
const custom = config?.customBalance
|
|
184
|
+
if (custom?.enabled !== true) {
|
|
185
|
+
const error = new Error('custom balance disabled')
|
|
186
|
+
error.soft = true
|
|
187
|
+
throw error
|
|
188
|
+
}
|
|
189
|
+
if (custom.adapter === 'aliyun') return queryAliyunBalance(ctx, config, { signal })
|
|
190
|
+
if (custom.adapter === 'tokenrhythm') return queryTokenRhythmBalance(ctx, config, { signal })
|
|
191
|
+
const request = custom.request
|
|
192
|
+
if (request === null || typeof request !== 'object' || typeof request.url !== 'string' || request.url.length === 0) {
|
|
193
|
+
throw new Error('customBalance.request.url is required')
|
|
194
|
+
}
|
|
195
|
+
const parsedUrl = resolveCustomBalanceUrl(request.url, ctx)
|
|
196
|
+
const loopbackTarget = isLoopbackHost(parsedUrl.hostname)
|
|
197
|
+
const host = parsedUrl.host.toLowerCase()
|
|
198
|
+
const allowed = Array.isArray(custom.allowedHosts)
|
|
199
|
+
? custom.allowedHosts.filter(h => typeof h === 'string' && h.trim()).map(h => h.trim().toLowerCase()) : null
|
|
200
|
+
// #309: non-loopback HTTP requires both an explicit switch and the exact host:port.
|
|
201
|
+
if (parsedUrl.protocol !== 'https:' && !(parsedUrl.protocol === 'http:'
|
|
202
|
+
&& (loopbackTarget || custom.allowInsecureHttp === true && allowed?.includes(host)))) {
|
|
203
|
+
throw new Error('customBalance.request.url must use https; non-loopback HTTP requires allowInsecureHttp and an exact allowedHosts entry (host:port)')
|
|
204
|
+
}
|
|
205
|
+
// 凭据外带防护(v1.6.8 → v1.7.6 扩展,issue #86):请求头里**解析出了真实凭据**
|
|
206
|
+
// ({{VAR}} 占位符)或**值本身疑似明文密钥**(Authorization / X-Api-Key / Bearer /
|
|
207
|
+
// sk- 等启发式,与 store.js 脱敏同一判定)时,把本次出站主机名与
|
|
208
|
+
// customBalance.allowedHosts(用户显式配置的白名单)比对——
|
|
209
|
+
// · 配置了白名单 → 不在名单内直接拒绝(防导入他人配置导致密钥外带);
|
|
210
|
+
// · 未配置白名单 → 放行但打一次警告,提示可配置 allowedHosts 收紧。
|
|
211
|
+
// 兼容取舍:硬性要求所有存量用户补 allowedHosts 会直接打断现有配置,故默认仅警告。
|
|
212
|
+
// v1.6.8 只认 {{VAR}} 占位符,明文 key(Bearer sk-xxx)完全绕过校验——而明文恰是
|
|
213
|
+
// 更常见、也更容易被「导入他人配置」带往任意主机的场景,v1.7.6 一并纳入。
|
|
214
|
+
const rawHeaders = request.headers ?? {}
|
|
215
|
+
const usesCredentials = Object.entries(rawHeaders).some(([name, value]) =>
|
|
216
|
+
hasCredentialPlaceholder(value) || looksLikeSecretHeaderValue(name, value))
|
|
217
|
+
if (usesCredentials) {
|
|
218
|
+
if (allowed !== null && allowed.length > 0 && !allowed.includes(host)) {
|
|
219
|
+
throw new Error(`customBalance request host "${host}" is not in customBalance.allowedHosts; refusing to send credentials there (add it explicitly to allow)`)
|
|
220
|
+
}
|
|
221
|
+
if (allowed === null && !warnedHosts.has(host)) {
|
|
222
|
+
warnedHosts.add(host)
|
|
223
|
+
console.warn(`[dsh-cost-meter] 自定义余额请求携带凭据发往 ${host}:建议在 customBalance.allowedHosts 中显式列出该主机,防止导入他人配置导致密钥外带`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const method = typeof request.method === 'string' ? request.method.toUpperCase() : 'GET'
|
|
227
|
+
const headers = await resolveHeaders(rawHeaders, ctx)
|
|
228
|
+
const init = { method, headers, redirect: 'manual', signal }
|
|
229
|
+
if (method !== 'GET' && method !== 'HEAD' && request.body !== undefined) {
|
|
230
|
+
init.body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body)
|
|
231
|
+
if (!Object.keys(headers).some(name => name.toLowerCase() === 'content-type')) {
|
|
232
|
+
init.headers = { ...headers, 'content-type': 'application/json' }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// 瞬时网络错误自动重试(issue #28 同一封装;body 为字符串可安全重放)。
|
|
236
|
+
// redirect: 'manual'(v1.6.8):禁止自动跟随重定向——凭据头会被原样带到 3xx 的
|
|
237
|
+
// 目标主机,一次跨源重定向就足以把密钥发去别处;遇到重定向直接报错。
|
|
238
|
+
const response = await fetchWithRetry(parsedUrl.href, init, { timeoutMs: 15000 })
|
|
239
|
+
if (response.status >= 300 && response.status < 400) {
|
|
240
|
+
try { await response.body?.cancel?.() } catch {}
|
|
241
|
+
throw new Error(`custom balance endpoint redirected (HTTP ${String(response.status)}); redirects are refused to avoid forwarding credentials to another host`)
|
|
242
|
+
}
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
try { await response.body?.cancel?.() } catch { /* 清理失败不掩盖 HTTP 错误。 */ }
|
|
245
|
+
throw new Error(`custom balance HTTP ${String(response.status)}`)
|
|
246
|
+
}
|
|
247
|
+
const data = await readJsonBounded(response)
|
|
248
|
+
const extract = custom.extract ?? {}
|
|
249
|
+
// 空值感知(B-3):extractByRule 失败返回 null,而 Number(null) === 0 会
|
|
250
|
+
// 蒙混过 isFinite 守卫——提取失败绝不能伪造成 remaining:0 的「成功」。
|
|
251
|
+
const rawRemaining = extractByRule(data, extract.remaining)
|
|
252
|
+
const remaining = toStrictNumber(rawRemaining)
|
|
253
|
+
if (!Number.isFinite(remaining)) {
|
|
254
|
+
throw new Error('custom balance extract.remaining is missing or not numeric')
|
|
255
|
+
}
|
|
256
|
+
const maxBudget = toStrictNumber(extract.maxBudget !== undefined ? extractByRule(data, extract.maxBudget) : null)
|
|
257
|
+
const spend = toStrictNumber(extract.spend !== undefined ? extractByRule(data, extract.spend) : null)
|
|
258
|
+
const unit = typeof custom.unit === 'string' && custom.unit.length > 0
|
|
259
|
+
? custom.unit
|
|
260
|
+
: (typeof extract.unit === 'string' && extract.unit.length > 0 ? extract.unit : 'USD')
|
|
261
|
+
return {
|
|
262
|
+
label: typeof custom.label === 'string' && custom.label.length > 0 ? custom.label : 'Custom',
|
|
263
|
+
unit,
|
|
264
|
+
remaining,
|
|
265
|
+
maxBudget: Number.isFinite(maxBudget) ? maxBudget : null,
|
|
266
|
+
spend: Number.isFinite(spend) ? spend : null,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function emptyCustomBalance() {
|
|
271
|
+
return {
|
|
272
|
+
status: 'off',
|
|
273
|
+
message: '',
|
|
274
|
+
fetchedAt: 0,
|
|
275
|
+
label: '',
|
|
276
|
+
unit: 'USD',
|
|
277
|
+
remaining: 0,
|
|
278
|
+
maxBudget: null,
|
|
279
|
+
spend: null,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Factory billing limits (#263); fixed read-only endpoint, no model requests.
|
|
2
|
+
* Protocol reference: Dalizi2026/dsh-factory-provider@801c3a2, lib/quota.js.
|
|
3
|
+
*/
|
|
4
|
+
import { fetchWithRetry, readJsonBounded } from './net.mjs'
|
|
5
|
+
|
|
6
|
+
export const FACTORY_QUOTA_URL = 'https://api.factory.ai/api/billing/limits'
|
|
7
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
export const normalizeFactoryOrgId = value => {
|
|
9
|
+
if (value == null || value === '') return ''
|
|
10
|
+
if (typeof value !== 'string') return null
|
|
11
|
+
const text = value.trim()
|
|
12
|
+
return text === '' || /^[A-Za-z0-9_.:-]{1,128}$/.test(text) ? text : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseFactoryQuota(payload, pool = 'standard', nowMs = Date.now()) {
|
|
16
|
+
if (!record(payload?.limits)) return null
|
|
17
|
+
const windows = {}
|
|
18
|
+
for (const bucket of pool === 'both' ? ['standard', 'core'] : [pool === 'core' ? 'core' : 'standard']) {
|
|
19
|
+
const limits = payload.limits[bucket]
|
|
20
|
+
if (!record(limits)) continue
|
|
21
|
+
for (const name of ['fiveHour', 'weekly', 'monthly']) {
|
|
22
|
+
const raw = limits[name]
|
|
23
|
+
if (!record(raw) || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent) || raw.usedPercent < 0 || raw.usedPercent > 100) continue
|
|
24
|
+
let resetsAt = ''
|
|
25
|
+
const end = typeof raw.windowEnd === 'string' ? Date.parse(raw.windowEnd) : NaN
|
|
26
|
+
if (Number.isFinite(end)) resetsAt = new Date(end).toISOString()
|
|
27
|
+
else if (typeof raw.secondsRemaining === 'number' && Number.isFinite(raw.secondsRemaining) && raw.secondsRemaining >= 0) {
|
|
28
|
+
const reset = nowMs + raw.secondsRemaining * 1000
|
|
29
|
+
if (Number.isFinite(reset) && Math.abs(reset) <= 8640000000000000) resetsAt = new Date(reset).toISOString()
|
|
30
|
+
}
|
|
31
|
+
windows[bucket === 'core' ? 'core_' + name : name] = { percent: raw.usedPercent, resetsAt }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return Object.keys(windows).length ? windows : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function queryFactoryQuota(key, locale, t, { orgId, factoryPool, signal } = {}) {
|
|
38
|
+
const provider = 'Factory (Droid)'
|
|
39
|
+
const failure = (code, vars = {}, soft = false) => Object.assign(new Error(t(locale, code, { provider, ...vars })), { soft })
|
|
40
|
+
if (typeof key !== 'string' || !key.trim()) throw failure('codingPlanKeyMissing', {}, true)
|
|
41
|
+
const org = normalizeFactoryOrgId(orgId)
|
|
42
|
+
if (org === null) throw failure('factoryOrgInvalid')
|
|
43
|
+
const headers = {
|
|
44
|
+
authorization: `Bearer ${key.trim()}`, 'x-client-version': '0.231.0',
|
|
45
|
+
'x-factory-client': 'cli', accept: 'application/json', 'accept-encoding': 'identity',
|
|
46
|
+
}
|
|
47
|
+
if (org) headers['x-factory-org-id'] = org
|
|
48
|
+
let response
|
|
49
|
+
try {
|
|
50
|
+
response = await fetchWithRetry(FACTORY_QUOTA_URL, { method: 'GET', headers, redirect: 'manual', signal }, { timeoutMs: 15000, attempts: 2 })
|
|
51
|
+
} catch {
|
|
52
|
+
signal?.throwIfAborted()
|
|
53
|
+
throw failure('factoryQueryFailed')
|
|
54
|
+
}
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
try { await response.body?.cancel?.() } catch {}
|
|
57
|
+
if (response.status === 401 || response.status === 403) throw failure('factoryUnauthorized', {}, true)
|
|
58
|
+
throw failure('codingPlanHttp', { code: String(response.status), url: FACTORY_QUOTA_URL })
|
|
59
|
+
}
|
|
60
|
+
let payload
|
|
61
|
+
try { payload = await readJsonBounded(response) } catch {
|
|
62
|
+
signal?.throwIfAborted()
|
|
63
|
+
throw failure('codingPlanNoUsage')
|
|
64
|
+
}
|
|
65
|
+
signal?.throwIfAborted()
|
|
66
|
+
const windows = parseFactoryQuota(payload, factoryPool)
|
|
67
|
+
if (windows === null) throw failure('codingPlanNoUsage')
|
|
68
|
+
return { windows, endpoint: FACTORY_QUOTA_URL }
|
|
69
|
+
}
|