@magpie-community/opencode-workbuddy-auth 0.1.20 → 0.1.22

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 +15 -0
  2. package/index.mjs +187 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -95,6 +95,14 @@ models to each shape below and Kimi's, GPT's, Gemini's and GLM's to some:
95
95
  `allOf`) at the root are an object, the union's fields merged;
96
96
  - a `pattern`'s `\0` is `\u0000`, which matches the same;
97
97
  - a tuple's `items` (a list) is one schema any of them fits;
98
+ - an `enum` with no `type` takes its values' type (or that and `"null"`),
99
+ and an `enum` of several types is an `anyOf` of one typed `enum` for
100
+ each, at any depth; an empty `enum` is left out (Kimi and DeepSeek, an
101
+ MCP server's tools, #122);
102
+ - `true` and `false` where a schema goes are `{}` and `{"not": {}}`
103
+ (`additionalProperties` is left); a `required` that isn't a list, and a
104
+ `type` JSON Schema doesn't name, are left out, and `"String"` is
105
+ `"string"`;
98
106
  - a tool's name with a character other than letters, digits, `_` and `-`
99
107
  (`.`, `:`, `/`, a space, CJK) is sent with `_` there, and the model's
100
108
  calls come back under the tool's own name.
@@ -134,6 +142,13 @@ is used.
134
142
 
135
143
  A model's reasoning levels are its variants (`reasoningEffort`).
136
144
 
145
+ A model's price is its `credits` ("x0.29") and the config's
146
+ `modelPromotions`. A discount for whole days (Step-5-Preview's 独家限免) is
147
+ the price now, with the price before it (`rateWas`). A price that holds only
148
+ some hours of the day (Hy4 preview, free 23:00–8:00) is given as its full
149
+ price, never as free: magpie keeps the list until it asks again, so a list
150
+ asked for at night would say "free" all day (#121).
151
+
137
152
  ## Credits in magpie
138
153
 
139
154
  magpie's usage card shows the account's credits from the resource summary.
package/index.mjs CHANGED
@@ -636,6 +636,139 @@ function fitSchema(s, depth = 0) {
636
636
  return changed
637
637
  }
638
638
 
639
+ // Kimi (kimi-k3-1) answers 11133 too, to the tools of an MCP server or a
640
+ // plugin (clearmann, #122). Tried on the owner's account, 2026-10-14, each
641
+ // alone in an otherwise plain chat: Kimi turns away an enum with no type
642
+ // (["a","b"] as much as [1,"a",null]), an empty enum, an enum whose type
643
+ // lists two types besides null, a schema that is true or false where a
644
+ // schema goes (properties.x: true, items: true), a required that isn't a
645
+ // list, and a type it doesn't know ("any", "String"). It takes an enum
646
+ // typed T or [T, "null"], an anyOf of such enums, {"not": {}}, {}, and
647
+ // additionalProperties: true or false. fitShape gives each the shape it
648
+ // takes, meaning the same: none of it narrows or widens what a call may
649
+ // hold, except an empty enum (which nothing fits) is left out.
650
+ const TYPES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"])
651
+
652
+ // SUB_ONE are the keywords whose value is a schema, SUB_LIST a list of
653
+ // schemas, SUB_MAP a map of names to schemas. additionalProperties and
654
+ // unevaluatedProperties are left as they are when true or false.
655
+ const SUB_ONE = ["items", "additionalItems", "unevaluatedItems", "contains", "not", "if", "then", "else", "propertyNames", "additionalProperties", "unevaluatedProperties"]
656
+ const SUB_LIST = ["anyOf", "oneOf", "allOf", "prefixItems"]
657
+ const SUB_MAP = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]
658
+ const BOOL_OK = new Set(["additionalProperties", "unevaluatedProperties"])
659
+
660
+ // valueType is the JSON Schema type of an enum's value.
661
+ function valueType(v) {
662
+ if (v === null) return "null"
663
+ if (Array.isArray(v)) return "array"
664
+ if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number"
665
+ return typeof v
666
+ }
667
+
668
+ // fitEnum gives s's enum a type: the one its values have, or that and
669
+ // "null"; values of several types are an anyOf of an enum for each. It
670
+ // reports whether it changed anything.
671
+ function fitEnum(s) {
672
+ if (!("enum" in s)) return false
673
+ if (!Array.isArray(s.enum) || s.enum.length === 0) {
674
+ delete s.enum
675
+ return true
676
+ }
677
+ const listed = Array.isArray(s.type) ? s.type : typeof s.type === "string" ? [s.type] : []
678
+ if (listed.filter((t) => t !== "null").length === 1) return false
679
+ const byType = new Map()
680
+ for (const v of s.enum) {
681
+ const t = valueType(v)
682
+ if (!byType.has(t)) byType.set(t, [])
683
+ byType.get(t).push(v)
684
+ }
685
+ if (byType.has("integer") && byType.has("number")) {
686
+ byType.get("number").push(...byType.get("integer"))
687
+ byType.delete("integer")
688
+ }
689
+ const hasNull = byType.delete("null")
690
+ if (byType.size <= 1) {
691
+ const [t] = byType.keys()
692
+ const want = t === undefined ? "null" : hasNull ? [t, "null"] : t
693
+ if (JSON.stringify(want) === JSON.stringify(s.type)) return false
694
+ s.type = want
695
+ return true
696
+ }
697
+ const split = [...byType].map(([t, vs]) => ({ type: t, enum: vs }))
698
+ if (hasNull) split.push({ type: "null" })
699
+ delete s.enum
700
+ delete s.type
701
+ if (Array.isArray(s.anyOf)) s.allOf = [...(Array.isArray(s.allOf) ? s.allOf : []), { anyOf: split }]
702
+ else s.anyOf = split
703
+ return true
704
+ }
705
+
706
+ // subSchema is v, a schema found under key, as an object Kimi takes: true
707
+ // is {}, false is {"not": {}}.
708
+ function subSchema(v, key) {
709
+ if (typeof v !== "boolean" || BOOL_OK.has(key)) return v
710
+ return v ? {} : { not: {} }
711
+ }
712
+
713
+ // fitShape mends s, a schema, and each schema under it, in place (see
714
+ // TYPES above); a name under properties is never read as a keyword. It
715
+ // reports whether it changed anything.
716
+ function fitShape(s, depth = 0) {
717
+ if (!s || typeof s !== "object" || Array.isArray(s) || depth > 64) return false
718
+ let changed = false
719
+ if ("type" in s) {
720
+ const list = (Array.isArray(s.type) ? s.type : [s.type]).map((t) => (typeof t === "string" ? t.toLowerCase() : t)).filter((t) => TYPES.has(t))
721
+ const uniq = [...new Set(list)]
722
+ const want = uniq.length === 0 ? undefined : uniq.length === 1 ? uniq[0] : uniq
723
+ if (JSON.stringify(want) !== JSON.stringify(s.type)) {
724
+ if (want === undefined) delete s.type
725
+ else s.type = want
726
+ changed = true
727
+ }
728
+ }
729
+ if ("required" in s && !Array.isArray(s.required)) {
730
+ delete s.required
731
+ changed = true
732
+ } else if (Array.isArray(s.required) && s.required.some((k) => typeof k !== "string")) {
733
+ s.required = s.required.filter((k) => typeof k === "string")
734
+ changed = true
735
+ }
736
+ if (fitEnum(s)) changed = true
737
+ for (const k of SUB_ONE) {
738
+ if (!(k in s)) continue
739
+ const v = subSchema(s[k], k)
740
+ if (v !== s[k]) {
741
+ s[k] = v
742
+ changed = true
743
+ }
744
+ if (fitShape(v, depth + 1)) changed = true
745
+ }
746
+ for (const k of SUB_LIST) {
747
+ if (!Array.isArray(s[k])) continue
748
+ s[k].forEach((v0, i) => {
749
+ const v = subSchema(v0, k)
750
+ if (v !== v0) {
751
+ s[k][i] = v
752
+ changed = true
753
+ }
754
+ if (fitShape(v, depth + 1)) changed = true
755
+ })
756
+ }
757
+ for (const k of SUB_MAP) {
758
+ const m = s[k]
759
+ if (!m || typeof m !== "object" || Array.isArray(m)) continue
760
+ for (const [name, v0] of Object.entries(m)) {
761
+ const v = subSchema(v0, k)
762
+ if (v !== v0) {
763
+ m[name] = v
764
+ changed = true
765
+ }
766
+ if (fitShape(v, depth + 1)) changed = true
767
+ }
768
+ }
769
+ return changed
770
+ }
771
+
639
772
  // localRef is the schema a local $ref in root names ("#/$defs/x",
640
773
  // "#/definitions/x"), or s itself.
641
774
  function localRef(root, s) {
@@ -698,8 +831,8 @@ function objectRoot(ps, root = ps, depth = 0) {
698
831
  }
699
832
 
700
833
  // fitTools mends b's function tools in place: their parameters (fitSchema,
701
- // objectRoot) and names. It reports whether it changed anything, and the
702
- // names it gave, each to the name the client knows (ours), so that the
834
+ // fitShape, objectRoot) and names. It reports whether it changed anything,
835
+ // and the names it gave, each to the name the client knows (ours), so that the
703
836
  // model's calls are named back. A call in the chat's history is named as
704
837
  // its tool now is.
705
838
  function fitTools(b) {
@@ -729,6 +862,7 @@ function fitTools(b) {
729
862
  continue
730
863
  }
731
864
  if (fitSchema(fn.parameters)) changed = true
865
+ if (fitShape(fn.parameters)) changed = true
732
866
  if (objectRoot(fn.parameters)) changed = true
733
867
  }
734
868
  if (theirs.size && Array.isArray(b.messages)) {
@@ -1187,6 +1321,8 @@ function modelOf(site, provider, m) {
1187
1321
  free: !!m.free,
1188
1322
  // magpie's own: the credits a request costs, as a multiple (0: none)
1189
1323
  rate: m.rate ?? 0,
1324
+ // magpie's own: the price before a discount running now (0: none)
1325
+ rateWas: m.rateWas ?? 0,
1190
1326
  }
1191
1327
  }
1192
1328
 
@@ -1210,6 +1346,53 @@ function freeCredits(c) {
1210
1346
  return creditsOf(c) === 0
1211
1347
  }
1212
1348
 
1349
+ // discountOf reads a promotion's price: its discountedCredits ("0.50x",
1350
+ // "0.00x"), else the factor times the model's own credits.
1351
+ function discountOf(p, base) {
1352
+ const said = creditsOf(String(p.discount?.discountedCredits ?? "").trim().replace(/x$/i, ""))
1353
+ if (said !== undefined) return said
1354
+ const f = Number(p.discount?.factor)
1355
+ return Number.isFinite(f) && f >= 0 && base !== undefined ? base * f : undefined
1356
+ }
1357
+
1358
+ // running says whether a promotion is on at now: enabled, and inside its
1359
+ // validFrom..validUntil. Its daily hours are not looked at here.
1360
+ function running(p, now) {
1361
+ if (!p || p.enabled === false) return false
1362
+ const from = Date.parse(p.schedule?.validFrom ?? "")
1363
+ const until = Date.parse(p.schedule?.validUntil ?? "")
1364
+ return !(from > now) && !(until <= now)
1365
+ }
1366
+
1367
+ // priceOf is a model's price as magpie keeps it, from its credits and the
1368
+ // config's modelPromotions (what WorkBuddy's picker shows as 限时免费,
1369
+ // 夜间免费, 夜间折扣). magpie keeps the list it is given until it asks
1370
+ // again, so a price that holds only some hours of the day (Hy4 preview,
1371
+ // free 23:00–8:00 only) is given as its full price, never as free: a list
1372
+ // asked for at night would otherwise say "free" all day (#121). A discount
1373
+ // for whole days (Step-5-Preview's 独家限免) is the price now, with the
1374
+ // price before it as rateWas.
1375
+ function priceOf(d, promotions, now = Date.now()) {
1376
+ const base = creditsOf(d.credits)
1377
+ const mine = (Array.isArray(promotions) ? promotions : []).filter(
1378
+ (p) => p?.kind === "discount" && Array.isArray(p.modelIds) && p.modelIds.includes(d.id) && running(p, now),
1379
+ )
1380
+ if (mine.some((p) => Array.isArray(p.schedule?.daily) && p.schedule.daily.length)) {
1381
+ // by the hour: the full price, which the credits are unless a night
1382
+ // discount already took them to its own price
1383
+ const night = mine.filter((p) => p.discount && Array.isArray(p.schedule?.daily) && p.schedule.daily.length)
1384
+ if (base === undefined || (base === 0 && night.some((p) => discountOf(p, base) === 0))) return { free: false }
1385
+ return { free: false, rate: base }
1386
+ }
1387
+ const off = mine.filter((p) => p.discount).map((p) => discountOf(p, base)).filter((r) => r !== undefined)
1388
+ if (off.length) {
1389
+ const r = Math.min(...off)
1390
+ return { free: r === 0, rate: r, ...(base !== undefined && base > r ? { rateWas: base } : {}) }
1391
+ }
1392
+ if (base === undefined) return {}
1393
+ return { free: base === 0, rate: base }
1394
+ }
1395
+
1213
1396
  async function liveModels(site, a) {
1214
1397
  const headers = new Headers()
1215
1398
  sign(site, a, headers)
@@ -1231,11 +1414,7 @@ async function liveModels(site, a) {
1231
1414
  m.context = d.maxInputTokens
1232
1415
  m.output = d.maxOutputTokens
1233
1416
  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
- }
1417
+ Object.assign(m, priceOf(d, cfg.modelPromotions))
1239
1418
  const es = d.reasoning?.supportedEfforts ?? []
1240
1419
  if (es.length) {
1241
1420
  const canOff = d.reasoning?.canDisableThinking
@@ -1513,4 +1692,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
1513
1692
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
1514
1693
 
1515
1694
  // for tests
1516
- 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, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf }
1695
+ export const _internal = { 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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",