opencode-ufr 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/ufr.ts +4 -0
  4. package/models.json +206 -0
  5. package/package.json +16 -0
  6. package/src/cli/catalog.ts +45 -0
  7. package/src/cli/connect.ts +146 -0
  8. package/src/cli/daemon-client.ts +19 -0
  9. package/src/cli/index.ts +93 -0
  10. package/src/cli/io.ts +98 -0
  11. package/src/cli/keys.ts +95 -0
  12. package/src/cli/stats.ts +37 -0
  13. package/src/cli/status.ts +48 -0
  14. package/src/cli/stop.ts +8 -0
  15. package/src/cli/ufr-check.ts +43 -0
  16. package/src/daemon/breaker.ts +146 -0
  17. package/src/daemon/catalog-source.ts +85 -0
  18. package/src/daemon/catalog.ts +127 -0
  19. package/src/daemon/daemon.ts +397 -0
  20. package/src/daemon/fallbacks.ts +41 -0
  21. package/src/daemon/keypool.ts +109 -0
  22. package/src/daemon/log.ts +15 -0
  23. package/src/daemon/main.ts +18 -0
  24. package/src/daemon/model.ts +15 -0
  25. package/src/daemon/router.ts +392 -0
  26. package/src/daemon/server.ts +54 -0
  27. package/src/daemon/stats.ts +105 -0
  28. package/src/daemon/transport.ts +21 -0
  29. package/src/daemon/upstream.ts +68 -0
  30. package/src/daemon/vpn/fortinet.ts +466 -0
  31. package/src/daemon/vpn/ip.ts +176 -0
  32. package/src/daemon/vpn/manager.ts +258 -0
  33. package/src/daemon/vpn/ppp.ts +450 -0
  34. package/src/daemon/vpn/proxy.ts +102 -0
  35. package/src/daemon/vpn/stack.ts +140 -0
  36. package/src/daemon/vpn/tcp.ts +356 -0
  37. package/src/daemon/window.ts +126 -0
  38. package/src/plugin/connect.ts +113 -0
  39. package/src/plugin/ensure-daemon.ts +101 -0
  40. package/src/plugin/index.ts +93 -0
  41. package/src/plugin/model-info.ts +35 -0
  42. package/src/shared/config.ts +170 -0
  43. package/src/shared/connect.ts +100 -0
  44. package/src/shared/errors.ts +6 -0
  45. package/src/shared/fs.ts +31 -0
  46. package/src/shared/models-file.ts +68 -0
  47. package/src/shared/paths.ts +62 -0
  48. package/src/shared/secrets.ts +62 -0
  49. package/src/shared/sleep.ts +15 -0
  50. package/src/shared/time.ts +6 -0
  51. package/src/shared/version.ts +3 -0
  52. package/src/shared/vpn.ts +8 -0
@@ -0,0 +1,93 @@
1
+ import { parseArgs } from "node:util"
2
+ import type { FetchLike } from "../daemon/catalog-source"
3
+ import { type Paths, resolvePaths } from "../shared/paths"
4
+ import { KeyringStore, type SecretStore } from "../shared/secrets"
5
+ import { cmdCatalogDiff } from "./catalog"
6
+ import { cmdConnect } from "./connect"
7
+ import { type Io, terminalIo } from "./io"
8
+ import { cmdKeys, cmdLogin } from "./keys"
9
+ import { cmdStats } from "./stats"
10
+ import { cmdStatus } from "./status"
11
+ import { cmdStop } from "./stop"
12
+
13
+ export type CliDeps = {
14
+ paths: Paths
15
+ secrets: SecretStore
16
+ io: Io
17
+ fetch: FetchLike
18
+ now: () => number
19
+ runOpencode: (args: string[]) => Promise<number>
20
+ }
21
+
22
+ const HELP = `ufr — opencode-ufr gateway
23
+
24
+ ufr connect set everything up in one call: API keys
25
+ [--keys "<k1>,<k2>"] (comma/newline separated,
26
+ whitespace filtered), optional uni login for the
27
+ built-in VPN [--login <u> --password <p>]
28
+ ufr keys list | remove <alias> | test [alias] manage stored keys
29
+ ufr login show | remove the uni login for the built-in VPN
30
+ ufr status gateway, keys, limits, breakers, spend today
31
+ ufr stats [--days N] requests, tokens and cost (default: today)
32
+ ufr catalog diff UFR's model list vs. models.json
33
+ ufr stop stop the gateway (opencode starts it again)
34
+
35
+ UFR needs the uni VPN off campus: https://wiki.uni-freiburg.de/rz/doku.php?id=vpn
36
+ `
37
+
38
+ async function runOpencode(args: string[]): Promise<number> {
39
+ const p = Bun.spawn(["opencode", ...args], { stdio: ["inherit", "inherit", "inherit"] })
40
+ return await p.exited
41
+ }
42
+
43
+ export async function main(argv: string[], partial: Partial<CliDeps> = {}): Promise<number> {
44
+ const d: CliDeps = {
45
+ paths: resolvePaths(),
46
+ secrets: new KeyringStore(),
47
+ io: terminalIo(),
48
+ fetch: (u, i) => fetch(u, i),
49
+ now: Date.now,
50
+ runOpencode,
51
+ ...partial,
52
+ }
53
+ const [cmd, ...rest] = argv
54
+ try {
55
+ switch (cmd) {
56
+ case "connect":
57
+ return await cmdConnect(d, rest)
58
+ case "keys":
59
+ return await cmdKeys(d, rest)
60
+ case "login":
61
+ return await cmdLogin(d, rest)
62
+ case "status":
63
+ return await cmdStatus(d)
64
+ case "stats": {
65
+ const { values } = parseArgs({ args: rest, options: { days: { type: "string", default: "1" } } })
66
+ const days = Number(values.days)
67
+ if (!Number.isFinite(days) || days <= 0) {
68
+ d.io.err("--days must be a positive number\n")
69
+ return 2
70
+ }
71
+ return await cmdStats(d, days)
72
+ }
73
+ case "catalog":
74
+ if (rest[0] === "diff") return await cmdCatalogDiff(d)
75
+ d.io.err("usage: ufr catalog diff\n")
76
+ return 2
77
+ case "stop":
78
+ return await cmdStop(d)
79
+ case undefined:
80
+ case "help":
81
+ case "--help":
82
+ case "-h":
83
+ d.io.out(HELP)
84
+ return 0
85
+ default:
86
+ d.io.err(`unknown command: ${cmd}\n\n${HELP}`)
87
+ return 2
88
+ }
89
+ } catch (e) {
90
+ d.io.err(`error: ${(e as Error).message}\n`)
91
+ return 1
92
+ }
93
+ }
package/src/cli/io.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { createInterface } from "node:readline/promises"
2
+ import type { Readable, Writable } from "node:stream"
3
+
4
+ export type Io = {
5
+ out(s: string): void
6
+ err(s: string): void
7
+ isTTY: boolean
8
+ prompt(question: string, o?: { secret?: boolean }): Promise<string>
9
+ confirm(question: string): Promise<boolean>
10
+ }
11
+
12
+ export function terminalIo(): Io {
13
+ return {
14
+ out: (s) => void process.stdout.write(s),
15
+ err: (s) => void process.stderr.write(s),
16
+ isTTY: process.stdin.isTTY === true,
17
+ prompt: (q, o) => (o?.secret ? (process.stdin.isTTY ? readSecret(q) : readPipedSecret(q)) : readLine(q)),
18
+ // Non-interactive sessions (scripts, pipes) can never answer a question:
19
+ // treat a confirm as "no" immediately instead of waiting on stdin forever.
20
+ confirm: async (q) =>
21
+ process.stdin.isTTY === true && /^(y|yes|j|ja)$/i.test((await readLine(`${q} [y/N] `)).trim()),
22
+ }
23
+ }
24
+
25
+ /** Testable variant: answers one prompt from a closed/ended stream with "" instead of hanging. */
26
+ export function readLineFrom(q: string, input: Readable, output: Writable): Promise<string> {
27
+ return readLine(q, input, output)
28
+ }
29
+
30
+ async function readLine(
31
+ q: string,
32
+ input: Readable = process.stdin,
33
+ output: Writable = process.stdout,
34
+ ): Promise<string> {
35
+ const rl = createInterface({ input, output })
36
+ try {
37
+ // Resolve on close as well: with stdin already at EOF (piped input exhausted,
38
+ // no terminal) question() would otherwise never settle and hang the CLI.
39
+ return await new Promise<string>((resolve) => {
40
+ rl.once("close", () => resolve(""))
41
+ rl.question(q).then(resolve, () => resolve(""))
42
+ })
43
+ } finally {
44
+ rl.close()
45
+ }
46
+ }
47
+
48
+ /**
49
+ * A secret piped in (stdin not a TTY): read one line with readline in non-terminal
50
+ * mode, which never writes the line back. The question goes to stderr, not to the
51
+ * output that readLine's terminal mode would echo the key into.
52
+ */
53
+ export function readPipedSecret(q: string, input: Readable = process.stdin, err: Writable = process.stderr): Promise<string> {
54
+ err.write(q)
55
+ const rl = createInterface({ input, terminal: false })
56
+ return new Promise((resolve) => {
57
+ let done = false
58
+ const finish = (line: string) => {
59
+ if (done) return
60
+ done = true
61
+ rl.close()
62
+ err.write("\n")
63
+ resolve(line)
64
+ }
65
+ rl.once("line", finish)
66
+ rl.once("close", () => finish(""))
67
+ })
68
+ }
69
+
70
+ /** Read one line without echo — for API keys. */
71
+ function readSecret(q: string): Promise<string> {
72
+ process.stdout.write(q)
73
+ const stdin = process.stdin
74
+ return new Promise((resolve) => {
75
+ let buf = ""
76
+ const finish = () => {
77
+ stdin.setRawMode(false)
78
+ stdin.pause()
79
+ stdin.off("data", onData)
80
+ process.stdout.write("\n")
81
+ resolve(buf)
82
+ }
83
+ const onData = (d: Buffer) => {
84
+ for (const ch of d.toString("utf8")) {
85
+ if (ch === "\r" || ch === "\n") return finish()
86
+ if (ch === "\u0003") {
87
+ stdin.setRawMode(false)
88
+ process.exit(130)
89
+ }
90
+ if (ch === "\u007f" || ch === "\b") buf = buf.slice(0, -1)
91
+ else buf += ch
92
+ }
93
+ }
94
+ stdin.setRawMode(true)
95
+ stdin.resume()
96
+ stdin.on("data", onData)
97
+ })
98
+ }
@@ -0,0 +1,95 @@
1
+ import { loadConfig, saveConfig } from "../shared/config"
2
+ import type { Paths } from "../shared/paths"
3
+ import { VPN_PASS, VPN_USER } from "../shared/secrets"
4
+ import { daemonRequest } from "./daemon-client"
5
+ import type { FetchLike } from "../daemon/catalog-source"
6
+ import type { CliDeps } from "./index"
7
+ import { type Check, checkKey } from "./ufr-check"
8
+
9
+ const LABEL: Record<Check | "missing", string> = {
10
+ ok: "ok",
11
+ invalid: "INVALID (UFR says 401)",
12
+ vpn: "cannot check — not on the uni VPN",
13
+ unreachable: "cannot check — UFR unreachable",
14
+ rate_limited: "ok (currently rate-limited)",
15
+ missing: "MISSING in keyring",
16
+ }
17
+
18
+ /** A running gateway read its keys at start: stop it while idle so opencode starts it with the new set. */
19
+ export async function applyKeyChange(d: { paths: Paths; fetch: FetchLike; log: (m: string) => void }): Promise<void> {
20
+ const res = await daemonRequest(d.paths, d.fetch, "/v1/_status")
21
+ if (!res?.ok) return // not running — it reads the keys when opencode next starts it
22
+ const { inFlight } = (await res.json()) as { inFlight?: number }
23
+ if (inFlight !== 0) {
24
+ d.log("the gateway is answering a request right now — run `ufr stop` later so it picks up the new keys")
25
+ return
26
+ }
27
+ const stopped = await daemonRequest(d.paths, d.fetch, "/v1/_shutdown", "POST")
28
+ d.log(stopped?.ok ? "gateway stopped — it restarts with the new keys when opencode next needs it"
29
+ : "could not stop the gateway — run `ufr stop` so it picks up the new keys")
30
+ }
31
+
32
+ export async function cmdKeys(d: CliDeps, args: string[]): Promise<number> {
33
+ const [sub, ...rest] = args
34
+ const cfg = await loadConfig(d.paths.configFile)
35
+ switch (sub) {
36
+ case "list": {
37
+ if (cfg.keys.length === 0) {
38
+ d.io.out("no keys — add them with `ufr connect`\n")
39
+ return 0
40
+ }
41
+ for (const a of cfg.keys) d.io.out(`${a}\t${(await d.secrets.get(a)) ? "stored" : LABEL.missing}\n`)
42
+ return 0
43
+ }
44
+ case "remove": {
45
+ const alias = rest[0]
46
+ if (!alias) {
47
+ d.io.err("usage: ufr keys remove <alias>\n")
48
+ return 2
49
+ }
50
+ await d.secrets.delete(alias)
51
+ const had = cfg.keys.includes(alias)
52
+ cfg.keys = cfg.keys.filter((k) => k !== alias)
53
+ await saveConfig(d.paths.configFile, cfg)
54
+ d.io.out(`removed "${alias}"\n`)
55
+ if (had) await applyKeyChange({ paths: d.paths, fetch: d.fetch, log: (m) => d.io.out(m + "\n") })
56
+ return 0
57
+ }
58
+ case "test": {
59
+ let bad = 0
60
+ for (const a of rest[0] ? [rest[0]] : cfg.keys) {
61
+ const key = await d.secrets.get(a)
62
+ const r: Check | "missing" = key ? await checkKey(d.fetch, cfg.upstream.baseUrl, key) : "missing"
63
+ if (r !== "ok" && r !== "rate_limited") bad++
64
+ d.io.out(`${a}\t${LABEL[r]}\n`)
65
+ }
66
+ return bad ? 1 : 0
67
+ }
68
+ default:
69
+ d.io.err("usage: ufr keys list | remove <alias> | test [alias]\n")
70
+ return 2
71
+ }
72
+ }
73
+
74
+ /** ufr login — inspect or remove the stored uni login (set it with `ufr connect`). */
75
+ export async function cmdLogin(d: CliDeps, args: string[]): Promise<number> {
76
+ const [sub] = args
77
+ if (sub === "remove") {
78
+ await d.secrets.delete(VPN_USER)
79
+ await d.secrets.delete(VPN_PASS)
80
+ d.io.out("removed the stored uni login\n")
81
+ return 0
82
+ }
83
+ if (sub === "show" || sub === undefined) {
84
+ const u = await d.secrets.get(VPN_USER)
85
+ const p = await d.secrets.get(VPN_PASS)
86
+ if (!u) {
87
+ d.io.out("no uni login stored — set it with `ufr connect --login <user> --password <pass> --keys …`\n")
88
+ return 1
89
+ }
90
+ d.io.out(`${u}\tpassword ${p ? "stored" : "MISSING in keyring"}\n`)
91
+ return p ? 0 : 1
92
+ }
93
+ d.io.err("usage: ufr login show | remove\n")
94
+ return 2
95
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync } from "node:fs"
2
+ import { Stats, type SummaryRow } from "../daemon/stats"
3
+ import { loadConfig } from "../shared/config"
4
+ import { startOfLocalDay } from "../shared/time"
5
+ import type { CliDeps } from "./index"
6
+
7
+ function table(label: string, rows: SummaryRow[], budget: number, showPct: boolean): string {
8
+ if (rows.length === 0) return `${label}: no requests\n`
9
+ const lines = rows.map((r) => {
10
+ const cost = `$${r.costUsd.toFixed(4)}${r.unpriced ? ` +${r.unpriced} unpriced` : ""}`
11
+ const pct = showPct && budget > 0 ? ` ${((r.costUsd / budget) * 100).toFixed(2)}% of $${budget} (est.)` : ""
12
+ return ` ${r.name.padEnd(36)} ${String(r.requests).padStart(6)} req ${String(r.errors).padStart(5)} err ` +
13
+ `${String(r.promptTokens).padStart(11)} in ${String(r.completionTokens).padStart(9)} out ${cost}${pct}`
14
+ })
15
+ return `${label}\n${lines.join("\n")}\n`
16
+ }
17
+
18
+ export async function cmdStats(d: CliDeps, days: number): Promise<number> {
19
+ if (!existsSync(d.paths.statsDb)) {
20
+ d.io.out("no stats yet — the gateway records requests once opencode uses it\n")
21
+ return 0
22
+ }
23
+ const cfg = await loadConfig(d.paths.configFile)
24
+ const stats = new Stats(d.paths.statsDb)
25
+ try {
26
+ const today = days === 1
27
+ const since = today ? startOfLocalDay(d.now()) : d.now() - days * 86_400_000
28
+ const { byModel, byKey } = stats.summary(since)
29
+ d.io.out(`${today ? "today (since local midnight)" : `last ${days} days`}\n\n`)
30
+ d.io.out(table("per model", byModel, cfg.dailyBudgetUsd, false))
31
+ d.io.out("\n")
32
+ d.io.out(table("per key", byKey, cfg.dailyBudgetUsd, today))
33
+ return 0
34
+ } finally {
35
+ stats.close()
36
+ }
37
+ }
@@ -0,0 +1,48 @@
1
+ import type { StatusJson } from "../daemon/daemon"
2
+ import { loadConfig } from "../shared/config"
3
+ import { VPN_PASS, VPN_USER } from "../shared/secrets"
4
+ import { daemonRequest } from "./daemon-client"
5
+ import type { CliDeps } from "./index"
6
+
7
+ const ago = (ms: number) =>
8
+ ms < 60_000 ? `${Math.round(ms / 1000)}s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 3_600_000)}h`
9
+
10
+ export function formatStatus(s: StatusJson): string {
11
+ const L: string[] = []
12
+ L.push(`gateway v${s.version} pid ${s.pid} 127.0.0.1:${s.port} up ${ago(s.uptimeMs)} in flight ${s.inFlight}`)
13
+ if (s.upstream.ok === false) L.push(`UFR NOT reachable — ${s.upstream.message}`)
14
+ else if (s.upstream.ok) L.push(`UFR reachable (checked ${ago(s.now - s.upstream.at)} ago)`)
15
+ else L.push("UFR not checked yet")
16
+ if (s.keys.length === 0) L.push("keys none — run `ufr connect`")
17
+ s.keys.forEach((k, i) => {
18
+ const spend = s.spendToday[k.alias] ?? 0
19
+ const pct = s.dailyBudgetUsd > 0 ? ` (${((spend / s.dailyBudgetUsd) * 100).toFixed(2)}% of $${s.dailyBudgetUsd}, est.)` : ""
20
+ const state = k.invalid ? " INVALID" : k.blockedForMs > 0 ? ` cooling down ${ago(k.blockedForMs)}` : ""
21
+ L.push(`${i === 0 ? "keys " : " "}${k.alias} ${k.used}/${k.cap} per 60s today $${spend.toFixed(4)}${pct}${state}`)
22
+ })
23
+ L.push(`pool ${s.pool.enabled ? `${s.pool.inWindow}/${s.pool.cap} per hour` : "limiter off"}`)
24
+ const open = Object.entries(s.breakers).filter(([, b]) => b.state !== "closed")
25
+ L.push(open.length === 0 ? "breakers all closed"
26
+ : `breakers ${open.map(([g, b]) => `${g} ${b.state} (rung ${b.level + 1}, ${ago(b.retryAfterMs)} left)`).join(", ")}`)
27
+ L.push(`catalog ${s.catalog.models} models; models.json from ${s.catalog.source}, UFR list from ${s.catalog.ufrSource}` +
28
+ (s.catalog.loadedAt ? ` (loaded ${ago(s.now - s.catalog.loadedAt)} ago)` : ""))
29
+ if (s.vpn) L.push(`vpn ${s.vpn.mode} — ${s.vpn.detail}`)
30
+ for (const w of s.catalog.warnings) L.push(`warning ${w}`)
31
+ return L.join("\n") + "\n"
32
+ }
33
+
34
+ export async function cmdStatus(d: CliDeps): Promise<number> {
35
+ const user = await d.secrets.get(VPN_USER)
36
+ const pass = await d.secrets.get(VPN_PASS)
37
+ const vpnLine = user
38
+ ? `vpn login ${user} (password ${pass ? "stored" : "MISSING in keyring"})\n`
39
+ : "vpn login not stored — off campus the built-in VPN needs one (ufr login add <user>)\n"
40
+ const res = await daemonRequest(d.paths, d.fetch, "/v1/_status")
41
+ if (!res || !res.ok) {
42
+ const cfg = await loadConfig(d.paths.configFile)
43
+ d.io.out(`gateway not running (opencode starts it)\nkeys ${cfg.keys.join(", ") || "none — run `ufr connect`"}\n${vpnLine}`)
44
+ return 0
45
+ }
46
+ d.io.out(formatStatus((await res.json()) as StatusJson) + vpnLine)
47
+ return 0
48
+ }
@@ -0,0 +1,8 @@
1
+ import { daemonRequest } from "./daemon-client"
2
+ import type { CliDeps } from "./index"
3
+
4
+ export async function cmdStop(d: CliDeps): Promise<number> {
5
+ const res = await daemonRequest(d.paths, d.fetch, "/v1/_shutdown", "POST")
6
+ d.io.out(res?.ok ? "gateway stopping — opencode starts it again when needed\n" : "gateway not running\n")
7
+ return 0
8
+ }
@@ -0,0 +1,43 @@
1
+ import type { FetchLike } from "../daemon/catalog-source"
2
+ import { isVpnPage } from "../shared/vpn"
3
+
4
+ export type Check = "ok" | "invalid" | "vpn" | "unreachable" | "rate_limited"
5
+
6
+ async function classify(res: Response): Promise<Check> {
7
+ const type = res.headers.get("content-type") ?? ""
8
+ if (type.includes("text/html")) {
9
+ const html = await res.text().catch(() => "")
10
+ return isVpnPage(html) ? "vpn" : "unreachable"
11
+ }
12
+ if (res.status >= 300 && res.status < 400) return "unreachable"
13
+ if (res.status === 401 || res.status === 403) return "invalid"
14
+ if (res.status === 429) return "rate_limited"
15
+ return res.ok ? "ok" : "unreachable"
16
+ }
17
+
18
+ export async function checkKey(f: FetchLike, baseUrl: string, key: string): Promise<Check> {
19
+ try {
20
+ return await classify(await f(`${baseUrl}/models`, {
21
+ headers: { Authorization: `Bearer ${key}` },
22
+ redirect: "manual",
23
+ signal: AbortSignal.timeout(15_000),
24
+ }))
25
+ } catch {
26
+ return "unreachable"
27
+ }
28
+ }
29
+
30
+ /** One minimal chat call (max_tokens 1) — proves the whole path incl. the VPN. */
31
+ export async function checkChat(f: FetchLike, baseUrl: string, key: string, model: string): Promise<Check> {
32
+ try {
33
+ return await classify(await f(`${baseUrl}/chat/completions`, {
34
+ method: "POST",
35
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
36
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: "user", content: "Hi" }] }),
37
+ redirect: "manual",
38
+ signal: AbortSignal.timeout(60_000),
39
+ }))
40
+ } catch {
41
+ return "unreachable"
42
+ }
43
+ }
@@ -0,0 +1,146 @@
1
+ export type BreakerState = "closed" | "open" | "half_open"
2
+ export type BreakerOptions = { tripThreshold: number; ladderMs: number[]; probeTimeoutMs: number; now: () => number }
3
+ export type BreakerSnapshot = { level: number; openUntil: number; consecutive: number; trips: number }
4
+
5
+ /**
6
+ * Per-model-group breaker (port of the author's former self-hosted LiteLLM
7
+ * proxy's BreakerCore). UFR walls model groups for hours and every request
8
+ * made while walled restarts the clock, so after tripThreshold rate-limited
9
+ * *client requests* the group goes quiet for an escalating time.
10
+ */
11
+ export class Breaker {
12
+ consecutive = 0
13
+ level = 0
14
+ openUntil = 0
15
+ trips = 0
16
+ rejected = 0
17
+ private probeInFlight = false
18
+ private probeIssuedAt = 0
19
+
20
+ constructor(private readonly o: BreakerOptions) {
21
+ if (o.tripThreshold < 1) throw new Error("tripThreshold must be >= 1")
22
+ if (o.ladderMs.length === 0) throw new Error("ladder must not be empty")
23
+ }
24
+
25
+ state(): BreakerState {
26
+ if (this.openUntil === 0) return "closed"
27
+ return this.o.now() >= this.openUntil ? "half_open" : "open"
28
+ }
29
+
30
+ retryAfterMs(): number {
31
+ return Math.max(0, this.openUntil - this.o.now())
32
+ }
33
+
34
+ allow(): { allowed: boolean; retryAfterMs: number } {
35
+ let state = this.state()
36
+ if (state === "closed") return { allowed: true, retryAfterMs: 0 }
37
+ // A probe whose outcome never came back (attributed to another group after
38
+ // a redirect) must not wedge this group in half-open forever.
39
+ if (this.probeInFlight && this.o.now() - this.probeIssuedAt > this.o.probeTimeoutMs) {
40
+ this.probeInFlight = false
41
+ this.open(this.level + 1)
42
+ state = this.state()
43
+ }
44
+ if (state === "half_open" && !this.probeInFlight) {
45
+ this.probeInFlight = true
46
+ this.probeIssuedAt = this.o.now()
47
+ return { allowed: true, retryAfterMs: 0 }
48
+ }
49
+ this.rejected++
50
+ return { allowed: false, retryAfterMs: this.retryAfterMs() || 5_000 }
51
+ }
52
+
53
+ onSuccess(): void {
54
+ this.consecutive = 0
55
+ this.level = 0
56
+ this.openUntil = 0
57
+ this.probeInFlight = false
58
+ this.probeIssuedAt = 0
59
+ }
60
+
61
+ onRateLimited(): void {
62
+ const wasProbing = this.probeInFlight
63
+ this.probeInFlight = false
64
+ this.consecutive++
65
+ if (wasProbing) {
66
+ this.open(this.level + 1)
67
+ return
68
+ }
69
+ if (this.state() === "open") return // was already in flight when we tripped
70
+ if (this.consecutive >= this.o.tripThreshold) {
71
+ // Once the ladder is engaged it keeps climbing (regression 2026-09-21).
72
+ this.open(this.openUntil ? this.level + 1 : 0)
73
+ }
74
+ }
75
+
76
+ /** Timeouts, context errors, 5xx: no evidence of a wall. Clears a probe. */
77
+ onOtherFailure(): void {
78
+ this.probeInFlight = false
79
+ }
80
+
81
+ private open(level: number): void {
82
+ this.level = Math.min(level, this.o.ladderMs.length - 1)
83
+ this.openUntil = this.o.now() + this.o.ladderMs[this.level]!
84
+ this.consecutive = 0
85
+ this.trips++
86
+ }
87
+
88
+ snapshot(): BreakerSnapshot {
89
+ return { level: this.level, openUntil: this.openUntil, consecutive: this.consecutive, trips: this.trips }
90
+ }
91
+
92
+ restore(s: BreakerSnapshot): void {
93
+ this.level = s.level
94
+ this.openUntil = s.openUntil
95
+ this.consecutive = s.consecutive
96
+ this.trips = s.trips
97
+ }
98
+ }
99
+
100
+ export class BreakerRegistry {
101
+ private readonly groups = new Map<string, Breaker>()
102
+
103
+ constructor(private readonly o: BreakerOptions) {}
104
+
105
+ get(group: string): Breaker {
106
+ let b = this.groups.get(group)
107
+ if (!b) {
108
+ b = new Breaker(this.o)
109
+ this.groups.set(group, b)
110
+ }
111
+ return b
112
+ }
113
+
114
+ /** First group that admits a request. Consumes a half-open probe slot on it. */
115
+ firstAvailable(groups: string[]): string | null {
116
+ for (const g of groups) if (this.get(g).allow().allowed) return g
117
+ return null
118
+ }
119
+
120
+ minRetryAfterMs(groups: string[]): number {
121
+ return Math.min(...groups.map((g) => this.get(g).retryAfterMs() || 5_000))
122
+ }
123
+
124
+ openGroups(): string[] {
125
+ return [...this.groups]
126
+ .filter(([, b]) => b.state() === "open")
127
+ .map(([g]) => g)
128
+ .sort()
129
+ }
130
+
131
+ snapshot(): Record<string, BreakerSnapshot> {
132
+ return Object.fromEntries(
133
+ [...this.groups].filter(([, b]) => b.openUntil !== 0 || b.consecutive > 0).map(([g, b]) => [g, b.snapshot()]),
134
+ )
135
+ }
136
+
137
+ restore(map: Record<string, BreakerSnapshot>): void {
138
+ for (const [g, s] of Object.entries(map)) this.get(g).restore(s)
139
+ }
140
+
141
+ states(): Record<string, { state: BreakerState; level: number; retryAfterMs: number }> {
142
+ return Object.fromEntries(
143
+ [...this.groups].map(([g, b]) => [g, { state: b.state(), level: b.level, retryAfterMs: b.retryAfterMs() }]),
144
+ )
145
+ }
146
+ }
@@ -0,0 +1,85 @@
1
+ import { fileURLToPath } from "node:url"
2
+ import { readJson, readText, writeFileAtomic } from "../shared/fs"
3
+ import { type ModelsFile, validateModelsFile } from "../shared/models-file"
4
+ import { VPN_MESSAGE, isVpnPage } from "../shared/vpn"
5
+ import { type UfrModel, parseUfrModels } from "./catalog"
6
+
7
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>
8
+ export type ModelsSource = "remote" | "cache" | "bundled"
9
+
10
+ export const BUNDLED_MODELS = fileURLToPath(new URL("../../models.json", import.meta.url))
11
+
12
+ /** models.json: remote (ETag) → last good cache → copy shipped in the package. */
13
+ export async function loadModelsFile(o: {
14
+ url: string
15
+ cachePath: string
16
+ etagPath: string
17
+ bundledPath?: string
18
+ fetch: FetchLike
19
+ log: (m: string) => void
20
+ }): Promise<{ file: ModelsFile; source: ModelsSource }> {
21
+ const cached = await readJson(o.cachePath)
22
+ const etag = cached ? (await readText(o.etagPath))?.trim() : undefined
23
+ try {
24
+ const res = await o.fetch(o.url, {
25
+ headers: etag ? { "If-None-Match": etag } : {},
26
+ signal: AbortSignal.timeout(15_000),
27
+ })
28
+ if (res.status === 304 && cached) return { file: validateModelsFile(cached), source: "remote" }
29
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
30
+ const text = await res.text()
31
+ const file = validateModelsFile(JSON.parse(text)) // validate before it can replace a good cache
32
+ await writeFileAtomic(o.cachePath, text)
33
+ const tag = res.headers.get("etag")
34
+ if (tag) await writeFileAtomic(o.etagPath, tag)
35
+ return { file, source: "remote" }
36
+ } catch (e) {
37
+ o.log(`models.json: remote copy unavailable or invalid (${(e as Error).message}) — using the ${cached ? "cached" : "bundled"} copy`)
38
+ }
39
+ if (cached) {
40
+ try {
41
+ return { file: validateModelsFile(cached), source: "cache" }
42
+ } catch (e) {
43
+ o.log(`models.json: cached copy invalid (${(e as Error).message})`)
44
+ }
45
+ }
46
+ const bundled: unknown = JSON.parse(await Bun.file(o.bundledPath ?? BUNDLED_MODELS).text())
47
+ return { file: validateModelsFile(bundled), source: "bundled" }
48
+ }
49
+
50
+ /** UFR's own model list (needs the VPN like every UFR path); last good list is cached. */
51
+ export async function loadUfrModels(o: {
52
+ baseUrl: string
53
+ key: string | null
54
+ cachePath: string
55
+ fetch: FetchLike
56
+ log: (m: string) => void
57
+ }): Promise<{ models: UfrModel[]; source: "remote" | "cache"; error: string | null }> {
58
+ let error: string
59
+ if (!o.key) {
60
+ error = "no key configured"
61
+ } else {
62
+ try {
63
+ const res = await o.fetch(`${o.baseUrl}/models`, {
64
+ headers: { Authorization: `Bearer ${o.key}` },
65
+ signal: AbortSignal.timeout(20_000),
66
+ redirect: "manual",
67
+ })
68
+ const type = res.headers.get("content-type") ?? ""
69
+ if (type.includes("text/html")) {
70
+ const html = await res.text()
71
+ throw new Error(isVpnPage(html) ? VPN_MESSAGE : `HTML instead of JSON (HTTP ${res.status})`)
72
+ }
73
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
74
+ const models = parseUfrModels(await res.json())
75
+ if (models.length === 0) throw new Error("UFR returned an empty model list")
76
+ await writeFileAtomic(o.cachePath, JSON.stringify(models))
77
+ return { models, source: "remote", error: null }
78
+ } catch (e) {
79
+ error = (e as Error).message
80
+ }
81
+ }
82
+ o.log(`UFR /api/models: ${error} — using the cached model list`)
83
+ const cached = await readJson(o.cachePath)
84
+ return { models: Array.isArray(cached) ? (cached as UfrModel[]) : [], source: "cache", error }
85
+ }