zcode-workbuddy-connect 0.0.0-stage → 1.0.0
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/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +22 -0
- package/NOTICE.md +49 -0
- package/README.md +264 -2
- package/bin/cli.mjs +8 -0
- package/docs/adr/0001-loopback-provider-for-zcode.md +66 -0
- package/docs/adr/0002-widest-window-and-output-ceiling.md +98 -0
- package/docs/adr/0003-region-by-url-path.md +97 -0
- package/docs/adr/0004-workbuddy-56-at-rest-credentials.md +111 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/docs/verify-endpoint.mjs +162 -0
- package/hooks/service-ensure.mjs +133 -0
- package/package.json +59 -4
- package/skills/workbuddy-models/SKILL.md +107 -0
- package/src/app-version.js +119 -0
- package/src/auth.js +441 -0
- package/src/catalog.js +100 -0
- package/src/cli.js +1010 -0
- package/src/client-identity.js +213 -0
- package/src/config.js +91 -0
- package/src/desktop-credential-protection.js +890 -0
- package/src/loopback.js +40 -0
- package/src/server.js +873 -0
- package/src/upstream.js +762 -0
package/src/upstream.js
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
|
|
3
|
+
* token refresh, model catalog, and credit balance.
|
|
4
|
+
*
|
|
5
|
+
* Ported from dsh-workbuddy-connect v0.5.4 (MIT, Corrine Hu), whose wire
|
|
6
|
+
* behaviour is itself ported from Sliverkiss/workbuddy2api. Only the CN
|
|
7
|
+
* variant is wired up here; the region gate is retained so an international
|
|
8
|
+
* credential still routes to the right base URLs.
|
|
9
|
+
*
|
|
10
|
+
* Dependency-free: Node 18+ global fetch only.
|
|
11
|
+
*
|
|
12
|
+
* @module zcode-workbuddy-connect/upstream
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appUserAgent, resolveAppVersion } from './app-version.js'
|
|
16
|
+
import { chatUserAgent, fallbackChatIdentity, resolveChatIdentity } from './client-identity.js'
|
|
17
|
+
|
|
18
|
+
export const CN_CHAT_BASE = 'https://copilot.tencent.com'
|
|
19
|
+
export const CN_BILLING_BASE = 'https://www.codebuddy.cn'
|
|
20
|
+
export const GLOBAL_BASE = 'https://www.workbuddy.ai'
|
|
21
|
+
|
|
22
|
+
/** Shared CLI-form User-Agent for refresh and the CN catalog. */
|
|
23
|
+
const CLIENT_UA = 'CLI/2.63.2 CodeBuddy/2.63.2'
|
|
24
|
+
const JSON_TIMEOUT_MS = 30_000
|
|
25
|
+
const ERROR_BODY_LIMIT = 4096
|
|
26
|
+
|
|
27
|
+
/** The concrete effort spellings WorkBuddy exposes on the wire. */
|
|
28
|
+
const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max']
|
|
29
|
+
|
|
30
|
+
/** Promotional badge keys the upstream tags carry, minus their color suffix. */
|
|
31
|
+
const BADGE_PREFIX = 'badge:'
|
|
32
|
+
|
|
33
|
+
/** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
|
|
34
|
+
const HARD_CREDIT_MARKERS = [
|
|
35
|
+
'insufficient credit', 'no credit', 'credit exhausted', 'credits exhausted', 'out of credit',
|
|
36
|
+
'quota exceeded', 'quota exhaust', 'payment required', 'credit not enough',
|
|
37
|
+
'not enough credit',
|
|
38
|
+
'积分不足', '额度不足', '余额不足', '积分用完', '额度用尽', '没有积分',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
42
|
+
const SESSION_DEAD_MARKERS = ['Offline user session not found', '12153']
|
|
43
|
+
|
|
44
|
+
/** Display name for the single synthetic row the enterprise endpoint produces. */
|
|
45
|
+
const enterprisePackageName = 'enterprise'
|
|
46
|
+
|
|
47
|
+
function isObject(value) {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positive(value) {
|
|
52
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function optionalString(value) {
|
|
56
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reduce an upstream credits string to its language-neutral display form.
|
|
61
|
+
* Some rows report a bare multiplier (`x0.79`) and others append a unit word
|
|
62
|
+
* (`x0.79 credits`); the unit word would pin the display to English.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeCredits(credits) {
|
|
65
|
+
if (credits === undefined) return undefined
|
|
66
|
+
const trimmed = credits.trim()
|
|
67
|
+
if (trimmed === '') return undefined
|
|
68
|
+
if (/^credits?$/iu.test(trimmed)) return undefined
|
|
69
|
+
const bare = trimmed.replace(/\s+credits?$/iu, '').trim()
|
|
70
|
+
return bare === '' ? undefined : bare
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Classify an upstream failure from its HTTP status and body excerpt. */
|
|
74
|
+
export function classifyUpstreamError(status, body) {
|
|
75
|
+
if (status === 402) return 'hard_credit'
|
|
76
|
+
const lower = body.toLowerCase()
|
|
77
|
+
for (const marker of HARD_CREDIT_MARKERS) {
|
|
78
|
+
if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return 'hard_credit'
|
|
79
|
+
}
|
|
80
|
+
for (const marker of SESSION_DEAD_MARKERS) {
|
|
81
|
+
if (body.includes(marker)) return 'session_dead'
|
|
82
|
+
}
|
|
83
|
+
if (status === 429) return 'soft_rate'
|
|
84
|
+
if (status === 404) return 'not_found'
|
|
85
|
+
if (status >= 500) return 'server'
|
|
86
|
+
return 'client'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Region for a login domain; an empty domain means CN. */
|
|
90
|
+
export function regionOf(domain) {
|
|
91
|
+
const lowered = String(domain ?? '').trim().toLowerCase()
|
|
92
|
+
if (lowered === 'workbuddy.ai' || lowered.endsWith('.workbuddy.ai')) return 'global'
|
|
93
|
+
return 'cn'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function chatBase(credential) {
|
|
97
|
+
return regionOf(credential.domain) === 'global' ? GLOBAL_BASE : CN_CHAT_BASE
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function billingBase(credential) {
|
|
101
|
+
return regionOf(credential.domain) === 'global' ? GLOBAL_BASE : CN_BILLING_BASE
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function originReferer(credential) {
|
|
105
|
+
return regionOf(credential.domain) === 'global' ? GLOBAL_BASE : CN_BILLING_BASE
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Headers every upstream request shares. */
|
|
109
|
+
function commonHeaders(credential) {
|
|
110
|
+
return {
|
|
111
|
+
'Accept': 'application/json, text/plain, */*',
|
|
112
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
113
|
+
'Origin': originReferer(credential),
|
|
114
|
+
'Referer': `${originReferer(credential)}/`,
|
|
115
|
+
'User-Agent': CLIENT_UA,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Chat request headers, including the X-No-* conventions the official CLI uses.
|
|
121
|
+
* `userAgent` carries the desktop identity; when absent the CLI-form UA applies.
|
|
122
|
+
*/
|
|
123
|
+
function chatHeaders(credential, userAgent) {
|
|
124
|
+
return {
|
|
125
|
+
...commonHeaders(credential),
|
|
126
|
+
...(userAgent === undefined ? {} : { 'User-Agent': userAgent }),
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
// 安全红线:chat 请求绝不携带 refresh token。
|
|
129
|
+
...(credential.uid === '' || credential.uid === undefined
|
|
130
|
+
? { 'X-No-User-Id': '1' } : { 'X-User-Id': credential.uid }),
|
|
131
|
+
...(credential.enterpriseId === undefined || credential.enterpriseId === ''
|
|
132
|
+
? { 'X-No-Enterprise-Id': '1' } : { 'X-Enterprise-Id': credential.enterpriseId }),
|
|
133
|
+
...(credential.domain === '' || credential.domain === undefined
|
|
134
|
+
? { 'X-No-Department-Info': '1' } : { 'X-Domain': credential.domain }),
|
|
135
|
+
'X-Product': 'SaaS',
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
|
|
140
|
+
function refreshHeaders(credential) {
|
|
141
|
+
const headers = {
|
|
142
|
+
...commonHeaders(credential),
|
|
143
|
+
'X-Refresh-Token': credential.refreshToken,
|
|
144
|
+
'X-Auth-Refresh-Source': 'workbuddy',
|
|
145
|
+
}
|
|
146
|
+
if (credential.enterpriseId !== undefined && credential.enterpriseId !== '') {
|
|
147
|
+
headers['X-Enterprise-Id'] = credential.enterpriseId
|
|
148
|
+
}
|
|
149
|
+
return headers
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Billing request headers. */
|
|
153
|
+
function billingHeaders(credential) {
|
|
154
|
+
const headers = {
|
|
155
|
+
'Authorization': `Bearer ${credential.accessToken}`,
|
|
156
|
+
'Accept': 'application/json',
|
|
157
|
+
'Content-Type': 'application/json',
|
|
158
|
+
}
|
|
159
|
+
if (credential.uid !== '' && credential.uid !== undefined) headers['X-User-Id'] = credential.uid
|
|
160
|
+
if (credential.enterpriseId !== undefined && credential.enterpriseId !== '') {
|
|
161
|
+
headers['X-Enterprise-Id'] = credential.enterpriseId
|
|
162
|
+
headers['X-Tenant-Id'] = credential.enterpriseId
|
|
163
|
+
}
|
|
164
|
+
if (credential.domain !== '' && credential.domain !== undefined) headers['X-Domain'] = credential.domain
|
|
165
|
+
return headers
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Rewrite `role: "developer"` messages to `role: "system"` (upstream rejects
|
|
170
|
+
* developer with HTTP 400 code 11128).
|
|
171
|
+
*/
|
|
172
|
+
function normalizeDeveloperRole(obj) {
|
|
173
|
+
const messages = obj['messages']
|
|
174
|
+
if (!Array.isArray(messages)) return
|
|
175
|
+
for (const message of messages) {
|
|
176
|
+
if (!isObject(message)) continue
|
|
177
|
+
if (message['role'] === 'developer') message['role'] = 'system'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Rewrite OpenAI `tool_choice` spellings into the upstream's string form. */
|
|
182
|
+
function normalizeToolChoice(obj) {
|
|
183
|
+
const suppress = () => {
|
|
184
|
+
delete obj['tools']
|
|
185
|
+
delete obj['functions']
|
|
186
|
+
}
|
|
187
|
+
if (!('tool_choice' in obj)) return
|
|
188
|
+
const choice = obj['tool_choice']
|
|
189
|
+
if (typeof choice === 'string') {
|
|
190
|
+
if (choice.trim().toLowerCase() === 'none') {
|
|
191
|
+
delete obj['tool_choice']
|
|
192
|
+
suppress()
|
|
193
|
+
}
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
if (isObject(choice)) {
|
|
197
|
+
const type = typeof choice['type'] === 'string' ? choice['type'].trim().toLowerCase() : ''
|
|
198
|
+
if (type === 'none') {
|
|
199
|
+
delete obj['tool_choice']
|
|
200
|
+
suppress()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
if (type === 'auto' || type === 'required') {
|
|
204
|
+
obj['tool_choice'] = type
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (type === 'function') {
|
|
208
|
+
const fn = isObject(choice['function']) ? choice['function'] : undefined
|
|
209
|
+
let name = typeof fn?.['name'] === 'string' ? fn['name'] : ''
|
|
210
|
+
if (name === '' && typeof choice['name'] === 'string') name = choice['name']
|
|
211
|
+
name = name.trim()
|
|
212
|
+
obj['tool_choice'] = name !== '' ? name : 'auto'
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
delete obj['tool_choice']
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
delete obj['tool_choice']
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Repair conversation history that carries an assistant tool_call with an
|
|
223
|
+
* empty `function.name`.
|
|
224
|
+
*
|
|
225
|
+
* A truncated model stream used to be able to yield a nameless call, and a
|
|
226
|
+
* client that stored it in history then failed on EVERY later turn with
|
|
227
|
+
* HTTP 400 code 11133 ("the request parameters were rejected") — the
|
|
228
|
+
* deepseek provider rejects `name: ""` outright while other providers
|
|
229
|
+
* tolerate it, so the failure looked model-specific and the session
|
|
230
|
+
* unrecoverable. Healing here runs before the upstream sees the body:
|
|
231
|
+
* a name recoverable from the paired `tool` message's `name` field is
|
|
232
|
+
* backfilled; otherwise the nameless call is dropped together with its
|
|
233
|
+
* `tool` result, because a tool message whose `tool_call_id` references
|
|
234
|
+
* nothing is its own rejection risk.
|
|
235
|
+
*/
|
|
236
|
+
function normalizeToolCallHistory(obj) {
|
|
237
|
+
const messages = obj['messages']
|
|
238
|
+
if (!Array.isArray(messages)) return
|
|
239
|
+
const nameByToolCallId = new Map()
|
|
240
|
+
for (const message of messages) {
|
|
241
|
+
if (!isObject(message) || message['role'] !== 'tool') continue
|
|
242
|
+
const id = message['tool_call_id']
|
|
243
|
+
if (typeof id !== 'string' || id === '') continue
|
|
244
|
+
if (typeof message['name'] === 'string' && message['name'] !== '') nameByToolCallId.set(id, message['name'])
|
|
245
|
+
}
|
|
246
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
247
|
+
const message = messages[i]
|
|
248
|
+
if (!isObject(message) || message['role'] !== 'assistant' || !Array.isArray(message['tool_calls'])) continue
|
|
249
|
+
const kept = []
|
|
250
|
+
const droppedIds = []
|
|
251
|
+
for (const call of message['tool_calls']) {
|
|
252
|
+
if (!isObject(call)) continue
|
|
253
|
+
const fn = isObject(call['function']) ? call['function'] : undefined
|
|
254
|
+
const id = typeof call['id'] === 'string' ? call['id'] : ''
|
|
255
|
+
let name = typeof fn?.['name'] === 'string' ? fn['name'] : ''
|
|
256
|
+
if (name === '' && id !== '' && nameByToolCallId.has(id)) name = nameByToolCallId.get(id)
|
|
257
|
+
if (name === '') {
|
|
258
|
+
if (id !== '') droppedIds.push(id)
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
kept.push(call)
|
|
262
|
+
}
|
|
263
|
+
if (kept.length === message['tool_calls'].length) continue
|
|
264
|
+
if (kept.length > 0) message['tool_calls'] = kept
|
|
265
|
+
else delete message['tool_calls']
|
|
266
|
+
for (const id of droppedIds) {
|
|
267
|
+
for (let j = i + 1; j < messages.length; j += 1) {
|
|
268
|
+
const candidate = messages[j]
|
|
269
|
+
if (isObject(candidate) && candidate['role'] === 'tool' && candidate['tool_call_id'] === id) {
|
|
270
|
+
messages.splice(j, 1)
|
|
271
|
+
j -= 1
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
280
|
+
* force `stream: true` (the upstream rejects non-streaming), flatten
|
|
281
|
+
* `tool_choice`, rewrite `developer` messages as `system`, and repair
|
|
282
|
+
* history polluted with nameless tool calls.
|
|
283
|
+
*/
|
|
284
|
+
export function prepareChatBody(source) {
|
|
285
|
+
let body
|
|
286
|
+
try {
|
|
287
|
+
body = JSON.parse(source)
|
|
288
|
+
} catch {
|
|
289
|
+
return source
|
|
290
|
+
}
|
|
291
|
+
if (!isObject(body)) return source
|
|
292
|
+
body['stream'] = true
|
|
293
|
+
normalizeDeveloperRole(body)
|
|
294
|
+
normalizeToolChoice(body)
|
|
295
|
+
normalizeToolCallHistory(body)
|
|
296
|
+
return JSON.stringify(body)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The international gateway rejects a body whose first message is not
|
|
301
|
+
* `system`. Prepended, never merged.
|
|
302
|
+
*/
|
|
303
|
+
export function prepareInternationalChatBody(source) {
|
|
304
|
+
const prepared = prepareChatBody(source)
|
|
305
|
+
let body
|
|
306
|
+
try {
|
|
307
|
+
body = JSON.parse(prepared)
|
|
308
|
+
} catch {
|
|
309
|
+
return prepared
|
|
310
|
+
}
|
|
311
|
+
if (!isObject(body)) return prepared
|
|
312
|
+
const messages = body['messages']
|
|
313
|
+
if (!Array.isArray(messages) || messages.length === 0) return prepared
|
|
314
|
+
const first = messages[0]
|
|
315
|
+
if (isObject(first) && first['role'] === 'system') return prepared
|
|
316
|
+
body['messages'] = [{ role: 'system', content: 'You are a helpful assistant.' }, ...messages]
|
|
317
|
+
return JSON.stringify(body)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Parse the upstream `reasoning` object into a WorkBuddyModelReasoning. */
|
|
321
|
+
function resolveUpstreamReasoning(wrapped) {
|
|
322
|
+
const supports = wrapped['supportsReasoning'] === true
|
|
323
|
+
const onlyReasoning = wrapped['onlyReasoning'] === true
|
|
324
|
+
const rawReasoning = wrapped['reasoning']
|
|
325
|
+
let supportedEfforts
|
|
326
|
+
let defaultEffort
|
|
327
|
+
let canDisableThinking = true
|
|
328
|
+
if (isObject(rawReasoning)) {
|
|
329
|
+
const rawEfforts = rawReasoning['supportedEfforts']
|
|
330
|
+
if (Array.isArray(rawEfforts)) {
|
|
331
|
+
const efforts = rawEfforts.filter(value => typeof value === 'string' && EFFORT_VALUES.includes(value))
|
|
332
|
+
if (efforts.length > 0) supportedEfforts = efforts
|
|
333
|
+
}
|
|
334
|
+
if (typeof rawReasoning['defaultEffort'] === 'string' && EFFORT_VALUES.includes(rawReasoning['defaultEffort'])) {
|
|
335
|
+
defaultEffort = rawReasoning['defaultEffort']
|
|
336
|
+
} else if (typeof rawReasoning['effort'] === 'string' && EFFORT_VALUES.includes(rawReasoning['effort'])) {
|
|
337
|
+
defaultEffort = rawReasoning['effort']
|
|
338
|
+
}
|
|
339
|
+
// Only an explicit `canDisableThinking: true` offers "thinking off".
|
|
340
|
+
canDisableThinking = rawReasoning['canDisableThinking'] === true
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
reasoning: {
|
|
344
|
+
supports,
|
|
345
|
+
onlyReasoning,
|
|
346
|
+
...(supportedEfforts === undefined ? {} : { supportedEfforts }),
|
|
347
|
+
...(defaultEffort === undefined ? {} : { defaultEffort }),
|
|
348
|
+
canDisableThinking,
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Parse the upstream `tags` / `credits` fields into billing metadata. */
|
|
354
|
+
function resolveUpstreamBilling(wrapped) {
|
|
355
|
+
const rawCredits = wrapped['credits']
|
|
356
|
+
const credits = typeof rawCredits === 'string' && rawCredits.trim() !== '' ? rawCredits.trim() : undefined
|
|
357
|
+
const badges = []
|
|
358
|
+
const rawTags = wrapped['tags']
|
|
359
|
+
if (Array.isArray(rawTags)) {
|
|
360
|
+
for (const tag of rawTags) {
|
|
361
|
+
if (typeof tag !== 'string') continue
|
|
362
|
+
const lowered = tag.toLowerCase()
|
|
363
|
+
if (!lowered.startsWith(BADGE_PREFIX)) continue
|
|
364
|
+
const label = tag.slice(BADGE_PREFIX.length).split(':')[0] ?? tag.slice(BADGE_PREFIX.length)
|
|
365
|
+
if (label !== '') badges.push(label)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// A `x0.00` multiplier means the model is currently free.
|
|
369
|
+
const free = credits !== undefined && /^x?0\.0+$/u.test(credits)
|
|
370
|
+
return {
|
|
371
|
+
billing: {
|
|
372
|
+
...(credits === undefined ? {} : { credits }),
|
|
373
|
+
...(badges.length === 0 ? {} : { badges }),
|
|
374
|
+
free,
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** One JSON-envelope response from the upstream, already unwrapped. */
|
|
380
|
+
async function readEnvelope(response) {
|
|
381
|
+
const text = await response.text()
|
|
382
|
+
let parsed
|
|
383
|
+
try {
|
|
384
|
+
parsed = JSON.parse(text)
|
|
385
|
+
} catch {
|
|
386
|
+
throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`)
|
|
387
|
+
}
|
|
388
|
+
if (!isObject(parsed)) {
|
|
389
|
+
throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`)
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
code: typeof parsed['code'] === 'number' ? parsed['code'] : 0,
|
|
393
|
+
msg: typeof parsed['msg'] === 'string' ? parsed['msg'] : '',
|
|
394
|
+
data: 'data' in parsed ? parsed['data'] : undefined,
|
|
395
|
+
document: parsed,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
|
|
400
|
+
function envelopeError(status, envelope) {
|
|
401
|
+
const kind = classifyUpstreamError(status, envelope.msg)
|
|
402
|
+
return new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Extract the promotions covering `model` from the `modelPromotions` array. */
|
|
406
|
+
function parsePromotions(value, model) {
|
|
407
|
+
if (!Array.isArray(value)) return []
|
|
408
|
+
return value.flatMap(item => {
|
|
409
|
+
if (!isObject(item) || item['enabled'] !== true) return []
|
|
410
|
+
const modelIds = item['modelIds']
|
|
411
|
+
if (!Array.isArray(modelIds) || !modelIds.includes(model)) return []
|
|
412
|
+
const schedule = item['schedule']
|
|
413
|
+
const discount = item['discount']
|
|
414
|
+
const badge = item['badge']
|
|
415
|
+
if (!isObject(schedule) || !isObject(discount) || !isObject(badge)) return []
|
|
416
|
+
// Only a replacement discount has an unambiguous display rule.
|
|
417
|
+
if (discount['displayMode'] !== 'replace') return []
|
|
418
|
+
const start = typeof schedule['validFrom'] === 'string' ? Date.parse(schedule['validFrom']) : Number.NaN
|
|
419
|
+
const end = typeof schedule['validUntil'] === 'string' ? Date.parse(schedule['validUntil']) : Number.NaN
|
|
420
|
+
const factor = discount['factor']
|
|
421
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return []
|
|
422
|
+
if (typeof factor !== 'number' || !Number.isFinite(factor) || factor < 0) return []
|
|
423
|
+
return [{
|
|
424
|
+
start,
|
|
425
|
+
end,
|
|
426
|
+
factor,
|
|
427
|
+
label: typeof badge['label'] === 'string' ? badge['label'] : '',
|
|
428
|
+
priority: typeof item['priority'] === 'number' && Number.isFinite(item['priority']) ? item['priority'] : 0,
|
|
429
|
+
}]
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Re-evaluate a model's promotion against the current time. Frozen at parse
|
|
435
|
+
* time, a cached "Free now" would keep claiming a discount after `validUntil`
|
|
436
|
+
* had passed.
|
|
437
|
+
*/
|
|
438
|
+
export function modelWithCurrentPromotion(model, now = Date.now()) {
|
|
439
|
+
if (model.promotions === undefined || model.promotions.length === 0) return model
|
|
440
|
+
const promotion = [...model.promotions]
|
|
441
|
+
.sort((a, b) => b.priority - a.priority)
|
|
442
|
+
.find(candidate => now >= candidate.start && now < candidate.end)
|
|
443
|
+
if (promotion === undefined) {
|
|
444
|
+
// The upstream bakes the discounted value into `credits`, so a stale row
|
|
445
|
+
// advertises a discount that has ended. The original price is not
|
|
446
|
+
// recoverable, so stop asserting one rather than keep claiming "free".
|
|
447
|
+
const derivedFromPromotion = model.billing?.free === true
|
|
448
|
+
|| (model.billing?.badges?.length ?? 0) > 0
|
|
449
|
+
|| model.promotions.some(candidate => candidate.factor !== 1)
|
|
450
|
+
if (!derivedFromPromotion) return model
|
|
451
|
+
return { ...model, billing: { free: false, rateUnknown: true } }
|
|
452
|
+
}
|
|
453
|
+
const rate = normalizeCredits(model.billing?.credits)
|
|
454
|
+
const original = rate !== undefined && rate.startsWith('x') ? Number(rate.slice(1)) : Number.NaN
|
|
455
|
+
if (promotion.factor !== 0 && !Number.isFinite(original)) return model
|
|
456
|
+
const value = promotion.factor === 0 ? 0 : original * promotion.factor
|
|
457
|
+
return {
|
|
458
|
+
...model,
|
|
459
|
+
billing: {
|
|
460
|
+
...model.billing,
|
|
461
|
+
credits: `x${value.toFixed(2)}`,
|
|
462
|
+
free: value === 0,
|
|
463
|
+
badges: [...(model.billing?.badges ?? []), ...(promotion.label === '' ? [] : [promotion.label])],
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Parse a catalog document into the CLI's model roster, in upstream order. */
|
|
469
|
+
export function parseModelCatalog(data, international = false) {
|
|
470
|
+
const rawModels = Array.isArray(data['models']) ? data['models'] : []
|
|
471
|
+
const agents = Array.isArray(data['agents']) ? data['agents'] : []
|
|
472
|
+
let cliIds
|
|
473
|
+
for (const agent of agents) {
|
|
474
|
+
if (isObject(agent) && agent['name'] === 'cli' && Array.isArray(agent['models'])) {
|
|
475
|
+
cliIds = agent['models'].filter(id => typeof id === 'string')
|
|
476
|
+
break
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (cliIds === undefined || cliIds.length === 0) {
|
|
480
|
+
throw new Error('workbuddy model catalog lists no cli agent models')
|
|
481
|
+
}
|
|
482
|
+
const byId = new Map()
|
|
483
|
+
for (const model of rawModels) {
|
|
484
|
+
if (!isObject(model)) continue
|
|
485
|
+
const id = typeof model['id'] === 'string' ? model['id'] : ''
|
|
486
|
+
if (id === '' || model['disabled'] === true) continue
|
|
487
|
+
const input = typeof model['maxInputTokens'] === 'number' ? model['maxInputTokens'] : 0
|
|
488
|
+
const output = typeof model['maxOutputTokens'] === 'number' ? model['maxOutputTokens'] : 0
|
|
489
|
+
if (input <= 0 || output <= 0) continue
|
|
490
|
+
byId.set(id, {
|
|
491
|
+
id,
|
|
492
|
+
name: typeof model['name'] === 'string' && model['name'] !== '' ? model['name'] : id,
|
|
493
|
+
// Window: `contextWindow.defaultLength` is a soft default, not a cap.
|
|
494
|
+
// Live-verified 2026-09-18 against the international gateway: a
|
|
495
|
+
// 312,856-token prompt was accepted, while 1,294,869 tokens was rejected
|
|
496
|
+
// with "> 1048576 maximum" — so `maxInputTokens` is the honest ceiling and
|
|
497
|
+
// the default understated it (deepseek-v4.1-flash read 300K, gpt-6-astra
|
|
498
|
+
// 400K, hy4-preview 200K). Declaring the default made ZCode compact early
|
|
499
|
+
// and waste the window. `defaultContextWindow` keeps the default for
|
|
500
|
+
// display. Do not switch this back to `defaultLength`.
|
|
501
|
+
contextWindow: input,
|
|
502
|
+
...(international ? {
|
|
503
|
+
...(isObject(model['contextWindow']) && positive(model['contextWindow']['defaultLength'])
|
|
504
|
+
? { defaultContextWindow: model['contextWindow']['defaultLength'] } : {}),
|
|
505
|
+
maxInputTokens: input,
|
|
506
|
+
supportedContextWindows: isObject(model['contextWindow']) && Array.isArray(model['contextWindow']['supportedLengths'])
|
|
507
|
+
? model['contextWindow']['supportedLengths'].filter(positive) : [],
|
|
508
|
+
promotions: parsePromotions(data['modelPromotions'], id),
|
|
509
|
+
} : {}),
|
|
510
|
+
maxTokens: output,
|
|
511
|
+
supportsImages: model['supportsImages'] === true && model['disabledMultimodal'] !== true,
|
|
512
|
+
...resolveUpstreamReasoning(model),
|
|
513
|
+
...resolveUpstreamBilling(model),
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
const models = cliIds.map(id => byId.get(id)).filter(model => model !== undefined)
|
|
517
|
+
if (models.length === 0) throw new Error('workbuddy model catalog resolved to an empty list')
|
|
518
|
+
return models
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Normalize the CN personal credits document. */
|
|
522
|
+
function normalizeCreditsDocument(data) {
|
|
523
|
+
const rawAccounts = Array.isArray(data['Accounts']) ? data['Accounts']
|
|
524
|
+
: Array.isArray(data['accounts']) ? data['accounts'] : []
|
|
525
|
+
const accounts = rawAccounts.flatMap(account => {
|
|
526
|
+
if (!isObject(account)) return []
|
|
527
|
+
const remain = typeof account['remain'] === 'number' ? account['remain'] : 0
|
|
528
|
+
const size = typeof account['size'] === 'number' ? account['size'] : 0
|
|
529
|
+
const unlimited = account['unlimited'] === true
|
|
530
|
+
return [{
|
|
531
|
+
packageName: typeof account['packageName'] === 'string' ? account['packageName'] : enterprisePackageName,
|
|
532
|
+
remain: Number.isFinite(remain) ? Math.max(remain, 0) : 0,
|
|
533
|
+
size: Number.isFinite(size) ? Math.max(size, 0) : 0,
|
|
534
|
+
...(unlimited ? { unlimited: true } : {}),
|
|
535
|
+
}]
|
|
536
|
+
})
|
|
537
|
+
return {
|
|
538
|
+
total: accounts.reduce((sum, account) => sum + account.remain, 0),
|
|
539
|
+
accounts,
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Upstream HTTP client. One instance serves the whole process; requests take
|
|
545
|
+
* the credential explicitly so token refreshes apply on the next call.
|
|
546
|
+
*/
|
|
547
|
+
/**
|
|
548
|
+
* 上游「响应头」到达的等待上限(毫秒)。只覆盖响应头阶段,拿到即清除;
|
|
549
|
+
* body 流由流内看门狗负责。可用 `ZC_UPSTREAM_HEADER_TIMEOUT_MS` 覆盖。
|
|
550
|
+
*/
|
|
551
|
+
const UPSTREAM_HEADER_TIMEOUT_MS = Number(process.env['ZC_UPSTREAM_HEADER_TIMEOUT_MS'] ?? 120_000)
|
|
552
|
+
|
|
553
|
+
export class WorkBuddyUpstreamClient {
|
|
554
|
+
constructor(options = {}) {
|
|
555
|
+
this.resolveAppVersion = options.resolveAppVersion ?? (() => resolveAppVersion())
|
|
556
|
+
this.resolveChatIdentity = options.resolveChatIdentity ?? (region => resolveChatIdentity(region))
|
|
557
|
+
/** Provenance of the most recent successful catalog fetch. */
|
|
558
|
+
this.lastCatalog = undefined
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** POST the chat endpoint; a successful answer is the raw SSE response. */
|
|
562
|
+
async chatStream(credential, bodyJson, signal) {
|
|
563
|
+
const region = regionOf(credential.domain)
|
|
564
|
+
// Identity resolution must never block a message: any failure degrades to
|
|
565
|
+
// the desktop fallback form, never to the legacy CLI UA.
|
|
566
|
+
let userAgent
|
|
567
|
+
try {
|
|
568
|
+
userAgent = chatUserAgent(await this.resolveChatIdentity(region), region)
|
|
569
|
+
} catch {
|
|
570
|
+
userAgent = chatUserAgent(fallbackChatIdentity(region), region)
|
|
571
|
+
}
|
|
572
|
+
let response
|
|
573
|
+
// ⚠️ 可清除的 AbortController(不能用 AbortSignal.timeout:它会被 undici 维持到
|
|
574
|
+
// body 流结束)。这里只覆盖「响应头到达之前」,上游排队不回时限时失败、让下游重试,
|
|
575
|
+
// 而不是让客户端永远等一个不会来的响应头。
|
|
576
|
+
const headerAbort = new AbortController()
|
|
577
|
+
const headerTimer = setTimeout(() => {
|
|
578
|
+
headerAbort.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
|
|
579
|
+
}, UPSTREAM_HEADER_TIMEOUT_MS)
|
|
580
|
+
try {
|
|
581
|
+
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
582
|
+
method: 'POST',
|
|
583
|
+
headers: { ...chatHeaders(credential, userAgent), 'Authorization': `Bearer ${credential.accessToken}` },
|
|
584
|
+
body: region === 'global' ? prepareInternationalChatBody(bodyJson) : bodyJson,
|
|
585
|
+
signal: signal === undefined ? headerAbort.signal : AbortSignal.any([signal, headerAbort.signal]),
|
|
586
|
+
})
|
|
587
|
+
} catch (error) {
|
|
588
|
+
return { ok: false, status: 0, kind: 'server', message: `transport error: ${String(error)}` }
|
|
589
|
+
} finally {
|
|
590
|
+
clearTimeout(headerTimer)
|
|
591
|
+
}
|
|
592
|
+
if (response.ok) return { ok: true, response }
|
|
593
|
+
const text = (await response.text()).slice(0, ERROR_BODY_LIMIT)
|
|
594
|
+
return { ok: false, status: response.status, kind: classifyUpstreamError(response.status, text), message: text }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** POST the token-refresh endpoint; the caller merges the outcome. */
|
|
598
|
+
async refreshToken(credential) {
|
|
599
|
+
const response = await fetch(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
|
|
600
|
+
method: 'POST',
|
|
601
|
+
headers: refreshHeaders(credential),
|
|
602
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
|
|
603
|
+
})
|
|
604
|
+
const envelope = await readEnvelope(response)
|
|
605
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope)
|
|
606
|
+
const data = isObject(envelope.data) ? envelope.data : {}
|
|
607
|
+
const accessToken = typeof data['accessToken'] === 'string' ? data['accessToken'] : ''
|
|
608
|
+
if (accessToken === '') {
|
|
609
|
+
throw new Error('workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app')
|
|
610
|
+
}
|
|
611
|
+
const outcome = { accessToken }
|
|
612
|
+
if (typeof data['refreshToken'] === 'string' && data['refreshToken'] !== '') outcome.refreshToken = data['refreshToken']
|
|
613
|
+
if (typeof data['expiresIn'] === 'number' && data['expiresIn'] > 0) outcome.expiresInSec = data['expiresIn']
|
|
614
|
+
if (typeof data['domain'] === 'string' && data['domain'] !== '') outcome.domain = data['domain']
|
|
615
|
+
return outcome
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* GET the personal catalog.
|
|
620
|
+
*
|
|
621
|
+
* UA discipline per region (live-verified 2026-09-18): the international
|
|
622
|
+
* gateway (`/v3/config`) REJECTS the App-shaped UA with HTTP 400 code 12403
|
|
623
|
+
* ("check ua, get coding copilot version error") and requires the CLI-form
|
|
624
|
+
* UA; the CN endpoint is indifferent but has always used the CLI UA. The
|
|
625
|
+
* upstream plugin's assumption that `/v3/config` needs the App UA is wrong
|
|
626
|
+
* on today's wire — keep this CLI UA unless a live probe says otherwise.
|
|
627
|
+
*/
|
|
628
|
+
async fetchModels(credential, signal) {
|
|
629
|
+
const international = regionOf(credential.domain) === 'global'
|
|
630
|
+
const url = `${chatBase(credential)}${international ? '/v3/config' : '/console/enterprises/personal/models'}`
|
|
631
|
+
const response = await fetch(url, {
|
|
632
|
+
headers: {
|
|
633
|
+
Authorization: `Bearer ${credential.accessToken}`,
|
|
634
|
+
Accept: 'application/json',
|
|
635
|
+
Origin: originReferer(credential),
|
|
636
|
+
Referer: `${originReferer(credential)}/`,
|
|
637
|
+
...(international ? { 'X-Requested-With': 'XMLHttpRequest', 'X-Product': 'SaaS' } : {}),
|
|
638
|
+
'User-Agent': CLIENT_UA,
|
|
639
|
+
},
|
|
640
|
+
signal: signal === undefined
|
|
641
|
+
? AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
642
|
+
: AbortSignal.any([signal, AbortSignal.timeout(JSON_TIMEOUT_MS)]),
|
|
643
|
+
})
|
|
644
|
+
const envelope = await readEnvelope(response)
|
|
645
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope)
|
|
646
|
+
// The CN endpoint always wraps in `{code,msg,data}`; `/v3/config` has also
|
|
647
|
+
// been observed answering with the product document bare.
|
|
648
|
+
const data = isObject(envelope.data) ? envelope.data
|
|
649
|
+
: ('models' in envelope.document || 'agents' in envelope.document) ? envelope.document
|
|
650
|
+
: {}
|
|
651
|
+
const models = parseModelCatalog(data, international)
|
|
652
|
+
this.lastCatalog = {
|
|
653
|
+
fetchedAtMs: Date.now(),
|
|
654
|
+
source: international ? 'workbuddy-ai:cli' : 'workbuddy:cli',
|
|
655
|
+
}
|
|
656
|
+
return models
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* POST the billing endpoint for the aggregated remaining credit.
|
|
661
|
+
*
|
|
662
|
+
* CN enterprise accounts (`enterpriseId` non-empty) ask the enterprise
|
|
663
|
+
* endpoint, which answers with a single cycle quota; the personal endpoint
|
|
664
|
+
* serves them an empty Accounts list, which reads as "0 credit".
|
|
665
|
+
*/
|
|
666
|
+
async fetchCredits(credential) {
|
|
667
|
+
if (regionOf(credential.domain) === 'cn'
|
|
668
|
+
&& credential.enterpriseId !== undefined && credential.enterpriseId !== '') {
|
|
669
|
+
return await this.fetchEnterpriseCredits(credential)
|
|
670
|
+
}
|
|
671
|
+
const now = new Date()
|
|
672
|
+
const format = date => [
|
|
673
|
+
date.getFullYear().toString().padStart(4, '0'),
|
|
674
|
+
(date.getMonth() + 1).toString().padStart(2, '0'),
|
|
675
|
+
date.getDate().toString().padStart(2, '0'),
|
|
676
|
+
].join('-') + ' ' + [
|
|
677
|
+
date.getHours().toString().padStart(2, '0'),
|
|
678
|
+
date.getMinutes().toString().padStart(2, '0'),
|
|
679
|
+
date.getSeconds().toString().padStart(2, '0'),
|
|
680
|
+
].join(':')
|
|
681
|
+
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
|
|
682
|
+
method: 'POST',
|
|
683
|
+
headers: billingHeaders(credential),
|
|
684
|
+
body: JSON.stringify({ startTime: format(new Date(now.getFullYear(), now.getMonth(), 1)), endTime: format(now) }),
|
|
685
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
|
|
686
|
+
})
|
|
687
|
+
const envelope = await readEnvelope(response)
|
|
688
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope)
|
|
689
|
+
return normalizeCreditsDocument(isObject(envelope.data) ? envelope.data : {})
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Daily check-in status (read-only). Mirrors the WorkBuddy app's 签到. */
|
|
693
|
+
async fetchCheckinStatus(credential) {
|
|
694
|
+
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
|
|
695
|
+
method: 'POST',
|
|
696
|
+
headers: billingHeaders(credential),
|
|
697
|
+
body: '{}',
|
|
698
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
|
|
699
|
+
})
|
|
700
|
+
const envelope = await readEnvelope(response)
|
|
701
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope)
|
|
702
|
+
const data = isObject(envelope.data) ? envelope.data : {}
|
|
703
|
+
const num = key => (typeof data[key] === 'number' ? data[key] : 0)
|
|
704
|
+
return {
|
|
705
|
+
active: data['active'] === true,
|
|
706
|
+
todayCheckedIn: data['today_checked_in'] === true,
|
|
707
|
+
streakDays: num('streak_days'),
|
|
708
|
+
dailyCredit: num('daily_credit'),
|
|
709
|
+
todayCredit: num('today_credit'),
|
|
710
|
+
isStreakDay: data['is_streak_day'] === true,
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Claim today's check-in credit. Write on the real account — call once a day. */
|
|
715
|
+
async claimDailyCheckin(credential) {
|
|
716
|
+
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/daily-checkin`, {
|
|
717
|
+
method: 'POST',
|
|
718
|
+
headers: billingHeaders(credential),
|
|
719
|
+
body: '{}',
|
|
720
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
|
|
721
|
+
})
|
|
722
|
+
const envelope = await readEnvelope(response)
|
|
723
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope)
|
|
724
|
+
const data = isObject(envelope.data) ? envelope.data : {}
|
|
725
|
+
return {
|
|
726
|
+
credit: typeof data['credit'] === 'number' ? data['credit'] : 0,
|
|
727
|
+
streakDays: typeof data['streak_days'] === 'number' ? data['streak_days'] : 0,
|
|
728
|
+
isStreakDay: data['is_streak_day'] === true,
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** The CN enterprise cycle-quota endpoint. */
|
|
733
|
+
async fetchEnterpriseCredits(credential) {
|
|
734
|
+
const response = await fetch(`${CN_BILLING_BASE}/v2/billing/meter/get-enterprise-user-usage`, {
|
|
735
|
+
method: 'POST',
|
|
736
|
+
headers: billingHeaders(credential),
|
|
737
|
+
body: JSON.stringify({}),
|
|
738
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
|
|
739
|
+
})
|
|
740
|
+
const envelope = await readEnvelope(response)
|
|
741
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope)
|
|
742
|
+
const data = isObject(envelope.data) ? envelope.data : {}
|
|
743
|
+
const limitNum = typeof data['limitNum'] === 'number' ? data['limitNum'] : 0
|
|
744
|
+
// The enterprise endpoint reports one cycle quota, not the personal
|
|
745
|
+
// endpoint's list of named packages.
|
|
746
|
+
if (limitNum === -1) {
|
|
747
|
+
return {
|
|
748
|
+
total: 0,
|
|
749
|
+
accounts: [{ packageName: enterprisePackageName, remain: 0, size: 0, unlimited: true }],
|
|
750
|
+
unlimited: true,
|
|
751
|
+
...(typeof data['cycleResetTime'] === 'string' ? { cycleResetTime: data['cycleResetTime'] } : {}),
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const used = typeof data['usedNum'] === 'number' ? data['usedNum'] : 0
|
|
755
|
+
const remain = Math.max(limitNum - used, 0)
|
|
756
|
+
return {
|
|
757
|
+
total: remain,
|
|
758
|
+
accounts: [{ packageName: enterprisePackageName, remain, size: Math.max(limitNum, 0) }],
|
|
759
|
+
...(typeof data['cycleResetTime'] === 'string' ? { cycleResetTime: data['cycleResetTime'] } : {}),
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|