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.
@@ -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
+ }