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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/ufr.ts +4 -0
- package/models.json +206 -0
- package/package.json +16 -0
- package/src/cli/catalog.ts +45 -0
- package/src/cli/connect.ts +146 -0
- package/src/cli/daemon-client.ts +19 -0
- package/src/cli/index.ts +93 -0
- package/src/cli/io.ts +98 -0
- package/src/cli/keys.ts +95 -0
- package/src/cli/stats.ts +37 -0
- package/src/cli/status.ts +48 -0
- package/src/cli/stop.ts +8 -0
- package/src/cli/ufr-check.ts +43 -0
- package/src/daemon/breaker.ts +146 -0
- package/src/daemon/catalog-source.ts +85 -0
- package/src/daemon/catalog.ts +127 -0
- package/src/daemon/daemon.ts +397 -0
- package/src/daemon/fallbacks.ts +41 -0
- package/src/daemon/keypool.ts +109 -0
- package/src/daemon/log.ts +15 -0
- package/src/daemon/main.ts +18 -0
- package/src/daemon/model.ts +15 -0
- package/src/daemon/router.ts +392 -0
- package/src/daemon/server.ts +54 -0
- package/src/daemon/stats.ts +105 -0
- package/src/daemon/transport.ts +21 -0
- package/src/daemon/upstream.ts +68 -0
- package/src/daemon/vpn/fortinet.ts +466 -0
- package/src/daemon/vpn/ip.ts +176 -0
- package/src/daemon/vpn/manager.ts +258 -0
- package/src/daemon/vpn/ppp.ts +450 -0
- package/src/daemon/vpn/proxy.ts +102 -0
- package/src/daemon/vpn/stack.ts +140 -0
- package/src/daemon/vpn/tcp.ts +356 -0
- package/src/daemon/window.ts +126 -0
- package/src/plugin/connect.ts +113 -0
- package/src/plugin/ensure-daemon.ts +101 -0
- package/src/plugin/index.ts +93 -0
- package/src/plugin/model-info.ts +35 -0
- package/src/shared/config.ts +170 -0
- package/src/shared/connect.ts +100 -0
- package/src/shared/errors.ts +6 -0
- package/src/shared/fs.ts +31 -0
- package/src/shared/models-file.ts +68 -0
- package/src/shared/paths.ts +62 -0
- package/src/shared/secrets.ts +62 -0
- package/src/shared/sleep.ts +15 -0
- package/src/shared/time.ts +6 -0
- package/src/shared/version.ts +3 -0
- package/src/shared/vpn.ts +8 -0
package/src/cli/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/keys.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/stats.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/stop.ts
ADDED
|
@@ -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
|
+
}
|