opencode-ufr 0.2.9 → 0.2.11

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 (42) hide show
  1. package/README.md +55 -62
  2. package/dist/tui.js +155 -0
  3. package/package.json +20 -7
  4. package/skills/pdf2md/SKILL.md +32 -0
  5. package/src/client/gateway.ts +101 -0
  6. package/src/client/pdf2md.ts +747 -0
  7. package/src/daemon/catalog-source.ts +9 -0
  8. package/src/daemon/daemon.ts +70 -18
  9. package/src/daemon/keypool.ts +12 -7
  10. package/src/daemon/main.ts +7 -1
  11. package/src/daemon/probe-all.ts +60 -38
  12. package/src/daemon/router.ts +215 -17
  13. package/src/daemon/server.ts +33 -1
  14. package/src/daemon/stats.ts +13 -1
  15. package/src/daemon/upstream.ts +14 -3
  16. package/src/daemon/vpn/fortinet.ts +42 -29
  17. package/src/daemon/vpn/manager.ts +50 -16
  18. package/src/daemon/vpn/proxy.ts +3 -1
  19. package/src/plugin/connect.ts +4 -4
  20. package/src/plugin/ensure-daemon.ts +48 -19
  21. package/src/plugin/index.ts +121 -46
  22. package/src/plugin/tui.tsx +77 -0
  23. package/src/shared/config.ts +53 -4
  24. package/src/shared/connect.ts +3 -4
  25. package/src/shared/daemon-client.ts +29 -0
  26. package/src/shared/errors.ts +1 -1
  27. package/src/shared/fs.ts +8 -3
  28. package/src/shared/keys.ts +65 -0
  29. package/src/shared/models-file.ts +1 -1
  30. package/src/shared/secrets.ts +2 -6
  31. package/bin/ufr.ts +0 -4
  32. package/src/cli/catalog.ts +0 -44
  33. package/src/cli/connect.ts +0 -146
  34. package/src/cli/context-probe.ts +0 -96
  35. package/src/cli/daemon-client.ts +0 -19
  36. package/src/cli/index.ts +0 -105
  37. package/src/cli/io.ts +0 -98
  38. package/src/cli/keys.ts +0 -130
  39. package/src/cli/stats.ts +0 -37
  40. package/src/cli/status.ts +0 -48
  41. package/src/cli/stop.ts +0 -8
  42. package/src/cli/ufr-check.ts +0 -43
package/src/cli/index.ts DELETED
@@ -1,105 +0,0 @@
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 { cmdContextProbe } from "./context-probe"
8
- import { type Io, terminalIo } from "./io"
9
- import { cmdDisconnect, cmdKeys, cmdLogin } from "./keys"
10
- import { cmdStats } from "./stats"
11
- import { cmdStatus } from "./status"
12
- import { cmdStop } from "./stop"
13
-
14
- export type CliDeps = {
15
- paths: Paths
16
- secrets: SecretStore
17
- io: Io
18
- fetch: FetchLike
19
- now: () => number
20
- runOpencode: (args: string[]) => Promise<number>
21
- }
22
-
23
- const HELP = `ufr — opencode-ufr gateway
24
-
25
- ufr connect set everything up in one call: API keys
26
- [--keys "<k1>,<k2>"] (comma/newline separated,
27
- whitespace filtered), optional uni login for the
28
- built-in VPN [--login <u> --password <p>]
29
- ufr disconnect remove everything: stored keys, uni login,
30
- config and the running gateway
31
- ufr keys list | remove <alias> | test [alias] manage stored keys
32
- ufr login show | remove the uni login for the built-in VPN
33
- ufr status gateway, keys, limits, breakers, spend today
34
- ufr stats [--days N] requests, tokens and cost (default: today)
35
- ufr catalog diff UFR's model list vs. models.json
36
- ufr context-probe [--write] [--vpn]
37
- measure every model's context limit live
38
- (rejected probes are free; --write patches
39
- the local models.json; --vpn routes the
40
- probes through the built-in tunnel)
41
- ufr stop stop the gateway (opencode starts it again)
42
-
43
- UFR needs the uni VPN off campus: https://wiki.uni-freiburg.de/rz/doku.php?id=vpn
44
- `
45
-
46
- async function runOpencode(args: string[]): Promise<number> {
47
- const p = Bun.spawn(["opencode", ...args], { stdio: ["inherit", "inherit", "inherit"] })
48
- return await p.exited
49
- }
50
-
51
- export async function main(argv: string[], partial: Partial<CliDeps> = {}): Promise<number> {
52
- const d: CliDeps = {
53
- paths: resolvePaths(),
54
- secrets: new KeyringStore(),
55
- io: terminalIo(),
56
- fetch: (u, i) => fetch(u, i),
57
- now: Date.now,
58
- runOpencode,
59
- ...partial,
60
- }
61
- const [cmd, ...rest] = argv
62
- try {
63
- switch (cmd) {
64
- case "connect":
65
- return await cmdConnect(d, rest)
66
- case "disconnect":
67
- return await cmdDisconnect(d)
68
- case "keys":
69
- return await cmdKeys(d, rest)
70
- case "login":
71
- return await cmdLogin(d, rest)
72
- case "status":
73
- return await cmdStatus(d)
74
- case "stats": {
75
- const { values } = parseArgs({ args: rest, options: { days: { type: "string", default: "1" } } })
76
- const days = Number(values.days)
77
- if (!Number.isFinite(days) || days <= 0) {
78
- d.io.err("--days must be a positive number\n")
79
- return 2
80
- }
81
- return await cmdStats(d, days)
82
- }
83
- case "catalog":
84
- if (rest[0] === "diff") return await cmdCatalogDiff(d)
85
- d.io.err("usage: ufr catalog diff\n")
86
- return 2
87
- case "context-probe":
88
- return await cmdContextProbe(d, rest)
89
- case "stop":
90
- return await cmdStop(d)
91
- case undefined:
92
- case "help":
93
- case "--help":
94
- case "-h":
95
- d.io.out(HELP)
96
- return 0
97
- default:
98
- d.io.err(`unknown command: ${cmd}\n\n${HELP}`)
99
- return 2
100
- }
101
- } catch (e) {
102
- d.io.err(`error: ${(e as Error).message}\n`)
103
- return 1
104
- }
105
- }
package/src/cli/io.ts DELETED
@@ -1,98 +0,0 @@
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
- }
package/src/cli/keys.ts DELETED
@@ -1,130 +0,0 @@
1
- import { loadConfig, saveConfig } from "../shared/config"
2
- import type { Paths } from "../shared/paths"
3
- import { VPN_PASS, VPN_USER, type SecretStore } 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 type DisconnectResult = { removed: string[]; loginRemoved: boolean }
33
-
34
- /**
35
- * Provider-removal cleanup: delete every stored key alias and the uni login
36
- * from the keyring, clear config.keys, and stop an idle gateway (it reads its
37
- * keys at start, so a later start serves nothing). Idempotent.
38
- */
39
- export async function disconnectAll(d: {
40
- paths: Paths
41
- secrets: SecretStore
42
- fetch: FetchLike
43
- log: (m: string) => void
44
- }): Promise<DisconnectResult> {
45
- const cfg = await loadConfig(d.paths.configFile)
46
- const removed: string[] = []
47
- for (const alias of cfg.keys) {
48
- if (await d.secrets.delete(alias)) removed.push(alias)
49
- }
50
- const userRemoved = await d.secrets.delete(VPN_USER)
51
- const passRemoved = await d.secrets.delete(VPN_PASS)
52
- const loginRemoved = userRemoved || passRemoved
53
- cfg.keys = []
54
- await saveConfig(d.paths.configFile, cfg)
55
- await applyKeyChange(d)
56
- return { removed, loginRemoved }
57
- }
58
-
59
- export async function cmdDisconnect(d: CliDeps): Promise<number> {
60
- const r = await disconnectAll({ paths: d.paths, secrets: d.secrets, fetch: d.fetch, log: (m) => d.io.out(m + "\n") })
61
- d.io.out(r.removed.length > 0 ? `removed from keyring: ${r.removed.join(", ")}\n` : "nothing was stored\n")
62
- if (r.loginRemoved) d.io.out("removed the uni login\n")
63
- d.io.out("done — reconnect any time with `ufr connect` or /connect in opencode\n")
64
- return 0
65
- }
66
-
67
- export async function cmdKeys(d: CliDeps, args: string[]): Promise<number> {
68
- const [sub, ...rest] = args
69
- const cfg = await loadConfig(d.paths.configFile)
70
- switch (sub) {
71
- case "list": {
72
- if (cfg.keys.length === 0) {
73
- d.io.out("no keys — add them with `ufr connect`\n")
74
- return 0
75
- }
76
- for (const a of cfg.keys) d.io.out(`${a}\t${(await d.secrets.get(a)) ? "stored" : LABEL.missing}\n`)
77
- return 0
78
- }
79
- case "remove": {
80
- const alias = rest[0]
81
- if (!alias) {
82
- d.io.err("usage: ufr keys remove <alias>\n")
83
- return 2
84
- }
85
- await d.secrets.delete(alias)
86
- const had = cfg.keys.includes(alias)
87
- cfg.keys = cfg.keys.filter((k) => k !== alias)
88
- await saveConfig(d.paths.configFile, cfg)
89
- d.io.out(`removed "${alias}"\n`)
90
- if (had) await applyKeyChange({ paths: d.paths, fetch: d.fetch, log: (m) => d.io.out(m + "\n") })
91
- return 0
92
- }
93
- case "test": {
94
- let bad = 0
95
- for (const a of rest[0] ? [rest[0]] : cfg.keys) {
96
- const key = await d.secrets.get(a)
97
- const r: Check | "missing" = key ? await checkKey(d.fetch, cfg.upstream.baseUrl, key) : "missing"
98
- if (r !== "ok" && r !== "rate_limited") bad++
99
- d.io.out(`${a}\t${LABEL[r]}\n`)
100
- }
101
- return bad ? 1 : 0
102
- }
103
- default:
104
- d.io.err("usage: ufr keys list | remove <alias> | test [alias]\n")
105
- return 2
106
- }
107
- }
108
-
109
- /** ufr login — inspect or remove the stored uni login (set it with `ufr connect`). */
110
- export async function cmdLogin(d: CliDeps, args: string[]): Promise<number> {
111
- const [sub] = args
112
- if (sub === "remove") {
113
- await d.secrets.delete(VPN_USER)
114
- await d.secrets.delete(VPN_PASS)
115
- d.io.out("removed the stored uni login\n")
116
- return 0
117
- }
118
- if (sub === "show" || sub === undefined) {
119
- const u = await d.secrets.get(VPN_USER)
120
- const p = await d.secrets.get(VPN_PASS)
121
- if (!u) {
122
- d.io.out("no uni login stored — set it with `ufr connect --login <user> --password <pass> --keys …`\n")
123
- return 1
124
- }
125
- d.io.out(`${u}\tpassword ${p ? "stored" : "MISSING in keyring"}\n`)
126
- return p ? 0 : 1
127
- }
128
- d.io.err("usage: ufr login show | remove\n")
129
- return 2
130
- }
package/src/cli/stats.ts DELETED
@@ -1,37 +0,0 @@
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
- }
package/src/cli/status.ts DELETED
@@ -1,48 +0,0 @@
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
- }
package/src/cli/stop.ts DELETED
@@ -1,8 +0,0 @@
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
- }
@@ -1,43 +0,0 @@
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
- }