@magpie-community/opencode-workbuddy-auth 0.1.30 → 0.1.33
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 +52 -0
- package/index.mjs +469 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,11 +56,63 @@ Desktop's sign-in is refreshed only when the app hasn't refreshed it
|
|
|
56
56
|
itself, and the new token is kept in memory, never written back, as magpie's
|
|
57
57
|
built-in WorkBuddy does.
|
|
58
58
|
|
|
59
|
+
A chat or usage read WorkBuddy answers 401 (since 0.1.31) is renewed once,
|
|
60
|
+
whatever the token's time, and sent once more. A token another process
|
|
61
|
+
saved since is taken first instead of spending the refresh token again. A
|
|
62
|
+
desktop sign-in is never renewed for it: the app's file is read again, and
|
|
63
|
+
its token is used if the app signed in again. A 401 again, or no new
|
|
64
|
+
token, ends in a 401 that says to sign in again (Providers → WorkBuddy →
|
|
65
|
+
this account's Sign in again in magpie; WorkBuddy desktop for a desktop
|
|
66
|
+
sign-in). The plugin marks the account expired in magpie
|
|
67
|
+
(`X-Magpie-Sign-In: expired`, usage's `signIn`) only on WorkBuddy's word
|
|
68
|
+
that the sign-in is gone: the refresh token refused (401, `invalid_grant`),
|
|
69
|
+
or the access token refused (`WWW-Authenticate: … error="invalid_token"`)
|
|
70
|
+
with no refresh token left to renew it with or just after renewing. A
|
|
71
|
+
failed renewal for another reason, or a bare 401 without `invalid_token`,
|
|
72
|
+
leaves the account as it is; a desktop sign-in is never marked. A 401
|
|
73
|
+
after a renewal takes a mark off (`renewed`).
|
|
74
|
+
|
|
59
75
|
## Requests
|
|
60
76
|
|
|
61
77
|
Chats are chat completions (`@ai-sdk/openai-compatible`) at `<API>/chat/completions`.
|
|
62
78
|
They are streamed only; WorkBuddy refuses a chat that isn't.
|
|
63
79
|
|
|
80
|
+
**On WorkBuddy (China), the Claude models are served on Anthropic's Messages
|
|
81
|
+
API** instead: `@ai-sdk/anthropic` at `<endpoint>/v1/messages` (streamed only
|
|
82
|
+
as well). `claude-opus-5.5`, `claude-sonnet-5.5`, `claude-opus-5` and
|
|
83
|
+
`claude-opus-4.8-1m` answered there when each model of `/v3/config`'s `cli`
|
|
84
|
+
agent was asked on 2026-10-24; `glm-5.3` and `deepseek-v4.1-flash` answered
|
|
85
|
+
500, `gpt-5.5` and `kimi-k3-1` 400, so every other model stays on chat.
|
|
86
|
+
WorkBuddy AI has no such route (`/v1/messages` is `404 Route Not Found` there
|
|
87
|
+
for every model), so all its models stay on chat, a Claude one too.
|
|
88
|
+
|
|
89
|
+
A Messages request goes as the agent sent it: no default system message, no
|
|
90
|
+
`max_completion_tokens`/`developer` rewrites, no tool reshaping, and nothing
|
|
91
|
+
moved for a refused system prompt — Claude Code's own prompt is taken as it
|
|
92
|
+
is, billing line and all, where a chat answers 11128 to it. One thing is
|
|
93
|
+
left out: a `thinking` block with no `signature`. A conversation that began
|
|
94
|
+
on chat (or on another provider) carries such blocks, and once a signed one
|
|
95
|
+
from this route sits beside them WorkBuddy refuses the whole request, 11159
|
|
96
|
+
"Invalid `signature` in `thinking` block … bound to a different
|
|
97
|
+
conversation", on every try. Signed blocks go back as they came. It carries
|
|
98
|
+
WorkBuddy's headers as a chat does, the same conversation and turn ids
|
|
99
|
+
included: a tool result in a `user` message of `tool_result` blocks answers
|
|
100
|
+
the step before it, as a `role: "tool"` message does in a chat.
|
|
101
|
+
|
|
102
|
+
The point of the route is effort. A chat carries `reasoning_effort` alone,
|
|
103
|
+
and WorkBuddy's chat endpoint answers at about the same length whatever it
|
|
104
|
+
says: asked the same question four times each (2026-10-24,
|
|
105
|
+
`claude-opus-5.5`), the median answer was 3884 output tokens at `low`, 5144
|
|
106
|
+
at `max` and 5715 at a value that isn't a level at all. Messages carries
|
|
107
|
+
`output_config.effort`, and `max` is honored there: a median of 30850
|
|
108
|
+
against 5748 at `low`, same model and question, four runs each. A Claude
|
|
109
|
+
model on Messages therefore lists `max` in its levels even where
|
|
110
|
+
`/v3/config` stops at `xhigh`.
|
|
111
|
+
|
|
112
|
+
`/v1/messages/count_tokens` **is not served** (404). magpie treats a 404 on
|
|
113
|
+
count_tokens as "no counter" (`unsupportedCount`) and answers with its own
|
|
114
|
+
estimate.
|
|
115
|
+
|
|
64
116
|
Each chat carries WorkBuddy's headers:
|
|
65
117
|
- `Authorization`, `X-User-Id`, `X-Domain`, `X-Product: SaaS`, `X-IDE-Type: WorkBuddy`;
|
|
66
118
|
- `User-Agent: WorkBuddy/5.5.6`. Without it, WorkBuddy answers error 10085;
|
package/index.mjs
CHANGED
|
@@ -68,6 +68,9 @@ const SITES = {
|
|
|
68
68
|
platform: "workbuddy",
|
|
69
69
|
ai: false,
|
|
70
70
|
models: CN_MODELS,
|
|
71
|
+
// it serves the Claude models on Anthropic's Messages API too (see
|
|
72
|
+
// nativeOf)
|
|
73
|
+
messages: true,
|
|
71
74
|
},
|
|
72
75
|
"workbuddy-ai": {
|
|
73
76
|
id: "workbuddy-ai",
|
|
@@ -103,6 +106,33 @@ const hex = () => randomBytes(16).toString("hex")
|
|
|
103
106
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
104
107
|
const api = (site) => site.endpoint + "/v2"
|
|
105
108
|
|
|
109
|
+
// MESSAGES is the API SDK package for Anthropic's Messages shape, which
|
|
110
|
+
// WorkBuddy serves the Claude models on, beside its chat completions:
|
|
111
|
+
// <endpoint>/v1/messages. Those models are Anthropic's own, and their
|
|
112
|
+
// thinking is asked for the way Anthropic asks it (thinking.type adaptive
|
|
113
|
+
// with output_config.effort), which the chat endpoint has no field for: a
|
|
114
|
+
// chat carries reasoning_effort alone, and WorkBuddy's chat endpoint gives
|
|
115
|
+
// the same answer at every level, so an agent that picked max got no more
|
|
116
|
+
// than one that picked low. See nativeOf.
|
|
117
|
+
const NPM_MESSAGES = "@ai-sdk/anthropic"
|
|
118
|
+
const apiMessages = (site) => site.endpoint + "/v1"
|
|
119
|
+
|
|
120
|
+
// nativeOf is whether site serves model on Messages rather than chat: a
|
|
121
|
+
// Claude model, on a site that serves Messages at all (site.messages).
|
|
122
|
+
// Measured 2026-10-24 on WorkBuddy's China build, each model of
|
|
123
|
+
// /v3/config's cli agent asked on /v1/messages: claude-opus-5.5,
|
|
124
|
+
// claude-sonnet-5.5, claude-opus-5 and claude-opus-4.8-1m answered in
|
|
125
|
+
// Anthropic's stream; glm-5.3 and deepseek-v4.1-flash answered 500, gpt-5.5
|
|
126
|
+
// and kimi-k3-1 400. An account may still be refused a Claude model it has
|
|
127
|
+
// no entitlement for (11158), which is the account's, not the endpoint's.
|
|
128
|
+
// WorkBuddy AI (www.workbuddy.ai) has no such route: /v1/messages,
|
|
129
|
+
// /v2/messages and /v1/messages?beta=true are all "404 Route Not Found"
|
|
130
|
+
// there, whatever the model, so its models stay on chat, a Claude one too
|
|
131
|
+
// should it list one.
|
|
132
|
+
function nativeOf(site, model) {
|
|
133
|
+
return site?.messages === true && /^claude-/i.test(String(model ?? ""))
|
|
134
|
+
}
|
|
135
|
+
|
|
106
136
|
// ---- WorkBuddy's API ------------------------------------------------------
|
|
107
137
|
|
|
108
138
|
class WBError extends Error {
|
|
@@ -370,11 +400,32 @@ function signatureOf(content) {
|
|
|
370
400
|
// words. A client that sends a tool result's images in a user message (pi's
|
|
371
401
|
// "Attached image(s) from tool result:") puts that message right after the
|
|
372
402
|
// result, and a turn keyed on it would be a turn of its own on every step.
|
|
403
|
+
//
|
|
404
|
+
// Anthropic's Messages shape says the same another way: a tool's result is a
|
|
405
|
+
// tool_result block in a user message, after an assistant message of
|
|
406
|
+
// tool_use blocks. Such a user message answers too, whatever text sits
|
|
407
|
+
// beside the results (Claude Code puts a reminder there, a user's words typed
|
|
408
|
+
// mid-task go there as well), and so does one right after it, as a chat's
|
|
409
|
+
// does after role "tool". Messages that are neither the user's nor the
|
|
410
|
+
// assistant's (Claude Code's role "system" ones, which carry a turn's
|
|
411
|
+
// effort, its environment and its token count between the steps) stand
|
|
412
|
+
// between them without changing which message answers.
|
|
373
413
|
function answeredBefore(msgs, i) {
|
|
374
|
-
|
|
414
|
+
if (hasBlock(msgs[i], "tool_result")) return true
|
|
415
|
+
let j = i - 1
|
|
416
|
+
while (j >= 0 && msgs[j]?.role === "system") j--
|
|
417
|
+
const prev = msgs[j]
|
|
375
418
|
if (prev === null || typeof prev !== "object") return false
|
|
376
419
|
if (prev.role === "tool") return true
|
|
377
|
-
|
|
420
|
+
if (prev.role === "user" && hasBlock(prev, "tool_result")) return true
|
|
421
|
+
if (prev.role !== "assistant") return false
|
|
422
|
+
if (Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) return true
|
|
423
|
+
return hasBlock(prev, "tool_use")
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// hasBlock is whether message m's content holds a block of type.
|
|
427
|
+
function hasBlock(m, type) {
|
|
428
|
+
return Array.isArray(m?.content) && m.content.some((p) => p?.type === type)
|
|
378
429
|
}
|
|
379
430
|
|
|
380
431
|
// turnKey names the user send a chat answers: the body's last user message
|
|
@@ -410,6 +461,100 @@ function conversationKey(chat, session) {
|
|
|
410
461
|
return ""
|
|
411
462
|
}
|
|
412
463
|
|
|
464
|
+
// isMessages says whether a request is an Anthropic Messages one, which
|
|
465
|
+
// site serves the Claude models natively: its URL ends at /messages or at
|
|
466
|
+
// the counting endpoint beside it (/v1/messages/count_tokens, which the
|
|
467
|
+
// chat route's rewrites would otherwise turn into a chat), or (a body is
|
|
468
|
+
// all there is to go on) its system is Anthropic's blocks rather than a
|
|
469
|
+
// chat's string, and its tools carry input_schema where a chat's carry a
|
|
470
|
+
// function's parameters. A chat's own tools can look either way, so the URL
|
|
471
|
+
// decides when there is one.
|
|
472
|
+
function isMessages(url, body) {
|
|
473
|
+
const path = (() => {
|
|
474
|
+
try {
|
|
475
|
+
return new URL(String(url ?? "")).pathname
|
|
476
|
+
} catch {
|
|
477
|
+
return ""
|
|
478
|
+
}
|
|
479
|
+
})()
|
|
480
|
+
if (path) return /\/messages(\/count_tokens)?$/.test(path)
|
|
481
|
+
const b = chatOf(body)
|
|
482
|
+
if (!b) return false
|
|
483
|
+
if (Array.isArray(b.system)) return true
|
|
484
|
+
const tools = Array.isArray(b.tools) ? b.tools : []
|
|
485
|
+
return tools.some((t) => t && typeof t === "object" && "input_schema" in t && !t.function)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// withoutUnsigned is a Messages body without the thinking blocks that carry
|
|
489
|
+
// no signature, or whose signature WorkBuddy doesn't take.
|
|
490
|
+
//
|
|
491
|
+
// A conversation that began elsewhere — on WorkBuddy's chat route, whose
|
|
492
|
+
// thinking comes back unsigned, or on any other provider — and goes on here
|
|
493
|
+
// carries such blocks in its history, and WorkBuddy refuses the whole request
|
|
494
|
+
// for one of them when another assistant message holds a signed block:
|
|
495
|
+
// 11159 "Invalid `signature` in `thinking` block. The block is bound to a
|
|
496
|
+
// different conversation" (or the same wrapped in 11133). Seen 2026-10-24 in
|
|
497
|
+
// real Claude Code sessions moved onto this route mid-way: every request of
|
|
498
|
+
// two of them refused, each attempt alike, until the session ended. Replayed
|
|
499
|
+
// the same day, with a real account (20+ runs each):
|
|
500
|
+
//
|
|
501
|
+
// unsigned thinking alone, no signed block anywhere ....... 200
|
|
502
|
+
// one unsigned-only assistant message + a signed one ...... 11159
|
|
503
|
+
// that message with the unsigned block taken out .......... 200
|
|
504
|
+
// unsigned and signed blocks in the SAME message .......... 200
|
|
505
|
+
// thinking.block_binding.prefix_mismatch_behavior=drop_block ... 11159
|
|
506
|
+
//
|
|
507
|
+
// So what binds a signed block is the history it sits in, and a message that
|
|
508
|
+
// is thinking alone can't be part of it; whether that thinking is signed
|
|
509
|
+
// makes no difference (a signed block alone is accepted too). The signature
|
|
510
|
+
// itself isn't checked for where it came from: a block keeping a signature
|
|
511
|
+
// of an unrelated turn, of another model (sonnet-5.5's on opus-5.5), or
|
|
512
|
+
// another account's answers 200, while a truncated or altered one is 11159.
|
|
513
|
+
// What WorkBuddy checks is that it reads as a signature.
|
|
514
|
+
//
|
|
515
|
+
// A block with no signature can't be verified by the model anyway, so
|
|
516
|
+
// nothing it could use is lost. A message whose only block that was gets an
|
|
517
|
+
// empty text block: measured the same day, that history still answers 200
|
|
518
|
+
// with a signed turn after it (three runs), where the unsigned block itself
|
|
519
|
+
// answers 11159 (three runs), and Anthropic's API refuses an empty text
|
|
520
|
+
// block — 200 here means WorkBuddy takes it. The signed blocks, this
|
|
521
|
+
// route's own, go back as they came, as Anthropic asks. A body without
|
|
522
|
+
// unsigned thinking is body itself, byte for byte.
|
|
523
|
+
function withoutUnsigned(body) {
|
|
524
|
+
const b = chatOf(body)
|
|
525
|
+
const msgs = b?.messages
|
|
526
|
+
if (!Array.isArray(msgs)) return body
|
|
527
|
+
let changed = false
|
|
528
|
+
for (const m of msgs) {
|
|
529
|
+
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue
|
|
530
|
+
const kept = m.content.filter((p) => !(p?.type === "thinking" && !p.signature))
|
|
531
|
+
if (kept.length === m.content.length) continue
|
|
532
|
+
changed = true
|
|
533
|
+
m.content = kept.length ? kept : [{ type: "text", text: "" }]
|
|
534
|
+
}
|
|
535
|
+
return changed ? JSON.stringify(b) : body
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// withoutSignedThinking is a Messages body without any thinking block at
|
|
539
|
+
// all, for a refusal saying a signature is not one WorkBuddy made (see the
|
|
540
|
+
// retry in the loader). An assistant message left with nothing gets an
|
|
541
|
+
// empty text block, as withoutUnsigned leaves it. null for a body with no
|
|
542
|
+
// thinking block to drop: the retry would send the same request again.
|
|
543
|
+
function withoutSignedThinking(body) {
|
|
544
|
+
const b = chatOf(body)
|
|
545
|
+
const msgs = b?.messages
|
|
546
|
+
if (!Array.isArray(msgs)) return null
|
|
547
|
+
let dropped = 0
|
|
548
|
+
for (const m of msgs) {
|
|
549
|
+
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue
|
|
550
|
+
const kept = m.content.filter((p) => p?.type !== "thinking")
|
|
551
|
+
if (kept.length === m.content.length) continue
|
|
552
|
+
dropped += m.content.length - kept.length
|
|
553
|
+
m.content = kept.length ? kept : [{ type: "text", text: "" }]
|
|
554
|
+
}
|
|
555
|
+
return dropped ? JSON.stringify(b) : null
|
|
556
|
+
}
|
|
557
|
+
|
|
413
558
|
// chatOf is a chat's body as the object it is, null for one that isn't: a
|
|
414
559
|
// body that isn't text (bytes), or isn't JSON at all.
|
|
415
560
|
function chatOf(body) {
|
|
@@ -894,6 +1039,41 @@ function objectRoot(ps, root = ps, depth = 0) {
|
|
|
894
1039
|
return true
|
|
895
1040
|
}
|
|
896
1041
|
|
|
1042
|
+
// Kimi (kimi-k3.1-pro) answers 400 "Invalid request parameters" to a oneOf
|
|
1043
|
+
// that has no type, at the root of a $ref's target or deeper, even when every
|
|
1044
|
+
// branch is an object (congee949, #378: dsh's REA tools, on workbuddy and
|
|
1045
|
+
// workbuddy-ai alike; a "type": "object" beside it is taken). typeUnions gives
|
|
1046
|
+
// such a oneOf type "object" when each branch is typed object itself or
|
|
1047
|
+
// through a chain of local $refs, and changes nothing else: a union of other
|
|
1048
|
+
// or mixed types, an anyOf, or a $ref that is missing, external or circular
|
|
1049
|
+
// is left as it came. It reports whether it changed anything.
|
|
1050
|
+
function typeUnions(s, root = s, depth = 0) {
|
|
1051
|
+
if (!s || typeof s !== "object" || Array.isArray(s) || depth > 64) return false
|
|
1052
|
+
let changed = false
|
|
1053
|
+
if (!("type" in s) && Array.isArray(s.oneOf) && s.oneOf.length && s.oneOf.every((b) => objectBranch(root, b))) {
|
|
1054
|
+
s.type = "object"
|
|
1055
|
+
changed = true
|
|
1056
|
+
}
|
|
1057
|
+
for (const k of SUB_ONE) if (typeUnions(s[k], root, depth + 1)) changed = true
|
|
1058
|
+
for (const k of SUB_LIST) for (const v of Array.isArray(s[k]) ? s[k] : []) if (typeUnions(v, root, depth + 1)) changed = true
|
|
1059
|
+
for (const k of SUB_MAP) {
|
|
1060
|
+
const m = s[k]
|
|
1061
|
+
if (!m || typeof m !== "object" || Array.isArray(m)) continue
|
|
1062
|
+
for (const v of Object.values(m)) if (typeUnions(v, root, depth + 1)) changed = true
|
|
1063
|
+
}
|
|
1064
|
+
return changed
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// objectBranch says whether b is typed object, itself or where its local
|
|
1068
|
+
// $refs lead; a $ref seen before on the way is a cycle, and not.
|
|
1069
|
+
function objectBranch(root, b, seen = []) {
|
|
1070
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) return false
|
|
1071
|
+
if ("type" in b) return b.type === "object"
|
|
1072
|
+
if (typeof b.$ref !== "string" || seen.includes(b.$ref) || seen.length > 16) return false
|
|
1073
|
+
const t = localRef(root, b)
|
|
1074
|
+
return t !== b && objectBranch(root, t, [...seen, b.$ref])
|
|
1075
|
+
}
|
|
1076
|
+
|
|
897
1077
|
// fitTools mends b's function tools in place: their parameters (fitSchema,
|
|
898
1078
|
// fitShape, objectRoot) and names. It reports whether it changed anything,
|
|
899
1079
|
// and the names it gave, each to the name the client knows (ours), so that the
|
|
@@ -927,6 +1107,7 @@ function fitTools(b) {
|
|
|
927
1107
|
}
|
|
928
1108
|
if (fitSchema(fn.parameters)) changed = true
|
|
929
1109
|
if (fitShape(fn.parameters)) changed = true
|
|
1110
|
+
if (typeUnions(fn.parameters)) changed = true
|
|
930
1111
|
if (objectRoot(fn.parameters)) changed = true
|
|
931
1112
|
}
|
|
932
1113
|
if (theirs.size && Array.isArray(b.messages)) {
|
|
@@ -1123,6 +1304,8 @@ function readDesktop(site) {
|
|
|
1123
1304
|
uid: acct.uid,
|
|
1124
1305
|
...(typeof acct.enterpriseId === "string" && acct.enterpriseId.trim() ? { enterpriseId: acct.enterpriseId.trim() } : {}),
|
|
1125
1306
|
name: acct.nickname || acct.phoneNumber || acct.uid,
|
|
1307
|
+
// what the errors below say to do goes by it: sign in to the app again
|
|
1308
|
+
source: "desktop",
|
|
1126
1309
|
}
|
|
1127
1310
|
} catch {
|
|
1128
1311
|
return null
|
|
@@ -1237,23 +1420,143 @@ async function unanswered(site, a, body, err, signal) {
|
|
|
1237
1420
|
const size = byteLength(body)
|
|
1238
1421
|
if (size <= EDGE_READS) throw err
|
|
1239
1422
|
let status = 0
|
|
1423
|
+
let challenge = ""
|
|
1240
1424
|
try {
|
|
1241
1425
|
const res = await meterAsked(site, a, "/billing/meter/get-user-resource-summary", {}, AbortSignal.timeout(20000))
|
|
1242
1426
|
status = res.status
|
|
1427
|
+
challenge = res.headers.get("www-authenticate") ?? ""
|
|
1243
1428
|
await res.body?.cancel()
|
|
1244
1429
|
} catch {
|
|
1245
1430
|
throw err
|
|
1246
1431
|
}
|
|
1247
1432
|
if (status !== 401) throw err
|
|
1248
1433
|
const mb = (size / (1024 * 1024)).toFixed(1)
|
|
1434
|
+
const note = `It closes the connection on a refused request over 2 MiB (this one was ${mb} MiB) instead of answering, which is why it read as ECONNRESET.`
|
|
1249
1435
|
const message =
|
|
1250
|
-
`${site.name} refused this account's sign-in (401).
|
|
1251
|
-
`(this one was ${mb} MiB) instead of answering, which is why it read as ECONNRESET. ` +
|
|
1436
|
+
`${site.name} refused this account's sign-in (401). ${note} ` +
|
|
1252
1437
|
(a.source === "desktop" ? `Sign in to ${site.name} desktop again.` : `Sign in to ${site.name} again in magpie.`)
|
|
1438
|
+
// the billing API's challenge goes with it: it says whether the token
|
|
1439
|
+
// itself was refused (invalidToken), as the chat's own 401 would have
|
|
1440
|
+
const headers = { "content-type": "application/json", ...(challenge ? { "www-authenticate": challenge } : {}) }
|
|
1441
|
+
const res = new Response(JSON.stringify({ error: { message, type: "authentication_error", code: 401 } }), {
|
|
1442
|
+
status: 401,
|
|
1443
|
+
statusText: "Unauthorized",
|
|
1444
|
+
headers,
|
|
1445
|
+
})
|
|
1446
|
+
edgeNotes.set(res, note)
|
|
1447
|
+
return res
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// edgeNotes is what unanswered's 401s say of the reset, for signedOut to
|
|
1451
|
+
// say again when the sign-in can't be renewed.
|
|
1452
|
+
const edgeNotes = new WeakMap()
|
|
1453
|
+
|
|
1454
|
+
// ---- a refused sign-in ----------------------------------------------------------
|
|
1455
|
+
|
|
1456
|
+
// invalidToken says res is WorkBuddy refusing the access token itself. Its
|
|
1457
|
+
// front door (APISIX, on both sites) answers a token it doesn't take with
|
|
1458
|
+
// 401 and WWW-Authenticate: Bearer realm="copilot", error="invalid_token",
|
|
1459
|
+
// on a chat (streamed or not) and on the billing API alike; measured on
|
|
1460
|
+
// www.workbuddy.ai on 2026-10-23 with a real account's token cut short,
|
|
1461
|
+
// and with "garbage". (Its /v3/config answers 200 to any token, so the
|
|
1462
|
+
// models list never meets a 401.) A 401 without it is not taken as the
|
|
1463
|
+
// sign-in's: WorkBuddy answers some paths with a bare 401 to an accepted
|
|
1464
|
+
// sign-in (magpie's check-in, モモコ on Discord).
|
|
1465
|
+
const invalidToken = (res) => res?.status === 401 && /invalid_token/i.test(res.headers.get("www-authenticate") ?? "")
|
|
1466
|
+
|
|
1467
|
+
// refreshRefused says e is WorkBuddy turning the refresh token itself
|
|
1468
|
+
// away: HTTP 401 with code 12153, "refresh token failed:400 Bad Request:
|
|
1469
|
+
// invalid_grant: Invalid refresh token" for a spent or unknown one, or
|
|
1470
|
+
// "10000:token format error" (measured 2026-10-23). Anything else (a 5xx,
|
|
1471
|
+
// a timeout) says nothing of the sign-in.
|
|
1472
|
+
const refreshRefused = (e) => e instanceof WBError && (e.status === 401 || /invalid_grant/i.test(e.message))
|
|
1473
|
+
|
|
1474
|
+
// reissued is the sign-in to send a request WorkBuddy refused a's access
|
|
1475
|
+
// token for (401) once more with, before that token's time: WorkBuddy can
|
|
1476
|
+
// refuse one long before it ends (a browser sign-in's lasts as long as
|
|
1477
|
+
// its refresh token, about 340 days), and renewing only by time left such
|
|
1478
|
+
// an account refused on every request for months. It is
|
|
1479
|
+
// { a }: a sign-in with another access token, to send the request with
|
|
1480
|
+
// once more;
|
|
1481
|
+
// { why, kind }: none to be had, why, and of what kind: "refresh
|
|
1482
|
+
// refused" (WorkBuddy turned the refresh token away too), "nothing to
|
|
1483
|
+
// renew with" (no refresh token, or one past its end), or "unknown"
|
|
1484
|
+
// (the refresh failed for another reason, the desktop's token is the
|
|
1485
|
+
// one refused).
|
|
1486
|
+
// began is the access token the request read at its start: a stored one
|
|
1487
|
+
// that isn't it was saved since by someone else (magpie's renewal,
|
|
1488
|
+
// another of its processes), and is taken rather than renewing again.
|
|
1489
|
+
//
|
|
1490
|
+
// A desktop sign-in is the app's: its file is read again, and what was
|
|
1491
|
+
// renewed here (desktopHeld) is let go, but its refresh token is never
|
|
1492
|
+
// spent for a refusal, which would sign the app itself out (see
|
|
1493
|
+
// desktopSignIn).
|
|
1494
|
+
async function reissued(site, client, getAuth, a, began) {
|
|
1495
|
+
if (a.source === "desktop") {
|
|
1496
|
+
desktopHeld.delete(site.id + "|" + a.uid)
|
|
1497
|
+
const d = readDesktop(site)
|
|
1498
|
+
if (!d) return { why: notDesktop(site), kind: "unknown" }
|
|
1499
|
+
if (d.access !== a.access) {
|
|
1500
|
+
desktopHeld.set(site.id + "|" + d.uid, d)
|
|
1501
|
+
return { a: d }
|
|
1502
|
+
}
|
|
1503
|
+
return { why: `${site.name} desktop's own sign-in is the one refused`, kind: "unknown" }
|
|
1504
|
+
}
|
|
1505
|
+
const newer = async () => {
|
|
1506
|
+
const now = await getAuth().catch(() => null)
|
|
1507
|
+
return now?.type === "oauth" && now.source !== "desktop" && now.access && now.access !== began && now.access !== a.access ? now : null
|
|
1508
|
+
}
|
|
1509
|
+
const saved = await newer()
|
|
1510
|
+
if (saved) return { a: saved }
|
|
1511
|
+
if (!refreshable(a)) return { why: a.refresh ? "its refresh token has ended" : "there is no refresh token to renew it with", kind: "nothing to renew with" }
|
|
1512
|
+
try {
|
|
1513
|
+
const next = await renew(site, a, async (n) => {
|
|
1514
|
+
try {
|
|
1515
|
+
await client?.auth?.set?.({ path: { id: site.id }, body: n })
|
|
1516
|
+
} catch {}
|
|
1517
|
+
})
|
|
1518
|
+
return next.access !== a.access ? { a: next } : { why: "renewing it gave the same token", kind: "unknown" }
|
|
1519
|
+
} catch (e) {
|
|
1520
|
+
if (refreshRefused(e)) {
|
|
1521
|
+
// spent by someone else, who saved what it gave
|
|
1522
|
+
const saved = await newer()
|
|
1523
|
+
if (saved) return { a: saved }
|
|
1524
|
+
return { why: `it refused the refresh token too (${e.said ?? e.message})`, kind: "refresh refused" }
|
|
1525
|
+
}
|
|
1526
|
+
return { why: `renewing it failed: ${e?.said ?? e?.message ?? e}`, kind: "unknown" }
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// gone says whether a sign-in reissued (r) couldn't renew is gone, on
|
|
1531
|
+
// WorkBuddy's word: it turned the refresh token away, or refused the access
|
|
1532
|
+
// token itself (tokenRefused, invalidToken) with none to renew it with. A
|
|
1533
|
+
// desktop sign-in never is: the app's file is read on each request, and
|
|
1534
|
+
// signing in to the app again is all it takes.
|
|
1535
|
+
const gone = (a, r, tokenRefused) =>
|
|
1536
|
+
a.source !== "desktop" && (r.kind === "refresh refused" || (r.kind === "nothing to renew with" && !!tokenRefused))
|
|
1537
|
+
|
|
1538
|
+
// refusedText says WorkBuddy refused a's sign-in, why it wasn't renewed,
|
|
1539
|
+
// and what to do.
|
|
1540
|
+
function refusedText(site, a, why, note) {
|
|
1541
|
+
return (
|
|
1542
|
+
`${site.name} refused this account's sign-in (401)${why ? "; " + why : ""}. ${note ? note + " " : ""}` +
|
|
1543
|
+
(a.source === "desktop"
|
|
1544
|
+
? `Sign in to ${site.name} desktop again; magpie reads its sign-in on the next request.`
|
|
1545
|
+
: `Sign in to it again in magpie: Providers → ${site.name} → this account's Sign in again.`)
|
|
1546
|
+
)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// signedOut is the answer to a request WorkBuddy refused this account's
|
|
1550
|
+
// sign-in for, renewed or not: a 401 that says so and what to do, which
|
|
1551
|
+
// WorkBuddy's own (an HTML page, its {code, msg}) doesn't. expired marks
|
|
1552
|
+
// the account in magpie (X-Magpie-Sign-In), out of routing and "sign in
|
|
1553
|
+
// again" on its row, till it is signed in again; else it is kept.
|
|
1554
|
+
function signedOut(site, a, why, expired, note) {
|
|
1555
|
+
const message = refusedText(site, a, why, note)
|
|
1253
1556
|
return new Response(JSON.stringify({ error: { message, type: "authentication_error", code: 401 } }), {
|
|
1254
1557
|
status: 401,
|
|
1255
1558
|
statusText: "Unauthorized",
|
|
1256
|
-
headers: { "content-type": "application/json" },
|
|
1559
|
+
headers: { "content-type": "application/json", "X-Magpie-Sign-In": expired ? "expired" : "kept" },
|
|
1257
1560
|
})
|
|
1258
1561
|
}
|
|
1259
1562
|
|
|
@@ -1267,9 +1570,12 @@ async function meter(site, a, path, body = {}) {
|
|
|
1267
1570
|
} catch {}
|
|
1268
1571
|
const code = Number.parseInt(env.code, 10) || 0
|
|
1269
1572
|
if (!res.ok) {
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1573
|
+
// the status and whether the token itself was refused go with it, for
|
|
1574
|
+
// usage's one renewal on a 401 (reissued)
|
|
1575
|
+
const said = { status: res.status, invalidToken: invalidToken(res) }
|
|
1576
|
+
if (code) throw Object.assign(new Error(env.msg || `error ${code}`), said)
|
|
1577
|
+
if (env.msg) throw Object.assign(new Error(env.msg), said)
|
|
1578
|
+
throw Object.assign(new Error(statusText(res.status)), said)
|
|
1273
1579
|
}
|
|
1274
1580
|
if (code) throw new Error(env.msg || `error ${code}`)
|
|
1275
1581
|
return env.data ?? null
|
|
@@ -1418,11 +1724,16 @@ function variants(efforts) {
|
|
|
1418
1724
|
return Object.fromEntries((efforts ?? []).map((e) => [e, { reasoningEffort: e }]))
|
|
1419
1725
|
}
|
|
1420
1726
|
|
|
1727
|
+
// configModels is the site's own list as a provider's models, each on the
|
|
1728
|
+
// API it is served on: a Claude model on Messages, the rest on chat.
|
|
1421
1729
|
function configModels(site) {
|
|
1422
1730
|
return Object.fromEntries(site.models.map(([id, name, context, efforts]) => [id, {
|
|
1423
1731
|
name,
|
|
1424
1732
|
limit: { context, output: 0 },
|
|
1425
1733
|
tool_call: true,
|
|
1734
|
+
// OpenCode (and magpie's host) read a config model's package and URL
|
|
1735
|
+
// from provider: { npm, api }, not api
|
|
1736
|
+
...(nativeOf(site, id) ? { provider: { npm: NPM_MESSAGES, api: apiMessages(site) } } : {}),
|
|
1426
1737
|
...(efforts ? { reasoning: true, variants: variants(efforts) } : {}),
|
|
1427
1738
|
}]))
|
|
1428
1739
|
}
|
|
@@ -1432,7 +1743,7 @@ function modelOf(site, provider, m) {
|
|
|
1432
1743
|
id: m.id,
|
|
1433
1744
|
providerID: provider.id,
|
|
1434
1745
|
name: m.name,
|
|
1435
|
-
api: { id: m.id, url: api(site), npm: NPM },
|
|
1746
|
+
api: { id: m.id, url: nativeOf(site, m.id) ? apiMessages(site) : api(site), npm: nativeOf(site, m.id) ? NPM_MESSAGES : NPM },
|
|
1436
1747
|
status: "active",
|
|
1437
1748
|
headers: {},
|
|
1438
1749
|
options: {},
|
|
@@ -1531,9 +1842,20 @@ function priceOf(d, promotions, now = Date.now()) {
|
|
|
1531
1842
|
// and a default reasoning.effort. Those take low, high and max (each
|
|
1532
1843
|
// reasoned under a real account, on four of them) and their own default.
|
|
1533
1844
|
const EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"]
|
|
1534
|
-
function effortsOf(d) {
|
|
1845
|
+
function effortsOf(d, site, id) {
|
|
1535
1846
|
const said = d.reasoning?.supportedEfforts
|
|
1536
|
-
if (Array.isArray(said) && said.length)
|
|
1847
|
+
if (Array.isArray(said) && said.length) {
|
|
1848
|
+
// A Claude model on Messages takes max though the config's list stops
|
|
1849
|
+
// at xhigh: measured 2026-10-24, output_config.effort "max" answered
|
|
1850
|
+
// 200 on each Claude model, and on claude-opus-5.5 with more thinking
|
|
1851
|
+
// than xhigh (11248 thinking tokens against 1999, one run each). How
|
|
1852
|
+
// much more a level gives varies by model and run; what matters here
|
|
1853
|
+
// is that a client picking max reaches the server as max, not xhigh.
|
|
1854
|
+
// On chat max means nothing more (see NPM_MESSAGES), so a model there
|
|
1855
|
+
// keeps what the config says.
|
|
1856
|
+
if (nativeOf(site, id) && !said.includes("max")) return [...said, "max"]
|
|
1857
|
+
return said
|
|
1858
|
+
}
|
|
1537
1859
|
if (d.supportsReasoning !== true) return []
|
|
1538
1860
|
const own = d.reasoning?.effort
|
|
1539
1861
|
const set = new Set(["low", "high", "max", ...(typeof own === "string" && EFFORT_ORDER.includes(own) ? [own] : [])])
|
|
@@ -1562,7 +1884,7 @@ async function liveModels(site, a) {
|
|
|
1562
1884
|
m.output = d.maxOutputTokens
|
|
1563
1885
|
m.images = d.supportsImages === true && !textOnly(site, id)
|
|
1564
1886
|
Object.assign(m, priceOf(d, cfg.modelPromotions))
|
|
1565
|
-
const es = effortsOf(d)
|
|
1887
|
+
const es = effortsOf(d, site, id)
|
|
1566
1888
|
if (es.length) {
|
|
1567
1889
|
const canOff = d.reasoning?.canDisableThinking
|
|
1568
1890
|
m.efforts = [...(!d.onlyReasoning && (canOff == null || canOff) ? ["none"] : []), ...es]
|
|
@@ -1668,12 +1990,15 @@ function makePlugin(site) {
|
|
|
1668
1990
|
const auth = await getAuth()
|
|
1669
1991
|
if (auth?.type !== "oauth") return {}
|
|
1670
1992
|
return {
|
|
1671
|
-
baseURL: api
|
|
1993
|
+
// no baseURL: a model's own api.url says where it is served —
|
|
1994
|
+
// <endpoint>/v2 for a chat, <endpoint>/v1 for a Claude model on
|
|
1995
|
+
// Messages. A provider-wide base would put /messages under /v2.
|
|
1996
|
+
baseURL: "",
|
|
1672
1997
|
apiKey: "",
|
|
1673
1998
|
async fetch(input, init) {
|
|
1674
|
-
|
|
1675
|
-
if (
|
|
1676
|
-
a = await current(site, client,
|
|
1999
|
+
const stored = await getAuth()
|
|
2000
|
+
if (stored?.type !== "oauth") throw new Error(`${site.name} isn't signed in`)
|
|
2001
|
+
let a = await current(site, client, stored)
|
|
1677
2002
|
const req = input instanceof Request ? input : null
|
|
1678
2003
|
const headers = new Headers(init?.headers ?? req?.headers)
|
|
1679
2004
|
headers.delete("authorization")
|
|
@@ -1683,23 +2008,90 @@ function makePlugin(site) {
|
|
|
1683
2008
|
sign(site, a, headers)
|
|
1684
2009
|
let body = init?.body
|
|
1685
2010
|
if (body === undefined && req) body = await req.clone().text()
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
2011
|
+
// An Anthropic request goes as the agent sent it: WorkBuddy
|
|
2012
|
+
// serves these models on Messages itself, and the rewrites
|
|
2013
|
+
// below (a default system message, the chat endpoint's
|
|
2014
|
+
// max_tokens spelling, tool shapes, the move of a refused
|
|
2015
|
+
// system prompt) exist for the chat endpoint alone. Claude
|
|
2016
|
+
// Code's own system prompt is taken there as it is — measured
|
|
2017
|
+
// 2026-10-24: the billing line, the "official CLI" line,
|
|
2018
|
+
// Codex's and DeepSeek Harness's openings all answer on
|
|
2019
|
+
// /v1/messages, where a chat is refused 11128.
|
|
2020
|
+
const native = isMessages(req ? req.url : String(input), body)
|
|
2021
|
+
let names = {}
|
|
2022
|
+
if (!native) {
|
|
2023
|
+
body = withSystem(body)
|
|
2024
|
+
const fit = fitted(body)
|
|
2025
|
+
body = normalizeToolChoice(fit.body)
|
|
2026
|
+
names = fit.names
|
|
2027
|
+
} else {
|
|
2028
|
+
body = withoutUnsigned(body)
|
|
2029
|
+
}
|
|
1689
2030
|
attend(headers, body, session)
|
|
1690
|
-
const
|
|
2031
|
+
const post = (b) =>
|
|
1691
2032
|
fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: b }).catch((e) => unanswered(site, a, b, e, init?.signal))
|
|
2033
|
+
// a 401 is answered once, on whichever try meets it: by a
|
|
2034
|
+
// sign-in with another access token (reissued), the request
|
|
2035
|
+
// sent once more with it, never again after that; else, or
|
|
2036
|
+
// when that is refused too, by signedOut. The body is a
|
|
2037
|
+
// string here (or bytes), which goes again as it is; one that
|
|
2038
|
+
// can't be read twice isn't sent again.
|
|
2039
|
+
//
|
|
2040
|
+
// The account is marked (expired) only on WorkBuddy's word
|
|
2041
|
+
// that the sign-in is gone: it turned the refresh token away,
|
|
2042
|
+
// or refused the access token itself (invalidToken) with none
|
|
2043
|
+
// to renew it with, or refused even the token just renewed. A
|
|
2044
|
+
// desktop sign-in is never marked: the app's file is read on
|
|
2045
|
+
// each request, so signing in to the app again is enough.
|
|
2046
|
+
let renewed = false
|
|
2047
|
+
const ours = new WeakSet() // the answers signedOut gave, passed on as they are
|
|
2048
|
+
const out = (why, expired, res) => {
|
|
2049
|
+
const r = signedOut(site, a, why, expired && a.source !== "desktop", edgeNotes.get(res))
|
|
2050
|
+
ours.add(r)
|
|
2051
|
+
return r
|
|
2052
|
+
}
|
|
2053
|
+
const resendable = (b) => b == null || typeof b === "string" || b instanceof ArrayBuffer || ArrayBuffer.isView(b)
|
|
2054
|
+
const send = async (b) => {
|
|
2055
|
+
const res = await post(b)
|
|
2056
|
+
if (res.status !== 401) return res
|
|
2057
|
+
if (renewed) return out("it refused the token renewed for it too", invalidToken(res), res)
|
|
2058
|
+
if (!resendable(b)) return res
|
|
2059
|
+
await res.body?.cancel().catch(() => {})
|
|
2060
|
+
const r = await reissued(site, client, getAuth, a, stored.access)
|
|
2061
|
+
if (!r.a) return out(r.why, gone(a, r, invalidToken(res)), res)
|
|
2062
|
+
renewed = true
|
|
2063
|
+
a = r.a
|
|
2064
|
+
sign(site, a, headers)
|
|
2065
|
+
const again = await post(b)
|
|
2066
|
+
if (again.status !== 401) return again
|
|
2067
|
+
return out("it refused the token renewed for it too", invalidToken(again), again)
|
|
2068
|
+
}
|
|
1692
2069
|
// a model known to turn tool results then the user's message away
|
|
1693
2070
|
// gets the empty reply between them up front (see endsAfterTools)
|
|
1694
2071
|
const model = chatOf(body)?.model
|
|
1695
2072
|
// a system prompt WorkBuddy refused before goes as the user's
|
|
1696
2073
|
// words at once (see systemAsUser)
|
|
1697
|
-
const sysKey = systemKey(body)
|
|
2074
|
+
const sysKey = native ? "" : systemKey(body)
|
|
1698
2075
|
if (sysKey && refusedSystems.has(sysKey)) body = systemAsUser(body) ?? body
|
|
1699
|
-
const shaped = (b) => (endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
|
|
2076
|
+
const shaped = (b) => (!native && endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
|
|
1700
2077
|
let res = await send(shaped(body))
|
|
1701
|
-
|
|
1702
|
-
|
|
2078
|
+
// a signature WorkBuddy's own signing didn't make — a session
|
|
2079
|
+
// moved here from another provider keeps its thinking blocks,
|
|
2080
|
+
// and a truncated or altered one reads the same way — is 11159
|
|
2081
|
+
// too, and none of them can be told apart from a good one
|
|
2082
|
+
// before sending. The request goes once more without them.
|
|
2083
|
+
// Measured 2026-10-24: another vendor's signature answers 11159
|
|
2084
|
+
// on its own, drop_block notwithstanding; without the block the
|
|
2085
|
+
// same history answers 200. Only when the history holds a
|
|
2086
|
+
// signature that isn't this route's own, so a session on this
|
|
2087
|
+
// route never sends twice.
|
|
2088
|
+
if (native && res.status >= 400) {
|
|
2089
|
+
const text = await res.text()
|
|
2090
|
+
const again = BAD_SIGNATURE.test(text) ? withoutSignedThinking(body) : null
|
|
2091
|
+
if (again) res = await send(again)
|
|
2092
|
+
else res = new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
|
|
2093
|
+
}
|
|
2094
|
+
if (res.status === 400 && !native) { let text = await res.text()
|
|
1703
2095
|
const moved = REFUSED.test(text) && sysKey && !refusedSystems.has(sysKey) ? systemAsUser(body) : null
|
|
1704
2096
|
if (moved) {
|
|
1705
2097
|
refusedSystems.add(sysKey)
|
|
@@ -1716,20 +2108,43 @@ function makePlugin(site) {
|
|
|
1716
2108
|
res = new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
|
|
1717
2109
|
}
|
|
1718
2110
|
}
|
|
1719
|
-
|
|
2111
|
+
if (ours.has(res)) return res
|
|
2112
|
+
// renewed here: a mark the account had comes off
|
|
2113
|
+
return res.status >= 400 ? explained(res) : kept(native ? res : await renamed(res, names), renewed ? "renewed" : "kept")
|
|
1720
2114
|
},
|
|
1721
2115
|
}
|
|
1722
2116
|
},
|
|
1723
2117
|
// the account's credits, as magpie's built-in showed them; each
|
|
1724
2118
|
// read keeps the sign-in (signIn), as the built-in's neither marked
|
|
1725
|
-
// an account lapsed nor cleared it
|
|
2119
|
+
// an account lapsed nor cleared it, but for a 401 (gone, below)
|
|
1726
2120
|
async usage(getAuth) {
|
|
1727
2121
|
const auth = await getAuth()
|
|
1728
2122
|
if (auth?.type !== "oauth" || !(auth.access || auth.source)) return { error: "not signed in", signIn: "kept" }
|
|
1729
2123
|
try {
|
|
1730
|
-
|
|
2124
|
+
let a = await current(site, client, auth)
|
|
1731
2125
|
// the meter has no /v2 prefix, on either site
|
|
1732
|
-
const
|
|
2126
|
+
const SUMMARY = "/billing/meter/get-user-resource-summary"
|
|
2127
|
+
// a 401 is answered as a chat's is: once, by a sign-in with
|
|
2128
|
+
// another access token, else the card says WorkBuddy refused
|
|
2129
|
+
// the sign-in and what to do; renewed, a mark comes off
|
|
2130
|
+
let said = "kept"
|
|
2131
|
+
let sum
|
|
2132
|
+
try {
|
|
2133
|
+
sum = await meter(site, a, SUMMARY)
|
|
2134
|
+
} catch (e) {
|
|
2135
|
+
if (e?.status !== 401) throw e
|
|
2136
|
+
const r = await reissued(site, client, getAuth, a, auth.access)
|
|
2137
|
+
if (!r.a) return { error: refusedText(site, a, r.why), signIn: gone(a, r, e.invalidToken) ? "expired" : "kept" }
|
|
2138
|
+
a = r.a
|
|
2139
|
+
try {
|
|
2140
|
+
sum = await meter(site, a, SUMMARY)
|
|
2141
|
+
} catch (e) {
|
|
2142
|
+
if (e?.status !== 401) throw e
|
|
2143
|
+
const why = "it refused the token renewed for it too"
|
|
2144
|
+
return { error: refusedText(site, a, why), signIn: a.source !== "desktop" && e.invalidToken ? "expired" : "kept" }
|
|
2145
|
+
}
|
|
2146
|
+
said = "renewed"
|
|
2147
|
+
}
|
|
1733
2148
|
const out = usageOf(sum, auth.plan)
|
|
1734
2149
|
// an enterprise account's summary has no packages: its credits
|
|
1735
2150
|
// are the enterprise meter's (#1904). A personal account's
|
|
@@ -1740,7 +2155,7 @@ function makePlugin(site) {
|
|
|
1740
2155
|
try {
|
|
1741
2156
|
ent = enterpriseUsageOf(await meter(site, a, "/billing/meter/get-enterprise-user-usage"))
|
|
1742
2157
|
} catch {}
|
|
1743
|
-
if (ent) return { plan: auth.plan || "Enterprise", windows: await spareFree(site, a, [ent]), signIn:
|
|
2158
|
+
if (ent) return { plan: auth.plan || "Enterprise", windows: await spareFree(site, a, [ent]), signIn: said }
|
|
1744
2159
|
}
|
|
1745
2160
|
// when the credits left run out, for routing to spend them first;
|
|
1746
2161
|
// a list that can't be read leaves it unknown, the card stands
|
|
@@ -1751,7 +2166,7 @@ function makePlugin(site) {
|
|
|
1751
2166
|
} catch {}
|
|
1752
2167
|
}
|
|
1753
2168
|
await spareFree(site, a, out.windows)
|
|
1754
|
-
return { ...out, signIn:
|
|
2169
|
+
return { ...out, signIn: said }
|
|
1755
2170
|
} catch (e) {
|
|
1756
2171
|
return { error: e?.message ?? String(e), signIn: "kept" }
|
|
1757
2172
|
}
|
|
@@ -1785,6 +2200,13 @@ const REFUSED_HINT =
|
|
|
1785
2200
|
"WorkBuddy refused words in this client's prompt (most often its system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group"
|
|
1786
2201
|
const REFUSED = /unapproved channel|illegal api invocation/i
|
|
1787
2202
|
|
|
2203
|
+
// BAD_SIGNATURE is WorkBuddy's refusal of a thinking block whose signature it
|
|
2204
|
+
// didn't make: internal code 11159, "Invalid `signature` in `thinking` block.
|
|
2205
|
+
// The block is bound to a different conversation", else 11133 wrapping
|
|
2206
|
+
// "thinking_signature_invalid: replay the original thinking block and
|
|
2207
|
+
// signature unchanged".
|
|
2208
|
+
const BAD_SIGNATURE = /thinking_signature_invalid|invalid\s+`?signature`?\s+in\s+`?thinking|block is bound to a different conversation|"internal_code"\s*:\s*"?11159/i
|
|
2209
|
+
|
|
1788
2210
|
// RESTRICTED_HINT is what the user can do about WorkBuddy's 403 code 11140
|
|
1789
2211
|
// "request illegal", whose displayMsg says "Your account has been restricted
|
|
1790
2212
|
// due to unusual activity" (magpie #1849): the account is out until WorkBuddy
|
|
@@ -1806,17 +2228,19 @@ const EXHAUSTED_HINT =
|
|
|
1806
2228
|
// another error, and insufficient_quota would rest the account as out of credit.
|
|
1807
2229
|
const EXHAUSTED_TEXT = /credits exhausted|额度已用尽|["']code["']\s*:\s*["']?14018(?![\d.])/i
|
|
1808
2230
|
|
|
1809
|
-
// kept is an answer that went through, saying the sign-in is kept
|
|
1810
|
-
// built-in never cleared a WorkBuddy account's mark, as it never set one
|
|
1811
|
-
|
|
2231
|
+
// kept is an answer that went through, saying the sign-in is kept (the
|
|
2232
|
+
// built-in never cleared a WorkBuddy account's mark, as it never set one),
|
|
2233
|
+
// or "renewed" when it went through on a sign-in renewed for a 401, which
|
|
2234
|
+
// takes a mark off (see signedOut).
|
|
2235
|
+
function kept(res, said = "kept") {
|
|
1812
2236
|
const headers = new Headers(res.headers)
|
|
1813
|
-
headers.set("X-Magpie-Sign-In",
|
|
2237
|
+
headers.set("X-Magpie-Sign-In", said)
|
|
1814
2238
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
1815
2239
|
}
|
|
1816
2240
|
|
|
1817
2241
|
// explained is res with REFUSED_HINT, RESTRICTED_HINT or EXHAUSTED_HINT added to that refusal's message.
|
|
1818
|
-
// Each refusal says the sign-in is kept:
|
|
1819
|
-
//
|
|
2242
|
+
// Each refusal says the sign-in is kept: a 401 never comes here, it is
|
|
2243
|
+
// signedOut's.
|
|
1820
2244
|
async function explained(res) {
|
|
1821
2245
|
const text = await res.text()
|
|
1822
2246
|
const again = (b) => {
|
|
@@ -1831,10 +2255,15 @@ async function explained(res) {
|
|
|
1831
2255
|
v = JSON.parse(text)
|
|
1832
2256
|
} catch {}
|
|
1833
2257
|
const at = v?.error && typeof v.error === "object" ? v.error : v && typeof v === "object" ? v : null
|
|
1834
|
-
|
|
2258
|
+
// the Messages route words its code two more ways (seen 2026-10-24):
|
|
2259
|
+
// {"error": {"code": "400001", "internal_code": "11159", …}}, and
|
|
2260
|
+
// {"code": 11102, "error": "11158:…"}, the code that says what happened
|
|
2261
|
+
// first in a string error, where the outer one only says it failed
|
|
2262
|
+
const inner = at?.internal_code ?? (typeof v?.error === "string" ? v.error.match(/^(\d{5}):/)?.[1] : undefined)
|
|
2263
|
+
const codes = [inner, at?.data?.code, at?.code, v?.code, v?.data?.code].filter((c) => c != null).map(Number)
|
|
1835
2264
|
const isRefused = REFUSED.test(text)
|
|
1836
|
-
const isRestricted = !isRefused && (
|
|
1837
|
-
const isExhausted = !isRestricted && (
|
|
2265
|
+
const isRestricted = !isRefused && (codes.includes(RESTRICTED_CODE) || RESTRICTED_TEXT.test(text))
|
|
2266
|
+
const isExhausted = !isRestricted && (codes.includes(14018) || EXHAUSTED_TEXT.test(text))
|
|
1838
2267
|
if (!isRefused && !isRestricted && !isExhausted) return again(text)
|
|
1839
2268
|
|
|
1840
2269
|
let msg = text.trim()
|
|
@@ -1860,4 +2289,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
|
|
|
1860
2289
|
export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
|
|
1861
2290
|
|
|
1862
2291
|
// for tests
|
|
1863
|
-
export const _internal = { systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf }
|
|
2292
|
+
export const _internal = { typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, answeredBefore, nativeOf, modelOf, configModels, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
|
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.33",
|
|
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",
|