@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 +23 -7
- package/index.mjs +71 -37
- package/opencode.mjs +5 -0
- package/opencode2.mjs +594 -0
- package/package.json +5 -3
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.
|
|
451
|
-
|
|
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
|
-
|
|
454
|
-
(`将在 … UTC+8 重置`), which reads as an edge the same way.
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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
|
|
2297
|
-
// against, and how
|
|
2298
|
-
//
|
|
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
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2305
|
-
//
|
|
2306
|
-
|
|
2307
|
-
|
|
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
|
|
2321
|
-
|
|
2322
|
-
// both
|
|
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(
|
|
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:
|
|
2470
|
+
name: win.name,
|
|
2444
2471
|
used: Math.min(100, (100 * amount) / win.cap),
|
|
2445
2472
|
amount,
|
|
2446
2473
|
limit: win.cap,
|
|
2447
|
-
unit:
|
|
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
|
-
|
|
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
|
|
2554
|
-
// own,
|
|
2555
|
-
// each other (the same phone number can be signed in to both)
|
|
2556
|
-
|
|
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
|
|
2621
|
-
//
|
|
2622
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
3887
|
-
// not show: what actually refuses a chat long before
|
|
3888
|
-
// are spent.
|
|
3889
|
-
// refusals)
|
|
3890
|
-
//
|
|
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
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
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,
|
|
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.
|
|
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": "./
|
|
7
|
-
"exports": "./
|
|
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": [
|