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,100 @@
1
+ /**
2
+ * Shared connect logic: parsing key lists and applying credentials to the
3
+ * keyring + config. Used by the CLI (`ufr connect`) and by the plugin's
4
+ * /connect integration watcher.
5
+ */
6
+
7
+ import { loadConfig, saveConfig } from "./config"
8
+ import type { Paths } from "./paths"
9
+ import type { SecretStore } from "./secrets"
10
+ import { VPN_PASS, VPN_USER } from "./secrets"
11
+
12
+ /** Splits a key list on commas AND newlines, trims whitespace, drops empties. */
13
+ export function splitKeys(spec: string): string[] {
14
+ return spec
15
+ .split(/[,\n\r]+/)
16
+ .map((k) => k.trim())
17
+ .filter((k) => k.length > 0)
18
+ }
19
+
20
+ /** Auto-aliases key1…keyN, skipping numbers already in use. */
21
+ export function nextAliases(existing: string[], count: number): string[] {
22
+ const used = new Set(existing)
23
+ const out: string[] = []
24
+ for (let n = 1; out.length < count; n++) {
25
+ const alias = `key${n}`
26
+ if (!used.has(alias)) {
27
+ used.add(alias)
28
+ out.push(alias)
29
+ }
30
+ }
31
+ return out
32
+ }
33
+
34
+ export const EMAIL_RE = /^[^\s@]+@[^\s@]+$/
35
+
36
+ export type ConnectInput = {
37
+ keys: string // comma/newline separated, whitespace filtered
38
+ login?: string // optional uni login (built-in VPN)
39
+ password?: string
40
+ }
41
+
42
+ export type ConnectApplied = {
43
+ aliases: { alias: string; key: string }[]
44
+ removedAliases: string[]
45
+ loginStored: boolean
46
+ errors: string[]
47
+ }
48
+
49
+ /**
50
+ * Applies a /connect submission: stores the keys as key1…keyN in the keyring
51
+ * (replacing previous key* aliases), optionally the uni login, and updates
52
+ * config.keys. Custom (non-key*) aliases are left untouched.
53
+ */
54
+ export async function applyConnect(
55
+ input: ConnectInput,
56
+ o: { paths: Paths; secrets: SecretStore; log?: (m: string) => void },
57
+ ): Promise<ConnectApplied> {
58
+ const log = o.log ?? (() => {})
59
+ const errors: string[] = []
60
+ const keys = splitKeys(input.keys)
61
+ if (keys.length === 0) {
62
+ errors.push("no keys given")
63
+ return { aliases: [], removedAliases: [], loginStored: false, errors }
64
+ }
65
+ if (input.login && !EMAIL_RE.test(input.login.trim())) {
66
+ errors.push(`"${input.login}" does not look like a uni login`)
67
+ return { aliases: [], removedAliases: [], loginStored: false, errors }
68
+ }
69
+
70
+ const cfg = await loadConfig(o.paths.configFile)
71
+ const custom = cfg.keys.filter((k) => !/^key\d+$/.test(k))
72
+
73
+ // replace the managed key1…keyN aliases
74
+ const removedAliases: string[] = []
75
+ const wanted = nextAliases(custom, keys.length)
76
+ for (const alias of cfg.keys) {
77
+ if (/^key\d+$/.test(alias) && !wanted.includes(alias)) {
78
+ await o.secrets.delete(alias)
79
+ removedAliases.push(alias)
80
+ }
81
+ }
82
+ const aliases: { alias: string; key: string }[] = []
83
+ for (let i = 0; i < keys.length; i++) {
84
+ const alias = wanted[i]!
85
+ await o.secrets.set(alias, keys[i]!)
86
+ aliases.push({ alias, key: keys[i]! })
87
+ }
88
+
89
+ let loginStored = false
90
+ if (input.login?.trim()) {
91
+ await o.secrets.set(VPN_USER, input.login.trim())
92
+ if (input.password) await o.secrets.set(VPN_PASS, input.password)
93
+ loginStored = true
94
+ }
95
+
96
+ cfg.keys = [...custom, ...wanted]
97
+ await saveConfig(o.paths.configFile, cfg)
98
+ log(`connect applied: ${aliases.length} key(s), login ${loginStored ? "stored" : "unchanged"}`)
99
+ return { aliases, removedAliases, loginStored, errors }
100
+ }
@@ -0,0 +1,6 @@
1
+ /** OpenAI-format error with a whole-second Retry-After when a wait is known. */
2
+ export function errorResponse(status: number, type: string, message: string, retryAfterMs?: number): Response {
3
+ const headers: Record<string, string> = { "content-type": "application/json" }
4
+ if (retryAfterMs !== undefined && retryAfterMs > 0) headers["retry-after"] = String(Math.max(1, Math.ceil(retryAfterMs / 1000)))
5
+ return new Response(JSON.stringify({ error: { message, type, code: status } }), { status, headers })
6
+ }
@@ -0,0 +1,31 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
2
+ import { dirname } from "node:path"
3
+
4
+ /** Write via a temp file + rename, so readers never see half a file. */
5
+ export async function writeFileAtomic(file: string, data: string, mode?: number): Promise<void> {
6
+ await mkdir(dirname(file), { recursive: true })
7
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
8
+ await writeFile(tmp, data, mode === undefined ? {} : { mode })
9
+ await rename(tmp, file)
10
+ }
11
+
12
+ /** File contents, or null if it does not exist. Other errors propagate. */
13
+ export async function readText(file: string): Promise<string | null> {
14
+ try {
15
+ return await readFile(file, "utf8")
16
+ } catch (e) {
17
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return null
18
+ throw e
19
+ }
20
+ }
21
+
22
+ /** Parsed JSON, or null if the file is missing or not JSON. */
23
+ export async function readJson(file: string): Promise<unknown | null> {
24
+ const text = await readText(file)
25
+ if (text === null) return null
26
+ try {
27
+ return JSON.parse(text)
28
+ } catch {
29
+ return null
30
+ }
31
+ }
@@ -0,0 +1,68 @@
1
+ /** USD per 1M tokens. */
2
+ export type Price = { input: number; output: number; cache_read?: number; cache_write?: number }
3
+
4
+ export type ModelEntry = {
5
+ context?: number
6
+ max_output?: number
7
+ vision?: boolean
8
+ tools?: boolean
9
+ price?: Price
10
+ note?: string
11
+ }
12
+
13
+ export type ModelsFile = {
14
+ schema: 1
15
+ updated: string
16
+ defaults: { context: number; max_output: number }
17
+ fallbacks: { free_escape_order: string[]; max_targets: number; context_hub: string | null }
18
+ exclude: string[]
19
+ aliases: Record<string, string>
20
+ models: Record<string, ModelEntry>
21
+ }
22
+
23
+ export class ModelsFileError extends Error {}
24
+
25
+ const isObj = (x: unknown): x is Record<string, unknown> => typeof x === "object" && x !== null && !Array.isArray(x)
26
+ const posInt = (x: unknown) => typeof x === "number" && Number.isInteger(x) && x > 0
27
+ const nonNeg = (x: unknown) => typeof x === "number" && Number.isFinite(x) && x >= 0
28
+ const strList = (x: unknown) => Array.isArray(x) && x.every((s) => typeof s === "string")
29
+
30
+ function fail(path: string, why: string): never {
31
+ throw new ModelsFileError(`models.json: ${path} ${why}`)
32
+ }
33
+
34
+ export function validateModelsFile(x: unknown): ModelsFile {
35
+ if (!isObj(x)) fail("(root)", "must be an object")
36
+ if (x.schema !== 1) fail("schema", `must be 1 (got ${JSON.stringify(x.schema)})`)
37
+ if (typeof x.updated !== "string") fail("updated", "must be a date string")
38
+ const d = x.defaults
39
+ if (!isObj(d) || !posInt(d.context) || !posInt(d.max_output)) fail("defaults", "needs positive integers context and max_output")
40
+ const fb = x.fallbacks
41
+ if (!isObj(fb)) fail("fallbacks", "must be an object")
42
+ if (!strList(fb.free_escape_order)) fail("fallbacks.free_escape_order", "must be a list of model ids")
43
+ if (!posInt(fb.max_targets)) fail("fallbacks.max_targets", "must be a positive integer")
44
+ if (fb.context_hub !== null && typeof fb.context_hub !== "string") fail("fallbacks.context_hub", "must be a model id or null")
45
+ if (!strList(x.exclude)) fail("exclude", "must be a list of model ids")
46
+ if (!isObj(x.aliases) || !Object.values(x.aliases).every((s) => typeof s === "string")) fail("aliases", "must map alias -> model id")
47
+ if (!isObj(x.models)) fail("models", "must be an object")
48
+ for (const [id, m] of Object.entries(x.models)) {
49
+ const p = `models["${id}"]`
50
+ if (!isObj(m)) fail(p, "must be an object")
51
+ if (m.context !== undefined && !posInt(m.context)) fail(`${p}.context`, "must be a positive integer")
52
+ if (m.max_output !== undefined && !posInt(m.max_output)) fail(`${p}.max_output`, "must be a positive integer")
53
+ if (m.vision !== undefined && typeof m.vision !== "boolean") fail(`${p}.vision`, "must be true or false")
54
+ if (m.tools !== undefined && typeof m.tools !== "boolean") fail(`${p}.tools`, "must be true or false")
55
+ if (m.note !== undefined && typeof m.note !== "string") fail(`${p}.note`, "must be a string")
56
+ if (m.price !== undefined) {
57
+ const pr = m.price
58
+ if (!isObj(pr) || !nonNeg(pr.input) || !nonNeg(pr.output)) fail(`${p}.price`, "needs non-negative input and output (USD per 1M tokens)")
59
+ for (const k of ["cache_read", "cache_write"] as const) {
60
+ if (pr[k] !== undefined && !nonNeg(pr[k])) fail(`${p}.price.${k}`, "must be a non-negative number")
61
+ }
62
+ }
63
+ }
64
+ for (const [alias, target] of Object.entries(x.aliases as Record<string, string>)) {
65
+ if (!(target in x.models)) fail(`aliases["${alias}"]`, `points at "${target}", which has no models entry`)
66
+ }
67
+ return x as unknown as ModelsFile
68
+ }
@@ -0,0 +1,62 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+
4
+ export type Paths = {
5
+ configDir: string
6
+ stateDir: string
7
+ cacheDir: string
8
+ dataDir: string
9
+ configFile: string
10
+ daemonFile: string
11
+ tokenFile: string
12
+ lockFile: string
13
+ logFile: string
14
+ statsDb: string
15
+ modelsCache: string
16
+ modelsEtag: string
17
+ ufrModelsCache: string
18
+ }
19
+
20
+ const APP = "opencode-ufr"
21
+
22
+ export function resolvePaths(
23
+ env: Record<string, string | undefined> = process.env,
24
+ platform: string = process.platform,
25
+ home: string = homedir(),
26
+ ): Paths {
27
+ let configDir: string, stateDir: string, cacheDir: string, dataDir: string
28
+ if (env.OPENCODE_UFR_HOME) {
29
+ const base = env.OPENCODE_UFR_HOME
30
+ configDir = join(base, "config")
31
+ stateDir = join(base, "state")
32
+ cacheDir = join(base, "cache")
33
+ dataDir = join(base, "data")
34
+ } else if (platform === "win32") {
35
+ const roaming = env.APPDATA ?? join(home, "AppData", "Roaming")
36
+ const local = env.LOCALAPPDATA ?? join(home, "AppData", "Local")
37
+ configDir = join(roaming, APP)
38
+ stateDir = join(local, APP, "state")
39
+ cacheDir = join(local, APP, "cache")
40
+ dataDir = join(local, APP, "data")
41
+ } else {
42
+ configDir = join(env.XDG_CONFIG_HOME ?? join(home, ".config"), APP)
43
+ stateDir = join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), APP)
44
+ cacheDir = join(env.XDG_CACHE_HOME ?? join(home, ".cache"), APP)
45
+ dataDir = join(env.XDG_DATA_HOME ?? join(home, ".local", "share"), APP)
46
+ }
47
+ return {
48
+ configDir,
49
+ stateDir,
50
+ cacheDir,
51
+ dataDir,
52
+ configFile: join(configDir, "config.json"),
53
+ daemonFile: join(stateDir, "daemon.json"),
54
+ tokenFile: join(stateDir, "token"),
55
+ lockFile: join(stateDir, "daemon.lock"),
56
+ logFile: join(stateDir, "daemon.log"),
57
+ statsDb: join(dataDir, "stats.db"),
58
+ modelsCache: join(cacheDir, "models.json"),
59
+ modelsEtag: join(cacheDir, "models.etag"),
60
+ ufrModelsCache: join(cacheDir, "ufr-models.json"),
61
+ }
62
+ }
@@ -0,0 +1,62 @@
1
+ export interface SecretStore {
2
+ get(alias: string): Promise<string | null>
3
+ set(alias: string, value: string): Promise<void>
4
+ delete(alias: string): Promise<boolean>
5
+ }
6
+
7
+ export const SERVICE = "opencode-ufr"
8
+
9
+ /** Keyring aliases for the built-in VPN's uni login (never listed as API keys). */
10
+ export const VPN_USER = "vpn-login"
11
+ export const VPN_PASS = "vpn-pass"
12
+
13
+ export class SecretStoreError extends Error {}
14
+
15
+ type Backend = {
16
+ get(o: { service: string; name: string }): Promise<string | null>
17
+ set(o: { service: string; name: string; value: string }): Promise<void>
18
+ delete(o: { service: string; name: string }): Promise<boolean>
19
+ }
20
+
21
+ /** UFR keys in the OS keyring: macOS Keychain, Windows Credential Manager, libsecret on Linux. */
22
+ export class KeyringStore implements SecretStore {
23
+ constructor(private readonly backend: Backend = Bun.secrets as unknown as Backend) {}
24
+
25
+ private async run<T>(what: string, fn: () => Promise<T>): Promise<T> {
26
+ try {
27
+ return await fn()
28
+ } catch (e) {
29
+ throw new SecretStoreError(
30
+ `OS keyring unavailable while trying to ${what} (${(e as Error).message}). ` +
31
+ "On Linux this needs a Secret Service (gnome-keyring or KWallet) running in your session.",
32
+ )
33
+ }
34
+ }
35
+
36
+ get(alias: string): Promise<string | null> {
37
+ return this.run(`read key "${alias}"`, () => this.backend.get({ service: SERVICE, name: alias }))
38
+ }
39
+
40
+ async set(alias: string, value: string): Promise<void> {
41
+ if (!value) throw new SecretStoreError("refusing to store an empty key")
42
+ await this.run(`store key "${alias}"`, () => this.backend.set({ service: SERVICE, name: alias, value }))
43
+ }
44
+
45
+ delete(alias: string): Promise<boolean> {
46
+ return this.run(`delete key "${alias}"`, () => this.backend.delete({ service: SERVICE, name: alias }))
47
+ }
48
+ }
49
+
50
+ /** In-memory store for tests. */
51
+ export class MemoryStore implements SecretStore {
52
+ private readonly m = new Map<string, string>()
53
+ async get(alias: string): Promise<string | null> {
54
+ return this.m.get(alias) ?? null
55
+ }
56
+ async set(alias: string, value: string): Promise<void> {
57
+ this.m.set(alias, value)
58
+ }
59
+ async delete(alias: string): Promise<boolean> {
60
+ return this.m.delete(alias)
61
+ }
62
+ }
@@ -0,0 +1,15 @@
1
+ /** setTimeout as a promise that rejects as soon as the signal aborts. */
2
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
3
+ return new Promise((resolve, reject) => {
4
+ if (signal?.aborted) return reject(signal.reason)
5
+ const onAbort = () => {
6
+ clearTimeout(timer)
7
+ reject(signal!.reason)
8
+ }
9
+ const timer = setTimeout(() => {
10
+ signal?.removeEventListener("abort", onAbort)
11
+ resolve()
12
+ }, ms)
13
+ signal?.addEventListener("abort", onAbort, { once: true })
14
+ })
15
+ }
@@ -0,0 +1,6 @@
1
+ /** Local midnight of the day containing `ms`. UFR's own reset time is unknown. */
2
+ export function startOfLocalDay(ms: number): number {
3
+ const d = new Date(ms)
4
+ d.setHours(0, 0, 0, 0)
5
+ return d.getTime()
6
+ }
@@ -0,0 +1,3 @@
1
+ import pkg from "../../package.json" with { type: "json" }
2
+
3
+ export const VERSION: string = pkg.version
@@ -0,0 +1,8 @@
1
+ export const VPN_GUIDE = "https://wiki.uni-freiburg.de/rz/doku.php?id=vpn"
2
+
3
+ export const VPN_MESSAGE = `UFR answered with its "VPN erforderlich" page — connect to the uni VPN (${VPN_GUIDE})`
4
+
5
+ /** Off the VPN, UFR answers every path with HTTP 200 and this page (measured 2026-09-28). */
6
+ export function isVpnPage(html: string): boolean {
7
+ return /VPN erforderlich|Zugriff eingeschr/i.test(html)
8
+ }