dsh-plugin-weapp-gateway 0.8.13 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }