@magpie-community/opencode-workbuddy-auth 0.1.1 → 0.1.3

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.
Files changed (2) hide show
  1. package/index.mjs +126 -5
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -243,7 +243,7 @@ async function browserSignIn(site) {
243
243
  const deadline = Date.now() + SIGN_IN_MS
244
244
  const q = encodeURIComponent(state.state)
245
245
  const token = await poll(site, `/v2/plugin/auth/token?state=${q}`, {}, [11217], deadline)
246
- if (!token.accessToken) return { type: "failed" }
246
+ if (!token.accessToken) return { type: "failed", error: `${site.name} sent back no token` }
247
247
  let a = merge({}, token)
248
248
  const who = await poll(site, `/v2/plugin/login/account?state=${q}`, {
249
249
  Authorization: "Bearer " + a.access,
@@ -253,8 +253,8 @@ async function browserSignIn(site) {
253
253
  }, [12151], deadline)
254
254
  a.uid = who.uid ?? ""
255
255
  return success(site, a, who.nickname || who.phoneNumber || who.uid)
256
- } catch {
257
- return { type: "failed" }
256
+ } catch (e) {
257
+ return { type: "failed", error: e.message }
258
258
  }
259
259
  },
260
260
  }
@@ -319,7 +319,7 @@ function desktopSignIn(site) {
319
319
  method: "auto",
320
320
  async callback() {
321
321
  const d = readDesktop(site)
322
- if (!d) return { type: "failed" }
322
+ if (!d) return { type: "failed", error: `${site.name} desktop isn't signed in` }
323
323
  return { type: "success", refresh: "", access: "", expires: 0, source: "desktop", accountId: d.name || site.name, uid: d.uid }
324
324
  },
325
325
  }
@@ -342,6 +342,65 @@ async function current(site, client, auth) {
342
342
  return a
343
343
  }
344
344
 
345
+ // ---- allowance ----------------------------------------------------------------
346
+
347
+ // STATUS_TEXT is Go's http.StatusText for the answers WorkBuddy gives.
348
+ const STATUS_TEXT = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed",
349
+ 408: "Request Timeout", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable",
350
+ 504: "Gateway Timeout" }
351
+
352
+ // meter asks the billing API as magpie's built-in did, its errors said
353
+ // the same: WorkBuddy's message, else its code, else the HTTP status.
354
+ async function meter(site, a, path) {
355
+ const res = await fetch(site.endpoint + path, {
356
+ method: "POST",
357
+ headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "WorkBuddy/" + UA_VERSION,
358
+ Authorization: "Bearer " + a.access, "X-User-Id": a.uid ?? "", "X-Domain": domainOf(site, a), "X-Product": "SaaS",
359
+ "X-IDE-Type": "WorkBuddy" },
360
+ body: "{}",
361
+ signal: AbortSignal.timeout(20000),
362
+ })
363
+ let env = {}
364
+ try {
365
+ env = JSON.parse(await res.text()) ?? {}
366
+ } catch {}
367
+ const code = Number.parseInt(env.code, 10) || 0
368
+ if (!res.ok) {
369
+ if (code) throw new Error(env.msg || `error ${code}`)
370
+ if (env.msg) throw new Error(env.msg)
371
+ throw new Error(STATUS_TEXT[res.status] ?? "")
372
+ }
373
+ if (code) throw new Error(env.msg || `error ${code}`)
374
+ return env.data ?? null
375
+ }
376
+
377
+ // capacity is a capacity the billing API sends, as a string ("438.88")
378
+ // or now and then a number; empty is 0.
379
+ function capacity(v) {
380
+ if (v == null || v === "") return 0
381
+ if (typeof v === "number") return v
382
+ const s = String(v)
383
+ const n = Number(s)
384
+ if (s.trim() !== s || Number.isNaN(n)) throw new Error(`strconv.ParseFloat: parsing ${JSON.stringify(s)}: invalid syntax`)
385
+ return n
386
+ }
387
+
388
+ // compact is a count as magpie says one: whole, else to two places.
389
+ const compact = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""))
390
+
391
+ // usageOf is the account's credits, from its resource summary: the plan's
392
+ // credits used against what the cycle grants, as magpie's built-in said.
393
+ function usageOf(sum, plan) {
394
+ const out = { plan: plan || (sum?.IsPaidUser ? "Pro" : "Free"), windows: [] }
395
+ let total = 0, used = 0
396
+ for (const p of sum?.Packages ?? []) {
397
+ total += capacity(p?.CycleTotalCapacity)
398
+ used += capacity(p?.CycleUsedCapacity)
399
+ }
400
+ if (total > 0) out.windows.push({ name: "Credits", used: (100 * used) / total, display: `${compact(used)} / ${compact(total)}` })
401
+ return out
402
+ }
403
+
345
404
  // ---- models -----------------------------------------------------------------
346
405
 
347
406
  // variants are the reasoning levels as OpenCode gives them to the AI SDK.
@@ -380,12 +439,22 @@ function modelOf(site, provider, m) {
380
439
  },
381
440
  release_date: "",
382
441
  variants: variants(m.efforts),
442
+ // magpie's own: served at no cost to the plan's credits
443
+ free: !!m.free,
383
444
  }
384
445
  }
385
446
 
386
447
  // liveModels asks WorkBuddy's product config for the plan's models: the
387
448
  // "cli" agent's list, each with its details. The CLI User-Agent picks
388
449
  // WorkBuddy's config (a bare WorkBuddy/<v> gets CodeBuddy IDE's, no cli).
450
+ // freeCredits says whether a model's credits are none: "x0.00", "0" or 0.
451
+ // A rate it can't read is not free.
452
+ function freeCredits(c) {
453
+ if (c == null || c === "") return false
454
+ const s = String(c).trim().toLowerCase().replace(/^x/, "")
455
+ return s !== "" && Number.isFinite(Number(s)) && Number(s) === 0
456
+ }
457
+
389
458
  async function liveModels(site, a) {
390
459
  const headers = new Headers()
391
460
  sign(site, a, headers)
@@ -407,6 +476,7 @@ async function liveModels(site, a) {
407
476
  m.context = d.maxInputTokens
408
477
  m.output = d.maxOutputTokens
409
478
  m.images = d.supportsImages === true
479
+ m.free = freeCredits(d.credits)
410
480
  const es = d.reasoning?.supportedEfforts ?? []
411
481
  if (es.length) {
412
482
  const canOff = d.reasoning?.canDisableThinking
@@ -480,10 +550,23 @@ function makePlugin(site) {
480
550
  sign(site, a, headers)
481
551
  let body = init?.body
482
552
  if (body === undefined && req) body = await req.clone().text()
483
- return fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: withSystem(body) })
553
+ const res = await fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: withSystem(body) })
554
+ return res.status >= 400 ? explained(res) : res
484
555
  },
485
556
  }
486
557
  },
558
+ // the account's credits, as magpie's built-in showed them
559
+ async usage(getAuth) {
560
+ const auth = await getAuth()
561
+ if (auth?.type !== "oauth" || !(auth.access || auth.source)) return { error: "not signed in" }
562
+ try {
563
+ const a = await current(site, client, auth)
564
+ // the meter has no /v2 prefix, on either site
565
+ return usageOf(await meter(site, a, "/billing/meter/get-user-resource-summary"), auth.plan)
566
+ } catch (e) {
567
+ return { error: e?.message ?? String(e) }
568
+ }
569
+ },
487
570
  methods: [
488
571
  {
489
572
  type: "oauth",
@@ -500,5 +583,43 @@ function makePlugin(site) {
500
583
  })
501
584
  }
502
585
 
586
+ // REFUSED_HINT is what the user can do about WorkBuddy's "Illegal API
587
+ // invocation from an unapproved channel": both builds answer it to a chat
588
+ // whose system prompt is Codex's or Claude Code's own (magpie #182),
589
+ // whatever the headers. It ends the error's message, as magpie's built-in
590
+ // put it (provider.WBRefusedHint), so magpie's routing page says it apart.
591
+ const REFUSED_HINT =
592
+ "WorkBuddy refuses chats from Codex and Claude Code (their system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group"
593
+ const REFUSED = /unapproved channel|illegal api invocation/i
594
+
595
+ // explained is res with REFUSED_HINT added to that refusal's message.
596
+ async function explained(res) {
597
+ const text = await res.text()
598
+ const again = (b) => {
599
+ const headers = new Headers(res.headers)
600
+ headers.delete("content-length")
601
+ headers.delete("content-encoding")
602
+ return new Response(b, { status: res.status, statusText: res.statusText, headers })
603
+ }
604
+ if (!REFUSED.test(text)) return again(text)
605
+ let msg = text.trim()
606
+ let v
607
+ try {
608
+ v = JSON.parse(text)
609
+ } catch {}
610
+ const at = v?.error && typeof v.error === "object" ? v.error : v && typeof v === "object" ? v : null
611
+ if (at && typeof at.message === "string") msg = at.message
612
+ else if (typeof v?.error === "string") msg = v.error
613
+ msg = `${msg} — ${REFUSED_HINT}`
614
+ const headers = new Headers(res.headers)
615
+ headers.set("content-type", "application/json")
616
+ headers.delete("content-length")
617
+ headers.delete("content-encoding")
618
+ return new Response(JSON.stringify({ error: { message: msg, type: "permission_error", code: null } }), { status: res.status, statusText: res.statusText, headers })
619
+ }
620
+
503
621
  export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
504
622
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
623
+
624
+ // for tests
625
+ export const _internal = { usageOf, desktopHeld, explained, REFUSED_HINT, freeCredits }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "OpenCode provider plugins for WorkBuddy's plans: WorkBuddy (CodeBuddy, copilot.tencent.com) and WorkBuddy AI (www.workbuddy.ai)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",