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,126 @@
1
+ export type Reservation = { verdict: "admit" | "queue" | "reject"; waitMs: number; at: number }
2
+
3
+ /**
4
+ * Exact sliding-window admission control (port of the author's former
5
+ * self-hosted LiteLLM proxy's PoolLimiterCore).
6
+ *
7
+ * `times` is a sorted list of admission times. It may hold times in the future:
8
+ * reservations of queued requests. Keeping them in the same list is what keeps
9
+ * the window exact under load — a queued request already owns the slot it waits
10
+ * for. Not a token bucket: capacity + refill would allow up to 2× cap per window.
11
+ */
12
+ export class SlidingWindow {
13
+ private times: number[] = []
14
+ readonly cap: number
15
+ readonly windowMs: number
16
+ readonly maxWaitMs: number
17
+ private readonly now: () => number
18
+ admitted = 0
19
+ queued = 0
20
+ rejected = 0
21
+
22
+ constructor(o: { cap: number; windowMs: number; maxWaitMs: number; now: () => number }) {
23
+ if (o.windowMs <= 0) throw new Error("windowMs must be > 0")
24
+ this.cap = Math.max(0, Math.floor(o.cap))
25
+ this.windowMs = o.windowMs
26
+ this.maxWaitMs = Math.max(0, o.maxWaitMs)
27
+ this.now = o.now
28
+ }
29
+
30
+ get enabled(): boolean {
31
+ return this.cap > 0
32
+ }
33
+
34
+ private prune(now: number): void {
35
+ const cutoff = now - this.windowMs
36
+ let i = 0
37
+ while (i < this.times.length && this.times[i]! <= cutoff) i++
38
+ if (i) this.times.splice(0, i)
39
+ }
40
+
41
+ private insert(t: number): void {
42
+ let i = this.times.length
43
+ while (i > 0 && this.times[i - 1]! > t) i--
44
+ this.times.splice(i, 0, t)
45
+ }
46
+
47
+ /** Admissions (including reservations) in the current window. */
48
+ inWindow(): number {
49
+ this.prune(this.now())
50
+ return this.times.length
51
+ }
52
+
53
+ headroom(): number {
54
+ return this.enabled ? Math.max(0, this.cap - this.inWindow()) : Number.POSITIVE_INFINITY
55
+ }
56
+
57
+ /** Milliseconds until a new request could be admitted; 0 = now. */
58
+ nextFreeInMs(): number {
59
+ if (!this.enabled) return 0
60
+ const now = this.now()
61
+ this.prune(now)
62
+ const n = this.times.length
63
+ if (n < this.cap) return 0
64
+ return Math.max(0, this.times[n - this.cap]! + this.windowMs - now)
65
+ }
66
+
67
+ reserve(): Reservation {
68
+ const now = this.now()
69
+ if (!this.enabled) return { verdict: "admit", waitMs: 0, at: now }
70
+ this.prune(now)
71
+ const n = this.times.length
72
+ if (n < this.cap) {
73
+ this.insert(now)
74
+ this.admitted++
75
+ return { verdict: "admit", waitMs: 0, at: now }
76
+ }
77
+ // The new request may enter the moment the cap-th most recent admission
78
+ // leaves the window: then cap-1 remain, plus this one = cap.
79
+ const at = this.times[n - this.cap]! + this.windowMs
80
+ const waitMs = Math.max(0, at - now)
81
+ if (waitMs <= this.maxWaitMs) {
82
+ this.insert(at)
83
+ this.queued++
84
+ return { verdict: "queue", waitMs, at }
85
+ }
86
+ this.rejected++
87
+ return { verdict: "reject", waitMs, at }
88
+ }
89
+
90
+ /** Book a slot at an explicit time (KeyPool uses it for keys in cooldown). */
91
+ reserveAt(t: number): void {
92
+ this.insert(t)
93
+ this.queued++
94
+ }
95
+
96
+ /** Give back a booked time, e.g. when the waiting client went away. */
97
+ release(t: number): boolean {
98
+ const i = this.times.indexOf(t)
99
+ if (i < 0) return false
100
+ this.times.splice(i, 1)
101
+ return true
102
+ }
103
+
104
+ /** Restore admissions after a restart. */
105
+ seed(times: number[]): void {
106
+ for (const t of times) this.insert(t)
107
+ this.prune(this.now())
108
+ }
109
+
110
+ oldest(): number | undefined {
111
+ this.prune(this.now())
112
+ return this.times[0]
113
+ }
114
+
115
+ snapshot() {
116
+ return {
117
+ enabled: this.enabled,
118
+ cap: this.cap,
119
+ windowMs: this.windowMs,
120
+ inWindow: this.inWindow(),
121
+ admitted: this.admitted,
122
+ queued: this.queued,
123
+ rejected: this.rejected,
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The /connect integration: opencode's built-in provider-connect panel lists
3
+ * the "unifreiburg" integration; this plugin replaces its key method with one
4
+ * that shows the optional uni-login form (login + password) alongside the API
5
+ * key(s). When the user submits, opencode stores a Credential with the form
6
+ * answers; this module watches for it and applies it to the keyring + config,
7
+ * then restarts the gateway so the keys go live.
8
+ */
9
+
10
+ import { applyConnect, splitKeys } from "../shared/connect"
11
+ import type { Paths } from "../shared/paths"
12
+ import { applyKeyChange } from "../cli/keys"
13
+
14
+ export const UFR_INTEGRATION_ID = "unifreiburg"
15
+
16
+ export type ConnectDeps = {
17
+ ctx: any // opencode plugin context (integration + storage domains)
18
+ paths: Paths
19
+ secrets: { get(alias: string): Promise<string | null>; set(alias: string, value: string): Promise<void>; delete(alias: string): Promise<boolean> }
20
+ log: (m: string) => void
21
+ }
22
+
23
+ type CredentialValue = { type?: string; key?: string; configuration?: Record<string, unknown> } | undefined
24
+
25
+ /** Normalizes one opencode credential into a ConnectInput. */
26
+ export function credentialToInput(cred: CredentialValue): { keys: string; login?: string; password?: string } | null {
27
+ if (!cred || cred.type && cred.type !== "key") return null
28
+ const cfg = (cred.configuration ?? {}) as Record<string, unknown>
29
+ const str = (v: unknown) => (typeof v === "string" ? v.trim() : "")
30
+ const keys = str(cred.key) || str(cfg.keys)
31
+ if (!keys) return null
32
+ return {
33
+ keys,
34
+ login: str(cfg.login) || undefined,
35
+ password: str(cfg.password) || undefined,
36
+ }
37
+ }
38
+
39
+ export type ConnectRegistration = { stop: () => void; applyNow: () => Promise<boolean> }
40
+
41
+ export async function registerConnect(d: ConnectDeps): Promise<ConnectRegistration> {
42
+ const { ctx } = d
43
+ if (typeof ctx?.integration?.transform !== "function") {
44
+ d.log("this opencode version has no integration API — /connect stays as configured elsewhere")
45
+ return { stop: () => {}, applyNow: async () => false }
46
+ }
47
+
48
+ // Replace the key method of the unifreiburg integration with ours: the
49
+ // standard API-key(s) prompt plus the optional uni-login form.
50
+ await ctx.integration.transform((editor: any) => {
51
+ if (typeof editor?.method?.update !== "function") return
52
+ editor.method.update({
53
+ integrationID: UFR_INTEGRATION_ID,
54
+ method: {
55
+ type: "key",
56
+ label: "API keys (comma-separated) + optional uni login",
57
+ form: [
58
+ {
59
+ key: "login",
60
+ title: "Uni login (optional)",
61
+ type: "string",
62
+ placeholder: "fl240@uni-freiburg.de",
63
+ description: "Only needed off campus — the built-in VPN uses it. Leave empty on the uni network.",
64
+ },
65
+ {
66
+ key: "password",
67
+ title: "Uni password (optional)",
68
+ type: "string",
69
+ description: "Stored in the OS keyring, never on disk. Only with a login above.",
70
+ },
71
+ ],
72
+ },
73
+ })
74
+ })
75
+
76
+ // Apply a submitted credential once, then watch for changes.
77
+ let lastApplied = ""
78
+ const applyOnce = async (): Promise<boolean> => {
79
+ try {
80
+ const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID)
81
+ if (!connection) return false
82
+ const cred = (await ctx.integration.connection.resolve(connection)) as CredentialValue
83
+ const sig = JSON.stringify(cred?.configuration ?? {}) + "|" + (cred?.key ?? "")
84
+ if (sig === lastApplied) return false
85
+ lastApplied = sig
86
+ const input = credentialToInput(cred)
87
+ if (!input) {
88
+ d.log("/connect credential has no keys — nothing applied")
89
+ return false
90
+ }
91
+ const r = await applyConnect(input, { paths: d.paths, secrets: d.secrets, log: d.log })
92
+ if (r.errors.length > 0) {
93
+ for (const e of r.errors) d.log(`/connect: ${e}`)
94
+ return false
95
+ }
96
+ await applyKeyChange({ paths: d.paths, fetch: (u, i) => fetch(u, i), log: d.log })
97
+ return true
98
+ } catch (e) {
99
+ d.log(`/connect apply failed: ${(e as Error).message}`)
100
+ return false
101
+ }
102
+ }
103
+
104
+ await applyOnce()
105
+ const timer = setInterval(() => void applyOnce(), 60_000)
106
+ ;(timer as { unref?: () => void }).unref?.()
107
+ return {
108
+ stop: () => clearInterval(timer),
109
+ applyNow: applyOnce,
110
+ }
111
+ }
112
+
113
+ export { splitKeys }
@@ -0,0 +1,101 @@
1
+ import { spawn } from "node:child_process"
2
+ import { mkdirSync } from "node:fs"
3
+ import { readJson, readText } from "../shared/fs"
4
+ import type { Paths } from "../shared/paths"
5
+
6
+ export type DaemonInfo = { port: number; pid: number; version: string }
7
+ export type Conn = { port: number; token: string }
8
+ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>
9
+
10
+ export function isNewer(a: string, b: string): boolean {
11
+ const pa = a.split(".").map(Number)
12
+ const pb = b.split(".").map(Number)
13
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
14
+ const x = pa[i] ?? 0
15
+ const y = pb[i] ?? 0
16
+ if (x !== y) return x > y
17
+ }
18
+ return false
19
+ }
20
+
21
+ export async function readDaemonInfo(paths: Paths): Promise<DaemonInfo | null> {
22
+ const j = (await readJson(paths.daemonFile)) as Partial<DaemonInfo> | null
23
+ return j && typeof j.port === "number" && typeof j.version === "string" ? { port: j.port, pid: j.pid ?? 0, version: j.version } : null
24
+ }
25
+
26
+ async function readToken(paths: Paths): Promise<string | null> {
27
+ return (await readText(paths.tokenFile))?.trim() || null
28
+ }
29
+
30
+ /** Find the shared daemon, replace it if we are newer and it is idle, or start one. */
31
+ export async function ensureDaemon(o: {
32
+ paths: Paths
33
+ version: string
34
+ spawn: () => void
35
+ fetch?: FetchLike
36
+ sleep?: (ms: number) => Promise<void>
37
+ timeoutMs?: number
38
+ now?: () => number
39
+ }): Promise<Conn> {
40
+ const f: FetchLike = o.fetch ?? ((u, i) => fetch(u, i))
41
+ const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms))
42
+ const now = o.now ?? Date.now
43
+ const health = async (port: number) => {
44
+ try {
45
+ const r = await f(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2_000) })
46
+ return r.ok ? ((await r.json()) as { version?: string }) : null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+ const authed = (token: string, method = "GET"): RequestInit => ({
52
+ method,
53
+ headers: { Authorization: `Bearer ${token}` },
54
+ signal: AbortSignal.timeout(2_000),
55
+ })
56
+
57
+ const info = await readDaemonInfo(o.paths)
58
+ if (info) {
59
+ const h = await health(info.port)
60
+ const token = await readToken(o.paths)
61
+ if (h && token) {
62
+ if (!h.version || !isNewer(o.version, h.version)) return { port: info.port, token }
63
+ const st = (await f(`http://127.0.0.1:${info.port}/v1/_status`, authed(token))
64
+ .then((r) => (r.ok ? r.json() : null))
65
+ .catch(() => null)) as { inFlight?: number } | null
66
+ if (!st || (st.inFlight ?? 0) > 0) return { port: info.port, token } // upgrade later, never mid-request
67
+ await f(`http://127.0.0.1:${info.port}/v1/_shutdown`, authed(token, "POST")).catch(() => null)
68
+ for (let i = 0; i < 25 && (await health(info.port)); i++) await sleep(200)
69
+ }
70
+ }
71
+
72
+ o.spawn()
73
+ // Startup can take up to ~35 s: two catalog fetches (models.json, UFR /api/models)
74
+ // with 15 s and 20 s timeouts before /health answers.
75
+ const timeoutMs = o.timeoutMs ?? 40_000
76
+ const deadline = now() + timeoutMs
77
+ while (now() < deadline) {
78
+ await sleep(200)
79
+ const next = await readDaemonInfo(o.paths)
80
+ const token = await readToken(o.paths)
81
+ if (next && token && (await health(next.port))) return { port: next.port, token }
82
+ }
83
+ throw new Error(`the opencode-ufr gateway did not start within ${Math.round(timeoutMs / 1000)} s — see daemon.log`)
84
+ }
85
+
86
+ /**
87
+ * Start the daemon detached on the runtime that runs opencode (BUN_BE_BUN: opencode's own Bun).
88
+ * It runs in `cwd` (the state directory), never in the spawning project: Bun auto-loads
89
+ * .env / bunfig.toml from the working directory, and on Windows the folder would stay locked.
90
+ */
91
+ export function spawnDaemon(entry: string, cwd: string): void {
92
+ mkdirSync(cwd, { recursive: true })
93
+ const child = spawn(process.execPath, [entry], {
94
+ cwd,
95
+ detached: true,
96
+ stdio: "ignore",
97
+ windowsHide: true,
98
+ env: { ...process.env, BUN_BE_BUN: "1" },
99
+ })
100
+ child.unref()
101
+ }
@@ -0,0 +1,93 @@
1
+ import { fileURLToPath } from "node:url"
2
+ import type { OpencodeStamp } from "../daemon/catalog"
3
+ import { type Paths, resolvePaths } from "../shared/paths"
4
+ import { KeyringStore } from "../shared/secrets"
5
+ import { VERSION } from "../shared/version"
6
+ import { type Conn, ensureDaemon, spawnDaemon } from "./ensure-daemon"
7
+ import { registerConnect } from "./connect"
8
+ import { toModelInfo } from "./model-info"
9
+
10
+ const DAEMON_ENTRY = fileURLToPath(new URL("../daemon/main.ts", import.meta.url))
11
+
12
+ function log(msg: string): void {
13
+ console.warn(`[opencode-ufr] ${msg}`)
14
+ }
15
+
16
+ async function connect(paths: Paths): Promise<Conn> {
17
+ return ensureDaemon({ paths, version: VERSION, spawn: () => spawnDaemon(DAEMON_ENTRY, paths.stateDir) })
18
+ }
19
+
20
+ async function heartbeat(conn: Conn, paths: Paths): Promise<void> {
21
+ const ok = await fetch(`http://127.0.0.1:${conn.port}/v1/_client/heartbeat`, {
22
+ method: "POST",
23
+ headers: { Authorization: `Bearer ${conn.token}` },
24
+ signal: AbortSignal.timeout(5_000),
25
+ })
26
+ .then((r) => r.ok)
27
+ .catch(() => false)
28
+ // Port and token are stable, so a restarted daemon serves the provider we already registered.
29
+ if (!ok) await connect(paths).catch((e) => log(`gateway restart failed: ${(e as Error).message}`))
30
+ }
31
+
32
+ export default {
33
+ id: "opencode-ufr",
34
+ async setup(ctx: any) {
35
+ const options = (ctx?.options ?? {}) as Record<string, unknown>
36
+ const providerId = typeof options.providerId === "string" ? options.providerId : "unifreiburg"
37
+ const providerName = typeof options.providerName === "string" ? options.providerName : "Uni Freiburg"
38
+ const paths = resolvePaths()
39
+
40
+ let conn: Conn
41
+ try {
42
+ conn = await connect(paths)
43
+ } catch (e) {
44
+ log(`${(e as Error).message} — run \`ufr status\``)
45
+ return
46
+ }
47
+ const list = await fetch(`http://127.0.0.1:${conn.port}/v1/models`, {
48
+ headers: { Authorization: `Bearer ${conn.token}` },
49
+ signal: AbortSignal.timeout(10_000),
50
+ })
51
+ .then(async (r) => (r.ok ? ((await r.json()) as { data: { id: string; opencode: OpencodeStamp }[] }).data : []))
52
+ .catch(() => [])
53
+ if (list.length === 0) {
54
+ // Never register an empty provider: it breaks the whole model picker.
55
+ log("no models available — run `ufr setup`, connect to the uni VPN, then `ufr status` for details")
56
+ return
57
+ }
58
+ if (typeof ctx?.provider?.transform !== "function") {
59
+ log("this opencode version has no ctx.provider.transform — cannot register the provider")
60
+ return
61
+ }
62
+ const models = list.map((m) => toModelInfo(providerId, m.id, m.opencode))
63
+ await ctx.provider.transform((editor: any) => {
64
+ editor.add({
65
+ info: {
66
+ id: providerId,
67
+ name: providerName,
68
+ activation: "enabled",
69
+ package: "@opencode/ai/providers/openai-compatible",
70
+ settings: { baseURL: `http://127.0.0.1:${conn.port}/v1`, apiKey: conn.token },
71
+ },
72
+ models,
73
+ })
74
+ })
75
+ log(`registered ${providerId} with ${models.length} models`)
76
+ // /connect integration: uni login (optional) form on the unifreiburg entry
77
+ const stopConnect = await registerConnect({
78
+ ctx,
79
+ paths,
80
+ secrets: new KeyringStore(),
81
+ log,
82
+ }).catch((e) => {
83
+ log(`/connect integration failed: ${(e as Error).message}`)
84
+ return { stop: () => {}, applyNow: async () => false }
85
+ })
86
+ const beat = setInterval(() => void heartbeat(conn, paths), 60_000)
87
+ ;(beat as { unref?: () => void }).unref?.()
88
+ return () => {
89
+ clearInterval(beat)
90
+ stopConnect.stop()
91
+ }
92
+ },
93
+ }
@@ -0,0 +1,35 @@
1
+ import type { OpencodeStamp } from "../daemon/catalog"
2
+
3
+ /** Map a daemon stamp onto opencode's V2 Model.Info shape (as of opencode 2.0.18). */
4
+ export function toModelInfo(providerID: string, id: string, s: Omit<OpencodeStamp, "cost"> & { cost?: OpencodeStamp["cost"] }) {
5
+ return {
6
+ id,
7
+ modelID: id,
8
+ providerID,
9
+ name: s.name,
10
+ capabilities: {
11
+ tools: s.tool_call,
12
+ input: s.attachment ? ["text", "image"] : ["text"],
13
+ output: ["text"],
14
+ },
15
+ limit: { context: s.limit.context, output: s.limit.output },
16
+ // Required: Model.snapshot() sorts the whole registry on time.released, so a
17
+ // model without it crashes model resolution for *every* provider. UFR has no
18
+ // release dates; 0 means unknown and keeps the sort deterministic.
19
+ time: { released: 0 },
20
+ // Required as an array even when empty: one entry without variants makes the
21
+ // server reject the whole /model list, and the picker shows nothing.
22
+ variants: [],
23
+ status: "active",
24
+ enabled: true,
25
+ // Always an array with every nested object present: opencode dereferences
26
+ // cost[].cache.read unguarded when it tallies a step, after the model replied.
27
+ cost: [
28
+ {
29
+ input: s.cost?.input ?? 0,
30
+ output: s.cost?.output ?? 0,
31
+ cache: { read: s.cost?.cache_read ?? 0, write: s.cost?.cache_write ?? 0 },
32
+ },
33
+ ],
34
+ }
35
+ }
@@ -0,0 +1,170 @@
1
+ import { readText, writeFileAtomic } from "./fs"
2
+
3
+ export type Config = {
4
+ schema: 1
5
+ port: number | null
6
+ transport: { type: "direct" | "auto" }
7
+ vpn: { gateway: string; mode: "auto" | "always" }
8
+ upstream: { baseUrl: string; requestTimeoutS: number }
9
+ keys: string[]
10
+ limits: {
11
+ keyRpm: number
12
+ keyWindowS: number
13
+ keyMaxWaitS: number
14
+ poolPerHour: number
15
+ poolWindowS: number
16
+ poolMaxWaitS: number
17
+ maxUpstreamAttempts: number
18
+ }
19
+ breaker: { tripThreshold: number; ladderS: number[]; probeTimeoutS: number }
20
+ allowPaid: boolean
21
+ dailyBudgetUsd: number
22
+ catalog: { url: string; refreshHours: number }
23
+ idleShutdownMin: number
24
+ }
25
+
26
+ export const DEFAULTS: Config = {
27
+ schema: 1,
28
+ port: null,
29
+ // "auto": reach UFR directly when possible, otherwise through the built-in
30
+ // Fortinet tunnel (needs a stored uni login; behaves like "direct" otherwise).
31
+ transport: { type: "auto" },
32
+ vpn: { gateway: "https://fortivpn.uni-freiburg.de", mode: "auto" },
33
+ upstream: { baseUrl: "https://openwebui.uni-freiburg.de/api", requestTimeoutS: 600 },
34
+ keys: [],
35
+ limits: {
36
+ keyRpm: 18,
37
+ keyWindowS: 60,
38
+ keyMaxWaitS: 60,
39
+ poolPerHour: 800,
40
+ poolWindowS: 3600,
41
+ poolMaxWaitS: 20,
42
+ maxUpstreamAttempts: 4,
43
+ },
44
+ breaker: { tripThreshold: 3, ladderS: [30, 120, 300, 900, 1800, 3600], probeTimeoutS: 120 },
45
+ allowPaid: false,
46
+ dailyBudgetUsd: 20,
47
+ catalog: { url: "https://raw.githubusercontent.com/FinleyLaempe/opencode-ufr/main/models.json", refreshHours: 6 },
48
+ idleShutdownMin: 5,
49
+ }
50
+
51
+ export class ConfigError extends Error {}
52
+
53
+ type Rule = "int>=0" | "int>=1" | "num>=0" | "bool" | "str" | "port|null" | "aliases" | "int>=1[]" | "transport" | "vpnMode"
54
+
55
+ const RULES: Record<string, Rule> = {
56
+ port: "port|null",
57
+ "transport.type": "transport",
58
+ "vpn.gateway": "str",
59
+ "vpn.mode": "vpnMode",
60
+ "upstream.baseUrl": "str",
61
+ "upstream.requestTimeoutS": "int>=1",
62
+ keys: "aliases",
63
+ "limits.keyRpm": "int>=1",
64
+ "limits.keyWindowS": "int>=1",
65
+ "limits.keyMaxWaitS": "int>=0",
66
+ "limits.poolPerHour": "int>=0",
67
+ "limits.poolWindowS": "int>=1",
68
+ "limits.poolMaxWaitS": "int>=0",
69
+ "limits.maxUpstreamAttempts": "int>=1",
70
+ "breaker.tripThreshold": "int>=1",
71
+ "breaker.ladderS": "int>=1[]",
72
+ "breaker.probeTimeoutS": "int>=1",
73
+ allowPaid: "bool",
74
+ dailyBudgetUsd: "num>=0",
75
+ "catalog.url": "str",
76
+ "catalog.refreshHours": "int>=1",
77
+ idleShutdownMin: "int>=1",
78
+ }
79
+
80
+ // Upper bounds for values that become setTimeout/setInterval delays: above 2^31-1 ms
81
+ // Bun clamps a timer to 1 ms, which would turn e.g. the catalog refresh into a tight loop.
82
+ // For an array (breaker.ladderS) the bound applies to every entry.
83
+ const MAX: Record<string, number> = {
84
+ "upstream.requestTimeoutS": 86_400,
85
+ "limits.keyWindowS": 86_400,
86
+ "limits.keyMaxWaitS": 86_400,
87
+ "limits.poolWindowS": 86_400,
88
+ "limits.poolMaxWaitS": 86_400,
89
+ "breaker.ladderS": 86_400,
90
+ "breaker.probeTimeoutS": 86_400,
91
+ "catalog.refreshHours": 168,
92
+ idleShutdownMin: 1_440,
93
+ }
94
+
95
+ export const ALIAS_RE = /^[A-Za-z0-9._-]{1,32}$/
96
+
97
+ const isObj = (x: unknown): x is Record<string, unknown> => typeof x === "object" && x !== null && !Array.isArray(x)
98
+ const isInt = (x: unknown): x is number => typeof x === "number" && Number.isInteger(x)
99
+
100
+ function deepMerge(base: unknown, over: unknown): unknown {
101
+ if (!isObj(base) || !isObj(over)) return over === undefined ? base : over
102
+ const out: Record<string, unknown> = { ...base }
103
+ for (const [k, v] of Object.entries(over)) out[k] = deepMerge(base[k], v)
104
+ return out
105
+ }
106
+
107
+ function get(obj: unknown, path: string): unknown {
108
+ return path.split(".").reduce<unknown>((o, k) => (isObj(o) ? o[k] : undefined), obj)
109
+ }
110
+
111
+ function valid(v: unknown, rule: Rule): boolean {
112
+ switch (rule) {
113
+ case "int>=0":
114
+ return isInt(v) && v >= 0
115
+ case "int>=1":
116
+ return isInt(v) && v >= 1
117
+ case "num>=0":
118
+ return typeof v === "number" && Number.isFinite(v) && v >= 0
119
+ case "bool":
120
+ return typeof v === "boolean"
121
+ case "str":
122
+ return typeof v === "string" && v.length > 0
123
+ case "port|null":
124
+ return v === null || (isInt(v) && v >= 1 && v <= 65535)
125
+ case "aliases":
126
+ return Array.isArray(v) && v.every((s) => typeof s === "string" && ALIAS_RE.test(s))
127
+ case "int>=1[]":
128
+ return Array.isArray(v) && v.length > 0 && v.every((x) => isInt(x) && x >= 1)
129
+ case "transport":
130
+ return v === "direct" || v === "auto"
131
+ case "vpnMode":
132
+ return v === "auto" || v === "always"
133
+ }
134
+ }
135
+
136
+ export function mergeConfig(raw: unknown): Config {
137
+ if (raw !== undefined && !isObj(raw)) throw new ConfigError("config: the top level must be a JSON object")
138
+ if (isObj(raw) && raw.schema !== undefined && raw.schema !== 1) {
139
+ throw new ConfigError(`config: unsupported schema ${JSON.stringify(raw.schema)} (this version reads schema 1)`)
140
+ }
141
+ const cfg = deepMerge(structuredClone(DEFAULTS), raw ?? {}) as Config
142
+ for (const [path, rule] of Object.entries(RULES)) {
143
+ const v = get(cfg, path)
144
+ if (!valid(v, rule)) throw new ConfigError(`config: ${path} is invalid (${JSON.stringify(v)}), expected ${rule}`)
145
+ const max = MAX[path]
146
+ if (max !== undefined && (Array.isArray(v) ? v : [v]).some((x) => (x as number) > max)) {
147
+ const what = Array.isArray(v) ? "every entry must be" : "must be"
148
+ throw new ConfigError(`config: ${path} is too large (${JSON.stringify(v)}), ${what} at most ${max}`)
149
+ }
150
+ }
151
+ if (new Set(cfg.keys).size !== cfg.keys.length) throw new ConfigError("config: keys contains duplicate aliases")
152
+ return { ...cfg, schema: 1 }
153
+ }
154
+
155
+ export async function loadConfig(file: string): Promise<Config> {
156
+ const text = await readText(file)
157
+ if (text === null) return structuredClone(DEFAULTS)
158
+ let raw: unknown
159
+ try {
160
+ raw = JSON.parse(text)
161
+ } catch (e) {
162
+ throw new ConfigError(`config: ${file} is not valid JSON (${(e as Error).message})`)
163
+ }
164
+ return mergeConfig(raw)
165
+ }
166
+
167
+ export async function saveConfig(file: string, cfg: Config): Promise<void> {
168
+ mergeConfig(cfg) // refuse to write something we could not read back
169
+ await writeFileAtomic(file, JSON.stringify(cfg, null, 2) + "\n")
170
+ }