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

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 +15 -1
  2. package/index.mjs +156 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -55,7 +55,21 @@ 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 AI also takes its client and conversation headers.
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.
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-Type", "main")
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
- const conv = hex()
253
- if (!headers.has("X-Conversation-ID")) headers.set("X-Conversation-ID", conv)
254
- if (!headers.has("X-Conversation-Request-ID")) headers.set("X-Conversation-Request-ID", conv)
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
@@ -674,6 +811,13 @@ function makePlugin(site) {
674
811
  p.api ??= api(site)
675
812
  p.models = { ...configModels(site), ...(p.models ?? {}) }
676
813
  },
814
+ // the conversation's id, which the chats below carry to WorkBuddy:
815
+ // magpie's (or OpenCode's) session for this request, when it names one
816
+ "chat.headers": async (input, output) => {
817
+ const id = input?.model?.providerID ?? input?.provider?.info?.id
818
+ if (id !== site.id || !input?.sessionID) return
819
+ output.headers[SESSION] = String(input.sessionID).slice(0, 128)
820
+ },
677
821
  provider: {
678
822
  id: site.id,
679
823
  // the account's own list, when it's signed in and WorkBuddy answers;
@@ -714,10 +858,13 @@ function makePlugin(site) {
714
858
  headers.delete("authorization")
715
859
  headers.delete("x-api-key")
716
860
  headers.delete("content-length")
861
+ const session = sessionOf(headers)
717
862
  sign(site, a, headers)
718
863
  let body = init?.body
719
864
  if (body === undefined && req) body = await req.clone().text()
720
- const res = await fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: withSystem(body) })
865
+ body = withSystem(body)
866
+ attend(headers, body, session)
867
+ const res = await fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body })
721
868
  return res.status >= 400 ? explained(res) : kept(res)
722
869
  },
723
870
  }
@@ -823,4 +970,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
823
970
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
824
971
 
825
972
  // for tests
826
- export const _internal = { withSystem, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, fresh, SITES, renewing, renewed }
973
+ export const _internal = { withSystem, 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.10",
3
+ "version": "0.1.11",
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",