@magpie-community/opencode-workbuddy-auth 0.1.19 → 0.1.21

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 +16 -0
  2. package/index.mjs +97 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -99,6 +99,15 @@ models to each shape below and Kimi's, GPT's, Gemini's and GLM's to some:
99
99
  (`.`, `:`, `/`, a space, CJK) is sent with `_` there, and the model's
100
100
  calls come back under the tool's own name.
101
101
 
102
+ WorkBuddy AI's `gemini-3.8-flash` answers 400 "the conversation must end
103
+ with a user message" (11153) to a chat whose tool results are followed by
104
+ the user's message, as magpie sends Claude Code's reminders, words typed
105
+ mid-task and a tool's images. Such a chat goes again with an empty
106
+ assistant message between the results and the user's message, which it
107
+ serves; a model that refused it once gets it that way from then on, until
108
+ the plugin restarts. A chat ending with the assistant's message (a prefill)
109
+ is refused as before.
110
+
102
111
  ## Models
103
112
 
104
113
  When you are signed in, the models are the ones WorkBuddy's product config
@@ -125,6 +134,13 @@ is used.
125
134
 
126
135
  A model's reasoning levels are its variants (`reasoningEffort`).
127
136
 
137
+ A model's price is its `credits` ("x0.29") and the config's
138
+ `modelPromotions`. A discount for whole days (Step-5-Preview's 独家限免) is
139
+ the price now, with the price before it (`rateWas`). A price that holds only
140
+ some hours of the day (Hy4 preview, free 23:00–8:00) is given as its full
141
+ price, never as free: magpie keeps the list until it asks again, so a list
142
+ asked for at night would say "free" all day (#121).
143
+
128
144
  ## Credits in magpie
129
145
 
130
146
  magpie's usage card shows the account's credits from the resource summary.
package/index.mjs CHANGED
@@ -1187,6 +1187,8 @@ function modelOf(site, provider, m) {
1187
1187
  free: !!m.free,
1188
1188
  // magpie's own: the credits a request costs, as a multiple (0: none)
1189
1189
  rate: m.rate ?? 0,
1190
+ // magpie's own: the price before a discount running now (0: none)
1191
+ rateWas: m.rateWas ?? 0,
1190
1192
  }
1191
1193
  }
1192
1194
 
@@ -1210,6 +1212,53 @@ function freeCredits(c) {
1210
1212
  return creditsOf(c) === 0
1211
1213
  }
1212
1214
 
1215
+ // discountOf reads a promotion's price: its discountedCredits ("0.50x",
1216
+ // "0.00x"), else the factor times the model's own credits.
1217
+ function discountOf(p, base) {
1218
+ const said = creditsOf(String(p.discount?.discountedCredits ?? "").trim().replace(/x$/i, ""))
1219
+ if (said !== undefined) return said
1220
+ const f = Number(p.discount?.factor)
1221
+ return Number.isFinite(f) && f >= 0 && base !== undefined ? base * f : undefined
1222
+ }
1223
+
1224
+ // running says whether a promotion is on at now: enabled, and inside its
1225
+ // validFrom..validUntil. Its daily hours are not looked at here.
1226
+ function running(p, now) {
1227
+ if (!p || p.enabled === false) return false
1228
+ const from = Date.parse(p.schedule?.validFrom ?? "")
1229
+ const until = Date.parse(p.schedule?.validUntil ?? "")
1230
+ return !(from > now) && !(until <= now)
1231
+ }
1232
+
1233
+ // priceOf is a model's price as magpie keeps it, from its credits and the
1234
+ // config's modelPromotions (what WorkBuddy's picker shows as 限时免费,
1235
+ // 夜间免费, 夜间折扣). magpie keeps the list it is given until it asks
1236
+ // again, so a price that holds only some hours of the day (Hy4 preview,
1237
+ // free 23:00–8:00 only) is given as its full price, never as free: a list
1238
+ // asked for at night would otherwise say "free" all day (#121). A discount
1239
+ // for whole days (Step-5-Preview's 独家限免) is the price now, with the
1240
+ // price before it as rateWas.
1241
+ function priceOf(d, promotions, now = Date.now()) {
1242
+ const base = creditsOf(d.credits)
1243
+ const mine = (Array.isArray(promotions) ? promotions : []).filter(
1244
+ (p) => p?.kind === "discount" && Array.isArray(p.modelIds) && p.modelIds.includes(d.id) && running(p, now),
1245
+ )
1246
+ if (mine.some((p) => Array.isArray(p.schedule?.daily) && p.schedule.daily.length)) {
1247
+ // by the hour: the full price, which the credits are unless a night
1248
+ // discount already took them to its own price
1249
+ const night = mine.filter((p) => p.discount && Array.isArray(p.schedule?.daily) && p.schedule.daily.length)
1250
+ if (base === undefined || (base === 0 && night.some((p) => discountOf(p, base) === 0))) return { free: false }
1251
+ return { free: false, rate: base }
1252
+ }
1253
+ const off = mine.filter((p) => p.discount).map((p) => discountOf(p, base)).filter((r) => r !== undefined)
1254
+ if (off.length) {
1255
+ const r = Math.min(...off)
1256
+ return { free: r === 0, rate: r, ...(base !== undefined && base > r ? { rateWas: base } : {}) }
1257
+ }
1258
+ if (base === undefined) return {}
1259
+ return { free: base === 0, rate: base }
1260
+ }
1261
+
1213
1262
  async function liveModels(site, a) {
1214
1263
  const headers = new Headers()
1215
1264
  sign(site, a, headers)
@@ -1231,11 +1280,7 @@ async function liveModels(site, a) {
1231
1280
  m.context = d.maxInputTokens
1232
1281
  m.output = d.maxOutputTokens
1233
1282
  m.images = d.supportsImages === true && !textOnly(site, id)
1234
- const r = creditsOf(d.credits)
1235
- if (r !== undefined) {
1236
- m.free = r === 0
1237
- m.rate = r
1238
- }
1283
+ Object.assign(m, priceOf(d, cfg.modelPromotions))
1239
1284
  const es = d.reasoning?.supportedEfforts ?? []
1240
1285
  if (es.length) {
1241
1286
  const canOff = d.reasoning?.canDisableThinking
@@ -1262,7 +1307,37 @@ async function liveModels(site, a) {
1262
1307
 
1263
1308
  // ---- the plugins --------------------------------------------------------------
1264
1309
 
1310
+ // AFTER_TOOLS_REFUSED is WorkBuddy's 400 code 11153, "the conversation must
1311
+ // end with a user message", which WorkBuddy AI's gemini-3.8-flash answers to a
1312
+ // chat whose tool results are followed by the user's message (#113): Claude
1313
+ // Code's reminders, a user's words sent mid-task, a tool's images, which
1314
+ // magpie hands over in a user message after the results. It does end with a
1315
+ // user message; the same chat with any other message between them is served.
1316
+ // The code is matched whole, as with 14018.
1317
+ const AFTER_TOOLS_REFUSED = /must end with a user message|["']code["']\s*:\s*["']?11153(?![\d.])/i
1318
+
1319
+ // replyAfterTools is a chat ending with tool results then the user's
1320
+ // message(s), with an empty assistant message between them, as if the model
1321
+ // had said nothing after the results. Tried with a real WorkBuddy AI
1322
+ // account on 2026-10-14: gemini-3.8-flash serves it and follows the user's
1323
+ // message as a user's, where folded into the tool's result it was read as
1324
+ // the tool's output and its ask left undone. null for any other chat — one
1325
+ // ending with the assistant's message is a prefill, refused as it is.
1326
+ function replyAfterTools(body) {
1327
+ const b = chatOf(body)
1328
+ const msgs = b?.messages
1329
+ if (!Array.isArray(msgs)) return null
1330
+ let i = msgs.length - 1
1331
+ while (i >= 0 && msgs[i]?.role === "user") i--
1332
+ if (i === msgs.length - 1 || i < 0 || msgs[i]?.role !== "tool") return null
1333
+ msgs.splice(i + 1, 0, { role: "assistant", content: "" })
1334
+ return JSON.stringify(b)
1335
+ }
1336
+
1265
1337
  function makePlugin(site) {
1338
+ // the models of this site that refused tool results then the user's
1339
+ // message, for this run: their next chats go with replyAfterTools at once
1340
+ const endsAfterTools = new Set()
1266
1341
  return async ({ client }) => ({
1267
1342
  config: async (config) => {
1268
1343
  config.provider ??= {}
@@ -1328,7 +1403,22 @@ function makePlugin(site) {
1328
1403
  const fit = fitted(body)
1329
1404
  body = normalizeToolChoice(fit.body)
1330
1405
  attend(headers, body, session)
1331
- const res = await fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body })
1406
+ const send = (b) => fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: b })
1407
+ // a model known to turn tool results then the user's message away
1408
+ // gets the empty reply between them up front (see endsAfterTools)
1409
+ const model = chatOf(body)?.model
1410
+ const quiet = endsAfterTools.has(model) ? replyAfterTools(body) : null
1411
+ let res = await send(quiet ?? body)
1412
+ if (res.status === 400 && !quiet) {
1413
+ const text = await res.text()
1414
+ const again = AFTER_TOOLS_REFUSED.test(text) ? replyAfterTools(body) : null
1415
+ if (again) {
1416
+ endsAfterTools.add(model)
1417
+ res = await send(again)
1418
+ } else {
1419
+ res = new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
1420
+ }
1421
+ }
1332
1422
  return res.status >= 400 ? explained(res) : kept(await renamed(res, fit.names))
1333
1423
  },
1334
1424
  }
@@ -1468,4 +1558,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
1468
1558
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
1469
1559
 
1470
1560
  // for tests
1471
- export const _internal = { 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, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf }
1561
+ export const _internal = { 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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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",