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

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 +94 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -228,6 +228,22 @@ some hours of the day (Hy4 preview, free 23:00–8:00) is given as its full
228
228
  price, never as free: magpie keeps the list until it asks again, so a list
229
229
  asked for at night would say "free" all day (#121).
230
230
 
231
+ ### Free models only
232
+
233
+ Each site (WorkBuddy, WorkBuddy AI) has a "Free models only" switch in its
234
+ provider settings in magpie, off by default (#430). When it is on:
235
+
236
+ - the list holds only the models the site serves at no cost now. A model free
237
+ only at night, or one with no price, counts as paid;
238
+ - a request for any other model is answered by the plugin with a 404
239
+ `model_not_found` and never sent, so no credits are spent. magpie rests
240
+ only that model and tries the next provider;
241
+ - if the price list can't be read, nothing is sent (503), since whether the
242
+ model is free is unknown. An API key WorkBuddy lists no prices for lists
243
+ no models.
244
+
245
+ The price list is read again at most once a minute for requests.
246
+
231
247
  ## Credits in magpie
232
248
 
233
249
  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 {
@@ -2027,6 +2033,29 @@ function replyAfterTools(body) {
2027
2033
  return JSON.stringify(b)
2028
2034
  }
2029
2035
 
2036
+ // FREE_ONLY is the switch that keeps an account off credits (#430): its
2037
+ // list holds only the models WorkBuddy serves at no cost (priceOf's free),
2038
+ // and a request for any other is answered here, never sent. A price that
2039
+ // can't be read is not free, and a list that can't be read sends nothing.
2040
+ const FREE_ONLY = "free_only"
2041
+ // PRICE_TTL is how long a list read for that check is used before it is
2042
+ // read again: a promotion that ends stops counting as free within it.
2043
+ const PRICE_TTL = 60_000
2044
+
2045
+ // notFree is the answer to a request the switch keeps from WorkBuddy. Its
2046
+ // words say the model, not the account, is what is turned away, so magpie
2047
+ // rests that model alone and asks another provider of it; they name no
2048
+ // quota or credit, which would rest the whole account as out of credit.
2049
+ function notFree(site, model, why) {
2050
+ const msg = why
2051
+ ? `${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`
2052
+ : `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`
2053
+ return new Response(JSON.stringify({ error: { message: msg, type: "invalid_request_error", code: why ? "free_only_unknown" : "model_not_found" } }), {
2054
+ status: why ? 503 : 404,
2055
+ headers: { "content-type": "application/json", "X-Magpie-Sign-In": "kept" },
2056
+ })
2057
+ }
2058
+
2030
2059
  function makePlugin(site) {
2031
2060
  // the models of this site that refused tool results then the user's
2032
2061
  // message, for this run: their next chats go with replyAfterTools at once
@@ -2034,7 +2063,30 @@ function makePlugin(site) {
2034
2063
  // the system prompts this run WorkBuddy refused as an unapproved channel:
2035
2064
  // their next chats go with systemAsUser at once, not refused first
2036
2065
  const refusedSystems = new Set()
2037
- return async ({ client }) => ({
2066
+ return async ({ client, magpie }) => {
2067
+ // each account's list as last read, for FREE_ONLY: {at, list}
2068
+ const lists = new Map()
2069
+ const listKey = (a) => `${a.uid ?? ""}|${a.enterpriseId ?? ""}|${a.source === "key" ? a.key : ""}`
2070
+ // listed is the account's live list, read again past PRICE_TTL
2071
+ const listed = async (a, fresh = false) => {
2072
+ const k = listKey(a)
2073
+ const was = lists.get(k)
2074
+ if (!fresh && was && Date.now() - was.at < PRICE_TTL) return was.list
2075
+ const list = await liveModels(site, a)
2076
+ lists.set(k, { at: Date.now(), list })
2077
+ if (lists.size > 64) lists.delete(lists.keys().next().value)
2078
+ return list
2079
+ }
2080
+ const freeOnly = () => magpie?.option?.(site.id, FREE_ONLY) === true
2081
+ // onlyFree is a list with the switch on: its free models, or a failure
2082
+ // when there are none to tell (magpie then keeps the list it had)
2083
+ const onlyFree = (ms) => {
2084
+ if (!freeOnly()) return ms
2085
+ const free = ms.filter((m) => m.free)
2086
+ if (!free.length) throw new Error(`${site.name} lists no free model, and "Free models only" is on`)
2087
+ return free
2088
+ }
2089
+ return {
2038
2090
  config: async (config) => {
2039
2091
  config.provider ??= {}
2040
2092
  config.provider[site.id] ??= {}
@@ -2059,23 +2111,34 @@ function makePlugin(site) {
2059
2111
  if (auth?.type === "api") {
2060
2112
  // asked as WorkBuddy's own CLI asks with a key (remoteConfig)
2061
2113
  const a = keyAccount(auth)
2062
- if (!a || !remoteConfig(a.key)) return provider.models
2114
+ if (!a || !remoteConfig(a.key)) {
2115
+ if (freeOnly()) throw new Error(`${site.name} lists no prices for this key, and "Free models only" is on`)
2116
+ return provider.models
2117
+ }
2118
+ let ms
2063
2119
  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 {
2120
+ ms = await listed(a, true)
2121
+ } catch (e) {
2122
+ if (freeOnly()) throw e
2067
2123
  return provider.models
2068
2124
  }
2125
+ if (!ms.length && !freeOnly()) return provider.models
2126
+ return Object.fromEntries(onlyFree(ms).map((m) => [m.id, modelOf(site, provider, m)]))
2069
2127
  }
2070
2128
  if (auth?.type !== "oauth" || !(auth.access || auth.source)) return provider.models
2129
+ let ms
2071
2130
  try {
2072
2131
  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 {
2132
+ ms = await listed(a, true)
2133
+ } catch (e) {
2134
+ // with the switch on, a list without prices would offer models
2135
+ // that cost credits: the failure goes up, and magpie keeps the
2136
+ // list it had
2137
+ if (freeOnly()) throw e
2077
2138
  return provider.models
2078
2139
  }
2140
+ if (!ms.length && !freeOnly()) return provider.models
2141
+ return Object.fromEntries(onlyFree(ms).map((m) => [m.id, modelOf(site, provider, m)]))
2079
2142
  },
2080
2143
  },
2081
2144
  auth: {
@@ -2087,6 +2150,13 @@ function makePlugin(site) {
2087
2150
  // and is renewed as before.
2088
2151
  refreshLead: LEAD_MS,
2089
2152
  refresh: (auth) => refreshed(site, auth),
2153
+ options: [
2154
+ {
2155
+ key: FREE_ONLY,
2156
+ label: "Free models only",
2157
+ 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.`,
2158
+ },
2159
+ ],
2090
2160
  async loader(getAuth) {
2091
2161
  const auth = await getAuth()
2092
2162
  if (auth?.type !== "oauth" && auth?.type !== "api") return {}
@@ -2110,6 +2180,18 @@ function makePlugin(site) {
2110
2180
  sign(site, a, headers)
2111
2181
  let body = init?.body
2112
2182
  if (body === undefined && req) body = await req.clone().text()
2183
+ // the switch is checked before anything is sent: a model not
2184
+ // free, or one whose price can't be read, never reaches WorkBuddy
2185
+ if (freeOnly()) {
2186
+ const model = chatOf(typeof body === "string" ? body : bodyString(body))?.model
2187
+ let list
2188
+ try {
2189
+ list = await listed(a)
2190
+ } catch (e) {
2191
+ return notFree(site, model, e?.message ?? String(e))
2192
+ }
2193
+ if (!list.some((m) => m.id === model && m.free)) return notFree(site, model)
2194
+ }
2113
2195
  // An Anthropic request goes as the agent sent it: WorkBuddy
2114
2196
  // serves these models on Messages itself, and the rewrites
2115
2197
  // below (a default system message, the chat endpoint's
@@ -2304,7 +2386,8 @@ function makePlugin(site) {
2304
2386
  },
2305
2387
  ],
2306
2388
  },
2307
- })
2389
+ }
2390
+ }
2308
2391
  }
2309
2392
 
2310
2393
  // REFUSED_HINT is what the user can do about WorkBuddy's "Illegal API
@@ -2409,4 +2492,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2409
2492
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2410
2493
 
2411
2494
  // 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 }
2495
+ 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, 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.38",
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",