@magpie-community/opencode-workbuddy-auth 0.2.16 → 0.2.18

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.
package/index.mjs CHANGED
@@ -2884,9 +2884,30 @@ function pinning() {
2884
2884
  // spent and extra ones are left.
2885
2885
  function usageOf(sum, plan, site) {
2886
2886
  const out = { plan: plan || (sum?.IsPaidUser ? "Pro" : "Free plan"), windows: [] }
2887
+ const { plan: mine, extra, left, bags } = kindsOf(sum, site)
2888
+ if (mine.total > 0 && extra.total > 0) {
2889
+ const credits = creditWindow("Credits", mine), more = creditWindow("Extra credits", extra)
2890
+ if (mine.used >= mine.total && extra.used < extra.total) credits.aside = true
2891
+ else more.aside = true
2892
+ out.windows.push(credits, more)
2893
+ } else {
2894
+ const total = mine.total + extra.total, used = mine.used + extra.used
2895
+ if (total > 0) out.windows.push(creditWindow("Credits", { used, total }))
2896
+ }
2897
+ const total = mine.total + extra.total
2898
+ if (bags > 0 && (total === 0 || left > 0)) out.windows.push({ name: "Prepaid credits", balance: left, display: `${compact(left)} credits`, unit: "credits" })
2899
+ return out
2900
+ }
2901
+
2902
+ // creditWindow is a window of credits used of a total.
2903
+ const creditWindow = (name, { used, total }) => ({ name, used: (100 * used) / total, display: `${compact(used)} / ${compact(total)}`, amount: used, limit: total, unit: "credits" })
2904
+
2905
+ // kindsOf is the summary's credits, the plan's and the extra ones (see
2906
+ // usageOf), and what is left in its packages with no total (a prepaid
2907
+ // balance).
2908
+ function kindsOf(sum, site) {
2887
2909
  const ofPlan = planCodes(sum, site)
2888
- const kinds = { plan: { total: 0, used: 0 }, extra: { total: 0, used: 0 } }
2889
- let left = 0, bags = 0
2910
+ const kinds = { plan: { total: 0, used: 0 }, extra: { total: 0, used: 0 }, left: 0, bags: 0 }
2890
2911
  const hasTotal = (p) => p?.CycleTotalCapacity != null && p.CycleTotalCapacity !== ""
2891
2912
  const add = (k, p) => ((k.total += capacity(p.CycleTotalCapacity)), (k.used += capacity(p?.CycleUsedCapacity)))
2892
2913
  const groups = (Array.isArray(sum?.CreditGroups) ? sum.CreditGroups : []).filter(hasTotal)
@@ -2895,24 +2916,45 @@ function usageOf(sum, plan, site) {
2895
2916
  if (hasTotal(p)) {
2896
2917
  if (!groups.length) add(kinds[ofPlan(p?.PackageCode) ? "plan" : "extra"], p)
2897
2918
  } else if (p?.CycleRemainCapacity != null && p.CycleRemainCapacity !== "") {
2898
- left += capacity(p.CycleRemainCapacity)
2899
- bags++
2919
+ kinds.left += capacity(p.CycleRemainCapacity)
2920
+ kinds.bags++
2900
2921
  }
2901
2922
  }
2902
- const window = (name, { used, total }) => ({ name, used: (100 * used) / total, display: `${compact(used)} / ${compact(total)}`, amount: used, limit: total, unit: "credits" })
2903
- const { plan: mine, extra } = kinds
2904
- if (mine.total > 0 && extra.total > 0) {
2905
- const credits = window("Credits", mine), more = window("Extra credits", extra)
2906
- if (mine.used >= mine.total && extra.used < extra.total) credits.aside = true
2907
- else more.aside = true
2908
- out.windows.push(credits, more)
2909
- } else {
2910
- const total = mine.total + extra.total, used = mine.used + extra.used
2911
- if (total > 0) out.windows.push(window("Credits", { used, total }))
2923
+ return kinds
2924
+ }
2925
+
2926
+ // listedExtra is the extra credits of the detail lists (packagesOf) that
2927
+ // the summary doesn't list at all, as WorkBuddy's own usage panel counts
2928
+ // them (jiabubu on Discord, magpie 0.1.2774, a China-site free account:
2929
+ // magpie's card read "0 / 500" alone, while the panel shows the extra
2930
+ // kinds beside the plan's). The panel's groups come from the detail
2931
+ // lists, not the summary (buildCreditGroups over fetchUserResources' paid
2932
+ // and free lists), and leave out a package past its end; a slice package
2933
+ // (CapacityType 4) counts the day's slice, as resolveResourceCapacity
2934
+ // does. null when there are none with credits.
2935
+ function listedExtra(sum, packages, site, now = Date.now()) {
2936
+ const summed = new Set((sum?.Packages ?? []).map((p) => p?.PackageCode))
2937
+ const codes = site?.ai ? [...EXTRA_CODES, ...EXTRA_AI] : EXTRA_CODES
2938
+ let total = 0, left = 0, at = 0, lapsing = 0
2939
+ for (const p of packages ?? []) {
2940
+ const code = p?.PackageCode
2941
+ if (!codes.includes(code) || summed.has(code)) continue
2942
+ const end = endOf(p)
2943
+ if (end > 0 && end <= now) continue
2944
+ const slice = String(p?.CapacityType) === SLICE ? p?.SlicePeriodUsageDetails?.[0] : null
2945
+ const of = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : 0 }
2946
+ const t = of(slice ? slice.SlicePeriodCapacitySizePrecise : p?.CycleCapacitySizePrecise)
2947
+ const l = Math.min(t, of(slice ? slice.SlicePeriodCapacityRemainPrecise : p?.CycleCapacityRemainPrecise))
2948
+ if (!(t > 0)) continue
2949
+ total += t
2950
+ left += l
2951
+ if (l > 0 && end > 0 && !slice) {
2952
+ if (!at || end < at) at = end, lapsing = l
2953
+ else if (end === at) lapsing += l
2954
+ }
2912
2955
  }
2913
- const total = mine.total + extra.total
2914
- if (bags > 0 && (total === 0 || left > 0)) out.windows.push({ name: "Prepaid credits", balance: left, display: `${compact(left)} credits`, unit: "credits" })
2915
- return out
2956
+ if (!(total > 0)) return null
2957
+ return { total, used: total - left, ...(at ? { runsOutAt: new Date(at).toISOString(), runsOut: lapsing } : {}) }
2916
2958
  }
2917
2959
 
2918
2960
  // enterpriseUsageOf is an enterprise (team) account's credits, from
@@ -2980,6 +3022,16 @@ const PLAN_CODES = [CODE("002", "AkiJS3ZHF5"), CODE("005", "maRGyrHhw1"), CODE("
2980
3022
  CODE("039", "KRcQj7wUat"), CODE("040", "mi9rCYg46x")]
2981
3023
  const FREE_MON = CODE("008", "cfWoLwvjU4")
2982
3024
 
3025
+ // EXTRA_CODES are the kinds of extra credits WorkBuddy's own usage panel
3026
+ // shows beside the plan's base (WorkBuddy 5.5.6, the China build's
3027
+ // resolveCreditGroup): the plan's bonus (bonus28, bonusIntl), top-ups
3028
+ // (extra 009, extra38 038, extraIntl 036) and platform rewards (activity
3029
+ // 007, bonus29, bonus30), and on WorkBuddy AI the free month (008) and
3030
+ // gift (006) too. The China build puts gift in no group, so it shows none.
3031
+ const EXTRA_CODES = [CODE("028", "NtpWi0jzXs"), CODE("037", "WxOD3MpI2o"), CODE("009", "0XmEQc2xOf"), CODE("038", "OhvqZtiPKr"),
3032
+ CODE("036", "lupO5WgNdG"), CODE("007", "nzdH5h4Nl0"), CODE("029", "6wCGEWquYy"), CODE("030", "BjSt89qTvr")]
3033
+ const EXTRA_AI = [FREE_MON, CODE("006", "DbXS0lrypC")]
3034
+
2983
3035
  // planCodes says whether a package of the summary is the plan's own: one
2984
3036
  // of PLAN_CODES on that site, or the subscription the summary names.
2985
3037
  function planCodes(sum, site) {
@@ -3816,6 +3868,18 @@ function makePlugin(site) {
3816
3868
  Object.assign(stops, runsOutOf(sum, packages) ?? {})
3817
3869
  const aside = extra ? (stops === credits ? extra : credits) : null
3818
3870
  if (aside) Object.assign(aside, runsOutOf(sum, packages, Date.now(), (code) => ofPlan(code) === (aside === credits)) ?? {})
3871
+ // the summary of the plan's credits alone: the extra ones
3872
+ // only the detail lists have (listedExtra) are their window,
3873
+ // always set aside. Whether WorkBuddy spends them once the
3874
+ // plan's are gone isn't seen, so the plan's window stops the
3875
+ // account as it did before.
3876
+ const kinds = !extra && kindsOf(sum, site)
3877
+ const more = kinds && kinds.plan.total > 0 && kinds.extra.total === 0 ? listedExtra(sum, packages, site) : null
3878
+ if (more) {
3879
+ const { runsOutAt, runsOut, ...counts } = more
3880
+ const w = { ...creditWindow("Extra credits", counts), aside: true, ...(runsOutAt ? { runsOutAt, runsOut } : {}) }
3881
+ out.windows.splice(out.windows.indexOf(credits) + 1, 0, w)
3882
+ }
3819
3883
  } catch {}
3820
3884
  }
3821
3885
  await spareFree(site, a, out.windows)
@@ -3973,4 +4037,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
3973
4037
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
3974
4038
 
3975
4039
  // for tests
3976
- export const _internal = { planCodes, idFor, OFF_TAKEN, replyAfterToolsOn, withDefaultEffort, SMALL_BUDGET, 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, pinning, rowTime, rowAccount, ofAccount, windowOffset, windowReset, FREE_WINDOW_MODELS, FREE_WINDOW_CN, FREE_WINDOW_AI, FREE_WINDOW_SPAN, FREE_WINDOW_GAP, FREE_WINDOW_LEAD, checkinOf, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
4040
+ export const _internal = { planCodes, listedExtra, idFor, OFF_TAKEN, replyAfterToolsOn, withDefaultEffort, SMALL_BUDGET, 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, pinning, rowTime, rowAccount, ofAccount, windowOffset, windowReset, FREE_WINDOW_MODELS, FREE_WINDOW_CN, FREE_WINDOW_AI, FREE_WINDOW_SPAN, FREE_WINDOW_GAP, FREE_WINDOW_LEAD, checkinOf, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
package/opencode.mjs ADDED
@@ -0,0 +1,5 @@
1
+ // The package's OpenCode 1.x plugins, as OpenCode 1.x and magpie's host
2
+ // load them: its plugin functions and nothing else (1.x refuses a
3
+ // package whose exports include anything but plugins). OpenCode 2.x
4
+ // loads opencode2.mjs, which runs these.
5
+ export { WorkBuddyAIAuthPlugin, WorkBuddyAuthPlugin } from "./index.mjs"
package/opencode2.mjs ADDED
@@ -0,0 +1,594 @@
1
+ // The OpenCode 2.x face of a package whose plugins are written for
2
+ // OpenCode 1.x (and magpie's plugin host).
3
+ //
4
+ // OpenCode 2.x loads a package's `exports["."]` and wants its default to be
5
+ // {id, setup(ctx)}; it has no fallback for a 1.x plugin's named functions
6
+ // (plugins#657). This file is that default. It runs each 1.x plugin the
7
+ // package's opencode.mjs exports and hands 2.x what the plugin's hooks say:
8
+ //
9
+ // auth.methods → the integration's sign-in methods
10
+ // config() → the provider, its package, base URL and models
11
+ // provider.models → the signed-in account's own model list
12
+ // auth.loader → each request's base URL, key, headers and fetch
13
+ //
14
+ // What 2.x has no place for is left out: chat.headers and chat.params
15
+ // (2.x's sdk hook doesn't carry the session), magpie's usage and checkin,
16
+ // tools and events.
17
+ //
18
+ // scripts/opencode2.mjs is the one copy that is edited. Every package that
19
+ // has it carries the same bytes as packages/<name>/opencode2.mjs, and
20
+ // scripts/check.mjs fails when a copy differs.
21
+
22
+ import { readFileSync } from "node:fs"
23
+ import * as v1 from "./opencode.mjs"
24
+
25
+ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"))
26
+
27
+ // The key the SDK is made with: the account's own goes on each request in
28
+ // its place (see wrapFetch), so a key read when the SDK was made can't go
29
+ // out after the account changed.
30
+ const PLACEHOLDER = "opencode2-v1-key"
31
+
32
+ // The SDK package a 1.x npm name runs as on 2.x. A version keeps 2.x from
33
+ // swapping @ai-sdk/* for its native providers, which never call the sdk
34
+ // hook below; openai-compatible's is the one 2.x ships, the others are AI
35
+ // SDK v6's dist-tag, the line 2.x's bridge runs.
36
+ export function packageOf(npm) {
37
+ const name = npm || "@ai-sdk/openai-compatible"
38
+ if (name === "@ai-sdk/openai-compatible") return "aisdk:@ai-sdk/openai-compatible@2.0.41"
39
+ if (/^@ai-sdk\/[^/@]+$/.test(name)) return `aisdk:${name}@ai-v6`
40
+ return `aisdk:${name}`
41
+ }
42
+
43
+ // The plugins a 1.x module exports, as magpie's host and OpenCode 1.x read
44
+ // them: a default {server} is the one plugin, else every function export
45
+ // and every {server} object.
46
+ export function pluginsOf(mod) {
47
+ const d = mod?.default
48
+ if (d && typeof d === "object" && ("server" in d || "id" in d || "tui" in d))
49
+ return typeof d.server === "function" ? [d.server] : []
50
+ const out = []
51
+ for (const value of Object.values(mod ?? {})) {
52
+ const fn = typeof value === "function" ? value : value && typeof value === "object" && typeof value.server === "function" ? value.server : null
53
+ if (fn && !out.includes(fn)) out.push(fn)
54
+ }
55
+ return out
56
+ }
57
+
58
+ const int = (n, fallback = 0) => {
59
+ const v = Math.trunc(Number(n))
60
+ return Number.isFinite(v) && v >= 0 ? v : fallback
61
+ }
62
+ const money = (n) => (Number.isFinite(Number(n)) && Number(n) >= 0 ? Number(n) : 0)
63
+ const record = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {})
64
+ const json = (v) => {
65
+ try {
66
+ return JSON.parse(JSON.stringify(v ?? null))
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+ const strings = (v) => Object.fromEntries(Object.entries(record(v)).filter(([, s]) => typeof s === "string"))
72
+ const nonEmpty = (o) => (Object.keys(o).length ? o : undefined)
73
+
74
+ // connKey names a connection the way 2.x does.
75
+ const connKey = (c) => (!c ? "" : c.type === "credential" ? `credential:${c.id}` : `env:${c.name}`)
76
+
77
+ // ---- sign-in ------------------------------------------------------------
78
+
79
+ // fields is a 1.x method's prompts as a 2.x form.
80
+ export function fieldsOf(prompts) {
81
+ const out = []
82
+ for (const p of prompts ?? []) {
83
+ if (!p?.key) continue
84
+ const f = { key: String(p.key), type: "string", title: String(p.message ?? p.key) }
85
+ if (p.placeholder) f.placeholder = String(p.placeholder)
86
+ if (p.type === "select" && Array.isArray(p.options))
87
+ f.options = p.options.map((o) => ({ value: String(o.value), label: String(o.label ?? o.value), ...(o.hint ? { description: String(o.hint) } : {}) }))
88
+ // a prompt shown only when another answer says so can't be required
89
+ if (!p.condition) f.required = true
90
+ out.push(f)
91
+ }
92
+ return out.length ? out : undefined
93
+ }
94
+
95
+ // v1Saved is what a 1.x sign-in's success is saved as, the way 1.x writes
96
+ // auth.json: the result without its type and provider.
97
+ function v1Saved(r) {
98
+ const { type: _t, provider: _p, ...rest } = r
99
+ if ("key" in r) return { type: "api", ...rest }
100
+ return { type: "oauth", ...rest }
101
+ }
102
+
103
+ // credentialOf is a 1.x sign-in's success as a 2.x credential: OAuth for
104
+ // both kinds, the 1.x auth kept whole in its metadata. No refresh is
105
+ // registered, so 2.x hands the credential over as it is and the plugin's
106
+ // own loader renews it, as it does on 1.x.
107
+ export function credentialOf(methodID, r) {
108
+ if (!r || r.type !== "success") throw new Error(r?.error || r?.message || "the sign-in failed")
109
+ const saved = json(v1Saved(r))
110
+ if (saved.type === "api")
111
+ return { type: "oauth", methodID, access: String(saved.key ?? ""), refresh: "", expires: 0, metadata: { v1: saved } }
112
+ return {
113
+ type: "oauth",
114
+ methodID,
115
+ access: String(saved.access ?? ""),
116
+ refresh: String(saved.refresh ?? ""),
117
+ expires: int(saved.expires),
118
+ metadata: { v1: saved },
119
+ }
120
+ }
121
+
122
+ // authOfValue is the 1.x auth a 2.x credential stands for.
123
+ export function authOfValue(value) {
124
+ if (!value) return null
125
+ if (value.type === "oauth") {
126
+ const v1 = record(value.metadata).v1
127
+ if (v1 && typeof v1 === "object") return v1
128
+ return { type: "oauth", access: value.access, refresh: value.refresh, expires: value.expires }
129
+ }
130
+ if (value.type === "key") {
131
+ const meta = value.metadata && Object.keys(value.metadata).length ? { metadata: value.metadata } : {}
132
+ return { type: "api", key: value.key, ...meta }
133
+ }
134
+ return null
135
+ }
136
+
137
+ const fingerprint = (a) => JSON.stringify([a?.type, a?.access ?? a?.key ?? "", a?.refresh ?? ""])
138
+
139
+ // ---- models -------------------------------------------------------------
140
+
141
+ const MODALITIES = ["text", "audio", "image", "video", "pdf"]
142
+
143
+ // internalOf is a config model as 1.x hands it to provider.models: what
144
+ // fromModelsDevModel makes of it, with the config's own variants.
145
+ export function internalOf(providerID, conf, key, m) {
146
+ const mods = record(m.modalities)
147
+ const has = (list, k) => Array.isArray(list) && list.includes(k)
148
+ const cost = record(m.cost)
149
+ return {
150
+ ...m,
151
+ id: key,
152
+ providerID,
153
+ name: m.name ?? key,
154
+ api: { id: m.id ?? key, url: m.provider?.api ?? conf.api ?? conf.options?.baseURL ?? "", npm: m.provider?.npm ?? conf.npm ?? "@ai-sdk/openai-compatible" },
155
+ status: m.status ?? "active",
156
+ headers: m.headers ?? {},
157
+ options: m.options ?? {},
158
+ cost: { input: money(cost.input), output: money(cost.output), cache: { read: money(cost.cache_read), write: money(cost.cache_write) } },
159
+ limit: { context: int(m.limit?.context), input: m.limit?.input, output: int(m.limit?.output) },
160
+ capabilities: {
161
+ temperature: m.temperature ?? false,
162
+ reasoning: m.reasoning ?? false,
163
+ attachment: m.attachment ?? false,
164
+ toolcall: m.tool_call ?? true,
165
+ input: Object.fromEntries(MODALITIES.map((k) => [k, mods.input ? has(mods.input, k) : k === "text"])),
166
+ output: Object.fromEntries(MODALITIES.map((k) => [k, mods.output ? has(mods.output, k) : k === "text"])),
167
+ interleaved: m.interleaved ?? false,
168
+ },
169
+ release_date: m.release_date ?? "",
170
+ variants: record(m.variants),
171
+ }
172
+ }
173
+
174
+ // modelOf is a 1.x model, in config or internal form, as a 2.x Model.Info.
175
+ export function modelOf(providerID, provider, key, m) {
176
+ const caps = record(m.capabilities)
177
+ const listFrom = (obj, list) => {
178
+ if (obj && typeof obj === "object") return MODALITIES.filter((k) => obj[k])
179
+ if (Array.isArray(list)) return list.map(String)
180
+ return ["text"]
181
+ }
182
+ const input = listFrom(caps.input, m.modalities?.input)
183
+ const output = listFrom(caps.output, m.modalities?.output)
184
+ const c = record(m.cost)
185
+ const cache = record(c.cache)
186
+ const npm = m.api?.npm ?? m.provider?.npm
187
+ const url = m.api?.url ?? m.provider?.api
188
+ const settings = { ...record(json(m.options)) }
189
+ if (url && url !== provider.baseURL) settings.baseURL = url
190
+ const variants = Object.entries(record(m.variants))
191
+ .filter(([, v]) => !record(v).disabled)
192
+ .map(([id, v]) => {
193
+ const { disabled: _d, ...rest } = record(json(v))
194
+ return nonEmpty(rest) ? { id, settings: rest } : { id }
195
+ })
196
+ const status = ["alpha", "beta", "deprecated", "active"].includes(m.status) ? m.status : "active"
197
+ const out = {
198
+ id: key,
199
+ modelID: String(m.api?.id ?? m.id ?? key),
200
+ providerID,
201
+ name: String(m.name ?? key),
202
+ capabilities: {
203
+ tools: caps.toolcall ?? m.tool_call ?? true,
204
+ input: input.length ? input : ["text"],
205
+ output: output.length ? output : ["text"],
206
+ ...(typeof (caps.temperature ?? m.temperature) === "boolean" ? { temperature: caps.temperature ?? m.temperature } : {}),
207
+ },
208
+ variants,
209
+ time: { released: Date.parse(m.release_date ?? "") || 0 },
210
+ cost: [{ input: money(c.input), output: money(c.output), cache: { read: money(cache.read ?? c.cache_read), write: money(cache.write ?? c.cache_write) } }],
211
+ status,
212
+ enabled: true,
213
+ limit: { context: int(m.limit?.context, 128000) || 128000, output: int(m.limit?.output, 8192) || 8192 },
214
+ }
215
+ if (m.limit?.input) out.limit.input = int(m.limit.input)
216
+ if (npm && npm !== provider.npm) out.package = packageOf(npm)
217
+ if (nonEmpty(settings)) out.settings = settings
218
+ const headers = strings(m.headers)
219
+ if (nonEmpty(headers)) out.headers = headers
220
+ return out
221
+ }
222
+
223
+ // ---- requests -----------------------------------------------------------
224
+
225
+ const urlOf = (input) => (typeof input === "string" ? input : input instanceof URL ? input.href : input?.url ?? String(input))
226
+
227
+ // wrapFetch sends a request the SDK made from the provider's settings the
228
+ // way 1.x would have sent it with the loader's options: the loader's base
229
+ // URL in place of the configured one, the account's key in place of the
230
+ // placeholder, the loader's headers added, through the loader's fetch.
231
+ export function wrapFetch(next, base, options) {
232
+ return async (input, init = {}) => {
233
+ const o = await options()
234
+ let url = urlOf(input)
235
+ const from = String(base ?? "").replace(/\/+$/, "")
236
+ const to = o.baseURL ? String(o.baseURL).replace(/\/+$/, "") : ""
237
+ if (from && to && from !== to && url.startsWith(from)) url = to + url.slice(from.length)
238
+ const req = input instanceof Request ? input : null
239
+ const headers = new Headers(init.headers ?? req?.headers ?? undefined)
240
+ for (const [k, v] of [...headers.entries()]) {
241
+ if (!v.includes(PLACEHOLDER)) continue
242
+ if (o.key) headers.set(k, v.replaceAll(PLACEHOLDER, o.key))
243
+ else headers.delete(k)
244
+ }
245
+ for (const [k, v] of Object.entries(strings(o.headers))) headers.set(k, v)
246
+ const send = typeof o.fetch === "function" ? o.fetch : next ?? fetch
247
+ if (req && init.body === undefined) {
248
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.text()
249
+ return send(url, { method: req.method, headers, body, signal: init.signal ?? req.signal })
250
+ }
251
+ return send(url, { ...init, headers })
252
+ }
253
+ }
254
+
255
+ // ---- one plugin ---------------------------------------------------------
256
+
257
+ async function setupPlugin(ctx, fn, live) {
258
+ const directory = ctx?.location?.directory ?? process.cwd()
259
+ // the active account each provider was last read for, and the renewed
260
+ // auth a plugin saved for it (2.x has no way for a plugin to write a
261
+ // credential back, so it is kept in the plugin's storage, under the
262
+ // credential it renews and the token it was renewed from)
263
+ const current = new Map()
264
+ const saved = new Map()
265
+ const signingIn = new Map()
266
+ const storeKey = (id, key) => `v1auth/${id}/${key}`
267
+
268
+ const client = {
269
+ auth: {
270
+ async set(arg) {
271
+ const id = arg?.path?.id
272
+ const body = json(arg?.body)
273
+ const cur = id && current.get(id)
274
+ // a sign-in's own saves belong to the credential it is about to
275
+ // make, not the one that was active when it started
276
+ if (!cur || !body || signingIn.get(id) > 0) return { data: true }
277
+ const entry = { fp: cur.fp, auth: body }
278
+ saved.set(storeKey(id, cur.key), entry)
279
+ await Promise.resolve()
280
+ .then(() => ctx.storage?.set?.(storeKey(id, cur.key), entry))
281
+ .catch(() => {})
282
+ return { data: true }
283
+ },
284
+ async get() {
285
+ return { data: undefined }
286
+ },
287
+ },
288
+ app: { log: async () => ({ data: true }) },
289
+ }
290
+
291
+ const hooks = (await fn({ client, directory, worktree: directory, project: {}, serverUrl: undefined, $: undefined }, {})) ?? {}
292
+ const cfg = { provider: {} }
293
+ try {
294
+ await hooks.config?.(cfg)
295
+ } catch {}
296
+ const auth = hooks.auth
297
+ const authID = auth?.provider ? String(auth.provider) : undefined
298
+ const providers = record(cfg.provider)
299
+
300
+ // getAuth reads the active account: the credential 2.x holds, or what the
301
+ // plugin last saved renewing it.
302
+ const getAuth = async (id) => {
303
+ const connection = await ctx.integration.connection.active(id).catch(() => undefined)
304
+ if (!connection) {
305
+ current.delete(id)
306
+ return undefined
307
+ }
308
+ const value = await ctx.integration.connection.resolve(connection).catch(() => undefined)
309
+ const base = authOfValue(value)
310
+ if (!base) {
311
+ current.delete(id)
312
+ return undefined
313
+ }
314
+ const key = connKey(connection)
315
+ const fp = fingerprint(base)
316
+ // ours: a credential one of this plugin's own sign-ins made
317
+ const ours = value?.type === "oauth" && !!record(value.metadata).v1
318
+ current.set(id, { key, fp, ours })
319
+ const sk = storeKey(id, key)
320
+ let entry = saved.get(sk)
321
+ if (!entry) {
322
+ entry = await Promise.resolve()
323
+ .then(() => ctx.storage?.get?.(sk))
324
+ .catch(() => undefined)
325
+ if (entry) saved.set(sk, entry)
326
+ }
327
+ return entry && entry.fp === fp && entry.auth ? entry.auth : base
328
+ }
329
+
330
+ // the sign-in methods
331
+ if (authID && Array.isArray(auth.methods) && auth.methods.length) {
332
+ const label = (c) => {
333
+ const a = authOfValue(c)
334
+ const name = a?.name ?? a?.email ?? a?.metadata?.name ?? a?.metadata?.email ?? a?.account
335
+ return typeof name === "string" && name ? name : undefined
336
+ }
337
+ const begin = () => signingIn.set(authID, (signingIn.get(authID) ?? 0) + 1)
338
+ const end = () => signingIn.set(authID, Math.max(0, (signingIn.get(authID) ?? 1) - 1))
339
+ const title = record(providers[authID]).name
340
+ // 2.x runs a transform again on every reload, from nothing
341
+ await ctx.integration.transform((editor) => {
342
+ let keyed = false
343
+ // the integration 2.x makes for an id it doesn't know is named by the
344
+ // id; the provider's own name reads better in the sign-in list
345
+ if (typeof title === "string" && title)
346
+ try {
347
+ editor.update?.(authID, (i) => {
348
+ if (i && (!i.name || i.name === authID)) i.name = title
349
+ })
350
+ } catch {}
351
+ auth.methods.forEach((m, i) => {
352
+ if (!m) return
353
+ const methodID = `v1-${i}`
354
+ const form = fieldsOf(m.prompts)
355
+ if (m.type === "api" && typeof m.authorize !== "function") {
356
+ if (keyed) return
357
+ keyed = true
358
+ editor.method.update({ integrationID: authID, method: { type: "key", label: String(m.label ?? "API key"), ...(form ? { form } : {}) } })
359
+ return
360
+ }
361
+ if (typeof m.authorize !== "function") return
362
+ editor.method.update({
363
+ integrationID: authID,
364
+ method: { id: methodID, type: "oauth", label: String(m.label ?? authID), ...(form ? { form } : {}) },
365
+ async authorize(answer) {
366
+ begin()
367
+ let ended = false
368
+ const done = () => {
369
+ if (!ended) end()
370
+ ended = true
371
+ }
372
+ try {
373
+ const inputs = Object.fromEntries(Object.entries(record(answer)).map(([k, v]) => [k, String(v)]))
374
+ const r = await m.authorize(inputs)
375
+ if (m.type === "api") {
376
+ done()
377
+ return { url: "", instructions: "", mode: "auto", callback: Promise.resolve().then(() => credentialOf(methodID, r)) }
378
+ }
379
+ if (r?.method === "code")
380
+ return {
381
+ url: String(r.url ?? ""),
382
+ instructions: String(r.instructions ?? ""),
383
+ mode: "code",
384
+ callback: async (code) => {
385
+ try {
386
+ return credentialOf(methodID, await r.callback(code))
387
+ } finally {
388
+ done()
389
+ }
390
+ },
391
+ }
392
+ const callback = Promise.resolve()
393
+ .then(() => r.callback())
394
+ .then((res) => credentialOf(methodID, res))
395
+ .finally(done)
396
+ callback.catch(() => {})
397
+ return { url: String(r?.url ?? ""), instructions: String(r?.instructions ?? ""), mode: "auto", callback }
398
+ } catch (e) {
399
+ done()
400
+ throw e
401
+ }
402
+ },
403
+ label,
404
+ })
405
+ })
406
+ })
407
+ }
408
+
409
+ // the loader's options for the account signed in now, read again when
410
+ // the account changes
411
+ let loaded = null
412
+ const loaderOptions = async (id) => {
413
+ if (id !== authID || typeof auth?.loader !== "function") {
414
+ const a = authID === id ? await getAuth(id) : undefined
415
+ return { key: a?.type === "api" ? a.key : undefined }
416
+ }
417
+ const a = await getAuth(id)
418
+ if (!a) return {}
419
+ const fp = current.get(id)?.key + "\0" + fingerprint(a)
420
+ if (loaded?.fp !== fp) {
421
+ const p = Promise.resolve()
422
+ .then(() => auth.loader(() => getAuth(id), providerOf(id)))
423
+ .then((o) => record(o))
424
+ loaded = { fp, p }
425
+ p.catch(() => {
426
+ if (loaded?.fp === fp) loaded = null
427
+ })
428
+ }
429
+ const o = await loaded.p
430
+ const key = typeof o.apiKey === "string" && o.apiKey ? o.apiKey : a.type === "api" ? a.key : undefined
431
+ return { baseURL: o.baseURL, key, headers: o.headers, fetch: o.fetch }
432
+ }
433
+
434
+ // the providers, with the list the account's own read last gave each
435
+ const source = new Map()
436
+ // providerOf is a provider as 1.x hands it to provider.models and
437
+ // auth.loader
438
+ const providerOf = (id) => {
439
+ const conf = record(providers[id])
440
+ const models = Object.fromEntries(Object.entries(record(conf.models)).map(([k, m]) => [k, internalOf(id, conf, k, record(m))]))
441
+ return { id, name: conf.name ?? id, source: "config", env: [], options: record(conf.options), models }
442
+ }
443
+ const listed = new Set()
444
+ for (const [id, conf0] of Object.entries(providers)) {
445
+ const conf = record(conf0)
446
+ // a provider entry that only names the sign-in (no package, no models)
447
+ // is not a provider of models
448
+ if (!conf.npm && !Object.keys(record(conf.models)).length) continue
449
+ const options = record(conf.options)
450
+ const { apiKey: _k, baseURL: _b, fetch: _f, headers: optHeaders, ...rest } = options
451
+ // with no base URL the config leaves it to the loader: a name that
452
+ // can't resolve stands in for it, and each request swaps in the
453
+ // loader's (wrapFetch)
454
+ const baseURL = options.baseURL ?? conf.api ?? `https://${id.replace(/[^a-z0-9-]/gi, "-")}.opencode2-v1.invalid`
455
+ const npm = conf.npm ?? "@ai-sdk/openai-compatible"
456
+ const info = {
457
+ id,
458
+ ...(id === authID ? { integrationID: id } : {}),
459
+ name: String(conf.name ?? id),
460
+ activation: "auto",
461
+ package: packageOf(npm),
462
+ settings: { ...record(json(rest)), ...(baseURL ? { baseURL: String(baseURL) } : {}) },
463
+ }
464
+ const headers = { ...strings(conf.headers), ...strings(optHeaders) }
465
+ if (nonEmpty(headers)) info.headers = headers
466
+ const prov = { baseURL, npm }
467
+ const fromConfig = () =>
468
+ Object.entries(record(conf.models)).map(([key, m]) => modelOf(id, prov, key, internalOf(id, conf, key, record(m))))
469
+ listed.add(id)
470
+ await ctx.provider.transform((editor) => {
471
+ const s = source.get(id)
472
+ editor.add({ info, models: s?.models ?? fromConfig(), sourceConnection: s?.connection })
473
+ })
474
+ source.set(id, { conf, prov, models: null, connection: undefined })
475
+ }
476
+
477
+ // each request: the placeholder key, and the fetch that swaps in the
478
+ // account's. A provider the package doesn't list (one 2.x has built in
479
+ // that the plugin only signs in to) is left to 2.x unless the plugin's
480
+ // loader serves it and one of the plugin's own sign-ins is active.
481
+ const own = authID && !listed.has(authID) && typeof auth?.loader === "function" ? [authID] : []
482
+ for (const id of new Set([...listed, ...own])) {
483
+ const base = source.get(id)?.prov.baseURL
484
+ await ctx.aisdk.hook(
485
+ "sdk",
486
+ async (evt) => {
487
+ const opts = evt.options
488
+ if (!opts || opts.__opencode2) return
489
+ if (!listed.has(id)) {
490
+ await getAuth(id)
491
+ if (!current.get(id)?.ours) return
492
+ }
493
+ opts.__opencode2 = true
494
+ opts.apiKey = PLACEHOLDER
495
+ opts.fetch = wrapFetch(opts.fetch, opts.baseURL ?? base, () => loaderOptions(id))
496
+ },
497
+ { providerID: id },
498
+ )
499
+ }
500
+
501
+ // refresh reads the account's own list after a sign-in or a switch
502
+ const listID = hooks.provider?.id ? String(hooks.provider.id) : undefined
503
+ let refreshing = null
504
+ let again = false
505
+ // the account the list was last read for: a credential.updated that
506
+ // leaves it as it was reads nothing again
507
+ let readFor = null
508
+ const stateOf = async () => {
509
+ const c = await ctx.integration.connection.active(authID).catch(() => undefined)
510
+ const a = c ? await getAuth(authID) : undefined
511
+ return connKey(c) + "\0" + (a ? fingerprint(a) : "")
512
+ }
513
+ const refresh = () => {
514
+ if (refreshing) {
515
+ again = true
516
+ return refreshing
517
+ }
518
+ refreshing = (async () => {
519
+ try {
520
+ if (!authID || !source.has(authID)) return
521
+ const s = source.get(authID)
522
+ const connection = await ctx.integration.connection.active(authID).catch(() => undefined)
523
+ const a = connection ? await getAuth(authID) : undefined
524
+ readFor = connKey(connection) + "\0" + (a ? fingerprint(a) : "")
525
+ if (!a || listID !== authID || typeof hooks.provider?.models !== "function") {
526
+ if (s.connection || s.models) {
527
+ s.connection = undefined
528
+ s.models = null
529
+ await ctx.provider.reload()
530
+ }
531
+ return
532
+ }
533
+ const provider = providerOf(authID)
534
+ let got
535
+ try {
536
+ got = await hooks.provider.models(provider, { auth: a })
537
+ } catch {
538
+ got = null
539
+ }
540
+ if (connKey(await ctx.integration.connection.active(authID).catch(() => undefined)) !== connKey(connection)) {
541
+ again = true
542
+ return
543
+ }
544
+ s.connection = connection
545
+ s.models = got && typeof got === "object" ? Object.entries(got).map(([k, m]) => modelOf(authID, s.prov, k, record(m))) : null
546
+ await ctx.provider.reload()
547
+ await ctx.model?.reload?.()
548
+ } finally {
549
+ refreshing = null
550
+ if (again && live.on) {
551
+ again = false
552
+ void refresh()
553
+ }
554
+ }
555
+ })()
556
+ return refreshing
557
+ }
558
+
559
+ if (authID && ctx.event?.subscribe)
560
+ void (async () => {
561
+ try {
562
+ // a switch names the integration; an update (a sign-in, a removal)
563
+ // names nothing, so it reads again only when this account changed
564
+ const types = ["credential.switched", "credential.updated"]
565
+ for await (const event of ctx.event.subscribe({ types })) {
566
+ if (!live.on) return
567
+ if (event?.type === "credential.switched" && event?.data?.integrationID === authID) {
568
+ loaded = null
569
+ await refresh()
570
+ } else if (event?.type === "credential.updated" && (await stateOf()) !== readFor) {
571
+ await refresh()
572
+ }
573
+ }
574
+ } catch {}
575
+ })()
576
+ void refresh()
577
+ }
578
+
579
+ export async function setup(ctx, mod = v1) {
580
+ const live = { on: true }
581
+ for (const fn of pluginsOf(mod)) {
582
+ try {
583
+ await setupPlugin(ctx, fn, live)
584
+ } catch (e) {
585
+ // one plugin failing to start leaves the package's others working
586
+ console.error(`${pkg.name}: ${e?.message ?? e}`)
587
+ }
588
+ }
589
+ return () => {
590
+ live.on = false
591
+ }
592
+ }
593
+
594
+ export default { id: pkg.name, setup: (ctx) => setup(ctx) }
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
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
- "main": "./index.mjs",
7
- "exports": "./index.mjs",
6
+ "main": "./opencode.mjs",
7
+ "exports": { ".": "./opencode2.mjs" },
8
8
  "files": [
9
9
  "index.mjs",
10
+ "opencode.mjs",
11
+ "opencode2.mjs",
10
12
  "README.md"
11
13
  ],
12
14
  "keywords": [