* plugin: a slow reader pauses its stream instead of racing a watchdog #664 gave a plugin reply a soft watermark (softQueuedBytes 4 MiB, softQueuedEvents 4096) and a 10-second idle watchdog: past the watermark, a reader that made no progress for stallIdle had its call failed with errStalledReader, and hardQueuedBytes/hardQueuedEvents (64 MiB / 65536) cut the reply outright. Both are guesses at how long a reader may take, and both end a reply that reader was still consuming. The host now hands each fetch a credit window instead. Go grants streamWindow (512 KiB) up front and returns credit as the reader takes bytes, so the child stops producing at the window instead of being timed out; the window is charged per delivered byte plus frameOverhead (2048), so a run of tiny frames cannot fill it with metadata. streamBudget (64 MiB) and maxFrame (64 KiB) bound a whole reply and a single frame, and a child that sends past its window is a protocol violation (errProtocolOverrun) rather than a slow reader. The plugin side grew the matching gate (makeGate, gate.take/spend/add, waitDrain): a fetch holds at the window until Go credits it, and its pending writes are cancellable. Cancellation and shutdown now have one owner per call - a five-state lifecycle (sending, active, draining, aborting, released) whose single transition helper disposes the queue, detaches the watcher, commits the terminal answer, creates the abort and returns capacity exactly once - so a host failure, a Close, a cancelled context and an already-delivered reply can no longer race each other over who cleans up. A reply the host already delivered keeps its bytes and its vendor outcome when the host dies. Tests: the JS cancellation cases run the production sections with controlled stdout backpressure (stopped gate, split chunk, terminal suppression, acquired-body cancel, 30,000 cancelled bootstrap waits with no retained waiter or ready reaction); the Go cases drive lifecycle orderings directly (terminal-before-failure, failure-before-terminal, abort acknowledged before capacity returns, blocked enqueue), and the shutdown, body-close and mid-setup-cancel cases block on explicit channels instead of sleeping. TestFlowBigReplyProd reads 100 MiB non-SSE and SSE through the real host and checks lengths and SHA-256, including a 12 s reader pause. CI installs Bun 1.4.2 on Linux and runs that case in its own step, since the plugin sandbox skips when bun is not on PATH. * plugin: the stream-head credit test waits for the credits before the reply ends (CI, macos-latest: TestHostStreamHeadChunksFinal failed on the macOS runner with "the credits were given back didn't happen", while the same package passed on ubuntu-latest and locally) — the test sent the child's final answer together with its 50 chunks, so the call could be marked stopped and its pending credits dropped before waitFor saw them; a completed fetch needs no credit, so waiting longer could not recover them and the expectation was a race, not a real accounting bug. The final answer is now withheld until io.ReadFull has taken the 140 payload bytes in order and exactly 140 + 49*frameOverhead credits have been observed with frameOverhead still owed, and only then does the child answer and the test assert a zero-byte EOF read. No production code changed. Tests: go test -run '^TestHostStreamHeadChunksFinal$' -count=500 ./internal/plugin/ and -count=200 -race pass; go test -count=1 and -race -count=1 ./internal/plugin/... pass; gofmt -l and git diff --check clean.
1685 lines
64 KiB
JavaScript
1685 lines
64 KiB
JavaScript
// magpie's plugin host: runs OpenCode (v1) server plugins under Bun and
|
||
// answers magpie over stdin/stdout, one JSON message a line.
|
||
//
|
||
// magpie → host: {id, method, params}; the host answers {id, result} or
|
||
// {id, error}. A fetch streams its reply first: {id, event: "head", status,
|
||
// headers}, then {id, event: "chunk", data} (base64) as the body comes, then
|
||
// {id, result: null}. {method: "abort", params: {id}} cancels one, and
|
||
// {method: "credit", params: {id, n}} gives a fetch back the n charged bytes
|
||
// its reader took (the payload it read, and a frame's overhead once the frame
|
||
// is done), so it may send that much more. A fetch starts with the window
|
||
// magpie gave it and sends no more than that ahead of its reader, so a slow
|
||
// reader pauses its upstream instead of the host buffering without bound.
|
||
// host → magpie, unasked: {event: "log", level, message} and {event:
|
||
// "toast", ...} (what a plugin logs or shows), {event: "auth", provider}
|
||
// (a sign-in the host saved or refreshed).
|
||
//
|
||
// The plugins get what OpenCode hands them (PluginInput): a client whose
|
||
// auth.set saves to magpie's plugin-auth.json in OpenCode's auth.json shape,
|
||
// tui.showToast and app.log, config.get; Bun's $; the folder magpie keeps
|
||
// its files in. What else a plugin asks the client for answers {data:
|
||
// undefined}.
|
||
//
|
||
// A provider may be signed in to more than once: the first account is kept
|
||
// under the provider's id, as OpenCode keeps it, the others under
|
||
// id#<slot>. Everything a request does runs in its account's scope, so the
|
||
// plugin's getAuth, client.auth.set and loader see that account alone.
|
||
|
||
import { AsyncLocalStorage } from "node:async_hooks"
|
||
import fs from "node:fs"
|
||
import net from "node:net"
|
||
import path from "node:path"
|
||
import readline from "node:readline"
|
||
import tls from "node:tls"
|
||
import { pathToFileURL } from "node:url"
|
||
|
||
const rpcWrite = process.stdout.write.bind(process.stdout)
|
||
|
||
// Everything the child writes goes through one ordered queue: a line goes out
|
||
// only when the stream can take it, so a parent that stops reading bounds what
|
||
// Bun buffers instead of it growing without bound. A caller may cancel its own
|
||
// wait, which drops its line if it has not been written yet, so an aborted
|
||
// request leaves no listener or closure behind. A write failure or a closed
|
||
// stream fails every line still waiting rather than leaving them pending.
|
||
const outq = [] // {line, settle}
|
||
let pumping = false
|
||
let outFail = null
|
||
|
||
function waitDrain() {
|
||
return new Promise((resolve, reject) => {
|
||
const done = () => { clear(); resolve() }
|
||
const bad = (e) => { clear(); reject(e ?? new Error("the host's stdout closed")) }
|
||
const clear = () => {
|
||
process.stdout.removeListener("drain", done)
|
||
process.stdout.removeListener("error", bad)
|
||
process.stdout.removeListener("close", bad)
|
||
}
|
||
process.stdout.once("drain", done)
|
||
process.stdout.once("error", bad)
|
||
process.stdout.once("close", bad)
|
||
})
|
||
}
|
||
|
||
function pump() {
|
||
if (pumping) return
|
||
pumping = true
|
||
while (outq.length) {
|
||
const item = outq[0]
|
||
if (outFail) {
|
||
outq.shift()
|
||
item.settle(outFail)
|
||
continue
|
||
}
|
||
// write returns false when the stream is full, but the line was still
|
||
// taken: wait for it to drain before the next one, never write it twice
|
||
const ok = rpcWrite(item.line)
|
||
outq.shift()
|
||
item.settle(null)
|
||
if (!ok) {
|
||
waitDrain().then(
|
||
() => { pumping = false; pump() },
|
||
(e) => { outFail = e; pumping = false; pump() },
|
||
)
|
||
return
|
||
}
|
||
}
|
||
pumping = false
|
||
}
|
||
|
||
// queueLine queues one line, giving a promise that settles with null once it is
|
||
// written or with the error that stopped it, and a way to give the wait up.
|
||
function queueLine(line) {
|
||
const item = { line, settle: () => {} }
|
||
const promise = new Promise((resolve) => { item.settle = (err) => resolve(err ?? null) })
|
||
outq.push(item)
|
||
pump()
|
||
return {
|
||
promise,
|
||
cancel: () => {
|
||
const i = outq.indexOf(item)
|
||
if (i >= 0) {
|
||
outq.splice(i, 1)
|
||
item.settle(null)
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
// send queues one message, ignoring whether it was written.
|
||
const send = (msg) => queueLine(JSON.stringify(msg) + "\n").promise
|
||
|
||
// sendCancelable is send, with a way to give the wait up.
|
||
const sendCancelable = (msg) => queueLine(JSON.stringify(msg) + "\n")
|
||
|
||
// A host cancellation suppresses the answer; a local abort still owes Go
|
||
// its error. The record, rather than the signal, owns pending wire output.
|
||
function transitionFetch(record, state) {
|
||
if (record.state === "finished") return
|
||
if (record.state !== "cancelled") record.state = state
|
||
if (state === "streaming") return
|
||
record.gate.stop()
|
||
const body = record.unconsumedBody
|
||
record.unconsumedBody = undefined
|
||
try { Promise.resolve(body?.cancel?.()).catch(() => {}) } catch {}
|
||
if (state === "finishing") return
|
||
record.pendingSend?.cancel()
|
||
record.readyCleanup?.()
|
||
record.readyCleanup = null
|
||
if (state === "cancelled") record.controller.abort()
|
||
else record.state = "finished"
|
||
}
|
||
|
||
function checkFetch(record) {
|
||
if (record.state === "cancelled" || record.state === "finished") throw new DOMException("Aborted", "AbortError")
|
||
record.controller.signal.throwIfAborted()
|
||
}
|
||
|
||
async function sendForFetch(record, msg) {
|
||
if (record.state === "cancelled" || record.state === "finished") return
|
||
const ctl = record.controller
|
||
const pendingSend = sendCancelable(msg)
|
||
record.pendingSend = pendingSend
|
||
const onAbort = () => pendingSend.cancel()
|
||
ctl.signal.addEventListener("abort", onAbort, { once: true })
|
||
try {
|
||
const err = await pendingSend.promise
|
||
if (err) throw err
|
||
} finally {
|
||
record.pendingSend = null
|
||
ctl.signal.removeEventListener("abort", onAbort)
|
||
}
|
||
}
|
||
|
||
// The largest decoded bytes one frame carries: the reader credits in larger
|
||
// steps than this, so frames stay small enough that a credit round trip is
|
||
// cheap without one frame dwarfing the window.
|
||
const MAX_FRAME = 64 << 10
|
||
|
||
// FRAME_OVERHEAD is what one queued frame costs the window besides its bytes,
|
||
// as Go counts it too: the message struct and the decoded slice behind it. It
|
||
// keeps a producer of tiny frames from filling a window with structs.
|
||
const FRAME_OVERHEAD = 2048
|
||
|
||
// HEAD_MAX and HEAD_ENTRIES are magpie's own head-envelope policy, not HTTP's:
|
||
// a fetch's head carries at most HEAD_MAX header bytes (the UTF-8 bytes of
|
||
// every name and value, plus HEAD_ENTRY_COST an entry) and HEAD_ENTRIES
|
||
// entries, counted the same by Go before it is queued, so a vendor's unbounded
|
||
// header set is not a free 0-charge line.
|
||
const HEAD_MAX = 64 << 10
|
||
const HEAD_ENTRIES = 256
|
||
const HEAD_ENTRY_COST = 4
|
||
|
||
// headCost is a head's header cost, as Go counts it too: the UTF-8 bytes of
|
||
// every name and value, plus HEAD_ENTRY_COST an entry.
|
||
function headCost(h) {
|
||
let n = 0
|
||
for (const [k, v] of Object.entries(h)) n += Buffer.byteLength(k) + Buffer.byteLength(v) + HEAD_ENTRY_COST
|
||
const entries = Object.keys(h).length
|
||
return { n, entries }
|
||
}
|
||
|
||
// makeGate is one fetch's credit: the child may have `window` decoded bytes
|
||
// outstanding, each frame costing its bytes plus FRAME_OVERHEAD. take returns
|
||
// how many bytes of a frame may go now (-1 when the fetch was stopped), spend
|
||
// takes the frame's cost, add is the reader giving credit back.
|
||
function makeGate(window) {
|
||
let free = window
|
||
let stopped = false
|
||
let wake = null
|
||
const fit = (want) => Math.min(want, free - FRAME_OVERHEAD)
|
||
return {
|
||
take(want) {
|
||
if (stopped) return Promise.resolve(-1)
|
||
const n = fit(want)
|
||
if (n > 0) return Promise.resolve(n)
|
||
return new Promise((resolve) => { wake = (ok) => resolve(ok ? fit(want) : -1) })
|
||
},
|
||
spend(cost) { free -= cost },
|
||
add(n) {
|
||
free += n
|
||
if (wake && free > FRAME_OVERHEAD) { const w = wake; wake = null; w(true) }
|
||
},
|
||
stop() {
|
||
stopped = true
|
||
if (wake) { const w = wake; wake = null; w(false) }
|
||
},
|
||
}
|
||
}
|
||
|
||
// A plugin writing to stdout would break the protocol: everything it
|
||
// prints goes to stderr, which magpie logs.
|
||
const toErr = (...xs) => process.stderr.write(xs.map((x) => (typeof x === "string" ? x : Bun.inspect(x))).join(" ") + "\n")
|
||
console.log = console.info = console.debug = console.warn = toErr
|
||
process.stdout.write = (chunk, enc, cb) => process.stderr.write(chunk, enc, cb)
|
||
|
||
// Nor may a program a plugin starts have them: one given the host's stdin
|
||
// reads magpie's messages away (pi-devin-plus runs `devin auth login` with
|
||
// stdio "inherit", which took the next requests as its answer and gave up,
|
||
// "Login canceled"), and its stdout is the host's answers. It reads
|
||
// nothing instead and writes to stderr. node:child_process starts every
|
||
// program through Bun.spawn and Bun.spawnSync, so these see all of them.
|
||
const own = (v, fd) => v === "inherit" || v === fd || v === (fd ? process.stdout : process.stdin)
|
||
const guard = (o) => {
|
||
if (!o || typeof o !== "object") return o
|
||
o = { ...o }
|
||
if (Array.isArray(o.stdio)) {
|
||
o.stdio = [...o.stdio]
|
||
if (own(o.stdio[0], 0)) o.stdio[0] = "ignore"
|
||
if (own(o.stdio[1], 1)) o.stdio[1] = 2
|
||
}
|
||
if (own(o.stdin, 0)) o.stdin = "ignore"
|
||
if (own(o.stdout, 1)) o.stdout = 2
|
||
return o
|
||
}
|
||
for (const name of ["spawn", "spawnSync"]) {
|
||
const run = Bun[name]
|
||
Bun[name] = function (a, b) {
|
||
return Array.isArray(a) ? run.call(this, a, guard(b)) : run.call(this, guard(a))
|
||
}
|
||
}
|
||
|
||
let authPath = ""
|
||
let modelsDevPath = ""
|
||
let piPath = "" // pi.js, which loads pi's extensions
|
||
let directory = process.cwd()
|
||
let userConfig = {}
|
||
const hooks = [] // {spec, hooks}
|
||
const loaded = [] // {spec, id, error}
|
||
const loaders = new Map() // account → options the auth loader returned
|
||
const sessions = new Map() // oauth sign-in in progress → its authorize result
|
||
const inflight = new Map() // fetch id → its lifecycle and owned resources
|
||
const renewing = new Map() // account → its sign-in's renewal under way
|
||
const unrenewed = new Map() // account → its last renewal that failed: {at, secret, gone}
|
||
let config = { provider: {} } // what the plugins' config hooks made of it
|
||
|
||
// ---- a provider's own proxy ---------------------------------------------------
|
||
|
||
// A request made for a provider or an account with a proxy of its own
|
||
// (#237) goes through it, and so does every fetch the plugin makes for
|
||
// it; one for a provider set to "direct" goes through none. Bun reads
|
||
// *_PROXY once, and a fetch given no proxy option can't be told to skip
|
||
// them, so the host is started with them as MAGPIE_*_PROXY and each
|
||
// fetch is given magpie's own here when it has none of its own.
|
||
// Loopback never goes through one.
|
||
const via = new AsyncLocalStorage() // the proxy: "", "direct" or its URL
|
||
const bunFetch = globalThis.fetch
|
||
|
||
function proxyVar(k) {
|
||
return process.env["MAGPIE_" + k] || process.env["MAGPIE_" + k.toLowerCase()] || ""
|
||
}
|
||
|
||
const globalProxy = {
|
||
https: proxyVar("HTTPS_PROXY") || proxyVar("ALL_PROXY"),
|
||
http: proxyVar("HTTP_PROXY") || proxyVar("ALL_PROXY"),
|
||
no: proxyVar("NO_PROXY")
|
||
.split(",")
|
||
.map((s) => s.trim().toLowerCase().replace(/^\*?\./, ""))
|
||
.filter(Boolean),
|
||
}
|
||
|
||
function hostOf(input) {
|
||
try {
|
||
const u = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
|
||
return { protocol: u.protocol, host: u.hostname.toLowerCase().replace(/^\[|\]$/g, "") }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function loopback(host) {
|
||
return host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127\./.test(host)
|
||
}
|
||
|
||
// proxyFor is the proxy a fetch of input goes through, "" for none.
|
||
function proxyFor(input) {
|
||
const u = hostOf(input)
|
||
if (!u || loopback(u.host)) return ""
|
||
const own = via.getStore()
|
||
if (own === "direct") return ""
|
||
if (own) return own
|
||
if (globalProxy.no.some((n) => n === "*" || u.host === n || u.host.endsWith("." + n))) return ""
|
||
return u.protocol === "https:" ? globalProxy.https : u.protocol === "http:" ? globalProxy.http : ""
|
||
}
|
||
|
||
// reach is what a check's fetches came to: whether one got an answer, and
|
||
// the first that got none
|
||
const reach = new AsyncLocalStorage()
|
||
|
||
function sent(input, init) {
|
||
if (init && "proxy" in init) return bunFetch(input, init)
|
||
const p = proxyFor(input)
|
||
if (!p) return bunFetch(input, init)
|
||
return bunFetch(input, { ...init, proxy: p }).catch(async (e) => {
|
||
if (e?.name === "AbortError" || init?.signal?.aborted) throw e
|
||
const why = await proxyRefuses(p, input)
|
||
if (why) throw Object.assign(new Error(`proxyconnect tcp: ${why}`), { code: e?.code, cause: e })
|
||
throw e
|
||
})
|
||
}
|
||
|
||
// proxyRefuses is why the proxy p won't carry a request to input, "" when
|
||
// it will: Bun says a proxy that is down, or that turns the tunnel away,
|
||
// only as a connection that failed, which a vendor's own failure is too.
|
||
// The tunnel is asked for again, as Go's transport asks for it, so a
|
||
// failure is said in its words ("proxyconnect …") and the gateway takes it
|
||
// for the proxy's, asking the next account rather than resting this one.
|
||
function proxyRefuses(p, input) {
|
||
let pu, target
|
||
try {
|
||
pu = new URL(p)
|
||
const u = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
|
||
target = `${u.hostname.includes(":") ? `[${u.hostname.replace(/^\[|\]$/g, "")}]` : u.hostname}:${u.port || (u.protocol === "http:" ? 80 : 443)}`
|
||
} catch {
|
||
return Promise.resolve("")
|
||
}
|
||
const host = pu.hostname.replace(/^\[|\]$/g, "")
|
||
const port = Number(pu.port || (pu.protocol === "https:" ? 443 : 80))
|
||
return new Promise((resolve) => {
|
||
let got = ""
|
||
let refused
|
||
const done = (why) => {
|
||
sock.destroy()
|
||
resolve(why)
|
||
}
|
||
const opts = { host, port, servername: net.isIP(host) ? undefined : host }
|
||
const sock = pu.protocol === "https:" ? tls.connect(opts) : net.connect(opts)
|
||
sock.setTimeout(10_000, () => done(`dial tcp ${host}:${port}: i/o timeout`))
|
||
sock.on("error", (e) => done(`dial tcp ${host}:${port}: ${e?.message ?? e}`))
|
||
sock.once(pu.protocol === "https:" ? "secureConnect" : "connect", () => {
|
||
let req = `CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n`
|
||
if (pu.username) {
|
||
const cred = `${decodeURIComponent(pu.username)}:${decodeURIComponent(pu.password)}`
|
||
req += `Proxy-Authorization: Basic ${Buffer.from(cred).toString("base64")}\r\n`
|
||
}
|
||
sock.write(req + "\r\n")
|
||
})
|
||
sock.on("data", (d) => {
|
||
got += d.toString("latin1")
|
||
const end = got.indexOf("\r\n\r\n")
|
||
if (end < 0 && got.length < 8192) return
|
||
const status = got.slice(0, got.indexOf("\r\n")).replace(/^HTTP\/\d(\.\d)?\s+/, "")
|
||
if (/^2\d\d/.test(status)) return done("")
|
||
// a bridge to a SOCKS proxy says why after its status
|
||
refused ??= () => {
|
||
const body = end < 0 ? "" : got.slice(end + 4, end + 304).trim()
|
||
return body ? `${status}: ${body}` : status
|
||
}
|
||
setTimeout(() => done(refused()), 50)
|
||
})
|
||
sock.on("end", () => done(refused ? refused() : got ? "" : `${host}:${port} closed the connection`))
|
||
})
|
||
}
|
||
|
||
// listing is what a models hook's fetches came to: whether it asked any,
|
||
// and whether the last it asked answered
|
||
const listing = new AsyncLocalStorage()
|
||
|
||
function asked(input, init) {
|
||
const l = listing.getStore()
|
||
if (!l) return sent(input, init)
|
||
l.tried = true
|
||
return sent(input, init).then(
|
||
(res) => ((l.lastOk = res.ok), res),
|
||
(e) => {
|
||
l.lastOk = false
|
||
throw e
|
||
},
|
||
)
|
||
}
|
||
|
||
globalThis.fetch = Object.assign(function fetch(input, init) {
|
||
const r = reach.getStore()
|
||
if (!r) return asked(input, init)
|
||
return asked(input, init).then(
|
||
(res) => ((r.reached = true), res),
|
||
(e) => {
|
||
r.failed ??= e
|
||
throw e
|
||
},
|
||
)
|
||
}, bunFetch)
|
||
|
||
// ---- auth.json ---------------------------------------------------------------
|
||
|
||
function readAuth() {
|
||
let text
|
||
for (let i = 0; ; i++) {
|
||
try {
|
||
text = fs.readFileSync(authPath, "utf8")
|
||
break
|
||
} catch (e) {
|
||
if (e?.code === "ENOENT") return {}
|
||
// Windows refuses a file being renamed over for a moment: read as
|
||
// none, the next setAuth would write the others' accounts away
|
||
if (i >= 50) throw e
|
||
pause(10)
|
||
}
|
||
}
|
||
try {
|
||
const v = JSON.parse(text)
|
||
return v && typeof v === "object" ? v : {}
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
function writeAuth(all) {
|
||
fs.mkdirSync(path.dirname(authPath), { recursive: true })
|
||
const tmp = authPath + ".tmp-" + process.pid
|
||
fs.writeFileSync(tmp, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 })
|
||
// and a file held open by a reader refuses to be renamed over
|
||
for (let i = 0; ; i++) {
|
||
try {
|
||
return fs.renameSync(tmp, authPath)
|
||
} catch (e) {
|
||
if (i >= 50 || !["EPERM", "EACCES", "EBUSY"].includes(e?.code)) throw e
|
||
pause(10)
|
||
}
|
||
}
|
||
}
|
||
|
||
function pause(ms) {
|
||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
||
}
|
||
|
||
// Two hosts run at once while one is restarted (the old one finishing its
|
||
// calls), and magpie writes the file when no host runs: each change to it
|
||
// is made under plugin-auth.json.lock, read afresh and written back with
|
||
// only its own accounts changed, so a host never writes another's newer
|
||
// token away with what it read before. Nothing awaits under the lock; one
|
||
// held longer than AUTH_LOCK_STALE was left by a host that died.
|
||
const AUTH_LOCK_STALE = 10 * 1000
|
||
|
||
function lockAuth() {
|
||
const lock = authPath + ".lock"
|
||
fs.mkdirSync(path.dirname(authPath), { recursive: true })
|
||
for (const start = Date.now(); ; ) {
|
||
try {
|
||
fs.closeSync(fs.openSync(lock, "wx", 0o600))
|
||
return () => {
|
||
try {
|
||
fs.unlinkSync(lock)
|
||
} catch {}
|
||
}
|
||
} catch (e) {
|
||
if (e?.code !== "EEXIST" && e?.code !== "EPERM" && e?.code !== "EACCES") throw e
|
||
}
|
||
let old = false
|
||
try {
|
||
old = Date.now() - fs.statSync(lock).mtimeMs > AUTH_LOCK_STALE
|
||
} catch {}
|
||
if (old || Date.now() - start > 2 * AUTH_LOCK_STALE) {
|
||
try {
|
||
fs.unlinkSync(lock)
|
||
} catch {}
|
||
}
|
||
pause(5)
|
||
}
|
||
}
|
||
|
||
// changeAuth runs change on the file as it is now, under the lock, and
|
||
// writes it back when change says it changed it.
|
||
function changeAuth(change) {
|
||
const unlock = lockAuth()
|
||
try {
|
||
const all = readAuth()
|
||
const out = change(all)
|
||
if (out !== false) writeAuth(all)
|
||
return out
|
||
} finally {
|
||
unlock()
|
||
}
|
||
}
|
||
|
||
function setAuth(key, info) {
|
||
changeAuth((all) => {
|
||
all[key] = info
|
||
})
|
||
loaders.delete(key)
|
||
send({ event: "auth", provider: providerOf(key), account: key })
|
||
}
|
||
|
||
function removeAuth(key) {
|
||
changeAuth((all) => {
|
||
delete all[key]
|
||
})
|
||
loaders.delete(key)
|
||
send({ event: "auth", provider: providerOf(key), account: key })
|
||
}
|
||
|
||
// ---- accounts ----------------------------------------------------------------
|
||
|
||
// scope is the account a request is for: {provider, key}.
|
||
const scope = new AsyncLocalStorage()
|
||
|
||
const providerOf = (key) => key.split("#")[0]
|
||
|
||
// accountsOf are the keys provider's accounts are kept under, the one under
|
||
// its own id first.
|
||
function accountsOf(all, provider) {
|
||
return Object.keys(all)
|
||
.filter((k) => k === provider || k.startsWith(provider + "#"))
|
||
.sort((a, b) => (a === provider ? -1 : b === provider ? 1 : a < b ? -1 : a > b ? 1 : 0))
|
||
}
|
||
|
||
// keyFor is where the plugin's id is kept in this scope: the scope's
|
||
// account when the plugin names the scope's provider, else its own id.
|
||
function keyFor(id) {
|
||
const s = scope.getStore()
|
||
return s && s.provider === id ? s.key : id
|
||
}
|
||
|
||
// accountKey is the account a request names, the provider's first when it
|
||
// names none.
|
||
function accountKey(provider, account) {
|
||
if (account && providerOf(account) === provider) return account
|
||
return accountsOf(readAuth(), provider)[0] ?? provider
|
||
}
|
||
|
||
// freshKey is where a new sign-in to provider goes: its own id while that
|
||
// is free.
|
||
function freshKey(provider) {
|
||
const all = readAuth()
|
||
if (!(provider in all)) return provider
|
||
for (;;) {
|
||
const k = provider + "#" + Math.random().toString(36).slice(2, 8)
|
||
if (!(k in all)) return k
|
||
}
|
||
}
|
||
|
||
function inScope(provider, key, fn) {
|
||
return scope.run({ provider, key }, fn)
|
||
}
|
||
|
||
function whoOf(a) {
|
||
return a?.accountId ?? a?.metadata?.email ?? a?.email ?? ""
|
||
}
|
||
|
||
// hintOf tells an account with no id from another: the end of its key.
|
||
function hintOf(a) {
|
||
return a?.type === "api" && typeof a.key === "string" && a.key.length >= 12 ? a.key.slice(-4) : ""
|
||
}
|
||
|
||
function secretOf(a) {
|
||
return a?.type === "oauth" ? a.refresh ?? a.access ?? "" : a?.key ?? ""
|
||
}
|
||
|
||
// uidOf is the vendor's id a plugin keeps beside an account's name
|
||
// (WorkBuddy's uid): two accounts of one name are told apart by it (#413).
|
||
function uidOf(a) {
|
||
return typeof a?.uid === "string" ? a.uid : ""
|
||
}
|
||
|
||
// settle keeps a sign-in just saved at key once: one to an account already
|
||
// signed in (the same account id and, where both have one, the same uid,
|
||
// else the same secret) replaces that one's and goes. It gives where it is
|
||
// kept.
|
||
function settle(provider, key) {
|
||
const k = changeAuth((all) => {
|
||
const now = all[key]
|
||
if (!now) return false
|
||
const who = whoOf(now)
|
||
const secret = secretOf(now)
|
||
for (const k of accountsOf(all, provider)) {
|
||
if (k === key) continue
|
||
const was = all[k]
|
||
const other = uidOf(now) && uidOf(was) && uidOf(now) !== uidOf(was)
|
||
if ((who && whoOf(was) === who && !other) || (!who && !whoOf(was) && secret && secretOf(was) === secret)) {
|
||
all[k] = now
|
||
delete all[key]
|
||
return k
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
if (k) {
|
||
loaders.delete(k)
|
||
loaders.delete(key)
|
||
send({ event: "auth", provider, account: k })
|
||
return k
|
||
}
|
||
return key
|
||
}
|
||
|
||
// ---- the client plugins are given --------------------------------------------
|
||
|
||
function stub(name) {
|
||
return new Proxy(async () => ({ data: undefined }), {
|
||
get(_, key) {
|
||
if (key === "then") return undefined
|
||
return stub(name + "." + String(key))
|
||
},
|
||
apply() {
|
||
return Promise.resolve({ data: undefined })
|
||
},
|
||
})
|
||
}
|
||
|
||
function makeClient() {
|
||
const known = {
|
||
auth: {
|
||
// OpenCode's SDK: auth.set({path: {id}, body})
|
||
set: async (opts) => {
|
||
const id = opts?.path?.id ?? opts?.id ?? opts?.providerID
|
||
const body = opts?.body ?? opts?.auth
|
||
if (typeof id === "string" && body && typeof body === "object") {
|
||
// OpenCode keeps what the plugin gives it, saving refreshed
|
||
// tokens over the old; nothing is merged. It goes to the
|
||
// account the request is for.
|
||
const key = keyFor(id)
|
||
const kept = changeAuth((all) => {
|
||
if (JSON.stringify(all[key]) === JSON.stringify(body)) return false
|
||
all[key] = body
|
||
})
|
||
if (kept !== false) {
|
||
loaders.delete(key)
|
||
send({ event: "auth", provider: providerOf(key), account: key })
|
||
}
|
||
}
|
||
return { data: true }
|
||
},
|
||
remove: async (opts) => {
|
||
const id = opts?.path?.id ?? opts?.id
|
||
if (typeof id === "string") removeAuth(keyFor(id))
|
||
return { data: true }
|
||
},
|
||
},
|
||
tui: {
|
||
showToast: async (opts) => {
|
||
const b = opts?.body ?? opts ?? {}
|
||
send({ event: "toast", title: b.title ?? "", message: String(b.message ?? ""), variant: b.variant ?? "info" })
|
||
return { data: true }
|
||
},
|
||
},
|
||
app: {
|
||
log: async (opts) => {
|
||
const b = opts?.body ?? opts ?? {}
|
||
send({ event: "log", level: b.level ?? "info", message: `[${b.service ?? "plugin"}] ${b.message ?? ""}` })
|
||
return { data: true }
|
||
},
|
||
},
|
||
config: {
|
||
get: async () => ({ data: config }),
|
||
},
|
||
}
|
||
const wrap = (obj, name) =>
|
||
new Proxy(obj, {
|
||
get(target, key) {
|
||
if (key in target) {
|
||
const v = target[key]
|
||
return v && typeof v === "object" ? wrap(v, name + "." + String(key)) : v
|
||
}
|
||
if (key === "then") return undefined
|
||
return stub(name + "." + String(key))
|
||
},
|
||
})
|
||
return wrap(known, "client")
|
||
}
|
||
|
||
// ---- loading plugins ---------------------------------------------------------
|
||
|
||
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
|
||
|
||
function readJSON(file) {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(file, "utf8"))
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
// entries are the files a plugin's server side may load from, as
|
||
// OpenCode looks for them: the package's exports["./server"], its main,
|
||
// exports["."], else an index file; a path to a file is that file. A
|
||
// package whose ./server is written for OpenCode's next plugin API
|
||
// (Plugin.define, opencode-gemini-auth 2) keeps its v1 plugin at main.
|
||
function entries(target) {
|
||
const stat = fs.statSync(target, { throwIfNoEntry: false })
|
||
if (!stat) throw new Error(`no such plugin: ${target}`)
|
||
if (!stat.isDirectory()) return [target]
|
||
const out = []
|
||
const add = (v) => typeof v === "string" && v.trim() && out.push(path.resolve(target, v.trim()))
|
||
const pick = (x) => (typeof x === "string" ? x : x?.import ?? x?.default)
|
||
const pkg = readJSON(path.join(target, "package.json"))
|
||
if (pkg) {
|
||
const ex = pkg.exports && typeof pkg.exports === "object" && !Array.isArray(pkg.exports) ? pkg.exports : undefined
|
||
if (ex) add(pick(ex["./server"]))
|
||
add(pkg.main)
|
||
if (ex) add(pick(ex["."]))
|
||
if (typeof pkg.exports === "string") add(pkg.exports)
|
||
}
|
||
for (const f of INDEX_FILES) {
|
||
const p = path.join(target, f)
|
||
if (fs.existsSync(p)) out.push(p)
|
||
}
|
||
if (out.length === 0) throw new Error(`plugin ${target} has no entry (package.json main, exports or index file)`)
|
||
return [...new Set(out)]
|
||
}
|
||
|
||
// servers are the plugin functions a module exports: default {id?,
|
||
// server} (v1), else every function it exports, or {server} objects
|
||
// (legacy), each once.
|
||
function servers(mod) {
|
||
const d = mod.default
|
||
if (d && typeof d === "object" && ("server" in d || "id" in d || "tui" in d)) {
|
||
return typeof d.server === "function" ? [d.server] : []
|
||
}
|
||
const seen = new Set()
|
||
const out = []
|
||
for (const v of Object.values(mod)) {
|
||
if (seen.has(v)) continue
|
||
seen.add(v)
|
||
if (typeof v === "function") out.push(v)
|
||
else if (v && typeof v === "object" && typeof v.server === "function") out.push(v.server)
|
||
}
|
||
return out
|
||
}
|
||
|
||
async function loadPlugins(list) {
|
||
const input = {
|
||
client: makeClient(),
|
||
project: { id: "magpie", worktree: directory, vcs: undefined, time: { created: Date.now() } },
|
||
directory,
|
||
worktree: directory,
|
||
experimental_workspace: { register() {} },
|
||
serverUrl: new URL("http://127.0.0.1:4096"),
|
||
$: Bun.$,
|
||
}
|
||
// pi's packages and extensions are loaded as pi loads them (pi.js)
|
||
let pi
|
||
if (piPath) {
|
||
try {
|
||
pi = await import(pathToFileURL(piPath).href)
|
||
} catch (e) {
|
||
toErr("pi.js:", e)
|
||
}
|
||
}
|
||
const pis = pi ? list.filter((p) => pi.isPi(p.target)) : []
|
||
if (pis.length) {
|
||
const h = { readAuth, changeAuth, keyFor, send, directory }
|
||
const got = await pi.load(h, pis).catch((e) => pis.map((p) => ({ spec: p.spec, error: String(e?.stack ?? e) })))
|
||
for (const r of got) {
|
||
for (const x of r.hooks ?? []) hooks.push(x)
|
||
loaded.push(r.error ? { spec: r.spec, error: r.error } : { spec: r.spec })
|
||
}
|
||
}
|
||
for (const p of list) {
|
||
if (pis.includes(p)) continue
|
||
try {
|
||
let fns = []
|
||
for (const file of entries(p.target)) {
|
||
fns = servers(await import(pathToFileURL(file).href))
|
||
if (fns.length) break
|
||
}
|
||
if (fns.length === 0) throw new Error("exports no OpenCode plugin function")
|
||
for (const fn of fns) hooks.push({ spec: p.spec, target: p.target, hooks: (await fn(input, p.options)) ?? {} })
|
||
loaded.push({ spec: p.spec })
|
||
} catch (e) {
|
||
loaded.push({ spec: p.spec, error: String(e?.stack ?? e) })
|
||
}
|
||
}
|
||
config = { provider: {}, ...structuredClone(userConfig) }
|
||
for (const h of hooks) {
|
||
if (typeof h.hooks.config !== "function") continue
|
||
try {
|
||
await h.hooks.config(config)
|
||
} catch (e) {
|
||
send({ event: "log", level: "error", message: `${h.spec}: config hook: ${e?.message ?? e}` })
|
||
}
|
||
}
|
||
}
|
||
|
||
// auths are the auth hooks by provider, the last plugin to name one
|
||
// winning, as in OpenCode.
|
||
function auths() {
|
||
const m = new Map()
|
||
for (const h of hooks) if (h.hooks.auth?.provider) m.set(h.hooks.auth.provider, { spec: h.spec, target: h.target, auth: h.hooks.auth })
|
||
return m
|
||
}
|
||
|
||
function authOf(provider) {
|
||
const a = auths().get(provider)
|
||
if (!a) throw new Error(`no plugin signs in to ${provider}`)
|
||
return a.auth
|
||
}
|
||
|
||
// ---- providers and models ----------------------------------------------------
|
||
|
||
let modelsDev
|
||
function mdev() {
|
||
if (modelsDev === undefined) modelsDev = (modelsDevPath && readJSON(modelsDevPath)) || {}
|
||
return modelsDev
|
||
}
|
||
|
||
function cost(c) {
|
||
return {
|
||
input: c?.input ?? 0,
|
||
output: c?.output ?? 0,
|
||
cache: { read: c?.cache_read ?? c?.cache?.read ?? 0, write: c?.cache_write ?? c?.cache?.write ?? 0 },
|
||
}
|
||
}
|
||
|
||
// fromModelsDev is a models.dev model as OpenCode's provider list has it.
|
||
function fromModelsDev(p, m) {
|
||
return {
|
||
id: m.id,
|
||
providerID: p.id,
|
||
name: m.name ?? m.id,
|
||
family: m.family,
|
||
api: { id: m.id, url: m.provider?.api ?? p.api ?? "", npm: m.provider?.npm ?? p.npm ?? "@ai-sdk/openai-compatible" },
|
||
status: m.status ?? "active",
|
||
headers: {},
|
||
options: {},
|
||
cost: cost(m.cost),
|
||
limit: { context: m.limit?.context ?? 0, input: m.limit?.input, output: m.limit?.output ?? 0 },
|
||
capabilities: {
|
||
temperature: m.temperature ?? false,
|
||
reasoning: m.reasoning ?? false,
|
||
attachment: m.attachment ?? false,
|
||
toolcall: m.tool_call ?? true,
|
||
input: {
|
||
text: true,
|
||
image: (m.modalities?.input ?? []).includes("image"),
|
||
audio: (m.modalities?.input ?? []).includes("audio"),
|
||
video: (m.modalities?.input ?? []).includes("video"),
|
||
pdf: (m.modalities?.input ?? []).includes("pdf"),
|
||
},
|
||
output: { text: true, image: false, audio: false, video: false, pdf: false },
|
||
interleaved: m.interleaved ?? false,
|
||
},
|
||
release_date: m.release_date ?? "",
|
||
variants: {},
|
||
}
|
||
}
|
||
|
||
// info is the provider as OpenCode builds it: models.dev's entry, what
|
||
// the config (with the plugins' config hooks) says of it, the plugin's
|
||
// provider.models hook.
|
||
// info is what OpenCode knows of provider id, its models as the plugin
|
||
// lists them for account key (the first when none); strict fails where the
|
||
// plugin's list does, rather than keeping the list it was given.
|
||
async function info(id, key, strict) {
|
||
const md = mdev()[id]
|
||
const cfg = config.provider?.[id]
|
||
const out = {
|
||
id,
|
||
name: cfg?.name ?? md?.name ?? id,
|
||
source: md ? "api" : "config",
|
||
env: cfg?.env ?? md?.env ?? [],
|
||
options: { ...(cfg?.options ?? {}) },
|
||
npm: cfg?.npm ?? md?.npm,
|
||
api: cfg?.api ?? md?.api,
|
||
models: {},
|
||
}
|
||
if (md) for (const m of Object.values(md.models ?? {})) out.models[m.id] = fromModelsDev(md, m)
|
||
for (const [key, m] of Object.entries(cfg?.models ?? {})) {
|
||
const was = out.models[m.id ?? key]
|
||
const npm = m.provider?.npm ?? cfg.npm ?? was?.api.npm ?? md?.npm ?? "@ai-sdk/openai-compatible"
|
||
out.models[key] = {
|
||
...(was ?? {}),
|
||
id: key,
|
||
providerID: id,
|
||
name: m.name ?? was?.name ?? key,
|
||
api: { id: m.id ?? was?.api.id ?? key, url: m.provider?.api ?? cfg.api ?? was?.api.url ?? md?.api ?? "", npm },
|
||
cost: m.cost ? cost(m.cost) : was?.cost ?? cost(),
|
||
limit: { ...(was?.limit ?? { context: 0, output: 0 }), ...(m.limit ?? {}) },
|
||
options: { ...(was?.options ?? {}), ...(m.options ?? {}) },
|
||
headers: { ...(was?.headers ?? {}), ...(m.headers ?? {}) },
|
||
capabilities: {
|
||
...(was?.capabilities ?? { temperature: true, toolcall: true, input: { text: true }, output: { text: true } }),
|
||
...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
|
||
...(m.attachment !== undefined ? { attachment: m.attachment } : {}),
|
||
...(m.tool_call !== undefined ? { toolcall: m.tool_call } : {}),
|
||
...(m.modalities?.input ? { input: Object.fromEntries(["text", "image", "audio", "video", "pdf"].map((k) => [k, m.modalities.input.includes(k)])) } : {}),
|
||
},
|
||
variants: m.variants ?? was?.variants ?? {},
|
||
}
|
||
}
|
||
for (const h of hooks) {
|
||
const ph = h.hooks.provider
|
||
if (ph?.id !== id || typeof ph.models !== "function") continue
|
||
const k = key ?? accountsOf(readAuth(), id)[0] ?? id
|
||
await fresh(id, k)
|
||
const all = readAuth()
|
||
try {
|
||
const given = JSON.parse(JSON.stringify(out))
|
||
const l = { tried: false, lastOk: false }
|
||
const next = await listing.run(l, () => inScope(id, k, () => ph.models(given, { auth: all[k] })))
|
||
// a hook that asked its vendor, got no list and gave back the one it
|
||
// was given fell back: magpie keeps the list it had, as a built-in
|
||
// whose fetch failed keeps the one it fetched last; so does one that
|
||
// says so, handing back a list of its own (Symbol.for("magpie.fellBack")
|
||
// on it: Command Code's Go table, ZCode's models)
|
||
out.fellBack = (next === given.models && l.tried && !l.lastOk) || next?.[Symbol.for("magpie.fellBack")] === true
|
||
out.models = Object.fromEntries(Object.entries(next ?? {}).map(([k, m]) => [k, { ...m, id: k, providerID: id }]))
|
||
} catch (e) {
|
||
// an error the models hook throws may say what it means for the
|
||
// sign-in, as an answer's X-Magpie-Sign-In does: a built-in whose
|
||
// model list the vendor refused marked the account
|
||
if (["expired", "kept", "renewed"].includes(e?.signIn) && all[k]) send({ event: "signIn", provider: id, account: k, said: e.signIn })
|
||
// strict (a move's check) fails on what the account can't do, but
|
||
// a sign-in the vendor refused isn't that: it is marked, as the
|
||
// built-in marked it, and goes along untried, as a lapsed one does
|
||
if (strict && !(e?.signIn === "expired" && all[k])) throw e
|
||
const r = reach.getStore()
|
||
if (r && e?.signIn === "expired") r.refused ??= e?.message ?? String(e)
|
||
// a hook that threw on its vendor's failure has no list to tell:
|
||
// magpie keeps the one it had, as for one that gave its defaults back
|
||
out.fellBack = true
|
||
send({ event: "log", level: "error", message: `${h.spec}: provider.models: ${e?.message ?? e}` })
|
||
}
|
||
}
|
||
for (const [k, m] of Object.entries(cfg?.models ?? {})) if (m?.disabled) delete out.models[k]
|
||
return out
|
||
}
|
||
|
||
// shown is a plugin's string as magpie shows it: trimmed and at most n
|
||
// characters, "" for anything else.
|
||
const shown = (v, n) => (typeof v === "string" ? v.trim().slice(0, n) : "")
|
||
|
||
// methods are the auth hook's ways to sign in. An "api" one may say what
|
||
// its key looks like (placeholder, magpie's own: OpenCode's dialog says
|
||
// "API key"); its label titles the key's field, as OpenCode's does.
|
||
function methods(auth) {
|
||
return (auth.methods ?? []).map((m) => ({
|
||
type: m.type,
|
||
label: m.label,
|
||
...(m.type === "api" && shown(m.placeholder, 200) ? { placeholder: shown(m.placeholder, 200) } : {}),
|
||
}))
|
||
}
|
||
|
||
// iconOf is the picture a plugin gives its provider, magpie's own field
|
||
// (OpenCode's providers have none): the auth hook's icon, else
|
||
// package.json's magpie.icon. An https URL or a data:image URI; magpie
|
||
// checks and keeps it (internal/provider), nothing is fetched here.
|
||
function iconOf(a) {
|
||
const ok = (v) => {
|
||
const s = typeof v === "string" ? v.trim() : ""
|
||
// a data URI of a picture over 1 MB, magpie's most, isn't carried
|
||
return s.length <= 3 << 19 && /^(https:\/\/|data:image\/)/i.test(s) ? s : ""
|
||
}
|
||
const own = ok(a.auth.icon)
|
||
if (own || !a.target) return own
|
||
const dir = fs.statSync(a.target, { throwIfNoEntry: false })?.isDirectory() ? a.target : path.dirname(a.target)
|
||
return ok(readJSON(path.join(dir, "package.json"))?.magpie?.icon)
|
||
}
|
||
|
||
// concurrencyOf is how many requests the plugin says each of its accounts
|
||
// takes at once, magpie's own field: the auth hook's maxConcurrency, else
|
||
// package.json's magpie.maxConcurrency. A whole number over 0, else none
|
||
// (0); the user's setting on the provider goes over it.
|
||
function concurrencyOf(a) {
|
||
const ok = (v) => (Number.isInteger(v) && v > 0 ? Math.min(v, 1000) : 0)
|
||
const own = ok(a.auth.maxConcurrency)
|
||
if (own || !a.target) return own
|
||
const dir = fs.statSync(a.target, { throwIfNoEntry: false })?.isDirectory() ? a.target : path.dirname(a.target)
|
||
return ok(readJSON(path.join(dir, "package.json"))?.magpie?.maxConcurrency)
|
||
}
|
||
|
||
// rateOf reads a model's credit rate as a plugin gives it: a number (0.5)
|
||
// or as the vendor's picker writes it ("x0.03", "0.5×"); 0 is none, as is
|
||
// one it can't read
|
||
function rateOf(v) {
|
||
const n = typeof v === "string" ? Number(v.trim().replace(/^[x×]\s*|\s*[x×]$/gi, "")) : v
|
||
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0
|
||
}
|
||
|
||
// providers lists each provider and its accounts' models, each asked
|
||
// through its proxy (proxies[provider][key], "" the provider's own), as a
|
||
// built-in fetches each account's list through the account's.
|
||
async function providers({ proxies } = {}) {
|
||
const stored = readAuth()
|
||
const out = []
|
||
for (const [id, a] of auths()) {
|
||
const keys = accountsOf(stored, id)
|
||
const through = (k) => proxies?.[id]?.[k ?? keys[0] ?? ""] ?? proxies?.[id]?.[""] ?? ""
|
||
const p = await via.run(through(), () => info(id))
|
||
// each account's own models, as the built-ins read each account's: a
|
||
// plan may serve fewer, or others, than the first account's. One that
|
||
// can't be read is taken to have them all, or the ones it was last
|
||
// told to have, as a built-in account whose fetch failed keeps its own.
|
||
const own = await Promise.all(keys.slice(1).map((k) => via.run(through(k), () => info(id, k, true)).catch(() => ({ failed: true }))))
|
||
const models = { ...p.models }
|
||
for (const q of own) for (const [k, m] of Object.entries(q?.models ?? {})) models[k] ??= m
|
||
const ids = (q) => Object.values(q.models).filter((m) => m.status !== "deprecated").map((m) => m.id)
|
||
const first = stored[keys[0]]
|
||
out.push({
|
||
id,
|
||
spec: a.spec,
|
||
name: p.name,
|
||
npm: p.npm ?? "",
|
||
api: p.api ?? "",
|
||
methods: methods(a.auth),
|
||
icon: iconOf(a),
|
||
usage: typeof a.auth.usage === "function",
|
||
maxConcurrency: concurrencyOf(a),
|
||
signedIn: keys.length > 0,
|
||
authType: first?.type ?? "",
|
||
accountId: whoOf(first),
|
||
fellBack: keys.length > 0 && !!p.fellBack,
|
||
accounts: keys.map((k, i) => ({
|
||
key: k,
|
||
type: stored[k]?.type ?? "",
|
||
accountId: whoOf(stored[k]),
|
||
hint: hintOf(stored[k]),
|
||
models: own.length === 0 ? undefined : i === 0 ? ids(p) : own[i - 1]?.models ? ids(own[i - 1]) : undefined,
|
||
fellBack: i === 0 ? !!p.fellBack : !!(own[i - 1]?.fellBack || own[i - 1]?.failed),
|
||
})),
|
||
models: Object.values(models)
|
||
.filter((m) => m.status !== "deprecated")
|
||
.map((m) => ({
|
||
id: m.id,
|
||
name: m.name,
|
||
npm: m.api?.npm ?? p.npm ?? "",
|
||
url: m.api?.url ?? "",
|
||
apiId: m.api?.id ?? m.id,
|
||
context: m.limit?.context ?? 0,
|
||
input: m.limit?.input ?? 0,
|
||
output: m.limit?.output ?? 0,
|
||
reasoning: !!m.capabilities?.reasoning,
|
||
image: !!m.capabilities?.input?.image,
|
||
imageSaid: typeof m.capabilities?.input?.image === "boolean",
|
||
released: m.release_date ?? "",
|
||
cost: m.cost,
|
||
variants: Object.keys(m.variants ?? {}),
|
||
free: m.free === true,
|
||
// what a request costs of the plan's credits, as a multiple,
|
||
// and before a discount running now (Qoder's price_factor)
|
||
rate: rateOf(m.rate),
|
||
rateWas: rateOf(m.rateWas),
|
||
})),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ---- signing in --------------------------------------------------------------
|
||
|
||
function applies(prompt, inputs) {
|
||
if (prompt.when) {
|
||
const v = inputs[prompt.when.key]
|
||
if (v === undefined) return false
|
||
const eq = v === prompt.when.value
|
||
if (prompt.when.op === "eq" ? !eq : eq) return false
|
||
}
|
||
if (typeof prompt.condition === "function" && !prompt.condition(inputs)) return false
|
||
return true
|
||
}
|
||
|
||
// nextPrompt is the method's next question for inputs so far, as
|
||
// OpenCode's CLI asks them: in order, those whose when/condition hold. A
|
||
// method may ask its own way (magpie's hook, which pi's sign-ins use, as
|
||
// they ask as they go): ask(inputs) → the next question, or null.
|
||
async function nextPrompt(provider, index, inputs) {
|
||
const m = authOf(provider).methods[index]
|
||
if (!m) throw new Error(`no sign-in method ${index} for ${provider}`)
|
||
if (typeof m.ask === "function") return (await m.ask(inputs)) ?? null
|
||
for (const p of m.prompts ?? []) {
|
||
if (p.key in inputs) continue
|
||
if (!applies(p, inputs)) continue
|
||
return {
|
||
type: p.type,
|
||
key: p.key,
|
||
message: p.message,
|
||
placeholder: p.placeholder ?? "",
|
||
options: p.type === "select" ? p.options.map((o) => ({ label: o.label, value: o.value, hint: o.hint ?? "" })) : undefined,
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function validate(provider, index, key, value) {
|
||
const p = (authOf(provider).methods[index]?.prompts ?? []).find((x) => x.key === key)
|
||
if (!p || typeof p.validate !== "function") return null
|
||
return p.validate(value) ?? null
|
||
}
|
||
|
||
// save keeps a successful sign-in as OpenCode's CLI does, at key (or, for
|
||
// a sign-in the plugin says is another provider's, a new account of
|
||
// that one). It gives the provider and where the account is kept.
|
||
function save(provider, key, result, inputs, apiKey) {
|
||
const id = result.provider ?? provider
|
||
if (id !== provider) key = freshKey(id)
|
||
if ("refresh" in result) {
|
||
const { type: _t, provider: _p, refresh, access, expires, ...extra } = result
|
||
setAuth(key, { type: "oauth", refresh, access, expires, ...extra })
|
||
} else if ("key" in result || apiKey) {
|
||
const md = { ...(inputs && Object.keys(inputs).length ? inputs : {}), ...(result.metadata ?? {}) }
|
||
setAuth(key, { type: "api", key: result.key ?? apiKey, ...(Object.keys(md).length ? { metadata: md } : {}) })
|
||
}
|
||
return { provider: id, account: settle(id, key) }
|
||
}
|
||
|
||
// signInKey is where a sign-in goes: the account named, else a new one.
|
||
function signInKey(provider, account) {
|
||
return account && account !== "new" && providerOf(account) === provider ? account : freshKey(provider)
|
||
}
|
||
|
||
let nextSession = 1
|
||
|
||
async function authorize({ provider, method, inputs, account }) {
|
||
const m = authOf(provider).methods[method]
|
||
if (!m) throw new Error(`no sign-in method ${method} for ${provider}`)
|
||
if (m.type !== "oauth") throw new Error("not an oauth method")
|
||
const key = signInKey(provider, account)
|
||
// the inputs go only to a method that asks something, as OpenCode's TUI
|
||
// (/connect) passes them: an object with none is how its CLI (opencode
|
||
// auth login) calls, and a plugin told so asks its questions on the
|
||
// terminal, magpie's stdin here (opencode-antigravity-auth waited on its
|
||
// "Project ID" prompt, the sign-in never starting)
|
||
const a = await inScope(provider, key, () => m.authorize(m.prompts?.length ? inputs ?? {} : undefined))
|
||
const session = String(nextSession++)
|
||
sessions.set(session, { provider, key, a, inputs })
|
||
return { session, url: a.url ?? "", instructions: a.instructions ?? "", method: a.method }
|
||
}
|
||
|
||
// failed is a sign-in's failure, with why, where the plugin tells it
|
||
// ({ type: "failed", error: "…" }; OpenCode's own result has no reason).
|
||
function failed(r) {
|
||
const why = typeof r?.error === "string" ? r.error.trim() : r?.error instanceof Error ? r.error.message : ""
|
||
return why ? { ok: false, error: why.slice(0, 500) } : { ok: false }
|
||
}
|
||
|
||
async function callback({ session, code }) {
|
||
const s = sessions.get(session)
|
||
if (!s) throw new Error("no such sign-in")
|
||
sessions.delete(session)
|
||
const r = await inScope(s.provider, s.key, () => (s.a.method === "code" ? s.a.callback(code ?? "") : s.a.callback()))
|
||
if (!r || r.type !== "success") return failed(r)
|
||
return { ok: true, ...save(s.provider, s.key, r, undefined) }
|
||
}
|
||
|
||
async function apiKey({ provider, method, inputs, key, account }) {
|
||
const m = authOf(provider).methods[method]
|
||
if (!m || m.type !== "api") throw new Error("not an API key method")
|
||
const at = signInKey(provider, account)
|
||
if (typeof m.authorize !== "function") {
|
||
const md = inputs && Object.keys(inputs).length ? { metadata: inputs } : {}
|
||
setAuth(at, { type: "api", key, ...md })
|
||
return { ok: true, provider, account: settle(provider, at) }
|
||
}
|
||
const r = await inScope(provider, at, () => m.authorize(inputs ?? {}))
|
||
if (!r || r.type !== "success") return failed(r)
|
||
return { ok: true, ...save(provider, at, r, inputs, key) }
|
||
}
|
||
|
||
// ---- renewing a sign-in ------------------------------------------------------
|
||
|
||
// A plugin may leave renewing its accounts' tokens to magpie (magpie's own
|
||
// hook, which OpenCode ignores):
|
||
// auth.refresh(auth, provider) → the account's sign-in renewed: the
|
||
// fields to keep over the old ({ access, refresh, expires, … }), or
|
||
// nothing when there is nothing to renew. It throws when it can't; an
|
||
// error with signIn "expired" says the vendor turned the sign-in away
|
||
// for good, and the account is marked.
|
||
// auth.refreshLead: how long (ms) before expires a token is renewed,
|
||
// 5 minutes when not said.
|
||
// An OAuth sign-in whose expires is that close is renewed before its
|
||
// loader, models, usage or a request runs, once at a time per account: the
|
||
// requests that find it due wait for the one renewal (a vendor that spends
|
||
// a refresh token once turns a second away). A renewal that fails leaves
|
||
// the sign-in as it was, for the vendor's answer to tell, and isn't tried
|
||
// again for RENEW_RETRY, or, turned away for good, till it is signed in
|
||
// again.
|
||
|
||
const RENEW_LEAD = 5 * 60 * 1000
|
||
const RENEW_WAIT = 15 * 1000
|
||
const RENEW_RETRY = 30 * 1000
|
||
|
||
function leadOf(a) {
|
||
const v = a?.refreshLead
|
||
return Number.isFinite(v) && v >= 0 ? Math.min(v, 24 * 3600e3) : RENEW_LEAD
|
||
}
|
||
|
||
// renewAt is when the sign-in at key is due to be renewed, 0 for never: an
|
||
// OAuth one with an expiry, of a plugin that renews through magpie.
|
||
function renewAt(a, stored) {
|
||
if (typeof a?.refresh !== "function" || stored?.type !== "oauth") return 0
|
||
const exp = Number(stored.expires)
|
||
return Number.isFinite(exp) && exp > 0 ? Math.max(1, exp - leadOf(a)) : 0
|
||
}
|
||
|
||
// fresh renews the sign-in at key when it is due, waiting at most
|
||
// RENEW_WAIT for it: a renewal that takes longer goes on, and is kept when
|
||
// it ends, but what waits for it goes on with the sign-in as it is.
|
||
async function fresh(provider, key) {
|
||
const a = auths().get(provider)?.auth
|
||
const stored = readAuth()[key]
|
||
const at = renewAt(a, stored)
|
||
if (!at || Date.now() < at) return
|
||
const f = unrenewed.get(key)
|
||
if (f && f.secret === secretOf(stored) && (f.gone || Date.now() - f.at < RENEW_RETRY)) return
|
||
let r = renewing.get(key)
|
||
if (!r) {
|
||
pending++ // the host doesn't leave in the middle of it
|
||
r = renew(provider, key, a).finally(() => {
|
||
renewing.delete(key)
|
||
// nor does magpie stop it in the middle of one (host.go's stop)
|
||
send({ event: "renewing", count: renewing.size })
|
||
done()
|
||
})
|
||
renewing.set(key, r)
|
||
send({ event: "renewing", count: renewing.size })
|
||
}
|
||
let t
|
||
await Promise.race([r, new Promise((ok) => (t = setTimeout(ok, RENEW_WAIT)))])
|
||
clearTimeout(t)
|
||
}
|
||
|
||
async function renew(provider, key, a) {
|
||
const was = readAuth()[key]
|
||
const at = renewAt(a, was)
|
||
if (!at || Date.now() < at) return // renewed meanwhile
|
||
let got
|
||
try {
|
||
got = await inScope(provider, key, () => a.refresh(JSON.parse(JSON.stringify(was)), provider))
|
||
} catch (e) {
|
||
unrenewed.set(key, { at: Date.now(), secret: secretOf(was), gone: e?.signIn === "expired" })
|
||
if (e?.signIn === "expired") send({ event: "signIn", provider, account: key, said: "expired" })
|
||
send({ event: "log", level: "error", message: `${auths().get(provider)?.spec ?? provider}: auth.refresh: ${e?.message ?? e}` })
|
||
return
|
||
}
|
||
if (!got || typeof got !== "object") return
|
||
// signed out, signed in again or renewed by another host while it ran:
|
||
// that one stands. Checked under the lock the save is made under, so
|
||
// nothing comes between the two.
|
||
const { type: _t, ...fields } = got
|
||
const kept = changeAuth((all) => {
|
||
const now = all[key]
|
||
if (!now || secretOf(now) !== secretOf(was) || now.access !== was.access) return false
|
||
all[key] = { ...was, ...fields, type: "oauth" }
|
||
})
|
||
if (kept === false) return
|
||
unrenewed.delete(key)
|
||
loaders.delete(key)
|
||
send({ event: "auth", provider, account: key })
|
||
send({ event: "signIn", provider, account: key, said: "renewed" })
|
||
}
|
||
|
||
// ---- requests ----------------------------------------------------------------
|
||
|
||
// options is what the provider's auth loader returned for the account at
|
||
// key, run once per sign-in as OpenCode runs it once per start. Run in
|
||
// the account's scope, whatever the loader keeps (its fetch) saves to it.
|
||
async function options(provider, key) {
|
||
await fresh(provider, key)
|
||
if (loaders.has(key)) return loaders.get(key)
|
||
const a = auths().get(provider)?.auth
|
||
const stored = readAuth()[key]
|
||
let opts = {}
|
||
if (a?.loader && stored) {
|
||
const p = await info(provider, key)
|
||
opts = (await inScope(provider, key, () => a.loader(async () => readAuth()[key], JSON.parse(JSON.stringify(p))))) ?? {}
|
||
}
|
||
if (!opts.apiKey && stored?.type === "api") opts = { ...opts, apiKey: stored.key }
|
||
loaders.set(key, opts)
|
||
return opts
|
||
}
|
||
|
||
async function load({ provider, account, proxy }) {
|
||
const key = accountKey(provider, account)
|
||
const o = await via.run(proxy ?? "", () => inScope(provider, key, () => options(provider, key)))
|
||
return {
|
||
baseURL: typeof o.baseURL === "string" ? o.baseURL : "",
|
||
apiKey: typeof o.apiKey === "string" ? o.apiKey : "",
|
||
headers: o.headers && typeof o.headers === "object" ? o.headers : {},
|
||
fetch: typeof o.fetch === "function",
|
||
}
|
||
}
|
||
|
||
// ---- usage -------------------------------------------------------------------
|
||
|
||
// usage is how much of its allowance the account at key has used, as the
|
||
// plugin's auth.usage says (magpie's own hook, which OpenCode ignores):
|
||
// auth.usage(getAuth, provider) → {
|
||
// plan?, user? (the account as the service names it), until?,
|
||
// renew?: "auto" | "off", balance?, error?,
|
||
// resets?: { count, until?, byWindow?, fiveHour?, weekly? } (the
|
||
// rate-limit resets the account may spend),
|
||
// windows?: [{ name, used (percent, 0–100), resetsAt? (ISO or ms),
|
||
// resetSecs?, display?, amount? / limit? / unit? (the window's own
|
||
// count, when it counts in amounts: amount of limit used, in unit,
|
||
// "credits"), span? (seconds the window runs), model? (a
|
||
// word in the ids of the only models it counts), models? / notModels?
|
||
// (the ids it counts, or all but these), aside? (using it up doesn't
|
||
// stop the account) }],
|
||
// signIn?: "expired" | "kept" | "renewed" (what the read means for the
|
||
// account's sign-in, as a model request's X-Magpie-Sign-In says;
|
||
// without it an error saying to sign in again marks the account and
|
||
// a clean read clears it)
|
||
// }
|
||
// Run in the account's scope, a token it renews is saved to that account.
|
||
async function usage({ provider, account, proxy }) {
|
||
return via.run(proxy ?? "", () => usageOf(provider, account))
|
||
}
|
||
|
||
async function usageOf(provider, account) {
|
||
const a = auths().get(provider)?.auth
|
||
if (typeof a?.usage !== "function") throw new Error(`${provider}'s plugin doesn't tell its usage`)
|
||
const key = accountKey(provider, account)
|
||
if (!readAuth()[key]) throw new Error("not signed in")
|
||
await fresh(provider, key)
|
||
const p = await info(provider, key)
|
||
const u = (await inScope(provider, key, () => a.usage(async () => readAuth()[key], JSON.parse(JSON.stringify(p))))) ?? {}
|
||
const when = (v) => {
|
||
if (v === undefined || v === null || v === "") return ""
|
||
const d = new Date(typeof v === "number" && v < 1e11 ? v * 1000 : v)
|
||
return isNaN(d) ? "" : d.toISOString()
|
||
}
|
||
const text = (v) => (typeof v === "string" ? v : "")
|
||
const num = (v) => (typeof v === "number" && isFinite(v) ? v : 0)
|
||
const ids = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : [])
|
||
return {
|
||
plan: text(u.plan),
|
||
until: when(u.until),
|
||
renew: u.renew === "auto" || u.renew === "off" ? u.renew : "",
|
||
balance: text(u.balance),
|
||
error: text(u.error),
|
||
user: text(u.user),
|
||
signIn: ["expired", "kept", "renewed"].includes(u.signIn) ? u.signIn : "",
|
||
resets:
|
||
u.resets && typeof u.resets === "object"
|
||
? { count: num(u.resets.count), until: when(u.resets.until), byWindow: !!u.resets.byWindow, fiveHour: num(u.resets.fiveHour), weekly: num(u.resets.weekly) }
|
||
: null,
|
||
windows: (Array.isArray(u.windows) ? u.windows : []).map((w) => ({
|
||
name: text(w?.name),
|
||
used: Math.max(0, num(w?.used)), // past 100 when overspent
|
||
resetsAt: when(w?.resetsAt),
|
||
resetSecs: Math.max(0, Math.round(num(w?.resetSecs))),
|
||
display: text(w?.display),
|
||
amount: Math.max(0, num(w?.amount)),
|
||
limit: Math.max(0, num(w?.limit)),
|
||
unit: text(w?.unit),
|
||
span: Math.max(0, num(w?.span)),
|
||
model: text(w?.model),
|
||
models: ids(w?.models),
|
||
notModels: ids(w?.notModels),
|
||
aside: !!w?.aside,
|
||
})),
|
||
}
|
||
}
|
||
|
||
// sdkHeaders are what the AI SDK package the model is on sends of the key.
|
||
function sdkHeaders(npm, key) {
|
||
if (!key) return {}
|
||
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") return { "x-api-key": key }
|
||
if (npm === "@ai-sdk/google") return { "x-goog-api-key": key }
|
||
return { authorization: `Bearer ${key}` }
|
||
}
|
||
|
||
// bodyOf is the body as OpenCode hands it to a plugin's fetch: the string
|
||
// the AI SDK built, as the plugins reshape only a string body. One that
|
||
// isn't UTF-8 text stays bytes.
|
||
function bodyOf(b64) {
|
||
if (!b64) return undefined
|
||
const b = Buffer.from(b64, "base64")
|
||
try {
|
||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(b)
|
||
} catch {
|
||
return b
|
||
}
|
||
}
|
||
|
||
async function doFetch(id, record, params) {
|
||
const key = accountKey(params.provider, params.account)
|
||
return via.run(params.proxy ?? "", () => inScope(params.provider, key, () => fetchAs(id, record, key, params)))
|
||
}
|
||
|
||
async function fetchAs(id, record, key, params) {
|
||
const { controller: ctl, gate } = record
|
||
const { provider, model, npm, url, method, headers, body, session } = params
|
||
try {
|
||
const o = await options(provider, key)
|
||
checkFetch(record)
|
||
const h = new Headers()
|
||
for (const [k, v] of Object.entries(sdkHeaders(npm, o.apiKey))) h.set(k, v)
|
||
for (const [k, v] of Object.entries(o.headers ?? {})) h.set(k, String(v))
|
||
for (const [k, v] of Object.entries(headers ?? {})) h.set(k, v)
|
||
// chat.headers: what the plugins add to the request, theirs winning
|
||
const p = await info(provider, key)
|
||
checkFetch(record)
|
||
const m = p.models[model] ?? { id: model, providerID: provider, api: { id: model, npm } }
|
||
for (const x of hooks) {
|
||
const fn = x.hooks["chat.headers"]
|
||
if (typeof fn !== "function") continue
|
||
const out = { headers: {} }
|
||
try {
|
||
await fn(
|
||
{
|
||
sessionID: session ?? "",
|
||
agent: "build",
|
||
model: m,
|
||
provider: { source: "custom", info: p, options: o },
|
||
message: { id: "", sessionID: session ?? "", role: "user", time: { created: Date.now() }, agent: "build", model: { providerID: provider, modelID: model } },
|
||
},
|
||
out,
|
||
)
|
||
} catch (e) {
|
||
send({ event: "log", level: "error", message: `${x.spec}: chat.headers: ${e?.message ?? e}` })
|
||
}
|
||
checkFetch(record)
|
||
for (const [k, v] of Object.entries(out.headers)) h.set(k, String(v))
|
||
}
|
||
const f = typeof o.fetch === "function" ? o.fetch : fetch
|
||
// the fetch is owned since its message was read: an abort that came while
|
||
// this setup was awaiting must not let the upstream start
|
||
checkFetch(record)
|
||
const res = await f(url, {
|
||
method: method ?? "POST",
|
||
headers: h,
|
||
body: bodyOf(body),
|
||
signal: ctl.signal,
|
||
})
|
||
record.unconsumedBody = res.body
|
||
checkFetch(record)
|
||
transitionFetch(record, "streaming")
|
||
const rh = {}
|
||
res.headers.forEach((v, k) => (rh[k] = v))
|
||
const head = headCost(rh)
|
||
if (head.entries > HEAD_ENTRIES || head.n > HEAD_MAX) {
|
||
// the reply's head is past magpie's envelope: give the fetch up rather
|
||
// than write a line the host would refuse, and let the body go
|
||
throw new Error(`the reply's headers are past the host's head envelope (${HEAD_MAX} bytes or ${HEAD_ENTRIES} entries)`)
|
||
}
|
||
await sendForFetch(record, { id, event: "head", status: res.status, headers: rh })
|
||
checkFetch(record)
|
||
if (res.body) {
|
||
record.unconsumedBody = undefined
|
||
// an abort gives up this fetch's own pending write, so its wait ends and
|
||
// the loop leaves rather than parking on a stream that never drains
|
||
for await (const chunk of res.body) {
|
||
checkFetch(record)
|
||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||
for (let off = 0; off < bytes.length; ) {
|
||
// one frame at a time, no more than the reader has credited: a
|
||
// reader that stopped leaves the upstream paused here
|
||
const n = await gate.take(Math.min(MAX_FRAME, bytes.length - off))
|
||
checkFetch(record)
|
||
if (n <= 0) break
|
||
const data = bytes.subarray(off, off + n).toString("base64")
|
||
gate.spend(n + FRAME_OVERHEAD)
|
||
await sendForFetch(record, { id, event: "chunk", data })
|
||
checkFetch(record)
|
||
off += n
|
||
}
|
||
}
|
||
}
|
||
checkFetch(record)
|
||
transitionFetch(record, "finishing")
|
||
await sendForFetch(record, { id, result: null })
|
||
} catch (e) {
|
||
transitionFetch(record, "finishing")
|
||
// a fetch hook that threw on its sign-in (a refresh the vendor turned
|
||
// away) marks the account, as a built-in's refused refresh marked it
|
||
if (["expired", "kept", "renewed"].includes(e?.signIn) && key) send({ event: "signIn", provider, account: key, said: e.signIn })
|
||
// a fetch the host already gave up on needs no late error: its slot is
|
||
// gone, and queueing an uncancellable line for it would only be noise
|
||
await sendForFetch(record, { id, error: { message: String(e?.message ?? e) } })
|
||
}
|
||
}
|
||
|
||
// ---- the loop ----------------------------------------------------------------
|
||
|
||
const handlers = {
|
||
async init(p) {
|
||
authPath = p.authPath
|
||
modelsDevPath = p.modelsDevPath ?? ""
|
||
piPath = p.piPath ?? ""
|
||
directory = p.directory ?? directory
|
||
userConfig = p.config ?? {}
|
||
await loadPlugins(p.plugins ?? [])
|
||
return { plugins: loaded }
|
||
},
|
||
providers: (p) => providers(p ?? {}),
|
||
prompt: async (p) => ({ prompt: await nextPrompt(p.provider, p.method, p.inputs ?? {}) }),
|
||
validate: (p) => ({ error: validate(p.provider, p.method, p.key, p.value) }),
|
||
authorize,
|
||
callback,
|
||
apiKey,
|
||
load,
|
||
usage,
|
||
// check tries one account as a request would: its loader, then its
|
||
// models as the plugin lists them for it, then its usage, which asks the
|
||
// vendor of the account itself. refused is the models hook saying the
|
||
// vendor turned the sign-in away. A models hook may fall back to a list
|
||
// it keeps when the vendor can't be reached, so a check that reached
|
||
// none of the places it asked proves nothing, and fails.
|
||
async check(p) {
|
||
const key = accountKey(p.provider, p.account)
|
||
const r = { reached: false, failed: null, refused: null }
|
||
return via.run(p.proxy ?? "", () => reach.run(r, async () => {
|
||
await load({ provider: p.provider, account: key })
|
||
const pi = await info(p.provider, key, true)
|
||
const u = typeof auths().get(p.provider)?.auth?.usage === "function" ? await usageOf(p.provider, key) : null
|
||
if (r.failed && !r.reached) throw new Error(`couldn't reach ${pi.name}: ${r.failed?.message ?? r.failed}`)
|
||
return { models: Object.keys(pi.models), usage: u, refused: r.refused ?? "" }
|
||
}))
|
||
},
|
||
// import keeps a sign-in made elsewhere (a built-in subscription's, moved
|
||
// onto its plugin) as one more account, or as the account it already is
|
||
// import keeps a sign-in made elsewhere as one of provider's accounts;
|
||
// with a key, as that account again (one taken back)
|
||
import(p) {
|
||
if (p.key && providerOf(p.key) === p.provider) {
|
||
setAuth(p.key, p.auth)
|
||
return { account: p.key }
|
||
}
|
||
const key = freshKey(p.provider)
|
||
setAuth(key, p.auth)
|
||
return { account: settle(p.provider, key) }
|
||
},
|
||
// take gives the accounts named (else every account of the provider) and
|
||
// forgets them in one step: nothing renews a token between the two
|
||
take(p) {
|
||
const out = {}
|
||
changeAuth((all) => {
|
||
for (const k of p.accounts?.length ? p.accounts : accountsOf(all, p.provider)) {
|
||
if (!(k in all)) continue
|
||
out[k] = all[k]
|
||
delete all[k]
|
||
}
|
||
if (!Object.keys(out).length) return false
|
||
})
|
||
for (const k of Object.keys(out)) {
|
||
loaders.delete(k)
|
||
send({ event: "auth", provider: providerOf(k), account: k })
|
||
}
|
||
return { auths: out }
|
||
},
|
||
// signOut forgets the account named, else every account of the provider
|
||
signOut(p) {
|
||
const keys = p.account ? [p.account] : accountsOf(readAuth(), p.provider)
|
||
for (const k of keys) removeAuth(k)
|
||
return null
|
||
},
|
||
reload(p) {
|
||
for (const k of p.account ? [p.account] : accountsOf(readAuth(), p.provider)) loaders.delete(k)
|
||
return null
|
||
},
|
||
}
|
||
|
||
// Everything waits for init; a request is answered even when magpie has
|
||
// closed stdin behind it, the host leaving once nothing is pending.
|
||
let ready
|
||
let readyState
|
||
const readyWaiters = new Set()
|
||
function setReady(promise) {
|
||
readyState = undefined
|
||
ready = promise
|
||
const settle = (state) => {
|
||
readyState = state
|
||
for (const waiter of readyWaiters) waiter(state)
|
||
readyWaiters.clear()
|
||
}
|
||
ready.then(
|
||
() => settle({}),
|
||
(error) => settle({ error }),
|
||
)
|
||
}
|
||
function waitForReady(record) {
|
||
const signal = record.controller.signal
|
||
if (!ready) return Promise.reject(new Error("the host has not been initialised"))
|
||
return new Promise((resolve, reject) => {
|
||
const finish = (state) => {
|
||
readyWaiters.delete(finish)
|
||
signal.removeEventListener("abort", abort)
|
||
record.readyCleanup = null
|
||
if ("error" in state) reject(state.error)
|
||
else resolve()
|
||
}
|
||
const abort = () => finish({ error: new DOMException("Aborted", "AbortError") })
|
||
record.readyCleanup = abort
|
||
if (signal.aborted) abort()
|
||
else if (readyState) finish(readyState)
|
||
else {
|
||
readyWaiters.add(finish)
|
||
signal.addEventListener("abort", abort, { once: true })
|
||
}
|
||
})
|
||
}
|
||
let pending = 0
|
||
let closed = false
|
||
const done = () => {
|
||
if (--pending === 0 && closed) process.exit(0)
|
||
}
|
||
|
||
const rl = readline.createInterface({ input: process.stdin, terminal: false })
|
||
rl.on("line", (line) => {
|
||
if (!line.trim()) return
|
||
let msg
|
||
try {
|
||
msg = JSON.parse(line)
|
||
} catch {
|
||
return
|
||
}
|
||
if (msg.method === "abort") {
|
||
const id = msg.params?.id
|
||
const record = inflight.get(id)
|
||
if (record) transitionFetch(record, "cancelled")
|
||
return
|
||
}
|
||
if (msg.method === "credit") {
|
||
const record = inflight.get(msg.params?.id)
|
||
if (record?.state === "streaming") record.gate.add(msg.params?.n ?? 0)
|
||
return
|
||
}
|
||
pending++
|
||
if (msg.method === "init") {
|
||
setReady(handlers.init(msg.params ?? {}))
|
||
ready.then(
|
||
(result) => send({ id: msg.id, result }),
|
||
(e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } }),
|
||
).finally(done)
|
||
return
|
||
}
|
||
if (msg.method === "fetch") {
|
||
const id = msg.id
|
||
// the fetch is owned the moment its message is read, before the async
|
||
// setup below: an abort that comes before the upstream is reached still
|
||
// stops it, and one that comes during the await is not missed
|
||
const record = {
|
||
state: "setup", controller: new AbortController(),
|
||
gate: makeGate(msg.params?.window > 0 ? msg.params.window : 512 << 10),
|
||
pendingSend: null, readyCleanup: null, unconsumedBody: undefined,
|
||
}
|
||
inflight.set(id, record)
|
||
// waiting for the host to be ready is the host's own await: an abort must
|
||
// end it too, so a request whose setup never finishes still runs its
|
||
// finally and lets its controller, gate and body go. The shared setup
|
||
// promise itself is not cancelled — only this request's wait on it.
|
||
const start = () => {
|
||
checkFetch(record)
|
||
return doFetch(id, record, msg.params ?? {})
|
||
}
|
||
waitForReady(record).then(start)
|
||
.catch((e) => {
|
||
transitionFetch(record, "finishing")
|
||
return sendForFetch(record, { id, error: { message: String(e?.message ?? e) } })
|
||
})
|
||
.finally(() => {
|
||
transitionFetch(record, "finished")
|
||
inflight.delete(id)
|
||
done()
|
||
})
|
||
return
|
||
}
|
||
const wait = ready ?? Promise.reject(new Error("the host has not been initialised"))
|
||
const fn = handlers[msg.method]
|
||
if (!fn) {
|
||
send({ id: msg.id, error: { message: `no method ${msg.method}` } })
|
||
done()
|
||
return
|
||
}
|
||
wait
|
||
.then(() => fn(msg.params ?? {}))
|
||
.then(
|
||
(result) => send({ id: msg.id, result: result ?? null }),
|
||
(e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } }),
|
||
)
|
||
.finally(done)
|
||
})
|
||
rl.on("close", () => {
|
||
closed = true
|
||
if (pending === 0) process.exit(0)
|
||
})
|