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
|
@@ -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
|
+
}
|