@magpie-community/opencode-workbuddy-auth 0.1.37 → 0.1.39

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 +22 -2
  2. package/index.mjs +125 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -164,8 +164,12 @@ refusal rests the account.
164
164
 
165
165
  The loader also translates OpenAI-compatible request spellings WorkBuddy
166
166
  doesn't take: `max_completion_tokens` becomes `max_tokens`, `developer`
167
- messages become `system`, and an object `tool_choice` becomes WorkBuddy's
168
- string form.
167
+ messages become `system`, an object `tool_choice` becomes WorkBuddy's
168
+ string form, and an earlier assistant turn's `reasoning_content` (or
169
+ `reasoning_details` text) goes as `reasoning`, the field WorkBuddy's own
170
+ client sends. WorkBuddy drops `reasoning_content`, so a client such as Grok
171
+ Build that sends it lost its earlier thinking; nothing is folded into the
172
+ message's content.
169
173
 
170
174
  A chat's tools are given shapes WorkBuddy's models take. Without them
171
175
  WorkBuddy AI answers 400 "Invalid request parameters" (11133), DeepSeek's
@@ -228,6 +232,22 @@ some hours of the day (Hy4 preview, free 23:00–8:00) is given as its full
228
232
  price, never as free: magpie keeps the list until it asks again, so a list
229
233
  asked for at night would say "free" all day (#121).
230
234
 
235
+ ### Free models only
236
+
237
+ Each site (WorkBuddy, WorkBuddy AI) has a "Free models only" switch in its
238
+ provider settings in magpie, off by default (#430). When it is on:
239
+
240
+ - the list holds only the models the site serves at no cost now. A model free
241
+ only at night, or one with no price, counts as paid;
242
+ - a request for any other model is answered by the plugin with a 404
243
+ `model_not_found` and never sent, so no credits are spent. magpie rests
244
+ only that model and tries the next provider;
245
+ - if the price list can't be read, nothing is sent (503), since whether the
246
+ model is free is unknown. An API key WorkBuddy lists no prices for lists
247
+ no models.
248
+
249
+ The price list is read again at most once a minute for requests.
250
+
231
251
  ## Credits in magpie
232
252
 
233
253
  magpie's usage card shows the account's credits from the resource summary.
package/index.mjs CHANGED
@@ -592,6 +592,12 @@ function withoutSignedThinking(body) {
592
592
 
593
593
  // chatOf is a chat's body as the object it is, null for one that isn't: a
594
594
  // body that isn't text (bytes), or isn't JSON at all.
595
+ // bodyString is a body given as bytes, as text; anything else is null.
596
+ function bodyString(body) {
597
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return new TextDecoder().decode(body)
598
+ return null
599
+ }
600
+
595
601
  function chatOf(body) {
596
602
  if (typeof body !== "string") return null
597
603
  try {
@@ -699,6 +705,36 @@ function unflag(messages) {
699
705
  // OpenAI-compatible client normally uses max_completion_tokens and may call a
700
706
  // reasoning prompt developer; WorkBuddy's endpoint takes max_tokens and
701
707
  // system instead.
708
+ // An earlier turn's thinking goes back as `reasoning`, the field WorkBuddy's
709
+ // own client (CodeBuddy's agents converter) sets on an assistant message.
710
+ // WorkBuddy drops `reasoning_content`, which Grok Build, DeepSeek-style
711
+ // clients and magpie's own replies use: measured 2026-10-27 on
712
+ // www.workbuddy.ai, a codeword in reasoning_content reached none of
713
+ // kimi-k2.6, kimi-k3, glm-5.3, deepseek-v4.1-flash and minimax-m3, while
714
+ // the same words as `reasoning` reached kimi-k2.6 and minimax-m3.
715
+ // OpenRouter-style reasoning_details are read for their text the same way.
716
+ function reasoningOf(m) {
717
+ if (typeof m.reasoning_content === "string" && m.reasoning_content) return m.reasoning_content
718
+ if (Array.isArray(m.reasoning_details)) {
719
+ const text = m.reasoning_details.map((d) => (typeof d?.text === "string" ? d.text : typeof d?.summary === "string" ? d.summary : "")).join("")
720
+ if (text) return text
721
+ }
722
+ return ""
723
+ }
724
+
725
+ function asReasoning(messages) {
726
+ let changed = false
727
+ for (const m of messages) {
728
+ if (m?.role !== "assistant" || (m.reasoning_content === undefined && m.reasoning_details === undefined)) continue
729
+ const text = reasoningOf(m)
730
+ if (text && !(typeof m.reasoning === "string" && m.reasoning)) m.reasoning = text
731
+ delete m.reasoning_content
732
+ delete m.reasoning_details
733
+ changed = true
734
+ }
735
+ return changed
736
+ }
737
+
702
738
  function withSystem(body) {
703
739
  // a body magpie or OpenCode hands over as bytes is read as the text it is
704
740
  if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) body = new TextDecoder().decode(body)
@@ -717,6 +753,7 @@ function withSystem(body) {
717
753
  m.role = "system"
718
754
  changed = true
719
755
  }
756
+ if (asReasoning(b.messages)) changed = true
720
757
  if (b.messages[0]?.role !== "system") {
721
758
  b.messages.unshift({ role: "system", content: "You are a helpful assistant." })
722
759
  changed = true
@@ -2027,6 +2064,29 @@ function replyAfterTools(body) {
2027
2064
  return JSON.stringify(b)
2028
2065
  }
2029
2066
 
2067
+ // FREE_ONLY is the switch that keeps an account off credits (#430): its
2068
+ // list holds only the models WorkBuddy serves at no cost (priceOf's free),
2069
+ // and a request for any other is answered here, never sent. A price that
2070
+ // can't be read is not free, and a list that can't be read sends nothing.
2071
+ const FREE_ONLY = "free_only"
2072
+ // PRICE_TTL is how long a list read for that check is used before it is
2073
+ // read again: a promotion that ends stops counting as free within it.
2074
+ const PRICE_TTL = 60_000
2075
+
2076
+ // notFree is the answer to a request the switch keeps from WorkBuddy. Its
2077
+ // words say the model, not the account, is what is turned away, so magpie
2078
+ // rests that model alone and asks another provider of it; they name no
2079
+ // quota or credit, which would rest the whole account as out of credit.
2080
+ function notFree(site, model, why) {
2081
+ const msg = why
2082
+ ? `${site.name}'s model list couldn't be read (${why}), so whether model ${model || "(none)"} is free is unknown; with "Free models only" on it isn't sent`
2083
+ : `model ${model || "(none)"} is not available while "Free models only" is on: ${site.name} charges for it. Turn the switch off in ${site.name}'s provider settings to use it`
2084
+ return new Response(JSON.stringify({ error: { message: msg, type: "invalid_request_error", code: why ? "free_only_unknown" : "model_not_found" } }), {
2085
+ status: why ? 503 : 404,
2086
+ headers: { "content-type": "application/json", "X-Magpie-Sign-In": "kept" },
2087
+ })
2088
+ }
2089
+
2030
2090
  function makePlugin(site) {
2031
2091
  // the models of this site that refused tool results then the user's
2032
2092
  // message, for this run: their next chats go with replyAfterTools at once
@@ -2034,7 +2094,30 @@ function makePlugin(site) {
2034
2094
  // the system prompts this run WorkBuddy refused as an unapproved channel:
2035
2095
  // their next chats go with systemAsUser at once, not refused first
2036
2096
  const refusedSystems = new Set()
2037
- return async ({ client }) => ({
2097
+ return async ({ client, magpie }) => {
2098
+ // each account's list as last read, for FREE_ONLY: {at, list}
2099
+ const lists = new Map()
2100
+ const listKey = (a) => `${a.uid ?? ""}|${a.enterpriseId ?? ""}|${a.source === "key" ? a.key : ""}`
2101
+ // listed is the account's live list, read again past PRICE_TTL
2102
+ const listed = async (a, fresh = false) => {
2103
+ const k = listKey(a)
2104
+ const was = lists.get(k)
2105
+ if (!fresh && was && Date.now() - was.at < PRICE_TTL) return was.list
2106
+ const list = await liveModels(site, a)
2107
+ lists.set(k, { at: Date.now(), list })
2108
+ if (lists.size > 64) lists.delete(lists.keys().next().value)
2109
+ return list
2110
+ }
2111
+ const freeOnly = () => magpie?.option?.(site.id, FREE_ONLY) === true
2112
+ // onlyFree is a list with the switch on: its free models, or a failure
2113
+ // when there are none to tell (magpie then keeps the list it had)
2114
+ const onlyFree = (ms) => {
2115
+ if (!freeOnly()) return ms
2116
+ const free = ms.filter((m) => m.free)
2117
+ if (!free.length) throw new Error(`${site.name} lists no free model, and "Free models only" is on`)
2118
+ return free
2119
+ }
2120
+ return {
2038
2121
  config: async (config) => {
2039
2122
  config.provider ??= {}
2040
2123
  config.provider[site.id] ??= {}
@@ -2059,23 +2142,34 @@ function makePlugin(site) {
2059
2142
  if (auth?.type === "api") {
2060
2143
  // asked as WorkBuddy's own CLI asks with a key (remoteConfig)
2061
2144
  const a = keyAccount(auth)
2062
- if (!a || !remoteConfig(a.key)) return provider.models
2145
+ if (!a || !remoteConfig(a.key)) {
2146
+ if (freeOnly()) throw new Error(`${site.name} lists no prices for this key, and "Free models only" is on`)
2147
+ return provider.models
2148
+ }
2149
+ let ms
2063
2150
  try {
2064
- const ms = await liveModels(site, a)
2065
- return ms.length ? Object.fromEntries(ms.map((m) => [m.id, modelOf(site, provider, m)])) : provider.models
2066
- } catch {
2151
+ ms = await listed(a, true)
2152
+ } catch (e) {
2153
+ if (freeOnly()) throw e
2067
2154
  return provider.models
2068
2155
  }
2156
+ if (!ms.length && !freeOnly()) return provider.models
2157
+ return Object.fromEntries(onlyFree(ms).map((m) => [m.id, modelOf(site, provider, m)]))
2069
2158
  }
2070
2159
  if (auth?.type !== "oauth" || !(auth.access || auth.source)) return provider.models
2160
+ let ms
2071
2161
  try {
2072
2162
  const a = await current(site, client, auth)
2073
- const ms = await liveModels(site, a)
2074
- if (!ms.length) return provider.models
2075
- return Object.fromEntries(ms.map((m) => [m.id, modelOf(site, provider, m)]))
2076
- } catch {
2163
+ ms = await listed(a, true)
2164
+ } catch (e) {
2165
+ // with the switch on, a list without prices would offer models
2166
+ // that cost credits: the failure goes up, and magpie keeps the
2167
+ // list it had
2168
+ if (freeOnly()) throw e
2077
2169
  return provider.models
2078
2170
  }
2171
+ if (!ms.length && !freeOnly()) return provider.models
2172
+ return Object.fromEntries(onlyFree(ms).map((m) => [m.id, modelOf(site, provider, m)]))
2079
2173
  },
2080
2174
  },
2081
2175
  auth: {
@@ -2087,6 +2181,13 @@ function makePlugin(site) {
2087
2181
  // and is renewed as before.
2088
2182
  refreshLead: LEAD_MS,
2089
2183
  refresh: (auth) => refreshed(site, auth),
2184
+ options: [
2185
+ {
2186
+ key: FREE_ONLY,
2187
+ label: "Free models only",
2188
+ description: `Lists only the models ${site.name} serves at no cost, and turns away a request for any other here instead of sending it, so no credits are spent. A model free only at night counts as paid.`,
2189
+ },
2190
+ ],
2090
2191
  async loader(getAuth) {
2091
2192
  const auth = await getAuth()
2092
2193
  if (auth?.type !== "oauth" && auth?.type !== "api") return {}
@@ -2110,6 +2211,18 @@ function makePlugin(site) {
2110
2211
  sign(site, a, headers)
2111
2212
  let body = init?.body
2112
2213
  if (body === undefined && req) body = await req.clone().text()
2214
+ // the switch is checked before anything is sent: a model not
2215
+ // free, or one whose price can't be read, never reaches WorkBuddy
2216
+ if (freeOnly()) {
2217
+ const model = chatOf(typeof body === "string" ? body : bodyString(body))?.model
2218
+ let list
2219
+ try {
2220
+ list = await listed(a)
2221
+ } catch (e) {
2222
+ return notFree(site, model, e?.message ?? String(e))
2223
+ }
2224
+ if (!list.some((m) => m.id === model && m.free)) return notFree(site, model)
2225
+ }
2113
2226
  // An Anthropic request goes as the agent sent it: WorkBuddy
2114
2227
  // serves these models on Messages itself, and the rewrites
2115
2228
  // below (a default system message, the chat endpoint's
@@ -2304,7 +2417,8 @@ function makePlugin(site) {
2304
2417
  },
2305
2418
  ],
2306
2419
  },
2307
- })
2420
+ }
2421
+ }
2308
2422
  }
2309
2423
 
2310
2424
  // REFUSED_HINT is what the user can do about WorkBuddy's "Illegal API
@@ -2409,4 +2523,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2409
2523
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2410
2524
 
2411
2525
  // for tests
2412
- export const _internal = { keyAccount, remoteConfig, cycleOf, 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 }
2526
+ export const _internal = { FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, 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, 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.37",
3
+ "version": "0.1.39",
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",