@magpie-community/opencode-workbuddy-auth 0.1.11 → 0.1.13

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 (3) hide show
  1. package/README.md +19 -0
  2. package/index.mjs +291 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,6 +43,14 @@ included, doesn't mark the account, as the built-in never did; magpie
43
43
  tries again later, and the old token is used meanwhile.
44
44
  OpenCode doesn't call `auth.refresh`; the check before each request stays.
45
45
 
46
+ An enterprise (team) account is asked for as the enterprise. A browser
47
+ sign-in keeps the enterprise id WorkBuddy names for the account (from
48
+ `/login/account`, else its account list), and desktop's is read from the
49
+ app's file. Every request then carries `X-Enterprise-Id` and `X-Tenant-Id`,
50
+ as WorkBuddy's own client sends them; without them WorkBuddy answers as the
51
+ personal account, with the personal plan's models. A browser sign-in made
52
+ before 0.1.13 has no enterprise id kept: sign in again.
53
+
46
54
  Desktop's sign-in is refreshed only when the app hasn't refreshed it
47
55
  itself, and the new token is kept in memory, never written back, as magpie's
48
56
  built-in WorkBuddy does.
@@ -74,6 +82,17 @@ Each chat carries WorkBuddy's headers:
74
82
  A chat that doesn't open with a system message is given WorkBuddy's
75
83
  default one, as the app does.
76
84
 
85
+ A chat's tools are given shapes WorkBuddy's models take. Without them
86
+ WorkBuddy AI answers 400 "Invalid request parameters" (11133), DeepSeek's
87
+ models to each shape below and Kimi's, GPT's, Gemini's and GLM's to some:
88
+ - a tool's parameters with no `type`, empty, or a union (`anyOf`, `oneOf`,
89
+ `allOf`) at the root are an object, the union's fields merged;
90
+ - a `pattern`'s `\0` is `\u0000`, which matches the same;
91
+ - a tuple's `items` (a list) is one schema any of them fits;
92
+ - a tool's name with a character other than letters, digits, `_` and `-`
93
+ (`.`, `:`, `/`, a space, CJK) is sent with `_` there, and the model's
94
+ calls come back under the tool's own name.
95
+
77
96
  ## Models
78
97
 
79
98
  When you are signed in, the models are the ones WorkBuddy's product config
package/index.mjs CHANGED
@@ -172,6 +172,7 @@ function renew(site, a, save) {
172
172
  "X-Refresh-Token": a.refresh,
173
173
  "X-Auth-Refresh-Source": "plugin",
174
174
  "X-Domain": domainOf(site, a),
175
+ ...enterpriseHeaders(a),
175
176
  }, {})
176
177
  if (!got?.accessToken) throw Object.assign(new Error("WorkBuddy gave no refreshed token"), { bare: true })
177
178
  const next = merge(a, got)
@@ -259,10 +260,48 @@ function sign(site, a, headers) {
259
260
  headers.set("X-Agent-Purpose", "conversation")
260
261
  headers.set("X-IDE-Name", "WorkBuddy")
261
262
  headers.set("X-IDE-Version", UA_VERSION)
263
+ for (const [k, v] of Object.entries(enterpriseHeaders(a))) headers.set(k, v)
262
264
  if (!site.ai) return
263
265
  headers.set("X-Agent-Type", "main")
264
266
  }
265
267
 
268
+ // enterpriseHeaders names the enterprise (team) an account signed in as, as
269
+ // WorkBuddy's own client does on every request it makes for that account
270
+ // (its auth interceptor: X-Enterprise-Id and X-Tenant-Id, both the
271
+ // account's enterpriseId). Without them WorkBuddy answers as the personal
272
+ // account: /v3/config lists the personal plan's models and not the
273
+ // enterprise's (#75). A personal account has none and sends none.
274
+ function enterpriseHeaders(a) {
275
+ const id = typeof a?.enterpriseId === "string" ? a.enterpriseId.trim() : ""
276
+ return id ? { "X-Enterprise-Id": id, "X-Tenant-Id": id } : {}
277
+ }
278
+
279
+ // enterpriseOf is the enterprise the account a sign-in gave belongs to:
280
+ // the one /login/account names, else, for an account that isn't personal,
281
+ // the one WorkBuddy's account list has for it — the same choice WorkBuddy's
282
+ // own client makes (mergeCurrentAccountDetails): the uid's only entry of that
283
+ // type, or the only one it last signed in to. None found is personal.
284
+ async function enterpriseOf(site, a, who) {
285
+ const own = typeof who?.enterpriseId === "string" ? who.enterpriseId.trim() : ""
286
+ if (own) return own
287
+ const type = String(who?.type ?? "").trim().toLowerCase()
288
+ if (!type || type === "personal") return ""
289
+ let list
290
+ try {
291
+ list = await call(site, "GET", "/v2/plugin/accounts", {
292
+ Authorization: "Bearer " + a.access,
293
+ "X-Domain": domainOf(site, a),
294
+ })
295
+ } catch {
296
+ return ""
297
+ }
298
+ const mine = (list?.accounts ?? []).filter((x) => x?.uid === who.uid && typeof x.enterpriseId === "string" && x.enterpriseId.trim())
299
+ const same = mine.filter((x) => String(x.type ?? "").trim().toLowerCase() === type)
300
+ const last = mine.filter((x) => x.lastLogin)
301
+ const pick = same.length === 1 ? same[0] : last.length === 1 ? last[0] : mine.length === 1 ? mine[0] : undefined
302
+ return pick ? pick.enterpriseId.trim() : ""
303
+ }
304
+
266
305
  // WorkBuddy's own client names each chat to its gateway four ways: the
267
306
  // conversation it belongs to, the user send (turn) it answers, the message
268
307
  // itself, and the request that carried it. Its backend counts a user send as
@@ -468,6 +507,250 @@ function withSystem(body) {
468
507
  }
469
508
  }
470
509
 
510
+ // ---- tools WorkBuddy takes ----------------------------------------------------
511
+
512
+ // WorkBuddy AI answers 400 code 11133 "Invalid request parameters" (the
513
+ // model provider rejected them) to a chat whose tools it can't pass on to
514
+ // the model (wyh on magpie's Discord: ZCode and DeepSeek Harness on
515
+ // deepseek-v4.1-flash). Tried on the owner's account, 2026-10-08: DeepSeek
516
+ // turns away each of the shapes below, and Kimi, GPT, Gemini and GLM some
517
+ // of them; MiniMax none. fitTools gives each a shape every model takes.
518
+ // A tool's name may hold letters, digits, _ and -; another character
519
+ // (". : / space", CJK) is refused.
520
+ const NAME_OK = /^[A-Za-z0-9_-]+$/
521
+
522
+ // safeName is name with each character WorkBuddy refuses as _, kept apart
523
+ // from the names in taken.
524
+ function safeName(name, taken) {
525
+ const base = String(name).replace(/[^A-Za-z0-9_-]/g, "_") || "tool"
526
+ let n = base
527
+ for (let i = 2; taken.has(n); i++) n = `${base}_${i}`
528
+ taken.add(n)
529
+ return n
530
+ }
531
+
532
+ // NUL_ESCAPE finds a regex's \0 (the NUL character), stepping over each \\
533
+ // (a backslash) so that a \\0 is left; \01 is an octal escape, also left.
534
+ const NUL_ESCAPE = /\\\\|\\0(?![0-9])/g
535
+
536
+ // fitSchema mends a schema in place, at every depth: a pattern's \0, which
537
+ // DeepSeek's validator says is "not a 'regex'", is \u0000, which matches
538
+ // the same (magpie's gateway does this for DeepSeek's own API,
539
+ // deepseekToolPatterns); a tuple's items (a list) become one schema any of
540
+ // them fits. It reports whether it changed anything.
541
+ function fitSchema(s, depth = 0) {
542
+ if (!s || typeof s !== "object" || depth > 64) return false
543
+ let changed = false
544
+ if (Array.isArray(s)) {
545
+ for (const v of s) if (fitSchema(v, depth + 1)) changed = true
546
+ return changed
547
+ }
548
+ if (typeof s.pattern === "string" && s.pattern.includes("\\0")) {
549
+ const p = s.pattern.replace(NUL_ESCAPE, (m) => (m === "\\\\" ? m : "\\u0000"))
550
+ if (p !== s.pattern) {
551
+ s.pattern = p
552
+ changed = true
553
+ }
554
+ }
555
+ if (Array.isArray(s.items)) {
556
+ const list = s.items.filter((v) => v && typeof v === "object")
557
+ s.items = list.length === 0 ? {} : list.length === 1 ? list[0] : { anyOf: list }
558
+ delete s.additionalItems
559
+ changed = true
560
+ }
561
+ for (const v of Object.values(s)) if (fitSchema(v, depth + 1)) changed = true
562
+ return changed
563
+ }
564
+
565
+ // localRef is the schema a local $ref in root names ("#/$defs/x",
566
+ // "#/definitions/x"), or s itself.
567
+ function localRef(root, s) {
568
+ const ref = s?.$ref
569
+ if (typeof ref !== "string" || !ref.startsWith("#/")) return s
570
+ let at = root
571
+ for (const k of ref.slice(2).split("/")) at = at?.[k.replaceAll("~1", "/").replaceAll("~0", "~")]
572
+ return at && typeof at === "object" ? at : s
573
+ }
574
+
575
+ // objectRoot makes a tool's parameters an object at the root, as magpie's
576
+ // gateway does for xAI (provider.ObjectRoot): allOf's branches are merged
577
+ // whole; of anyOf's and oneOf's object branches the properties are merged
578
+ // and a field each requires stays required. A root with no type, or an
579
+ // empty one, is an object. It reports whether it changed anything.
580
+ function objectRoot(ps, root = ps, depth = 0) {
581
+ const unions = ["allOf", "anyOf", "oneOf"].filter((k) => Array.isArray(ps[k]))
582
+ if (ps.type === "object" && !unions.length) return false
583
+ const props = ps.properties && typeof ps.properties === "object" ? ps.properties : {}
584
+ const required = Array.isArray(ps.required) ? [...ps.required] : []
585
+ for (const b0 of ps.allOf ?? []) {
586
+ let b = localRef(root, b0)
587
+ if (!b || typeof b !== "object") continue
588
+ if (depth < 8 && ["allOf", "anyOf", "oneOf"].some((k) => Array.isArray(b[k]))) objectRoot((b = structuredClone(b)), root, depth + 1)
589
+ for (const [k, v] of Object.entries(b.properties ?? {})) if (!(k in props)) props[k] = v
590
+ if (Array.isArray(b.required)) required.push(...b.required)
591
+ }
592
+ const branches = []
593
+ for (const key of ["anyOf", "oneOf"]) {
594
+ for (const b0 of ps[key] ?? []) {
595
+ let b = localRef(root, b0)
596
+ if (!b || typeof b !== "object") continue
597
+ if (depth < 8 && ["allOf", "anyOf", "oneOf"].some((k) => Array.isArray(b[k]))) objectRoot((b = structuredClone(b)), root, depth + 1)
598
+ if (b.type !== "object" && !b.properties) continue
599
+ branches.push(b)
600
+ }
601
+ }
602
+ const from = new Map()
603
+ for (const b of branches) {
604
+ for (const [k, v] of Object.entries(b.properties ?? {})) {
605
+ if (k in props) continue
606
+ const seen = from.get(k) ?? []
607
+ if (!seen.some((x) => JSON.stringify(x) === JSON.stringify(v))) seen.push(v)
608
+ from.set(k, seen)
609
+ }
610
+ }
611
+ for (const [k, list] of from) props[k] = list.length === 1 ? list[0] : { anyOf: list }
612
+ if (branches.length) {
613
+ for (const k of Object.keys(branches[0].properties ?? {})) {
614
+ if (branches.every((b) => Array.isArray(b.required) && b.required.includes(k))) required.push(k)
615
+ }
616
+ }
617
+ for (const k of unions) delete ps[k]
618
+ ps.type = "object"
619
+ ps.properties = props
620
+ const req = [...new Set(required)].filter((k) => typeof k === "string" && k in props)
621
+ if (req.length) ps.required = req
622
+ else delete ps.required
623
+ return true
624
+ }
625
+
626
+ // fitTools mends b's function tools in place: their parameters (fitSchema,
627
+ // objectRoot) and names. It reports whether it changed anything, and the
628
+ // names it gave, each to the name the client knows (ours), so that the
629
+ // model's calls are named back. A call in the chat's history is named as
630
+ // its tool now is.
631
+ function fitTools(b) {
632
+ const names = new Map()
633
+ if (!Array.isArray(b?.tools) || !b.tools.length) return { changed: false, names }
634
+ let changed = false
635
+ const taken = new Set()
636
+ for (const t of b.tools) {
637
+ const n = t?.function?.name
638
+ if (typeof n === "string" && NAME_OK.test(n)) taken.add(n)
639
+ }
640
+ const theirs = new Map()
641
+ for (const t of b.tools) {
642
+ const fn = t?.type === "function" || t?.function ? t.function : null
643
+ if (!fn || typeof fn !== "object") continue
644
+ if (typeof fn.name === "string" && !NAME_OK.test(fn.name)) {
645
+ const safe = safeName(fn.name, taken)
646
+ names.set(safe, fn.name)
647
+ theirs.set(fn.name, safe)
648
+ fn.name = safe
649
+ changed = true
650
+ }
651
+ if (fn.parameters === undefined) continue
652
+ if (!fn.parameters || typeof fn.parameters !== "object" || Array.isArray(fn.parameters)) {
653
+ fn.parameters = { type: "object", properties: {} }
654
+ changed = true
655
+ continue
656
+ }
657
+ if (fitSchema(fn.parameters)) changed = true
658
+ if (objectRoot(fn.parameters)) changed = true
659
+ }
660
+ if (theirs.size && Array.isArray(b.messages)) {
661
+ for (const m of b.messages) {
662
+ for (const c of Array.isArray(m?.tool_calls) ? m.tool_calls : []) {
663
+ const safe = theirs.get(c?.function?.name)
664
+ if (safe) c.function.name = safe
665
+ }
666
+ if (m?.role === "tool" && theirs.has(m.name)) m.name = theirs.get(m.name)
667
+ }
668
+ }
669
+ const choice = b.tool_choice?.function
670
+ if (choice && theirs.has(choice.name)) choice.name = theirs.get(choice.name)
671
+ return { changed, names }
672
+ }
673
+
674
+ // fitted is a chat body with its tools mended (fitTools), and the names
675
+ // given its tools, each to the client's own; a body that isn't a chat is
676
+ // as it was.
677
+ function fitted(body) {
678
+ if (typeof body !== "string") return { body, names: new Map() }
679
+ try {
680
+ const b = JSON.parse(body)
681
+ const { changed, names } = fitTools(b)
682
+ return { body: changed ? JSON.stringify(b) : body, names }
683
+ } catch {
684
+ return { body, names: new Map() }
685
+ }
686
+ }
687
+
688
+ // namedBack gives the model's calls in a chat answer (one JSON body, or an
689
+ // SSE stream's chunks) the names the client gave its tools.
690
+ function namedBack(v, names) {
691
+ let changed = false
692
+ for (const c of Array.isArray(v?.choices) ? v.choices : []) {
693
+ for (const at of [c?.message, c?.delta]) {
694
+ for (const tc of Array.isArray(at?.tool_calls) ? at.tool_calls : []) {
695
+ const ours = names.get(tc?.function?.name)
696
+ if (ours) {
697
+ tc.function.name = ours
698
+ changed = true
699
+ }
700
+ }
701
+ }
702
+ }
703
+ return changed
704
+ }
705
+
706
+ // renamed is res with namedBack on its body, read as it comes.
707
+ function renamed(res, names) {
708
+ if (!names.size || !res.body) return res
709
+ const headers = new Headers(res.headers)
710
+ headers.delete("content-length")
711
+ headers.delete("content-encoding")
712
+ const json = /json/i.test(res.headers.get("content-type") ?? "")
713
+ if (json) {
714
+ const out = res.text().then((text) => {
715
+ try {
716
+ const v = JSON.parse(text)
717
+ return namedBack(v, names) ? JSON.stringify(v) : text
718
+ } catch {
719
+ return text
720
+ }
721
+ })
722
+ return out.then((text) => new Response(text, { status: res.status, statusText: res.statusText, headers }))
723
+ }
724
+ const dec = new TextDecoder()
725
+ const enc = new TextEncoder()
726
+ let rest = ""
727
+ const line = (l) => {
728
+ const m = /^data:\s?(.*)$/.exec(l)
729
+ if (!m || m[1] === "[DONE]") return l
730
+ try {
731
+ const v = JSON.parse(m[1])
732
+ return namedBack(v, names) ? `data: ${JSON.stringify(v)}` : l
733
+ } catch {
734
+ return l
735
+ }
736
+ }
737
+ const body = res.body.pipeThrough(
738
+ new TransformStream({
739
+ transform(chunk, ctl) {
740
+ rest += dec.decode(chunk, { stream: true })
741
+ const lines = rest.split("\n")
742
+ rest = lines.pop()
743
+ if (lines.length) ctl.enqueue(enc.encode(lines.map(line).join("\n") + "\n"))
744
+ },
745
+ flush(ctl) {
746
+ rest += dec.decode()
747
+ if (rest) ctl.enqueue(enc.encode(line(rest)))
748
+ },
749
+ }),
750
+ )
751
+ return new Response(body, { status: res.status, statusText: res.statusText, headers })
752
+ }
753
+
471
754
  // ---- signing in -------------------------------------------------------------
472
755
 
473
756
  // poll asks path until WorkBuddy answers with data, retrying the codes
@@ -515,6 +798,7 @@ async function browserSignIn(site) {
515
798
  "X-No-Enterprise-Id": "true",
516
799
  }, [12151], deadline)
517
800
  a.uid = who.uid ?? ""
801
+ a.enterpriseId = await enterpriseOf(site, a, who)
518
802
  return success(site, a, who.nickname || who.phoneNumber || who.uid)
519
803
  } catch (e) {
520
804
  return { type: "failed", error: e.message }
@@ -532,6 +816,7 @@ function success(site, a, name) {
532
816
  accountId: name || site.name,
533
817
  uid: a.uid ?? "",
534
818
  domain: a.domain ?? "",
819
+ ...(a.enterpriseId ? { enterpriseId: a.enterpriseId } : {}),
535
820
  ...(a.refreshExpiresAt ? { refreshExpiresAt: a.refreshExpiresAt } : {}),
536
821
  ...(a.tokenType ? { tokenType: a.tokenType } : {}),
537
822
  }
@@ -564,6 +849,7 @@ function readDesktop(site) {
564
849
  domain: typeof t.domain === "string" ? t.domain : "",
565
850
  tokenType: typeof t.tokenType === "string" ? t.tokenType : "",
566
851
  uid: acct.uid,
852
+ ...(typeof acct.enterpriseId === "string" && acct.enterpriseId.trim() ? { enterpriseId: acct.enterpriseId.trim() } : {}),
567
853
  name: acct.nickname || acct.phoneNumber || acct.uid,
568
854
  }
569
855
  } catch {
@@ -636,7 +922,7 @@ async function meter(site, a, path) {
636
922
  method: "POST",
637
923
  headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "WorkBuddy/" + UA_VERSION,
638
924
  Authorization: "Bearer " + a.access, "X-User-Id": a.uid ?? "", "X-Domain": domainOf(site, a), "X-Product": "SaaS",
639
- "X-IDE-Type": "WorkBuddy" },
925
+ "X-IDE-Type": "WorkBuddy", ...enterpriseHeaders(a) },
640
926
  body: "{}",
641
927
  signal: AbortSignal.timeout(20000),
642
928
  })
@@ -863,9 +1149,11 @@ function makePlugin(site) {
863
1149
  let body = init?.body
864
1150
  if (body === undefined && req) body = await req.clone().text()
865
1151
  body = withSystem(body)
1152
+ const fit = fitted(body)
1153
+ body = fit.body
866
1154
  attend(headers, body, session)
867
1155
  const res = await fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body })
868
- return res.status >= 400 ? explained(res) : kept(res)
1156
+ return res.status >= 400 ? explained(res) : kept(await renamed(res, fit.names))
869
1157
  },
870
1158
  }
871
1159
  },
@@ -970,4 +1258,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
970
1258
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
971
1259
 
972
1260
  // for tests
973
- export const _internal = { withSystem, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf }
1261
+ export const _internal = { enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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",