@magpie-community/opencode-workbuddy-auth 0.2.3 → 0.2.5

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 +14 -0
  2. package/index.mjs +106 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -239,6 +239,20 @@ serves; a model that refused it once gets it that way from then on, until
239
239
  the plugin restarts. A chat ending with the assistant's message (a prefill)
240
240
  is refused as before.
241
241
 
242
+ WorkBuddy's safety filter can refuse a reply it has begun:
243
+ `deepseek-v4.1-flash` reasoned, said "Sorry, I can't respond to this
244
+ question.", and ended with `finish_reason: "content_filter"` and no output
245
+ tokens, all under a 200 (#540). Since 0.2.4 that line is taken out of a
246
+ streamed chat ending `content_filter`, so the reply says nothing before the
247
+ filter's stop: magpie hands such a refusal to the group's next member
248
+ instead of passing it to the agent as an answer. Only text that is still
249
+ the filter's line is held, so any other reply's words go as they come; the
250
+ same words ending `stop`, or the filter cutting off a reply after other
251
+ words, go through as they came.
252
+ copilot.tencent.com sends the line with a newline on each side
253
+ (`"\nSorry, I can't respond to this question.\n"`); since 0.2.5 white
254
+ space around it no longer lets it through.
255
+
242
256
  ## Models
243
257
 
244
258
  When you are signed in, the models are the ones WorkBuddy's product config
package/index.mjs CHANGED
@@ -1599,6 +1599,110 @@ function renamed(res, names) {
1599
1599
  return new Response(body, { status: res.status, statusText: res.statusText, headers })
1600
1600
  }
1601
1601
 
1602
+ // FILTER_SAYS is the text WorkBuddy's safety filter puts in a reply it ends
1603
+ // with finish_reason "content_filter": deepseek-v4.1-flash on
1604
+ // copilot.tencent.com reasoned, said this, and ended content_filter with
1605
+ // no output tokens counted (itspig, plugins #540).
1606
+ const FILTER_SAYS = ["Sorry, I can't respond to this question."]
1607
+
1608
+ // filterSaid tells whether text said so far may still be FILTER_SAYS, with
1609
+ // white space on either side: copilot.tencent.com sends it as
1610
+ // "\nSorry, I can't respond to this question.\n" (itspig, plugins #540).
1611
+ // Text that is only white space so far may still be it; text that goes on
1612
+ // past it is not.
1613
+ const filterSaid = (text) => {
1614
+ const t = text.replace(/’/g, "'").trim()
1615
+ return FILTER_SAYS.some((s) => s.startsWith(t))
1616
+ }
1617
+
1618
+ // unsaid is a streamed chat whose reply WorkBuddy's filter refused with its
1619
+ // text (FILTER_SAYS) and finish_reason content_filter, with the text taken
1620
+ // out: a reply that says nothing before content_filter is the refusal magpie
1621
+ // asks the group's next member for, where a 200 that said something is an
1622
+ // answer the agent fails on ("The model refused to complete the request",
1623
+ // plugins #540). Only text that is still the filter's is held, so any other
1624
+ // reply's first words go as they come, and one that only begins like it is
1625
+ // let through at its first word that isn't, or at its end.
1626
+ function unsaid(res) {
1627
+ if (!res.body || !/event-stream/i.test(res.headers.get("content-type") ?? "")) return res
1628
+ const headers = new Headers(res.headers)
1629
+ headers.delete("content-length")
1630
+ headers.delete("content-encoding")
1631
+ const dec = new TextDecoder()
1632
+ const enc = new TextEncoder()
1633
+ let rest = ""
1634
+ let open = true // the reply may still be the filter's
1635
+ let said = ""
1636
+ let held = [] // events held while it may be
1637
+ const chunkOf = (ev) => {
1638
+ const m = /^data:\s?(.*)$/m.exec(ev)
1639
+ if (!m || m[1] === "[DONE]") return null
1640
+ try {
1641
+ const v = JSON.parse(m[1])
1642
+ return v && typeof v === "object" ? v : null
1643
+ } catch {
1644
+ return null
1645
+ }
1646
+ }
1647
+ // without is ev with the filter's text taken out, or "" when nothing
1648
+ // else is in it
1649
+ const without = (ev) => {
1650
+ const v = chunkOf(ev)
1651
+ const c = v?.choices?.[0]
1652
+ if (!c?.delta || typeof c.delta.content !== "string") return ev
1653
+ delete c.delta.content
1654
+ const left = Object.keys(c.delta).filter((k) => k !== "role")
1655
+ if (!left.length && c.finish_reason == null && v.usage == null) return ""
1656
+ return `data: ${JSON.stringify(v)}`
1657
+ }
1658
+ const event = (ev) => {
1659
+ if (!open) return [ev]
1660
+ const c = chunkOf(ev)?.choices?.[0]
1661
+ const text = typeof c?.delta?.content === "string" ? c.delta.content : ""
1662
+ const finish = c?.finish_reason
1663
+ if (c?.delta?.tool_calls || (text && !filterSaid(said + text))) {
1664
+ open = false
1665
+ const out = [...held, ev]
1666
+ held = []
1667
+ return out
1668
+ }
1669
+ said += text
1670
+ if (finish === "content_filter" && said) {
1671
+ open = false
1672
+ const out = [...held, ev].map(without).filter(Boolean)
1673
+ held = []
1674
+ return out
1675
+ }
1676
+ if (finish) {
1677
+ open = false
1678
+ const out = [...held, ev]
1679
+ held = []
1680
+ return out
1681
+ }
1682
+ if (!said) return [ev] // reasoning, the role, before any text
1683
+ held.push(ev)
1684
+ return []
1685
+ }
1686
+ const body = res.body.pipeThrough(
1687
+ new TransformStream({
1688
+ transform(chunk, ctl) {
1689
+ rest = (rest + dec.decode(chunk, { stream: true })).replace(/\r\n/g, "\n")
1690
+ const evs = rest.split("\n\n")
1691
+ rest = evs.pop()
1692
+ const out = evs.flatMap(event)
1693
+ if (out.length) ctl.enqueue(enc.encode(out.map((e) => e + "\n\n").join("")))
1694
+ },
1695
+ flush(ctl) {
1696
+ rest += dec.decode()
1697
+ const out = held.map((e) => e + "\n\n").join("") + rest
1698
+ held = []
1699
+ if (out) ctl.enqueue(enc.encode(out))
1700
+ },
1701
+ }),
1702
+ )
1703
+ return new Response(body, { status: res.status, statusText: res.statusText, headers })
1704
+ }
1705
+
1602
1706
  // ---- signing in -------------------------------------------------------------
1603
1707
 
1604
1708
  // poll asks path until WorkBuddy answers with data, retrying the codes
@@ -3439,7 +3543,7 @@ function makePlugin(site) {
3439
3543
  }
3440
3544
  if (ours.has(res)) return res
3441
3545
  // renewed here: a mark the account had comes off
3442
- return res.status >= 400 ? explained(res) : kept(native ? res : await renamed(res, names), renewed ? "renewed" : "kept")
3546
+ return res.status >= 400 ? explained(res) : kept(native ? res : unsaid(await renamed(res, names)), renewed ? "renewed" : "kept")
3443
3547
  },
3444
3548
  }
3445
3549
  },
@@ -3652,4 +3756,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
3652
3756
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
3653
3757
 
3654
3758
  // for tests
3655
- export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, accountName, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, REFUSED_DOC, commaPaused, commaPausedOn, opened, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, scopeOf, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, prepared, answeredBefore, nativeOf, modelOf, configModels, freeWindowOf, tokensShort, freeWindowRows, freeWindowLedger, readLedgerTail, pinned, rowTime, rowAccount, ofAccount, windowOffset, windowReset, FREE_WINDOW_MODELS, FREE_WINDOW_SPAN, FREE_WINDOW_GAP, FREE_WINDOW_LEAD, checkinOf, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
3759
+ export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, accountName, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, normalizeToolChoice, fitTools, fitted, renamed, unsaid, FILTER_SAYS, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, REFUSED_DOC, commaPaused, commaPausedOn, opened, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, scopeOf, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, prepared, answeredBefore, nativeOf, modelOf, configModels, freeWindowOf, tokensShort, freeWindowRows, freeWindowLedger, readLedgerTail, pinned, rowTime, rowAccount, ofAccount, windowOffset, windowReset, FREE_WINDOW_MODELS, FREE_WINDOW_SPAN, FREE_WINDOW_GAP, FREE_WINDOW_LEAD, checkinOf, 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.2.3",
3
+ "version": "0.2.5",
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",