@magpie-community/opencode-workbuddy-auth 0.2.17 → 0.2.19

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/README.md CHANGED
@@ -447,14 +447,30 @@ What is measured, and what is easy to read wrongly:
447
447
  rather than +08:00 guessed.
448
448
 
449
449
  A window that has lapsed reports nothing rather than 0%: nothing is
450
- exhausted, and the next request opens a fresh one. The China build
451
- (`copilot.tencent.com`) carries the same window: two Free-plan accounts
450
+ exhausted, and the next request opens a fresh one.
451
+
452
+ ### The China build's windows (measured, shown as estimates)
453
+
454
+ The China build (`copilot.tencent.com`) carries the same kind of window,
455
+ measured one model at a time. `deepseek-v4.1-flash`: two Free-plan accounts
452
456
  refused on 2026-11-02 at 200,389,043 and 200,485,923 tokens, so its cap is
453
- measured at about 2e8 — and its refusals name the reset in Chinese
454
- (`将在 … UTC+8 重置`), which reads as an edge the same way. Its card reads
455
- against 2e8. In 0.2.12 and 0.2.13 it read against a soft cap of 1.9e8, so
456
- that routing rested the model before the vendor refused it; with the sum set
457
- aside that headroom only stopped the model early, so it is gone.
457
+ about **2e8** — and its refusals name the reset in Chinese
458
+ (`将在 … UTC+8 重置`), which reads as an edge the same way.
459
+
460
+ Unlike the AI build's, the China model is **charged in credits** (`x0.11`)
461
+ — the token window and the credits meter are two limits on one model — so
462
+ the card is not a "free model" card. It is named after its model and says
463
+ its cap is an estimate: **`deepseek-v4.1-flash tokens (est.)`**, with
464
+ `~200M` in its text (`151.5M / ~200M`). The estimate is this machine's ledger
465
+ against a cap measured on two accounts; the vendor reports neither, and the
466
+ first refusal of a window pins the card full at the reset the vendor names.
467
+
468
+ The measured windows live in one table (`CN_ESTIMATED_WINDOWS`), one entry
469
+ per model. Only measured models have a card — a guessed cap would read wrong
470
+ every day. Add an entry when another model's window is measured; remove one
471
+ when the vendor stops applying it. Because the card is set aside (shown, not
472
+ routed on), a stale cap costs a wrong percentage, never a wrongly rested
473
+ model: the refusal is what stops a model, on both builds.
458
474
 
459
475
  ## When free models work but a credit model does not
460
476
 
package/index.mjs CHANGED
@@ -2212,7 +2212,7 @@ const compact = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/
2212
2212
 
2213
2213
  // The window WorkBuddy rate limits deepseek-v4.1-flash by, which its credit
2214
2214
  // meter does not show: WorkBuddy AI measured at about 1e8 over ten days, the
2215
- // China build at about 2e8 on 2026-11-02 (see FREE_WINDOW_CN).
2215
+ // China build at about 2e8 on 2026-11-02 (see CN_ESTIMATED_WINDOWS).
2216
2216
  //
2217
2217
  // A free account's usage() reports only the credits window, so a plan with
2218
2218
  // 160 credits untouched reads 0% while chats to deepseek-v4.1-flash are
@@ -2293,18 +2293,46 @@ const compact = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/
2293
2293
  // ~/.config/magpie otherwise. No endpoint reports the window; a ledger that
2294
2294
  // cannot be read yields no window rather than a made-up one.
2295
2295
 
2296
- // A window: the build's ledger rows it counts (provider), the cap it reports
2297
- // against, and how deep its display rounds. Two builds, two windows, two
2298
- // caps: the same phone number can be signed in to both, and their ledgers
2296
+ // A window: the build's ledger rows it counts (provider and model), the cap
2297
+ // it reports against, and how its card reads. Two builds, two sets of
2298
+ // windows: the same phone number can be signed in to both, and their ledgers
2299
2299
  // stay apart — one build's spending must not fill the other's window.
2300
2300
  //
2301
- // The China cap is the measured one, 2e8 (two accounts refused at 200.39M
2302
- // and 200.49M). It was a soft 1.9e8 in 0.2.12 and 0.2.13, so that routing
2303
- // rested the model before the vendor refused it; since the sum is set aside
2304
- // (freeWindowOf's card), the cap only scales what the card says, and the
2305
- // headroom bought nothing but a model stopped 10M tokens early (plugins#641).
2306
- const FREE_WINDOW_CN = { provider: "workbuddy", cap: 2e8, short: "200M" }
2307
- const FREE_WINDOW_AI = { provider: "workbuddy-ai", cap: 1e8, short: "100M" }
2301
+ // The China caps are measured on this machine from the vendor's own
2302
+ // refusals, never the vendor's word, and each is an estimate: 2e8 for
2303
+ // deepseek-v4.1-flash (two accounts refused at 200.39M and 200.49M on
2304
+ // 2026-11-02), with the card saying so (`~` and "(est.)"). The card is
2305
+ // shown only — set aside, plugins#641 — so a cap that goes stale costs a
2306
+ // wrong percentage, never a wrongly rested model; the first refusal of a
2307
+ // window pins the card full at the reset the vendor names.
2308
+ //
2309
+ // One entry per model, and only models that have been measured: a guessed
2310
+ // cap would read wrong every day. Add an entry when another model's window
2311
+ // is measured; remove one when the vendor stops applying it.
2312
+ const CN_ESTIMATED_WINDOWS = [
2313
+ {
2314
+ provider: "workbuddy",
2315
+ model: "deepseek-v4.1-flash",
2316
+ aliases: ["deepseek-v4.1-flash-sg"],
2317
+ cap: 2e8,
2318
+ name: "deepseek-v4.1-flash tokens (est.)",
2319
+ short: "~200M",
2320
+ // no unit: the name already says tokens, and magpie's narrow account
2321
+ // cards cut a unit short ("tok…") beside the nine-digit numbers
2322
+ unit: "",
2323
+ },
2324
+ ]
2325
+ // WorkBuddy AI's window keeps the name it shipped with: there the model is
2326
+ // served at x0.00, so "Free model tokens" is what it is.
2327
+ const FREE_WINDOW_AI = {
2328
+ provider: "workbuddy-ai",
2329
+ model: "deepseek-v4.1-flash",
2330
+ aliases: ["deepseek-v4.1-flash-sg"],
2331
+ cap: 1e8,
2332
+ name: "Free model tokens",
2333
+ short: "100M",
2334
+ unit: "tokens",
2335
+ }
2308
2336
  const FREE_WINDOW_SPAN = 24 * 3600 * 1000
2309
2337
  // The lead: how far back of an edge the read goes on. A chain read from a row
2310
2338
  // that opens a window needs the window the span makes of the rows above it,
@@ -2317,10 +2345,9 @@ const FREE_WINDOW_LEAD = 86400 * 1000
2317
2345
  // earlier row began at most 24h before it, and so ended 24h after it at the
2318
2346
  // latest. This is one of the two edges the ledger itself spells out.
2319
2347
  const FREE_WINDOW_GAP = FREE_WINDOW_SPAN
2320
- // the model the window is counted for; -sg shares it
2321
- const FREE_WINDOW_MODEL = "deepseek-v4.1-flash"
2322
- // both ids, so magpie's exact match finds the -sg one too
2323
- const FREE_WINDOW_MODELS = ["deepseek-v4.1-flash", "deepseek-v4.1-flash-sg"]
2348
+ // the model a window is counted for is its spec's `model`; `aliases` are the
2349
+ // ids that share the window (-sg is the same model under its paid id), and
2350
+ // both are named in the card's models so magpie's exact match finds them
2324
2351
 
2325
2352
  // windowOffset is the UTC offset a refusal spells out, in minutes, from
2326
2353
  // "UTC+8", "UTC+08:00", "UTC-5" or "GMT+8". null when it spells out none,
@@ -2367,7 +2394,7 @@ function freeWindowOf(rows, account, now = Date.now(), win = FREE_WINDOW_AI) {
2367
2394
  r?.provider === win.provider &&
2368
2395
  r.providerAccount === account &&
2369
2396
  typeof r.model === "string" &&
2370
- r.model.startsWith(FREE_WINDOW_MODEL) &&
2397
+ r.model.startsWith(win.model) &&
2371
2398
  r.t,
2372
2399
  )
2373
2400
  if (!mine.length) return null
@@ -2440,15 +2467,17 @@ function freeWindowOf(rows, account, now = Date.now(), win = FREE_WINDOW_AI) {
2440
2467
  if (now >= end) return null // lapsed: nothing is exhausted, a new window opens on the next request
2441
2468
 
2442
2469
  const card = (amount, resetsAt, refused) => ({
2443
- name: "Free model tokens",
2470
+ name: win.name,
2444
2471
  used: Math.min(100, (100 * amount) / win.cap),
2445
2472
  amount,
2446
2473
  limit: win.cap,
2447
- unit: "tokens",
2474
+ unit: win.unit,
2448
2475
  display: `${tokensShort(amount)} / ${win.short}`,
2449
2476
  resetsAt: new Date(resetsAt).toISOString(),
2450
2477
  span: FREE_WINDOW_SPAN / 1000,
2451
- models: FREE_WINDOW_MODELS.slice(),
2478
+ // the window's own model and the ids that share it, so magpie's exact
2479
+ // match finds every id it limits
2480
+ models: [win.model, ...win.aliases],
2452
2481
  // the ledger's sum is an estimate, shown and set aside; the vendor's
2453
2482
  // refusal is the one that stops the model (see the window above)
2454
2483
  ...(refused ? { refused: true } : { aside: true }),
@@ -2550,10 +2579,11 @@ function freeWindowRows(account, now = Date.now(), cwd = process.cwd(), env = pr
2550
2579
  // way this ledger is written; one rewritten in place (a rotation or a
2551
2580
  // compaction) moves mtime even when its size lands the same.
2552
2581
  const file = path + "|" + st.mtimeMs + ":" + st.size
2553
- // the key carries the account and the build: one account's rows are its
2554
- // own, and the two builds' chains over one ledger must not answer for
2555
- // each other (the same phone number can be signed in to both)
2556
- const key = file + "|" + win.provider + "|" + account
2582
+ // the key carries the account, the build and the model: one account's
2583
+ // rows are its own, the two builds' chains over one ledger must not
2584
+ // answer for each other (the same phone number can be signed in to both),
2585
+ // and two windows of one build count different models
2586
+ const key = file + "|" + win.provider + "|" + win.model + "|" + account
2557
2587
  if (ledgerParsed?.key === key) return ledgerParsed.rows
2558
2588
  let got
2559
2589
  try {
@@ -2617,15 +2647,15 @@ function rowAccount(line) {
2617
2647
  }
2618
2648
 
2619
2649
  // ofAccount is whether a row belongs to the chain read for account: this
2620
- // account's own row of the build's free model, either id. A refusal of
2621
- // another account's, of the other build, or of a paid model says nothing
2622
- // about where this chain opens.
2650
+ // account's own row of the window's model, either id. A refusal of another
2651
+ // account's, of the other build, or of another model says nothing about
2652
+ // where this chain opens.
2623
2653
  function ofAccount(row, account, win = FREE_WINDOW_AI) {
2624
2654
  return (
2625
2655
  row?.provider === win.provider &&
2626
2656
  row.providerAccount === account &&
2627
2657
  typeof row.model === "string" &&
2628
- row.model.startsWith(FREE_WINDOW_MODEL)
2658
+ row.model.startsWith(win.model)
2629
2659
  )
2630
2660
  }
2631
2661
 
@@ -3883,17 +3913,21 @@ function makePlugin(site) {
3883
3913
  } catch {}
3884
3914
  }
3885
3915
  await spareFree(site, a, out.windows)
3886
- // the free model's own window, which the credit meter above does
3887
- // not show: what actually refuses a chat long before 160 credits
3888
- // are spent. Both builds: WorkBuddy AI at 1e8 (ten days of
3889
- // refusals), the China build's at about 2e8 (2026-11-02). Set
3890
- // aside until the vendor refuses (see the window definitions above)
3916
+ // each measured model's own window, which the credit meter above
3917
+ // does not show: what actually refuses a chat long before the
3918
+ // credits are spent. WorkBuddy AI has one (deepseek, 1e8, ten days
3919
+ // of refusals); the China build's are measured one model at a time
3920
+ // (deepseek at about 2e8, 2026-11-02), each its own card. Shown
3921
+ // only, set aside: the vendor's refusal is what stops a model
3922
+ // (see the window definitions above)
3891
3923
  {
3892
3924
  const account = a.accountId || a.user || a.uid || ""
3893
- const win = site.ai ? FREE_WINDOW_AI : FREE_WINDOW_CN
3894
- const rows = freeWindowRows(account, Date.now(), process.cwd(), process.env, homedir(), win)
3895
- const w = rows && freeWindowOf(rows, account, Date.now(), win)
3896
- if (w) out.windows.push(w)
3925
+ const wins = site.ai ? [FREE_WINDOW_AI] : CN_ESTIMATED_WINDOWS
3926
+ for (const win of wins) {
3927
+ const rows = freeWindowRows(account, Date.now(), process.cwd(), process.env, homedir(), win)
3928
+ const w = rows && freeWindowOf(rows, account, Date.now(), win)
3929
+ if (w) out.windows.push(w)
3930
+ }
3897
3931
  }
3898
3932
  return { ...out, signIn: said }
3899
3933
  } catch (e) {
@@ -4037,4 +4071,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
4037
4071
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
4038
4072
 
4039
4073
  // for tests
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 }
4074
+ 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, CN_ESTIMATED_WINDOWS, 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.17",
3
+ "version": "0.2.19",
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": [