@magpie-community/opencode-workbuddy-auth 0.1.10 → 0.1.12
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/README.md +26 -1
- package/index.mjs +403 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,11 +55,36 @@ They are streamed only; WorkBuddy refuses a chat that isn't.
|
|
|
55
55
|
Each chat carries WorkBuddy's headers:
|
|
56
56
|
- `Authorization`, `X-User-Id`, `X-Domain`, `X-Product: SaaS`, `X-IDE-Type: WorkBuddy`;
|
|
57
57
|
- `User-Agent: WorkBuddy/5.5.6`. Without it, WorkBuddy answers error 10085;
|
|
58
|
-
- WorkBuddy
|
|
58
|
+
- the client's, which WorkBuddy's usage list shows as the client that asked
|
|
59
|
+
(使用端): `X-Agent-Purpose: conversation`, `X-IDE-Name: WorkBuddy`,
|
|
60
|
+
`X-IDE-Version`, `X-Agent-Intent: craft`, `X-Requested-With`. Both builds
|
|
61
|
+
send them, as WorkBuddy's own client does; a chat without them is counted
|
|
62
|
+
under no client at all;
|
|
63
|
+
- the conversation's ids: `X-Conversation-ID`, `X-Conversation-Request-ID`,
|
|
64
|
+
`X-Conversation-Message-ID`, `X-Request-ID`. WorkBuddy's backend counts a
|
|
65
|
+
user send as one request by `X-Conversation-Request-ID`, so every step of
|
|
66
|
+
one send — a tool result sent back, a retry, another account answering —
|
|
67
|
+
carries the same one, and the user's next message carries another. The
|
|
68
|
+
conversation's is the session magpie (or OpenCode) names the request by,
|
|
69
|
+
else the chat's first user message; nothing is made up when neither names
|
|
70
|
+
one, a made-up conversation of the request's own being what splits one
|
|
71
|
+
conversation into many in the usage detail;
|
|
72
|
+
- WorkBuddy AI also takes `X-Agent-Type: main`.
|
|
59
73
|
|
|
60
74
|
A chat that doesn't open with a system message is given WorkBuddy's
|
|
61
75
|
default one, as the app does.
|
|
62
76
|
|
|
77
|
+
A chat's tools are given shapes WorkBuddy's models take. Without them
|
|
78
|
+
WorkBuddy AI answers 400 "Invalid request parameters" (11133), DeepSeek's
|
|
79
|
+
models to each shape below and Kimi's, GPT's, Gemini's and GLM's to some:
|
|
80
|
+
- a tool's parameters with no `type`, empty, or a union (`anyOf`, `oneOf`,
|
|
81
|
+
`allOf`) at the root are an object, the union's fields merged;
|
|
82
|
+
- a `pattern`'s `\0` is `\u0000`, which matches the same;
|
|
83
|
+
- a tuple's `items` (a list) is one schema any of them fits;
|
|
84
|
+
- a tool's name with a character other than letters, digits, `_` and `-`
|
|
85
|
+
(`.`, `:`, `/`, a space, CJK) is sent with `_` there, and the model's
|
|
86
|
+
calls come back under the tool's own name.
|
|
87
|
+
|
|
63
88
|
## Models
|
|
64
89
|
|
|
65
90
|
When you are signed in, the models are the ones WorkBuddy's product config
|
package/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// streamed only, with WorkBuddy's headers: a request whose User-Agent isn't
|
|
9
9
|
// WorkBuddy/<version> is refused (error 10085).
|
|
10
10
|
|
|
11
|
-
import { randomBytes } from "node:crypto"
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
12
12
|
import { readFileSync } from "node:fs"
|
|
13
13
|
import { homedir } from "node:os"
|
|
14
14
|
import { join } from "node:path"
|
|
@@ -235,7 +235,18 @@ async function refreshed(site, auth) {
|
|
|
235
235
|
return Object.keys(out).length ? out : undefined
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
// sign sets on headers what WorkBuddy's desktop app sends with a chat
|
|
238
|
+
// sign sets on headers what WorkBuddy's desktop app sends with a chat: the
|
|
239
|
+
// account's own, the client's, and the attribution WorkBuddy's usage list
|
|
240
|
+
// shows as the client that asked (使用端). The chat's ids — the
|
|
241
|
+
// conversation's, the turn's, the message's — are attend's below.
|
|
242
|
+
//
|
|
243
|
+
// WorkBuddy's own client sends the whole of this on both builds
|
|
244
|
+
// (application-manifest.js: X-Agent-Purpose "conversation", X-IDE-Name/Type
|
|
245
|
+
// "WorkBuddy", X-IDE-Version, X-Product "SaaS"); a chat without them is
|
|
246
|
+
// counted under no client at all, which is the empty 使用端 a request shows
|
|
247
|
+
// when only some of the group is there. The gate below used to give the China
|
|
248
|
+
// build none of the client's headers, so every China-build request was
|
|
249
|
+
// attribution-less.
|
|
239
250
|
function sign(site, a, headers) {
|
|
240
251
|
headers.set("Authorization", "Bearer " + a.access)
|
|
241
252
|
headers.set("X-User-Id", a.uid ?? "")
|
|
@@ -243,18 +254,144 @@ function sign(site, a, headers) {
|
|
|
243
254
|
headers.set("X-Product", "SaaS")
|
|
244
255
|
headers.set("X-IDE-Type", "WorkBuddy")
|
|
245
256
|
headers.set("User-Agent", "WorkBuddy/" + UA_VERSION)
|
|
246
|
-
if (!site.ai) return
|
|
247
257
|
headers.set("X-Requested-With", "XMLHttpRequest")
|
|
248
258
|
headers.set("X-Agent-Intent", "craft")
|
|
249
|
-
headers.set("X-Agent-
|
|
259
|
+
headers.set("X-Agent-Purpose", "conversation")
|
|
250
260
|
headers.set("X-IDE-Name", "WorkBuddy")
|
|
251
261
|
headers.set("X-IDE-Version", UA_VERSION)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
262
|
+
if (!site.ai) return
|
|
263
|
+
headers.set("X-Agent-Type", "main")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// WorkBuddy's own client names each chat to its gateway four ways: the
|
|
267
|
+
// conversation it belongs to, the user send (turn) it answers, the message
|
|
268
|
+
// itself, and the request that carried it. Its backend counts a user send as
|
|
269
|
+
// one request by X-Conversation-Request-ID: a chat carrying a new one every
|
|
270
|
+
// time is counted on its own, so one send that takes several steps — a tool
|
|
271
|
+
// call answered and sent again, a retry, another account answering — shows up
|
|
272
|
+
// in the usage detail as many requests. The ids below are made from what
|
|
273
|
+
// names the conversation and the turn, so every step of one send carries the
|
|
274
|
+
// same turn id and the user's next message carries another, as WorkBuddy's
|
|
275
|
+
// own client does.
|
|
276
|
+
|
|
277
|
+
// SESSION carries the conversation magpie (or OpenCode) names a request by,
|
|
278
|
+
// from the chat.headers hook to the loader's fetch. It goes no further.
|
|
279
|
+
const SESSION = "x-magpie-workbuddy-session"
|
|
280
|
+
|
|
281
|
+
// SALT is what the ids are derived with, so an id says nothing of the words
|
|
282
|
+
// it was made from. New in each process, as WorkBuddy's own ids are one
|
|
283
|
+
// conversation's own within a run.
|
|
284
|
+
const SALT = randomBytes(16).toString("hex")
|
|
285
|
+
|
|
286
|
+
// idFor is the 32 hex digits WorkBuddy's own ids are made of, made of a
|
|
287
|
+
// name of the thing the id is for (a turn, a conversation) and what names it.
|
|
288
|
+
function idFor(kind, key) {
|
|
289
|
+
return createHash("sha256").update(`${SALT}|${kind}|${key}`).digest("hex").slice(0, 32)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// signatureOf is a message content's signature: its text, and a digest of
|
|
293
|
+
// every part that isn't text (an image, a file), so a turn of images alone is
|
|
294
|
+
// a turn of its own too. A tool result's part — carried in a user message by
|
|
295
|
+
// clients of Anthropic's shape — is left out: it is not what the user said,
|
|
296
|
+
// and it changes with every step of the send.
|
|
297
|
+
function signatureOf(content) {
|
|
298
|
+
if (typeof content === "string") return content
|
|
299
|
+
if (!Array.isArray(content)) return ""
|
|
300
|
+
let out = ""
|
|
301
|
+
for (const part of content) {
|
|
302
|
+
const type = part?.type ?? ""
|
|
303
|
+
if (type === "" || type === "text") out += typeof part?.text === "string" ? part.text : ""
|
|
304
|
+
else if (type !== "tool_result" && type !== "tool-result") {
|
|
305
|
+
out += `[${type}:${createHash("sha256").update(JSON.stringify(part)).digest("hex").slice(0, 8)}]`
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return out
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// answeredBefore is whether msgs[i] answers the message before it — a tool's
|
|
312
|
+
// result, or an assistant's calls — rather than carrying the user's own
|
|
313
|
+
// words. A client that sends a tool result's images in a user message (pi's
|
|
314
|
+
// "Attached image(s) from tool result:") puts that message right after the
|
|
315
|
+
// result, and a turn keyed on it would be a turn of its own on every step.
|
|
316
|
+
function answeredBefore(msgs, i) {
|
|
317
|
+
const prev = msgs[i - 1]
|
|
318
|
+
if (prev === null || typeof prev !== "object") return false
|
|
319
|
+
if (prev.role === "tool") return true
|
|
320
|
+
return prev.role === "assistant" && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// turnKey names the user send a chat answers: the body's last user message
|
|
324
|
+
// that is the user's own, by its place and its signature. Every step of one
|
|
325
|
+
// send repeats it; the user's next message changes it. "" for a chat whose
|
|
326
|
+
// last such message has nothing signable in it (an empty message) or which
|
|
327
|
+
// has none at all: the caller then makes a fresh id for that request, as
|
|
328
|
+
// before these were derived at all.
|
|
329
|
+
function turnKey(chat) {
|
|
330
|
+
const msgs = Array.isArray(chat?.messages) ? chat.messages : []
|
|
331
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
332
|
+
if (msgs[i]?.role !== "user" || answeredBefore(msgs, i)) continue
|
|
333
|
+
const sig = signatureOf(msgs[i].content)
|
|
334
|
+
return sig ? `u${i}:${sig}` : ""
|
|
335
|
+
}
|
|
336
|
+
return ""
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// conversationKey names the conversation a chat belongs to: the session
|
|
340
|
+
// magpie or OpenCode names (its chat.headers hook), else the chat's first
|
|
341
|
+
// user message, which every later turn of it repeats. "" when neither names
|
|
342
|
+
// one — a body that isn't a chat, or one with no user message in it — and no
|
|
343
|
+
// conversation id is sent then: a made-up one would be a conversation of the
|
|
344
|
+
// request's own, which is what fragments the usage detail in the first place.
|
|
345
|
+
function conversationKey(chat, session) {
|
|
346
|
+
if (session) return `session:${session}`
|
|
347
|
+
const msgs = Array.isArray(chat?.messages) ? chat.messages : []
|
|
348
|
+
for (const m of msgs) {
|
|
349
|
+
if (m?.role !== "user") continue
|
|
350
|
+
const sig = signatureOf(m.content)
|
|
351
|
+
if (sig) return `first:${sig}`
|
|
352
|
+
}
|
|
353
|
+
return ""
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// chatOf is a chat's body as the object it is, null for one that isn't: a
|
|
357
|
+
// body that isn't text (bytes), or isn't JSON at all.
|
|
358
|
+
function chatOf(body) {
|
|
359
|
+
if (typeof body !== "string") return null
|
|
360
|
+
try {
|
|
361
|
+
const b = JSON.parse(body)
|
|
362
|
+
return b !== null && typeof b === "object" ? b : null
|
|
363
|
+
} catch {
|
|
364
|
+
return null
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// sessionOf takes the session a chat.headers hook named off the headers,
|
|
369
|
+
// with the header: it goes no further than here. Its length is capped as
|
|
370
|
+
// magpie's own session ids are, the id below being derived from it anyway.
|
|
371
|
+
function sessionOf(headers) {
|
|
372
|
+
const s = String(headers.get(SESSION) ?? "").trim()
|
|
373
|
+
headers.delete(SESSION)
|
|
374
|
+
return s.slice(0, 128)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// attend sets a chat's ids on the headers its request goes to WorkBuddy
|
|
378
|
+
// with: the message's (each request its own, as the client makes them), the
|
|
379
|
+
// turn's (one user send, whatever it takes), and the conversation's. A header
|
|
380
|
+
// already there — the caller's own, or another plugin's — is kept, and the
|
|
381
|
+
// message and request ids are the same one, as WorkBuddy's client has them.
|
|
382
|
+
function attend(headers, body, session) {
|
|
383
|
+
const chat = chatOf(body)
|
|
384
|
+
const conv = conversationKey(chat, session)
|
|
255
385
|
const msg = hex()
|
|
256
386
|
headers.set("X-Conversation-Message-ID", msg)
|
|
257
387
|
headers.set("X-Request-ID", msg)
|
|
388
|
+
if (!headers.has("X-Conversation-Request-ID")) {
|
|
389
|
+
const turn = turnKey(chat)
|
|
390
|
+
// a turn is a conversation's own: two conversations whose user message
|
|
391
|
+
// reads the same are still two sends, not one
|
|
392
|
+
headers.set("X-Conversation-Request-ID", turn ? idFor("turn", `${conv}|${turn}`) : hex())
|
|
393
|
+
}
|
|
394
|
+
if (conv && !headers.has("X-Conversation-ID")) headers.set("X-Conversation-ID", idFor("conversation", conv))
|
|
258
395
|
}
|
|
259
396
|
|
|
260
397
|
// FLAGGED are the words WorkBuddy refuses a chat for, "Illegal API
|
|
@@ -331,6 +468,250 @@ function withSystem(body) {
|
|
|
331
468
|
}
|
|
332
469
|
}
|
|
333
470
|
|
|
471
|
+
// ---- tools WorkBuddy takes ----------------------------------------------------
|
|
472
|
+
|
|
473
|
+
// WorkBuddy AI answers 400 code 11133 "Invalid request parameters" (the
|
|
474
|
+
// model provider rejected them) to a chat whose tools it can't pass on to
|
|
475
|
+
// the model (wyh on magpie's Discord: ZCode and DeepSeek Harness on
|
|
476
|
+
// deepseek-v4.1-flash). Tried on the owner's account, 2026-10-08: DeepSeek
|
|
477
|
+
// turns away each of the shapes below, and Kimi, GPT, Gemini and GLM some
|
|
478
|
+
// of them; MiniMax none. fitTools gives each a shape every model takes.
|
|
479
|
+
// A tool's name may hold letters, digits, _ and -; another character
|
|
480
|
+
// (". : / space", CJK) is refused.
|
|
481
|
+
const NAME_OK = /^[A-Za-z0-9_-]+$/
|
|
482
|
+
|
|
483
|
+
// safeName is name with each character WorkBuddy refuses as _, kept apart
|
|
484
|
+
// from the names in taken.
|
|
485
|
+
function safeName(name, taken) {
|
|
486
|
+
const base = String(name).replace(/[^A-Za-z0-9_-]/g, "_") || "tool"
|
|
487
|
+
let n = base
|
|
488
|
+
for (let i = 2; taken.has(n); i++) n = `${base}_${i}`
|
|
489
|
+
taken.add(n)
|
|
490
|
+
return n
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// NUL_ESCAPE finds a regex's \0 (the NUL character), stepping over each \\
|
|
494
|
+
// (a backslash) so that a \\0 is left; \01 is an octal escape, also left.
|
|
495
|
+
const NUL_ESCAPE = /\\\\|\\0(?![0-9])/g
|
|
496
|
+
|
|
497
|
+
// fitSchema mends a schema in place, at every depth: a pattern's \0, which
|
|
498
|
+
// DeepSeek's validator says is "not a 'regex'", is \u0000, which matches
|
|
499
|
+
// the same (magpie's gateway does this for DeepSeek's own API,
|
|
500
|
+
// deepseekToolPatterns); a tuple's items (a list) become one schema any of
|
|
501
|
+
// them fits. It reports whether it changed anything.
|
|
502
|
+
function fitSchema(s, depth = 0) {
|
|
503
|
+
if (!s || typeof s !== "object" || depth > 64) return false
|
|
504
|
+
let changed = false
|
|
505
|
+
if (Array.isArray(s)) {
|
|
506
|
+
for (const v of s) if (fitSchema(v, depth + 1)) changed = true
|
|
507
|
+
return changed
|
|
508
|
+
}
|
|
509
|
+
if (typeof s.pattern === "string" && s.pattern.includes("\\0")) {
|
|
510
|
+
const p = s.pattern.replace(NUL_ESCAPE, (m) => (m === "\\\\" ? m : "\\u0000"))
|
|
511
|
+
if (p !== s.pattern) {
|
|
512
|
+
s.pattern = p
|
|
513
|
+
changed = true
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (Array.isArray(s.items)) {
|
|
517
|
+
const list = s.items.filter((v) => v && typeof v === "object")
|
|
518
|
+
s.items = list.length === 0 ? {} : list.length === 1 ? list[0] : { anyOf: list }
|
|
519
|
+
delete s.additionalItems
|
|
520
|
+
changed = true
|
|
521
|
+
}
|
|
522
|
+
for (const v of Object.values(s)) if (fitSchema(v, depth + 1)) changed = true
|
|
523
|
+
return changed
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// localRef is the schema a local $ref in root names ("#/$defs/x",
|
|
527
|
+
// "#/definitions/x"), or s itself.
|
|
528
|
+
function localRef(root, s) {
|
|
529
|
+
const ref = s?.$ref
|
|
530
|
+
if (typeof ref !== "string" || !ref.startsWith("#/")) return s
|
|
531
|
+
let at = root
|
|
532
|
+
for (const k of ref.slice(2).split("/")) at = at?.[k.replaceAll("~1", "/").replaceAll("~0", "~")]
|
|
533
|
+
return at && typeof at === "object" ? at : s
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// objectRoot makes a tool's parameters an object at the root, as magpie's
|
|
537
|
+
// gateway does for xAI (provider.ObjectRoot): allOf's branches are merged
|
|
538
|
+
// whole; of anyOf's and oneOf's object branches the properties are merged
|
|
539
|
+
// and a field each requires stays required. A root with no type, or an
|
|
540
|
+
// empty one, is an object. It reports whether it changed anything.
|
|
541
|
+
function objectRoot(ps, root = ps, depth = 0) {
|
|
542
|
+
const unions = ["allOf", "anyOf", "oneOf"].filter((k) => Array.isArray(ps[k]))
|
|
543
|
+
if (ps.type === "object" && !unions.length) return false
|
|
544
|
+
const props = ps.properties && typeof ps.properties === "object" ? ps.properties : {}
|
|
545
|
+
const required = Array.isArray(ps.required) ? [...ps.required] : []
|
|
546
|
+
for (const b0 of ps.allOf ?? []) {
|
|
547
|
+
let b = localRef(root, b0)
|
|
548
|
+
if (!b || typeof b !== "object") continue
|
|
549
|
+
if (depth < 8 && ["allOf", "anyOf", "oneOf"].some((k) => Array.isArray(b[k]))) objectRoot((b = structuredClone(b)), root, depth + 1)
|
|
550
|
+
for (const [k, v] of Object.entries(b.properties ?? {})) if (!(k in props)) props[k] = v
|
|
551
|
+
if (Array.isArray(b.required)) required.push(...b.required)
|
|
552
|
+
}
|
|
553
|
+
const branches = []
|
|
554
|
+
for (const key of ["anyOf", "oneOf"]) {
|
|
555
|
+
for (const b0 of ps[key] ?? []) {
|
|
556
|
+
let b = localRef(root, b0)
|
|
557
|
+
if (!b || typeof b !== "object") continue
|
|
558
|
+
if (depth < 8 && ["allOf", "anyOf", "oneOf"].some((k) => Array.isArray(b[k]))) objectRoot((b = structuredClone(b)), root, depth + 1)
|
|
559
|
+
if (b.type !== "object" && !b.properties) continue
|
|
560
|
+
branches.push(b)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const from = new Map()
|
|
564
|
+
for (const b of branches) {
|
|
565
|
+
for (const [k, v] of Object.entries(b.properties ?? {})) {
|
|
566
|
+
if (k in props) continue
|
|
567
|
+
const seen = from.get(k) ?? []
|
|
568
|
+
if (!seen.some((x) => JSON.stringify(x) === JSON.stringify(v))) seen.push(v)
|
|
569
|
+
from.set(k, seen)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const [k, list] of from) props[k] = list.length === 1 ? list[0] : { anyOf: list }
|
|
573
|
+
if (branches.length) {
|
|
574
|
+
for (const k of Object.keys(branches[0].properties ?? {})) {
|
|
575
|
+
if (branches.every((b) => Array.isArray(b.required) && b.required.includes(k))) required.push(k)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
for (const k of unions) delete ps[k]
|
|
579
|
+
ps.type = "object"
|
|
580
|
+
ps.properties = props
|
|
581
|
+
const req = [...new Set(required)].filter((k) => typeof k === "string" && k in props)
|
|
582
|
+
if (req.length) ps.required = req
|
|
583
|
+
else delete ps.required
|
|
584
|
+
return true
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// fitTools mends b's function tools in place: their parameters (fitSchema,
|
|
588
|
+
// objectRoot) and names. It reports whether it changed anything, and the
|
|
589
|
+
// names it gave, each to the name the client knows (ours), so that the
|
|
590
|
+
// model's calls are named back. A call in the chat's history is named as
|
|
591
|
+
// its tool now is.
|
|
592
|
+
function fitTools(b) {
|
|
593
|
+
const names = new Map()
|
|
594
|
+
if (!Array.isArray(b?.tools) || !b.tools.length) return { changed: false, names }
|
|
595
|
+
let changed = false
|
|
596
|
+
const taken = new Set()
|
|
597
|
+
for (const t of b.tools) {
|
|
598
|
+
const n = t?.function?.name
|
|
599
|
+
if (typeof n === "string" && NAME_OK.test(n)) taken.add(n)
|
|
600
|
+
}
|
|
601
|
+
const theirs = new Map()
|
|
602
|
+
for (const t of b.tools) {
|
|
603
|
+
const fn = t?.type === "function" || t?.function ? t.function : null
|
|
604
|
+
if (!fn || typeof fn !== "object") continue
|
|
605
|
+
if (typeof fn.name === "string" && !NAME_OK.test(fn.name)) {
|
|
606
|
+
const safe = safeName(fn.name, taken)
|
|
607
|
+
names.set(safe, fn.name)
|
|
608
|
+
theirs.set(fn.name, safe)
|
|
609
|
+
fn.name = safe
|
|
610
|
+
changed = true
|
|
611
|
+
}
|
|
612
|
+
if (fn.parameters === undefined) continue
|
|
613
|
+
if (!fn.parameters || typeof fn.parameters !== "object" || Array.isArray(fn.parameters)) {
|
|
614
|
+
fn.parameters = { type: "object", properties: {} }
|
|
615
|
+
changed = true
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
618
|
+
if (fitSchema(fn.parameters)) changed = true
|
|
619
|
+
if (objectRoot(fn.parameters)) changed = true
|
|
620
|
+
}
|
|
621
|
+
if (theirs.size && Array.isArray(b.messages)) {
|
|
622
|
+
for (const m of b.messages) {
|
|
623
|
+
for (const c of Array.isArray(m?.tool_calls) ? m.tool_calls : []) {
|
|
624
|
+
const safe = theirs.get(c?.function?.name)
|
|
625
|
+
if (safe) c.function.name = safe
|
|
626
|
+
}
|
|
627
|
+
if (m?.role === "tool" && theirs.has(m.name)) m.name = theirs.get(m.name)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const choice = b.tool_choice?.function
|
|
631
|
+
if (choice && theirs.has(choice.name)) choice.name = theirs.get(choice.name)
|
|
632
|
+
return { changed, names }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// fitted is a chat body with its tools mended (fitTools), and the names
|
|
636
|
+
// given its tools, each to the client's own; a body that isn't a chat is
|
|
637
|
+
// as it was.
|
|
638
|
+
function fitted(body) {
|
|
639
|
+
if (typeof body !== "string") return { body, names: new Map() }
|
|
640
|
+
try {
|
|
641
|
+
const b = JSON.parse(body)
|
|
642
|
+
const { changed, names } = fitTools(b)
|
|
643
|
+
return { body: changed ? JSON.stringify(b) : body, names }
|
|
644
|
+
} catch {
|
|
645
|
+
return { body, names: new Map() }
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// namedBack gives the model's calls in a chat answer (one JSON body, or an
|
|
650
|
+
// SSE stream's chunks) the names the client gave its tools.
|
|
651
|
+
function namedBack(v, names) {
|
|
652
|
+
let changed = false
|
|
653
|
+
for (const c of Array.isArray(v?.choices) ? v.choices : []) {
|
|
654
|
+
for (const at of [c?.message, c?.delta]) {
|
|
655
|
+
for (const tc of Array.isArray(at?.tool_calls) ? at.tool_calls : []) {
|
|
656
|
+
const ours = names.get(tc?.function?.name)
|
|
657
|
+
if (ours) {
|
|
658
|
+
tc.function.name = ours
|
|
659
|
+
changed = true
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return changed
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// renamed is res with namedBack on its body, read as it comes.
|
|
668
|
+
function renamed(res, names) {
|
|
669
|
+
if (!names.size || !res.body) return res
|
|
670
|
+
const headers = new Headers(res.headers)
|
|
671
|
+
headers.delete("content-length")
|
|
672
|
+
headers.delete("content-encoding")
|
|
673
|
+
const json = /json/i.test(res.headers.get("content-type") ?? "")
|
|
674
|
+
if (json) {
|
|
675
|
+
const out = res.text().then((text) => {
|
|
676
|
+
try {
|
|
677
|
+
const v = JSON.parse(text)
|
|
678
|
+
return namedBack(v, names) ? JSON.stringify(v) : text
|
|
679
|
+
} catch {
|
|
680
|
+
return text
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
return out.then((text) => new Response(text, { status: res.status, statusText: res.statusText, headers }))
|
|
684
|
+
}
|
|
685
|
+
const dec = new TextDecoder()
|
|
686
|
+
const enc = new TextEncoder()
|
|
687
|
+
let rest = ""
|
|
688
|
+
const line = (l) => {
|
|
689
|
+
const m = /^data:\s?(.*)$/.exec(l)
|
|
690
|
+
if (!m || m[1] === "[DONE]") return l
|
|
691
|
+
try {
|
|
692
|
+
const v = JSON.parse(m[1])
|
|
693
|
+
return namedBack(v, names) ? `data: ${JSON.stringify(v)}` : l
|
|
694
|
+
} catch {
|
|
695
|
+
return l
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const body = res.body.pipeThrough(
|
|
699
|
+
new TransformStream({
|
|
700
|
+
transform(chunk, ctl) {
|
|
701
|
+
rest += dec.decode(chunk, { stream: true })
|
|
702
|
+
const lines = rest.split("\n")
|
|
703
|
+
rest = lines.pop()
|
|
704
|
+
if (lines.length) ctl.enqueue(enc.encode(lines.map(line).join("\n") + "\n"))
|
|
705
|
+
},
|
|
706
|
+
flush(ctl) {
|
|
707
|
+
rest += dec.decode()
|
|
708
|
+
if (rest) ctl.enqueue(enc.encode(line(rest)))
|
|
709
|
+
},
|
|
710
|
+
}),
|
|
711
|
+
)
|
|
712
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers })
|
|
713
|
+
}
|
|
714
|
+
|
|
334
715
|
// ---- signing in -------------------------------------------------------------
|
|
335
716
|
|
|
336
717
|
// poll asks path until WorkBuddy answers with data, retrying the codes
|
|
@@ -674,6 +1055,13 @@ function makePlugin(site) {
|
|
|
674
1055
|
p.api ??= api(site)
|
|
675
1056
|
p.models = { ...configModels(site), ...(p.models ?? {}) }
|
|
676
1057
|
},
|
|
1058
|
+
// the conversation's id, which the chats below carry to WorkBuddy:
|
|
1059
|
+
// magpie's (or OpenCode's) session for this request, when it names one
|
|
1060
|
+
"chat.headers": async (input, output) => {
|
|
1061
|
+
const id = input?.model?.providerID ?? input?.provider?.info?.id
|
|
1062
|
+
if (id !== site.id || !input?.sessionID) return
|
|
1063
|
+
output.headers[SESSION] = String(input.sessionID).slice(0, 128)
|
|
1064
|
+
},
|
|
677
1065
|
provider: {
|
|
678
1066
|
id: site.id,
|
|
679
1067
|
// the account's own list, when it's signed in and WorkBuddy answers;
|
|
@@ -714,11 +1102,16 @@ function makePlugin(site) {
|
|
|
714
1102
|
headers.delete("authorization")
|
|
715
1103
|
headers.delete("x-api-key")
|
|
716
1104
|
headers.delete("content-length")
|
|
1105
|
+
const session = sessionOf(headers)
|
|
717
1106
|
sign(site, a, headers)
|
|
718
1107
|
let body = init?.body
|
|
719
1108
|
if (body === undefined && req) body = await req.clone().text()
|
|
720
|
-
|
|
721
|
-
|
|
1109
|
+
body = withSystem(body)
|
|
1110
|
+
const fit = fitted(body)
|
|
1111
|
+
body = fit.body
|
|
1112
|
+
attend(headers, body, session)
|
|
1113
|
+
const res = await fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body })
|
|
1114
|
+
return res.status >= 400 ? explained(res) : kept(await renamed(res, fit.names))
|
|
722
1115
|
},
|
|
723
1116
|
}
|
|
724
1117
|
},
|
|
@@ -823,4 +1216,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
|
|
|
823
1216
|
export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
|
|
824
1217
|
|
|
825
1218
|
// for tests
|
|
826
|
-
export const _internal = { withSystem, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, fresh, SITES, renewing, renewed }
|
|
1219
|
+
export const _internal = { 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.
|
|
3
|
+
"version": "0.1.12",
|
|
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",
|