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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对外 HTTP 请求的统一重试封装(issue #28)。
|
|
3
|
+
* opencode.ai 等端点部署在 Cloudflare 之后会间歇性重置连接(ECONNRESET),
|
|
4
|
+
* Node fetch 统一抛为 `fetch failed`;对这类瞬时网络错误做少量重试即可消除
|
|
5
|
+
* 面板偶发报错,而 401/403 等业务错误重试无意义,仍由调用方原样处理。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { gunzipSync } from 'node:zlib'
|
|
9
|
+
|
|
10
|
+
/** 视为瞬时的网络错误码(Node DNS/socket 层 + undici 内部码)。 */
|
|
11
|
+
const TRANSIENT_CODES = new Set([
|
|
12
|
+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EHOSTUNREACH',
|
|
13
|
+
'ENETUNREACH', 'EPIPE', 'EAI_AGAIN',
|
|
14
|
+
'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
|
|
15
|
+
'UND_ERR_BODY_TIMEOUT', 'UND_ERR_READ_ERROR', 'UND_ERR_ABORTED',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 判定 fetch 抛出的错误是否为瞬时网络错误(值得重试)。
|
|
20
|
+
* @param {unknown} error - fetch reject 的错误。
|
|
21
|
+
*/
|
|
22
|
+
export function isTransientFetchError(error) {
|
|
23
|
+
if (typeof error !== 'object' || error === null) return false
|
|
24
|
+
// 超时信号两种历史形态均可安全重试:AbortSignal.timeout 抛 DOMException
|
|
25
|
+
// TimeoutError;老版本 undici 把超时包成 AbortError + cause.code
|
|
26
|
+
// === 'UND_ERR_ABORTED'。其余裸 AbortError 是调用方手动取消,重试既无意义
|
|
27
|
+
// 也违背取消意图,不再重试(本插件内部调用方目前只传超时信号,保持如此)。
|
|
28
|
+
if (error.name === 'TimeoutError') return true
|
|
29
|
+
if (error.name === 'AbortError') return error.cause?.code === 'UND_ERR_ABORTED'
|
|
30
|
+
const code = error.cause?.code ?? error.code
|
|
31
|
+
// 有具体 code 时以白名单为准:证书过期(EPERM/CERT_*)等持久性错误不重试。
|
|
32
|
+
if (typeof code === 'string') return TRANSIENT_CODES.has(code)
|
|
33
|
+
// 无具体 code 的纯 'fetch failed'(TypeError)按瞬时处理。
|
|
34
|
+
return error instanceof TypeError && String(error.message ?? '').includes('fetch failed')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 退避也响应调用方取消,及时清理计时器与监听器。 */
|
|
38
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
39
|
+
const abort = () => { clearTimeout(timer); reject(signal.reason) }
|
|
40
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve() }, ms)
|
|
41
|
+
if (signal?.aborted) abort()
|
|
42
|
+
else signal?.addEventListener('abort', abort, { once: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 带重试的 fetch:仅对瞬时网络错误自动重试(默认共 4 次尝试,退避 300/600/1200ms,
|
|
47
|
+
* 上限 1500ms),其余错误与 HTTP 状态码原样交回调用方(业务语义各自判断)。
|
|
48
|
+
* 每次尝试用 timeoutMs 新建超时信号——重试若复用已中止的 AbortSignal.timeout
|
|
49
|
+
* 会立即再抛 AbortError,重试形同虚设。
|
|
50
|
+
* @param {string} url - 请求地址。
|
|
51
|
+
* @param {RequestInit} [init] - fetch init(headers/method/body 等);未显式指定
|
|
52
|
+
* accept-encoding 时默认注入 identity(见函数首注释)。
|
|
53
|
+
* @param {{ attempts?: number, backoffMs?: number, timeoutMs?: number }} [options]
|
|
54
|
+
* - attempts:总尝试次数(含首次),默认 4。
|
|
55
|
+
* - backoffMs:退避基数,默认 300(实际等待 backoffMs * 2^(尝试序-2))。
|
|
56
|
+
* - timeoutMs:单次尝试超时;>0 时覆盖 init.signal,默认 0(沿用调用方信号)。
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchWithRetry(url, init = {}, { attempts = 4, backoffMs = 300, timeoutMs = 0, fetchImpl = fetch } = {}) {
|
|
59
|
+
// 部分 CDN(实测 api.commandcode.ai:DSH 0.1.7 宿主的全局 undici 8 dispatcher
|
|
60
|
+
// 默认在请求头里协商 br)会返回压缩响应体却丢失 Content-Encoding 响应头——
|
|
61
|
+
// 违反 HTTP 语义,fetch 因此不做自动解压,response.json() 对着压缩字节直接报
|
|
62
|
+
// 「额度响应无法读取为有效 JSON」。brotli 流没有可靠魔数,事后嗅探无法稳健
|
|
63
|
+
// 补救(与 #172/#173 的裸 gzip 场景同族但更难防);这些接口的响应都很小,压缩
|
|
64
|
+
// 本无收益,故调用方未显式指定时统一默认 accept-encoding: identity 关闭
|
|
65
|
+
// 内容协商,从请求侧根除,显式指定的值不被覆盖。仅对纯对象形态的 headers
|
|
66
|
+
// 注入(本仓库所有调用方均如此)且不改其形状,Headers 实例等其余形态原样透传。
|
|
67
|
+
const headers = init.headers
|
|
68
|
+
if (headers === undefined || headers === null) {
|
|
69
|
+
init = { ...init, headers: { 'accept-encoding': 'identity' } }
|
|
70
|
+
} else if (typeof headers === 'object' && typeof headers.entries !== 'function'
|
|
71
|
+
&& !Object.keys(headers).some(name => name.toLowerCase() === 'accept-encoding')) {
|
|
72
|
+
init = { ...init, headers: { ...headers, 'accept-encoding': 'identity' } }
|
|
73
|
+
}
|
|
74
|
+
const request = typeof fetchImpl === 'function' ? fetchImpl : fetch
|
|
75
|
+
for (let attempt = 1; ; attempt++) {
|
|
76
|
+
init.signal?.throwIfAborted()
|
|
77
|
+
if (attempt > 1) await sleep(Math.min(1500, backoffMs * 2 ** (attempt - 2)), init.signal)
|
|
78
|
+
let perAttempt = init
|
|
79
|
+
if (timeoutMs > 0) {
|
|
80
|
+
// 超时信号不得吞掉调用方的取消信号:两者并存时用 AbortSignal.any 组合
|
|
81
|
+
// (任一触发即中止);运行时不支持 any 时退回旧行为(仅超时信号)。
|
|
82
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
83
|
+
const signal = init.signal !== undefined && init.signal !== null && typeof AbortSignal.any === 'function'
|
|
84
|
+
? AbortSignal.any([init.signal, timeoutSignal])
|
|
85
|
+
: timeoutSignal
|
|
86
|
+
perAttempt = { ...init, signal }
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return await request(url, perAttempt)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (init.signal?.aborted) throw init.signal.reason
|
|
92
|
+
if (!isTransientFetchError(error) || attempt >= attempts) throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 裸 gzip 正文的解压兜底(issue #172):个别 CDN 边缘(实测 MiFE/3.4.34)会返回
|
|
99
|
+
* gzip 压缩字节却不带 Content-Encoding/Content-Type;undici 只按 Content-Encoding
|
|
100
|
+
* 解码,这类正文会以乱码进入 JSON.parse,报 "response body is not valid JSON"。
|
|
101
|
+
* JSON 文本不可能以 gzip 魔数(1f 8b)开头,按魔数识别无误判风险;解压输出同样受
|
|
102
|
+
* maxBytes 约束(maxOutputLength),压缩体膨胀超限按 tooLarge 处理,防止小压缩体
|
|
103
|
+
* 绕过读取上限;解压失败(伪魔数/损坏流)保留原字节,交给 JSON.parse 统一报
|
|
104
|
+
* 不回显正文的解析错误。
|
|
105
|
+
*/
|
|
106
|
+
const decodeBoundedBody = (buffer, maxBytes, tooLarge) => {
|
|
107
|
+
if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) return buffer
|
|
108
|
+
try { return gunzipSync(buffer, { maxOutputLength: maxBytes }) }
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error?.code === 'ERR_BUFFER_TOO_LARGE') throw tooLarge()
|
|
111
|
+
return buffer
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 流式限制实际响应体,不依赖可能缺失或不准确的 Content-Length。 */
|
|
116
|
+
export async function readJsonBounded(response, maxBytes = 262144) {
|
|
117
|
+
const tooLarge = () => Object.assign(new Error('response body is too large'), { code: 'RESPONSE_TOO_LARGE' })
|
|
118
|
+
const parse = text => {
|
|
119
|
+
try { return JSON.parse(text) } catch { throw new Error('response body is not valid JSON') }
|
|
120
|
+
}
|
|
121
|
+
if (Number(response.headers?.get?.('content-length')) > maxBytes) {
|
|
122
|
+
try { await response.body?.cancel?.() } catch { /* 清理失败不掩盖长度错误。 */ }
|
|
123
|
+
throw tooLarge()
|
|
124
|
+
}
|
|
125
|
+
if (response.body?.[Symbol.asyncIterator]) {
|
|
126
|
+
const chunks = []
|
|
127
|
+
let bytes = 0
|
|
128
|
+
for await (const chunk of response.body) {
|
|
129
|
+
const buffer = Buffer.from(chunk)
|
|
130
|
+
bytes += buffer.length
|
|
131
|
+
if (bytes > maxBytes) throw tooLarge()
|
|
132
|
+
chunks.push(buffer)
|
|
133
|
+
}
|
|
134
|
+
return parse(decodeBoundedBody(Buffer.concat(chunks), maxBytes, tooLarge).toString('utf8'))
|
|
135
|
+
}
|
|
136
|
+
// 兼容没有流接口的 adapter/测试替身;标准 Node Response 均走上面的有界流读取。
|
|
137
|
+
const text = typeof response.text === 'function' ? await response.text() : JSON.stringify(await response.json())
|
|
138
|
+
if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > maxBytes) throw tooLarge()
|
|
139
|
+
return parse(text)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── 请求头密钥判定(v1.7.9 自 store.js 迁入) ─────────────────────────────
|
|
143
|
+
//
|
|
144
|
+
// 本模块是插件的零本地依赖底层(store / custom-balance / coding-plans / gateway
|
|
145
|
+
// 都只依赖 net),这里存放共享判定可避免「coding-plans → custom-balance →
|
|
146
|
+
// store → plan-billing → coding-plans」的 ESM 环(v1.7.6 引入环边 custom-balance
|
|
147
|
+
// → store;v1.7.8 在 DSH Desktop 的加载顺序下爆发 TDZ:
|
|
148
|
+
// Cannot access 'CODING_PLAN_PROVIDER_IDS' before initialization)。
|
|
149
|
+
// 共享判定避免各条持久化、回传和请求路径的安全规则分叉。
|
|
150
|
+
|
|
151
|
+
/** {{VAR}} 占位符形态(与 custom-balance.js resolveTemplateString 同一文法)。 */
|
|
152
|
+
const HEADER_PLACEHOLDER_RE = /\{\{\s*[A-Za-z_][A-Za-z0-9_]*\s*\}\}/
|
|
153
|
+
|
|
154
|
+
/** 敏感头名:这类头下的非占位符值一律按密钥处理(自定义余额场景里它们就是凭据)。 */
|
|
155
|
+
const SENSITIVE_HEADER_NAME_RE = /authorization|api[-_]?key|apikey|token|secret|cookie|session|credential|private[-_]?key/i
|
|
156
|
+
|
|
157
|
+
/** 典型密钥值形状:sk- 系 / Google AIza / GitHub ghp_ / Slack xoxb- / JWT eyJ 头。 */
|
|
158
|
+
const KNOWN_KEY_VALUE_RE = /^(sk-[A-Za-z0-9_-]|rk-[A-Za-z0-9_-]|gsk_[A-Za-z0-9]|AIza[0-9A-Za-z_-]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[bap]-|eyJ[A-Za-z0-9_-]{10,})/
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 判定一个请求头的值是否疑似明文密钥(issue #86)。
|
|
162
|
+
* 两级判定:敏感头名(authorization / api-key / token 等)的非占位符值一律视为密钥;
|
|
163
|
+
* 其余头名只在值具备典型密钥形状(Bearer 前缀 / 已知 key 前缀 / ≥32 位混合字母数字的
|
|
164
|
+
* 不透明长串)时判密钥——Content-Type、Accept 等普通值不会误判(误判会让该头无法落盘)。
|
|
165
|
+
* @param {unknown} name - 头名。
|
|
166
|
+
* @param {unknown} value - 头值。
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function looksLikeSecretHeaderValue(name, value) {
|
|
170
|
+
if (typeof value !== 'string' || value.length === 0) return false
|
|
171
|
+
// 只能豁免纯引用及常见认证前缀;追加一个占位符不能使已有明文密钥绕过脱敏。
|
|
172
|
+
if (HEADER_PLACEHOLDER_RE.test(value)) {
|
|
173
|
+
const literal = value.replace(new RegExp(HEADER_PLACEHOLDER_RE.source, 'g'), '').trim()
|
|
174
|
+
if (/^(?:(?:bearer|basic|token)\s*)?[:\s]*$/i.test(literal)) return false
|
|
175
|
+
value = literal
|
|
176
|
+
}
|
|
177
|
+
if (SENSITIVE_HEADER_NAME_RE.test(String(name ?? ''))) return true
|
|
178
|
+
const trimmed = value.trim()
|
|
179
|
+
if (/^(bearer|basic|token)\s+\S/i.test(trimmed)) return true
|
|
180
|
+
if (KNOWN_KEY_VALUE_RE.test(trimmed)) return true
|
|
181
|
+
if (trimmed.length >= 32 && /^[A-Za-z0-9_-]+$/.test(trimmed) && /[A-Za-z]/.test(trimmed) && /[0-9]/.test(trimmed)) return true
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/** OpenDesign public list prices and workspace-scoped, read-only billing queries. */
|
|
2
|
+
import { fetchWithRetry, readJsonBounded } from './net.mjs'
|
|
3
|
+
|
|
4
|
+
export const OPENDESIGN_API = 'https://amr-api.open-design.ai/api/v1'
|
|
5
|
+
export const OPENDESIGN_PRICES_URL = OPENDESIGN_API + '/public/model-prices'
|
|
6
|
+
export const OPENDESIGN_PROVIDER_IDS = ['opendesign', 'open-design', 'open-design-api', 'amr-link.open-design.ai']
|
|
7
|
+
/** Machine-readable query failure codes so callers/logs can distinguish causes without parsing text. */
|
|
8
|
+
export const OPEN_DESIGN_QUOTA_FAILURES = Object.freeze({
|
|
9
|
+
invalidRequest: 'invalid_request',
|
|
10
|
+
unauthorized: 'unauthorized',
|
|
11
|
+
http: 'http_error',
|
|
12
|
+
noSubscription: 'no_subscription',
|
|
13
|
+
invalidResponse: 'invalid_response',
|
|
14
|
+
network: 'network_error',
|
|
15
|
+
identityMismatch: 'identity_mismatch',
|
|
16
|
+
})
|
|
17
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
18
|
+
const rate = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1e6
|
|
19
|
+
const id = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value)
|
|
20
|
+
const queryError = (message, soft = false, code) => Object.assign(new Error(message), { opendesignPublic: true, ...(code === undefined ? {} : { code }), ...(soft ? { soft: true } : {}) })
|
|
21
|
+
/** Issue #274: `billing/preflight` wraps the same member identity under `codingPlan` when present. */
|
|
22
|
+
const quotaMemberOf = value => object(value)
|
|
23
|
+
? value.workspaceMemberId ?? (object(value.codingPlan) ? value.codingPlan.workspaceMemberId : undefined)
|
|
24
|
+
: undefined
|
|
25
|
+
/** Preflight also reports balanceUsd; it is only a fallback when the wallet endpoint fails. */
|
|
26
|
+
const balanceWindowOf = value => {
|
|
27
|
+
const raw = object(value) ? value.balanceUsd : undefined
|
|
28
|
+
const text = typeof raw === 'string' ? raw : typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : ''
|
|
29
|
+
return /^-?\d{1,12}(?:\.\d{1,12})?$/.test(text) ? { text: '$' + text, resetsAt: '' } : null
|
|
30
|
+
}
|
|
31
|
+
export function normalizeOpenDesignWorkspace(value) {
|
|
32
|
+
if (value === undefined || value === '') return ''
|
|
33
|
+
return typeof value === 'string' && id(value.trim()) ? value.trim() : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The public API explicitly uses USD per million tokens; never apply a 1M conversion twice. */
|
|
37
|
+
export function parseOpenDesignPrices(value, checkedAt = new Date().toISOString().slice(0, 10)) {
|
|
38
|
+
if (!object(value) || !Array.isArray(value.data) || value.data.length > 10000) throw Error('OpenDesign: invalid public price catalog')
|
|
39
|
+
const models = Object.create(null)
|
|
40
|
+
const tier = raw => {
|
|
41
|
+
if (!object(raw) || !rate(raw.input) || !rate(raw.output)) return null
|
|
42
|
+
if (['cacheRead', 'cacheWrite'].some(key => raw[key] !== undefined && !rate(raw[key]))) return null
|
|
43
|
+
return { cacheMiss: raw.input, output: raw.output,
|
|
44
|
+
...(raw.cacheRead !== undefined ? { cacheHit: raw.cacheRead } : {}), ...(raw.cacheWrite !== undefined ? { cacheWrite: raw.cacheWrite } : {}) }
|
|
45
|
+
}
|
|
46
|
+
for (const row of value.data) {
|
|
47
|
+
if (!object(row) || typeof row.model !== 'string' || !/^[\w./:@+-]{1,200}$/.test(row.model) || ['__proto__', 'constructor', 'prototype'].includes(row.model)) throw Error('OpenDesign: invalid model id')
|
|
48
|
+
if (Object.hasOwn(models, row.model)) throw Error('OpenDesign: duplicate model price')
|
|
49
|
+
if (row.pricing?.unit !== '1m_tokens' || row.pricing?.currency !== 'USD') throw Error('OpenDesign: unsupported pricing unit or currency')
|
|
50
|
+
const price = tier(row.pricing)
|
|
51
|
+
if (!price) throw Error('OpenDesign: invalid token price')
|
|
52
|
+
if (row.pricing.longContext !== undefined) {
|
|
53
|
+
const raw = row.pricing.longContext, long = tier(raw)
|
|
54
|
+
if (!long || !Number.isSafeInteger(raw.inputTokensAbove) || raw.inputTokensAbove <= 0) throw Error('OpenDesign: invalid long-context price')
|
|
55
|
+
price.longContext = { aboveInputTokens: raw.inputTokensAbove, ...long }
|
|
56
|
+
}
|
|
57
|
+
models[row.model] = { ...price, billingMode: 'flat', sourceUrl: OPENDESIGN_PRICES_URL, checkedAt }
|
|
58
|
+
}
|
|
59
|
+
if (!Object.keys(models).length) throw Error('OpenDesign: empty public price catalog')
|
|
60
|
+
return { models }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Each quota window is an independent simultaneous limit, not an additive balance. */
|
|
64
|
+
export function parseOpenDesignUsage(value, workspaceId) {
|
|
65
|
+
// Issue #274: the `billing/preflight` form wraps the identical window payload in `codingPlan`.
|
|
66
|
+
if (object(value) && object(value.codingPlan)) {
|
|
67
|
+
if ([value.workspaceId, value.codingPlan.workspaceId].some(id => id !== undefined && id !== workspaceId)
|
|
68
|
+
|| value.workspaceMemberId !== undefined && value.codingPlan.workspaceMemberId !== undefined && value.workspaceMemberId !== value.codingPlan.workspaceMemberId) return null
|
|
69
|
+
value = { ...value, ...value.codingPlan, workspaceMemberId: value.workspaceMemberId ?? value.codingPlan.workspaceMemberId }
|
|
70
|
+
}
|
|
71
|
+
if (!object(value) || value.workspaceId !== workspaceId || value.workspaceMemberId !== undefined && !id(value.workspaceMemberId) || typeof value.eligible !== 'boolean'
|
|
72
|
+
|| !Array.isArray(value.windows) || value.windows.length > 32) return null
|
|
73
|
+
const windows = {}, seen = new Set()
|
|
74
|
+
for (const row of value.windows) {
|
|
75
|
+
if (!object(row) || typeof row.policyId !== 'string' || !row.policyId || seen.has(row.policyId)
|
|
76
|
+
|| !Number.isSafeInteger(row.durationSeconds) || row.durationSeconds <= 0
|
|
77
|
+
|| !['usedCredits', 'remainingCredits', 'limitCredits'].every(key => typeof row[key] === 'string' && /^\d{1,80}$/.test(row[key]))) return null
|
|
78
|
+
seen.add(row.policyId)
|
|
79
|
+
const used = BigInt(row.usedCredits), remaining = BigInt(row.remainingCredits), limit = BigInt(row.limitCredits)
|
|
80
|
+
if (limit <= 0n || remaining > limit || remaining !== (used >= limit ? 0n : limit - used)) return null
|
|
81
|
+
const resetsAt = row.resetsAt == null ? '' : typeof row.resetsAt === 'string' && Number.isFinite(Date.parse(row.resetsAt)) ? new Date(row.resetsAt).toISOString() : null
|
|
82
|
+
if (resetsAt === null) return null
|
|
83
|
+
// Identify the window by its duration, keeping usage and reset time on that same row.
|
|
84
|
+
const duration = row.durationSeconds, base = duration === 5 * 60 * 60 ? 'fiveHour' : duration === 7 * 24 * 60 * 60 ? 'weekly' : 'window_' + duration + 's'
|
|
85
|
+
const key = Object.hasOwn(windows, base) ? base + '_' + seen.size : base
|
|
86
|
+
windows[key] = { percent: Number((used > limit ? limit : used) * 100000n / limit) / 1000, resetsAt }
|
|
87
|
+
}
|
|
88
|
+
if (value.eligible && !Object.keys(windows).length) return null
|
|
89
|
+
if (!value.eligible && value.windows.length) return null
|
|
90
|
+
return windows
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseOpenDesignWallet(value, workspaceId) {
|
|
94
|
+
if (!object(value) || value.billingScopeVersion !== 2 || value.workspaceId !== workspaceId || !id(value.workspaceMemberId)
|
|
95
|
+
|| typeof value.balanceUsd !== 'string' || !/^-?\d{1,12}(?:\.\d{1,12})?$/.test(value.balanceUsd)) return null
|
|
96
|
+
return { text: '$' + value.balanceUsd, resetsAt: '' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function queryOpenDesignQuota(key, locale, t, { workspaceId, signal, fetchImpl } = {}) {
|
|
100
|
+
const zh = locale === 'zh', workspace = normalizeOpenDesignWorkspace(workspaceId)
|
|
101
|
+
if (!workspace) throw queryError(zh ? 'OpenDesign:请先填写工作区 ID。' : 'OpenDesign: configure a workspace ID first.', true, OPEN_DESIGN_QUOTA_FAILURES.invalidRequest)
|
|
102
|
+
if (typeof key !== 'string' || !key.trim()) throw queryError(zh
|
|
103
|
+
? 'OpenDesign:额度查询需要 Control Key(OPENDESIGN_CONTROL_KEY / VELA_CONTROL_KEY),不能使用推理 API Key。'
|
|
104
|
+
: 'OpenDesign: quota queries require a Control Key (OPENDESIGN_CONTROL_KEY / VELA_CONTROL_KEY), not an inference API key.', true, OPEN_DESIGN_QUOTA_FAILURES.invalidRequest)
|
|
105
|
+
const controller = new AbortController(), combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
|
|
106
|
+
const timer = setTimeout(() => controller.abort(), 15000); timer.unref?.()
|
|
107
|
+
const prefix = OPENDESIGN_API + '/workspaces/' + encodeURIComponent(workspace)
|
|
108
|
+
// The preflight and wallet routes accept a Control Key without a browser cookie.
|
|
109
|
+
// Read both sides independently and only fail
|
|
110
|
+
// when both fail; a single 401 must never discard the other side's valid answer.
|
|
111
|
+
const read = async (path, side) => {
|
|
112
|
+
const url = prefix + path
|
|
113
|
+
let response
|
|
114
|
+
try {
|
|
115
|
+
response = await fetchWithRetry(url, { method: 'GET', redirect: 'error', signal: combined,
|
|
116
|
+
headers: { accept: 'application/json', authorization: 'Bearer ' + key.trim(), 'x-vela-workspace-id': workspace } }, { attempts: 2, fetchImpl })
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (combined.aborted) throw error // caller cancellation / 15 s deadline must not degrade to a partial card
|
|
119
|
+
return { ok: false, side, kind: OPEN_DESIGN_QUOTA_FAILURES.network, url }
|
|
120
|
+
}
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
try { await response.body?.cancel?.() } catch {}
|
|
123
|
+
const unauthorized = response.status === 401 || response.status === 403
|
|
124
|
+
return { ok: false, side, kind: unauthorized ? OPEN_DESIGN_QUOTA_FAILURES.unauthorized : OPEN_DESIGN_QUOTA_FAILURES.http, status: response.status, url }
|
|
125
|
+
}
|
|
126
|
+
try { return { ok: true, side, value: await readJsonBounded(response, 256 * 1024), url } }
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (combined.aborted) throw error
|
|
129
|
+
return { ok: false, side, kind: OPEN_DESIGN_QUOTA_FAILURES.invalidResponse, source: 'body', url }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const describe = failure => {
|
|
133
|
+
if (failure.kind === OPEN_DESIGN_QUOTA_FAILURES.unauthorized) return queryError(t(locale, 'codingPlanUnauthorized', { provider: 'OpenDesign', code: String(failure.status) }), true, failure.kind)
|
|
134
|
+
if (failure.kind === OPEN_DESIGN_QUOTA_FAILURES.http) return queryError(t(locale, 'codingPlanHttp', { provider: 'OpenDesign', code: String(failure.status), url: failure.url }), false, failure.kind)
|
|
135
|
+
if (failure.kind === OPEN_DESIGN_QUOTA_FAILURES.invalidResponse && failure.source === 'body') return queryError(t(locale, 'codingPlanInvalidJson', { provider: 'OpenDesign', url: failure.url }), false, failure.kind)
|
|
136
|
+
if (failure.kind === OPEN_DESIGN_QUOTA_FAILURES.invalidResponse) return queryError(failure.side === 'wallet'
|
|
137
|
+
? (zh ? 'OpenDesign:钱包响应的工作区、成员或数据无效。' : 'OpenDesign: invalid wallet response or workspace/member identity.')
|
|
138
|
+
: (zh ? 'OpenDesign:额度响应的工作区、成员或数据无效。' : 'OpenDesign: invalid quota response or workspace/member identity.'), false, failure.kind)
|
|
139
|
+
if (failure.kind === OPEN_DESIGN_QUOTA_FAILURES.noSubscription) {
|
|
140
|
+
const code = 'codingPlanNoSubscription', text = t(locale, code, { provider: 'OpenDesign' })
|
|
141
|
+
// Fallback keeps "no subscription" distinguishable from "bad credential" until the catalog ships the key.
|
|
142
|
+
return queryError(text === code
|
|
143
|
+
? (zh ? 'OpenDesign:当前账户没有生效的套餐订阅(未订阅或已到期)。' : 'OpenDesign: no active plan subscription was detected for this workspace (not subscribed or expired).')
|
|
144
|
+
: text, true, failure.kind)
|
|
145
|
+
}
|
|
146
|
+
return queryError(zh ? 'OpenDesign:查询失败,请检查控制端凭据、工作区权限和网络。' : 'OpenDesign: query failed; check the control credential, workspace access and connection.', false, failure.kind)
|
|
147
|
+
}
|
|
148
|
+
const partialResult = (windows, endpoint, failures, fallback = false) => ({
|
|
149
|
+
windows, endpoint,
|
|
150
|
+
partial: Object.fromEntries(failures.map(([side, failure]) => [side, failure.kind])),
|
|
151
|
+
message: [
|
|
152
|
+
...(fallback ? [zh ? '钱包余额来自 billing/preflight。' : 'Wallet balance is from billing/preflight.'] : []),
|
|
153
|
+
...failures.map(([side, failure]) => (side === 'usage' ? (zh ? '套餐额度:' : 'Plan quota: ') : (zh ? '钱包端点:' : 'Wallet endpoint: ')) + describe(failure).message),
|
|
154
|
+
].join('\n'),
|
|
155
|
+
})
|
|
156
|
+
try {
|
|
157
|
+
// #274 / #313: do not spend a request on the browser-session-only usage route.
|
|
158
|
+
const [usageRead, walletRead] = await Promise.all([read('/billing/preflight', 'preflight'), read('/wallet/balance', 'wallet')])
|
|
159
|
+
combined.throwIfAborted()
|
|
160
|
+
// The preflight answer is already scoped by URL and x-vela-workspace-id; tolerate a body that
|
|
161
|
+
// omits the redundant workspaceId, but never accept one that names a different workspace.
|
|
162
|
+
const preflight = usageRead.ok && usageRead.side === 'preflight'
|
|
163
|
+
const identifiesOtherWorkspace = value => object(value) && value.workspaceId !== undefined && value.workspaceId !== workspace
|
|
164
|
+
const usageWorkspaceConflict = preflight && object(usageRead.value)
|
|
165
|
+
&& (identifiesOtherWorkspace(usageRead.value) || identifiesOtherWorkspace(usageRead.value.codingPlan))
|
|
166
|
+
const usagePayload = preflight && object(usageRead.value) && usageRead.value.workspaceId === undefined
|
|
167
|
+
? { ...usageRead.value, workspaceId: workspace } : usageRead.value
|
|
168
|
+
const usageWindows = usageRead.ok && !usageWorkspaceConflict ? parseOpenDesignUsage(usagePayload, workspace) : null
|
|
169
|
+
const usageFailure = !usageRead.ok ? usageRead
|
|
170
|
+
: usageWindows === null ? { ok: false, side: usageRead.side, kind: OPEN_DESIGN_QUOTA_FAILURES.invalidResponse, source: 'shape', url: usageRead.url }
|
|
171
|
+
// The parser returns {} only for eligible:false with no windows: a definitive no-subscription answer.
|
|
172
|
+
: Object.keys(usageWindows).length === 0 ? { ok: false, side: usageRead.side, kind: OPEN_DESIGN_QUOTA_FAILURES.noSubscription } : null
|
|
173
|
+
const balance = walletRead.ok ? parseOpenDesignWallet(walletRead.value, workspace) : null
|
|
174
|
+
const walletFailure = !walletRead.ok ? walletRead
|
|
175
|
+
: balance === null ? { ok: false, side: 'wallet', kind: OPEN_DESIGN_QUOTA_FAILURES.invalidResponse, source: 'shape', url: walletRead.url } : null
|
|
176
|
+
// A preflight answer carries balanceUsd too; it only fills in when the wallet endpoint failed.
|
|
177
|
+
const fallbackBalance = usageWindows !== null && preflight ? balanceWindowOf(usageRead.value) : null
|
|
178
|
+
if (usageWindows !== null && balance !== null) {
|
|
179
|
+
// Only an identity the quota response actually provides must agree with the wallet member.
|
|
180
|
+
const usageMember = quotaMemberOf(usageRead.value)
|
|
181
|
+
if (usageMember !== undefined && walletRead.value.workspaceMemberId !== usageMember) {
|
|
182
|
+
throw queryError(zh ? 'OpenDesign:钱包与额度的工作区成员不一致。' : 'OpenDesign: wallet and quota member identities do not match.', false, OPEN_DESIGN_QUOTA_FAILURES.identityMismatch)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (usageFailure === null && walletFailure === null) return { windows: { ...usageWindows, balance }, endpoint: usageRead.url }
|
|
186
|
+
// Partial degradation (issue #274): keep whichever side answered. `partial` names the failing
|
|
187
|
+
// side and its machine-readable cause; it never replaces the existing windows contract.
|
|
188
|
+
if (usageFailure === null) return partialResult(fallbackBalance === null ? usageWindows : { ...usageWindows, balance: fallbackBalance }, usageRead.url, [['wallet', walletFailure]], fallbackBalance !== null)
|
|
189
|
+
if (walletFailure === null) return partialResult({ balance }, walletRead.url, [['usage', usageFailure]])
|
|
190
|
+
// No subscription is a valid preflight answer too: its readable wallet balance
|
|
191
|
+
// must survive an unavailable wallet route, without inventing any quota windows.
|
|
192
|
+
if (fallbackBalance !== null) return partialResult({ balance: fallbackBalance }, usageRead.url, [['usage', usageFailure], ['wallet', walletFailure]], true)
|
|
193
|
+
// Both failed: report the most actionable cause, preferring a definitive account state
|
|
194
|
+
// (no subscription / rejected credential) over a secondary transport or shape failure.
|
|
195
|
+
const priority = [OPEN_DESIGN_QUOTA_FAILURES.noSubscription, OPEN_DESIGN_QUOTA_FAILURES.unauthorized,
|
|
196
|
+
OPEN_DESIGN_QUOTA_FAILURES.invalidResponse, OPEN_DESIGN_QUOTA_FAILURES.http, OPEN_DESIGN_QUOTA_FAILURES.network]
|
|
197
|
+
throw describe(priority.indexOf(walletFailure.kind) < priority.indexOf(usageFailure.kind) ? walletFailure : usageFailure)
|
|
198
|
+
} catch (error) {
|
|
199
|
+
combined.throwIfAborted()
|
|
200
|
+
// Never put an upstream body, arbitrary network exception or credential in the UI.
|
|
201
|
+
if (error?.opendesignPublic === true) throw error
|
|
202
|
+
throw Error(zh ? 'OpenDesign:查询失败,请检查控制端凭据、工作区权限和网络。' : 'OpenDesign: query failed; check the control credential, workspace access and connection.')
|
|
203
|
+
} finally { clearTimeout(timer) }
|
|
204
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Token Rhythm 账户可用余额(#290);复用余额缓存及 DSH 只写凭据库。 */
|
|
2
|
+
import { credentialRef } from './credentials-shim.mjs'
|
|
3
|
+
import { fetchWithRetry, readJsonBounded } from './net.mjs'
|
|
4
|
+
|
|
5
|
+
// 与官方网站账户页相同的 wallet summary / availableBalanceCny,见 docs/tokenrhythm-balance.md。
|
|
6
|
+
export const TOKENRHYTHM_BALANCE_URL = 'https://tokenrhythm.studio/api/wallet/summary'
|
|
7
|
+
export const TOKENRHYTHM_BALANCE_CREDENTIAL_VARS = ['TOKENRHYTHM_COOKIE']
|
|
8
|
+
const messages = {
|
|
9
|
+
zh: {
|
|
10
|
+
missing: '请填写 Token Rhythm 网站登录 Cookie(tr_session,不是模型 API Key)',
|
|
11
|
+
cookie: 'Token Rhythm Cookie 格式无效,请填写 tr_session 或包含它的 Cookie',
|
|
12
|
+
network: 'Token Rhythm 余额网络请求失败,请稍后重试',
|
|
13
|
+
redirect: 'Token Rhythm 余额接口发生重定向,已拒绝转发 Cookie',
|
|
14
|
+
permission: 'Token Rhythm 登录已失效或无权查询,请登录官网后更新 Cookie',
|
|
15
|
+
http: 'Token Rhythm 余额接口返回 HTTP {status}',
|
|
16
|
+
payload: 'Token Rhythm 余额响应无效或过大,请稍后重试',
|
|
17
|
+
amount: 'Token Rhythm 未返回有效的可用余额,未将其视为零余额',
|
|
18
|
+
currency: 'Token Rhythm 余额币种不是 CNY,请检查平台接口',
|
|
19
|
+
},
|
|
20
|
+
en: {
|
|
21
|
+
missing: 'Enter the Token Rhythm website session cookie (tr_session, not a model API key)',
|
|
22
|
+
cookie: 'Invalid Token Rhythm cookie; enter tr_session or a Cookie header containing it',
|
|
23
|
+
network: 'Token Rhythm balance network request failed; please retry later',
|
|
24
|
+
redirect: 'Token Rhythm balance redirect refused to protect the cookie',
|
|
25
|
+
permission: 'Token Rhythm session expired or access denied; sign in on the website and update the cookie',
|
|
26
|
+
http: 'Token Rhythm balance returned HTTP {status}',
|
|
27
|
+
payload: 'Token Rhythm balance response is invalid or too large; please retry later',
|
|
28
|
+
amount: 'Token Rhythm returned no valid available balance; it was not treated as zero',
|
|
29
|
+
currency: 'Token Rhythm balance currency is not CNY; check the platform API',
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
const failure = (locale, key, status = '') => new Error(messages[locale === 'en' ? 'en' : 'zh'][key].replace('{status}', String(status)))
|
|
33
|
+
|
|
34
|
+
export function parseTokenRhythmCookie(value, locale = 'zh') {
|
|
35
|
+
const text = String(value ?? '').trim()
|
|
36
|
+
if (!text || text.length > 16384 || /[\r\n]/.test(text)) throw failure(locale, 'cookie')
|
|
37
|
+
if (/^[A-Za-z0-9._-]+$/.test(text)) return text
|
|
38
|
+
const parts = text.replace(/^Cookie:\s*/i, '').split(';').map(part => part.trim())
|
|
39
|
+
const sessions = parts.filter(part => /^tr_session\s*=/.test(part))
|
|
40
|
+
const match = sessions.length === 1 && /^tr_session\s*=\s*([A-Za-z0-9._-]+)$/.exec(sessions[0])
|
|
41
|
+
if (!match) throw failure(locale, 'cookie')
|
|
42
|
+
return match[1]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseTokenRhythmBalance(payload, locale = 'zh') {
|
|
46
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw failure(locale, 'payload')
|
|
47
|
+
// 官网成功码为 0 / 200(兼容字符串);拒绝夹带余额的业务错误,不回显原始响应。
|
|
48
|
+
if ((payload.code !== undefined && ![0, '0', 200, '200'].includes(payload.code)) || payload.success === false) throw failure(locale, 'permission')
|
|
49
|
+
const data = Object.hasOwn(payload, 'data') ? payload.data : payload
|
|
50
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw failure(locale, 'payload')
|
|
51
|
+
if (data.currency !== undefined && data.currency !== 'CNY') throw failure(locale, 'currency')
|
|
52
|
+
const raw = Object.hasOwn(data, 'availableBalanceCny') ? data.availableBalanceCny : data.available_balance_cny
|
|
53
|
+
const text = typeof raw === 'string' ? raw.trim() : ''
|
|
54
|
+
const remaining = typeof raw === 'number' ? raw : /^[+-]?\d+(\.\d+)?$/.test(text) ? Number(text) : NaN
|
|
55
|
+
if (!Number.isFinite(remaining)) throw failure(locale, 'amount')
|
|
56
|
+
// 可用余额与累计消费/赠额/冻结金额分开;接口没有预算上限时不伪造百分比。
|
|
57
|
+
return { remaining, unit: 'CNY', maxBudget: null, spend: null }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function queryTokenRhythmBalance(ctx, config, { fetchImpl = fetch, signal } = {}) {
|
|
61
|
+
signal?.throwIfAborted()
|
|
62
|
+
const locale = config?.locale
|
|
63
|
+
let value
|
|
64
|
+
try { value = (await ctx?.get?.('credentials')?.resolve(credentialRef('TOKENRHYTHM_COOKIE')))?.value } catch { /* 回落环境变量。 */ }
|
|
65
|
+
if (typeof value !== 'string' || !value.trim()) value = process.env.TOKENRHYTHM_COOKIE
|
|
66
|
+
if (!value?.trim()) throw Object.assign(failure(locale, 'missing'), { soft: true })
|
|
67
|
+
const session = parseTokenRhythmCookie(value, locale)
|
|
68
|
+
let response
|
|
69
|
+
try {
|
|
70
|
+
response = await fetchWithRetry(TOKENRHYTHM_BALANCE_URL, {
|
|
71
|
+
method: 'GET', redirect: 'manual', signal,
|
|
72
|
+
headers: {
|
|
73
|
+
Accept: 'application/json', Cookie: 'tr_session=' + session,
|
|
74
|
+
Referer: 'https://tokenrhythm.studio/account', Origin: 'https://tokenrhythm.studio',
|
|
75
|
+
'User-Agent': 'Mozilla/5.0 (compatible; dsh-cost-meter)',
|
|
76
|
+
},
|
|
77
|
+
}, { attempts: 2, timeoutMs: 15000, fetchImpl })
|
|
78
|
+
} catch {
|
|
79
|
+
signal?.throwIfAborted()
|
|
80
|
+
throw failure(locale, 'network')
|
|
81
|
+
}
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
await response.body?.cancel().catch(() => {})
|
|
84
|
+
if (response.status >= 300 && response.status < 400) throw failure(locale, 'redirect')
|
|
85
|
+
if (response.status === 401 || response.status === 403) throw failure(locale, 'permission')
|
|
86
|
+
throw failure(locale, 'http', response.status)
|
|
87
|
+
}
|
|
88
|
+
let payload
|
|
89
|
+
try { payload = await readJsonBounded(response) } catch {
|
|
90
|
+
signal?.throwIfAborted()
|
|
91
|
+
throw failure(locale, 'payload')
|
|
92
|
+
}
|
|
93
|
+
signal?.throwIfAborted()
|
|
94
|
+
return { label: config?.customBalance?.label || 'Token Rhythm', ...parseTokenRhythmBalance(payload, locale) }
|
|
95
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { fetchWorkBuddyLocal, workBuddyOrigin } from './workbuddy-transport.mjs'
|
|
2
|
+
import { fetchWithRetry, readJsonBounded } from './net.mjs'
|
|
3
|
+
|
|
4
|
+
export const validWorkBuddyAccountId = id => typeof id === 'string' && /^(cn|global):[^\s\u0000-\u001f\u007f]{1,256}$/.test(id)
|
|
5
|
+
const amount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
6
|
+
|
|
7
|
+
/** Read the connector's published per-account endpoint; never switch its active account. */
|
|
8
|
+
export async function fetchWorkBuddyAccounts(hostOrigin, config = {}, { signal, fetchImpl = fetchWorkBuddyLocal } = {}) {
|
|
9
|
+
if (!hostOrigin) return null
|
|
10
|
+
const origin = workBuddyOrigin(hostOrigin)
|
|
11
|
+
const results = await Promise.all(['cn', 'global'].map(async region => {
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetchWithRetry(new URL('/plugins/dsh-connect-workbuddy/account-credits?region=' + region, origin),
|
|
14
|
+
{ method: 'GET', redirect: 'error', signal }, { attempts: 1, timeoutMs: 35000, fetchImpl })
|
|
15
|
+
if (response.status === 404 || response.headers.get('content-type')?.includes('text/html')) { await response.body?.cancel?.(); return { missing: true } }
|
|
16
|
+
if (!response.ok) { await response.body?.cancel?.(); return { failed: true } }
|
|
17
|
+
const body = await readJsonBounded(response)
|
|
18
|
+
if (!Array.isArray(body?.accounts) || body.accounts.length > 200) return { failed: true }
|
|
19
|
+
const seen = new Set(), accounts = []
|
|
20
|
+
for (const row of body.accounts) {
|
|
21
|
+
const id = region + ':' + row?.id
|
|
22
|
+
if (!validWorkBuddyAccountId(id) || typeof row?.id !== 'string' || seen.has(id)) return { failed: true }
|
|
23
|
+
seen.add(id)
|
|
24
|
+
accounts.push({ id, region, name: typeof row.accountName === 'string' ? row.accountName.slice(0, 160) : '',
|
|
25
|
+
current: row.selected === true, remaining: amount(row.credits) ? row.credits : null })
|
|
26
|
+
}
|
|
27
|
+
return { accounts }
|
|
28
|
+
} catch { signal?.throwIfAborted(); return { failed: true } }
|
|
29
|
+
}))
|
|
30
|
+
// An older connector has no account list; its selected-account route remains usable.
|
|
31
|
+
if (!results.some(result => result.accounts)) return null
|
|
32
|
+
const accounts = results.flatMap(result => result.accounts ?? [])
|
|
33
|
+
const scope = ['all', 'selected'].includes(config.accountScope) ? config.accountScope : 'current'
|
|
34
|
+
const ids = new Set(Array.isArray(config.accountIds) ? config.accountIds : [])
|
|
35
|
+
if (scope === 'selected') for (const id of ids) if (validWorkBuddyAccountId(id) && !accounts.some(row => row.id === id)) {
|
|
36
|
+
accounts.push({ id, region: id.split(':')[0], name: '', current: false, remaining: null })
|
|
37
|
+
}
|
|
38
|
+
const selected = accounts.filter(row => scope === 'all' || (scope === 'current' ? row.current : ids.has(row.id)))
|
|
39
|
+
const known = selected.filter(row => row.remaining !== null)
|
|
40
|
+
return { scope, accounts, selectedIds: selected.map(row => row.id),
|
|
41
|
+
knownRemaining: known.reduce((sum, row) => sum + row.remaining, 0),
|
|
42
|
+
unknownCount: selected.length - known.length,
|
|
43
|
+
incomplete: results.some(result => result.failed), fetchedAt: Date.now() }
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { request } from 'node:http'
|
|
2
|
+
|
|
3
|
+
export function workBuddyOrigin(value) {
|
|
4
|
+
let url
|
|
5
|
+
try { url = new URL(value) } catch {}
|
|
6
|
+
if (!url || url.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)
|
|
7
|
+
|| url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
8
|
+
throw Object.assign(new Error('workbuddyConnectUnavailable'), { code: 'workbuddyConnectUnavailable' })
|
|
9
|
+
}
|
|
10
|
+
return url
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Host-local reads must not use DSH's global fetch proxy/dispatcher. No credentials,
|
|
14
|
+
// redirects, or configurable remote paths; the byte limit includes the whole body.
|
|
15
|
+
export function fetchWorkBuddyLocal(url, { signal } = {}) {
|
|
16
|
+
workBuddyOrigin(new URL(url).origin)
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const req = request(url, { method: 'GET', agent: false, signal, headers: { accept: 'application/json', 'accept-encoding': 'identity' } }, res => {
|
|
19
|
+
const chunks = []; let bytes = 0
|
|
20
|
+
res.on('error', reject)
|
|
21
|
+
res.on('data', chunk => {
|
|
22
|
+
bytes += chunk.length
|
|
23
|
+
if (bytes > 1024 * 1024) { res.destroy(new Error('WorkBuddy response too large')); return }
|
|
24
|
+
chunks.push(chunk)
|
|
25
|
+
})
|
|
26
|
+
res.on('end', () => {
|
|
27
|
+
const status = res.statusCode ?? 502
|
|
28
|
+
if (status >= 300 && status < 400) { reject(new TypeError('fetch failed: redirect refused')); return }
|
|
29
|
+
resolve(new Response([204, 205, 304].includes(status) ? null : Buffer.concat(chunks), {
|
|
30
|
+
status, headers: { 'content-type': res.headers['content-type'] ?? '' },
|
|
31
|
+
}))
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
req.on('error', reject)
|
|
35
|
+
req.setTimeout(35000, () => req.destroy(new Error('WorkBuddy query timed out')))
|
|
36
|
+
req.end()
|
|
37
|
+
})
|
|
38
|
+
}
|