opencode-ufr 0.2.2 → 0.2.3

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/README.md CHANGED
@@ -60,8 +60,16 @@ ufr stats # requests, tokens and cost
60
60
  ufr keys list # stored aliases
61
61
  ufr keys test # check your keys against UFR
62
62
  ufr login show # the stored uni login
63
+ ufr disconnect # remove everything: keys, uni login, config, gateway
63
64
  ```
64
65
 
66
+ Removing the **unifreiburg** integration in opencode's `/connect` panel does the
67
+ same as `ufr disconnect`: the stored keys and uni login are wiped, the gateway
68
+ stops, and the models disappear from the picker on the next opencode start.
69
+ While opencode runs, the removal is noticed within a minute. If opencode was
70
+ closed when you removed the integration, run `ufr disconnect` once to wipe what
71
+ is left.
72
+
65
73
  ## The built-in VPN
66
74
 
67
75
  On the campus network (or the VPN you already run) UFR is reached directly and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ufr",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Uni Freiburg (UFR) Open WebUI models in opencode — local gateway with key pool, UFR-aware rate limiting, circuit breaker and fallbacks",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli/index.ts CHANGED
@@ -5,7 +5,7 @@ import { KeyringStore, type SecretStore } from "../shared/secrets"
5
5
  import { cmdCatalogDiff } from "./catalog"
6
6
  import { cmdConnect } from "./connect"
7
7
  import { type Io, terminalIo } from "./io"
8
- import { cmdKeys, cmdLogin } from "./keys"
8
+ import { cmdDisconnect, cmdKeys, cmdLogin } from "./keys"
9
9
  import { cmdStats } from "./stats"
10
10
  import { cmdStatus } from "./status"
11
11
  import { cmdStop } from "./stop"
@@ -25,6 +25,8 @@ const HELP = `ufr — opencode-ufr gateway
25
25
  [--keys "<k1>,<k2>"] (comma/newline separated,
26
26
  whitespace filtered), optional uni login for the
27
27
  built-in VPN [--login <u> --password <p>]
28
+ ufr disconnect remove everything: stored keys, uni login,
29
+ config and the running gateway
28
30
  ufr keys list | remove <alias> | test [alias] manage stored keys
29
31
  ufr login show | remove the uni login for the built-in VPN
30
32
  ufr status gateway, keys, limits, breakers, spend today
@@ -55,6 +57,8 @@ export async function main(argv: string[], partial: Partial<CliDeps> = {}): Prom
55
57
  switch (cmd) {
56
58
  case "connect":
57
59
  return await cmdConnect(d, rest)
60
+ case "disconnect":
61
+ return await cmdDisconnect(d)
58
62
  case "keys":
59
63
  return await cmdKeys(d, rest)
60
64
  case "login":
package/src/cli/keys.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadConfig, saveConfig } from "../shared/config"
2
2
  import type { Paths } from "../shared/paths"
3
- import { VPN_PASS, VPN_USER } from "../shared/secrets"
3
+ import { VPN_PASS, VPN_USER, type SecretStore } from "../shared/secrets"
4
4
  import { daemonRequest } from "./daemon-client"
5
5
  import type { FetchLike } from "../daemon/catalog-source"
6
6
  import type { CliDeps } from "./index"
@@ -29,6 +29,41 @@ export async function applyKeyChange(d: { paths: Paths; fetch: FetchLike; log: (
29
29
  : "could not stop the gateway — run `ufr stop` so it picks up the new keys")
30
30
  }
31
31
 
32
+ export type DisconnectResult = { removed: string[]; loginRemoved: boolean }
33
+
34
+ /**
35
+ * Provider-removal cleanup: delete every stored key alias and the uni login
36
+ * from the keyring, clear config.keys, and stop an idle gateway (it reads its
37
+ * keys at start, so a later start serves nothing). Idempotent.
38
+ */
39
+ export async function disconnectAll(d: {
40
+ paths: Paths
41
+ secrets: SecretStore
42
+ fetch: FetchLike
43
+ log: (m: string) => void
44
+ }): Promise<DisconnectResult> {
45
+ const cfg = await loadConfig(d.paths.configFile)
46
+ const removed: string[] = []
47
+ for (const alias of cfg.keys) {
48
+ if (await d.secrets.delete(alias)) removed.push(alias)
49
+ }
50
+ const userRemoved = await d.secrets.delete(VPN_USER)
51
+ const passRemoved = await d.secrets.delete(VPN_PASS)
52
+ const loginRemoved = userRemoved || passRemoved
53
+ cfg.keys = []
54
+ await saveConfig(d.paths.configFile, cfg)
55
+ await applyKeyChange(d)
56
+ return { removed, loginRemoved }
57
+ }
58
+
59
+ export async function cmdDisconnect(d: CliDeps): Promise<number> {
60
+ const r = await disconnectAll({ paths: d.paths, secrets: d.secrets, fetch: d.fetch, log: (m) => d.io.out(m + "\n") })
61
+ d.io.out(r.removed.length > 0 ? `removed from keyring: ${r.removed.join(", ")}\n` : "nothing was stored\n")
62
+ if (r.loginRemoved) d.io.out("removed the uni login\n")
63
+ d.io.out("done — reconnect any time with `ufr connect` or /connect in opencode\n")
64
+ return 0
65
+ }
66
+
32
67
  export async function cmdKeys(d: CliDeps, args: string[]): Promise<number> {
33
68
  const [sub, ...rest] = args
34
69
  const cfg = await loadConfig(d.paths.configFile)
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { applyConnect, splitKeys } from "../shared/connect"
11
11
  import type { Paths } from "../shared/paths"
12
- import { applyKeyChange } from "../cli/keys"
12
+ import { applyKeyChange, disconnectAll } from "../cli/keys"
13
13
 
14
14
  export const UFR_INTEGRATION_ID = "unifreiburg"
15
15
 
@@ -18,6 +18,11 @@ export type ConnectDeps = {
18
18
  paths: Paths
19
19
  secrets: { get(alias: string): Promise<string | null>; set(alias: string, value: string): Promise<void>; delete(alias: string): Promise<boolean> }
20
20
  log: (m: string) => void
21
+ /** (Re-)registers the provider with opencode — called on first connect and after a removal+reconnect.
22
+ * Returns false when the registration did not succeed (e.g. no models yet); the watcher retries. */
23
+ register?: () => Promise<boolean | void> | boolean | void
24
+ /** Whether setup() already registered the provider (it does when a connection exists at startup). */
25
+ providerRegistered?: boolean
21
26
  }
22
27
 
23
28
  type CredentialValue = { type?: string; key?: string; configuration?: Record<string, unknown> } | undefined
@@ -73,28 +78,52 @@ export async function registerConnect(d: ConnectDeps): Promise<ConnectRegistrati
73
78
  })
74
79
  })
75
80
 
76
- // Apply a submitted credential once, then watch for changes.
81
+ // Apply a submitted credential once, then watch for changes. Phases:
82
+ // "registered" (provider live), "unregistered" (no provider yet), and
83
+ // "disconnected" (the connection was removed in opencode — everything
84
+ // wiped; a later connection re-applies and re-registers).
85
+ type Phase = "registered" | "unregistered" | "disconnected"
86
+ let phase: Phase = d.providerRegistered ? "registered" : "unregistered"
77
87
  let lastApplied = ""
78
88
  const applyOnce = async (): Promise<boolean> => {
79
89
  try {
80
90
  const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID)
81
- if (!connection) return false
91
+ if (!connection) {
92
+ if (phase === "registered") {
93
+ phase = "disconnected"
94
+ lastApplied = ""
95
+ const r = await disconnectAll({ paths: d.paths, secrets: d.secrets, fetch: (u, i) => fetch(u, i), log: d.log })
96
+ d.log(`unifreiburg removed in opencode — wiped from keyring: ${r.removed.length > 0 ? r.removed.join(", ") : "nothing"}${r.loginRemoved ? " + uni login" : ""}`)
97
+ return true // the disconnect cleaned something up — a state change
98
+ }
99
+ return false
100
+ }
82
101
  const cred = (await ctx.integration.connection.resolve(connection)) as CredentialValue
83
102
  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
103
+ let changed = false
104
+ if (sig !== lastApplied) {
105
+ lastApplied = sig
106
+ const input = credentialToInput(cred)
107
+ if (!input) {
108
+ d.log("/connect credential has no keys — nothing applied")
109
+ return false
110
+ }
111
+ const r = await applyConnect(input, { paths: d.paths, secrets: d.secrets, log: d.log })
112
+ if (r.errors.length > 0) {
113
+ for (const e of r.errors) d.log(`/connect: ${e}`)
114
+ lastApplied = "" // retry on the next poll
115
+ return false
116
+ }
117
+ if (r.changed) await applyKeyChange({ paths: d.paths, fetch: (u, i) => fetch(u, i), log: d.log })
118
+ changed = true
90
119
  }
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
120
+ if (phase !== "registered") {
121
+ // first connect mid-session, or a reconnect after a removal
122
+ const ok = await d.register?.()
123
+ if (ok !== false) phase = "registered" // false = registration failed — retry on the next poll
124
+ changed = true
95
125
  }
96
- await applyKeyChange({ paths: d.paths, fetch: (u, i) => fetch(u, i), log: d.log })
97
- return true
126
+ return changed
98
127
  } catch (e) {
99
128
  d.log(`/connect apply failed: ${(e as Error).message}`)
100
129
  return false
@@ -1,10 +1,11 @@
1
1
  import { fileURLToPath } from "node:url"
2
2
  import type { OpencodeStamp } from "../daemon/catalog"
3
+ import { daemonRequest } from "../cli/daemon-client"
3
4
  import { type Paths, resolvePaths } from "../shared/paths"
4
5
  import { KeyringStore } from "../shared/secrets"
5
6
  import { VERSION } from "../shared/version"
6
7
  import { type Conn, ensureDaemon, spawnDaemon } from "./ensure-daemon"
7
- import { registerConnect } from "./connect"
8
+ import { registerConnect, UFR_INTEGRATION_ID } from "./connect"
8
9
  import { toModelInfo } from "./model-info"
9
10
 
10
11
  const DAEMON_ENTRY = fileURLToPath(new URL("../daemon/main.ts", import.meta.url))
@@ -17,13 +18,69 @@ async function connect(paths: Paths): Promise<Conn> {
17
18
  return ensureDaemon({ paths, version: VERSION, spawn: () => spawnDaemon(DAEMON_ENTRY, paths.stateDir) })
18
19
  }
19
20
 
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",
21
+ /** Whether this opencode exposes the integration API (needed to see connection state). */
22
+ function hasIntegration(ctx: any): boolean {
23
+ return typeof ctx?.integration?.connection?.active === "function"
24
+ }
25
+
26
+ /** Starts the daemon if needed, fetches its models and registers the provider with opencode. */
27
+ async function registerProvider(
28
+ ctx: any,
29
+ providerId: string,
30
+ providerName: string,
31
+ paths: Paths,
32
+ ): Promise<boolean> {
33
+ let conn: Conn
34
+ try {
35
+ conn = await connect(paths)
36
+ } catch (e) {
37
+ log(`${(e as Error).message} — run \`ufr status\``)
38
+ return false
39
+ }
40
+ const list = await fetch(`http://127.0.0.1:${conn.port}/v1/models`, {
23
41
  headers: { Authorization: `Bearer ${conn.token}` },
24
- signal: AbortSignal.timeout(5_000),
42
+ signal: AbortSignal.timeout(10_000),
43
+ })
44
+ .then(async (r) => (r.ok ? ((await r.json()) as { data: { id: string; opencode: OpencodeStamp }[] }).data : []))
45
+ .catch(() => [])
46
+ if (list.length === 0) {
47
+ // Never register an empty provider: it breaks the whole model picker.
48
+ log("no models available — run `ufr setup`, connect to the uni VPN, then `ufr status` for details")
49
+ return false
50
+ }
51
+ if (typeof ctx?.provider?.transform !== "function") {
52
+ log("this opencode version has no ctx.provider.transform — cannot register the provider")
53
+ return false
54
+ }
55
+ const models = list.map((m) => toModelInfo(providerId, m.id, m.opencode))
56
+ await ctx.provider.transform((editor: any) => {
57
+ editor.add({
58
+ info: {
59
+ id: providerId,
60
+ name: providerName,
61
+ activation: "enabled",
62
+ package: "@opencode/ai/providers/openai-compatible",
63
+ settings: { baseURL: `http://127.0.0.1:${conn.port}/v1`, apiKey: conn.token },
64
+ },
65
+ models,
66
+ })
25
67
  })
26
- .then((r) => r.ok)
68
+ log(`registered ${providerId} with ${models.length} models`)
69
+ return true
70
+ }
71
+
72
+ /**
73
+ * Keeps the daemon alive while the provider is connected. When the connection
74
+ * was removed in opencode, the daemon must stay down — the watcher wipes the
75
+ * keys and opencode no longer offers the provider.
76
+ */
77
+ export async function heartbeat(ctx: any, paths: Paths): Promise<void> {
78
+ if (hasIntegration(ctx)) {
79
+ const active = await ctx.integration.connection.active(UFR_INTEGRATION_ID).catch(() => undefined)
80
+ if (!active) return
81
+ }
82
+ const ok = await daemonRequest(paths, (u, i) => fetch(u, i), "/v1/_client/heartbeat", "POST")
83
+ .then((r) => r?.ok ?? false)
27
84
  .catch(() => false)
28
85
  // Port and token are stable, so a restarted daemon serves the provider we already registered.
29
86
  if (!ok) await connect(paths).catch((e) => log(`gateway restart failed: ${(e as Error).message}`))
@@ -37,53 +94,38 @@ export default {
37
94
  const providerName = typeof options.providerName === "string" ? options.providerName : "Uni Freiburg"
38
95
  const paths = resolvePaths()
39
96
 
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
97
+ let registered = false
98
+ const registerProviderFn = async (): Promise<boolean> => {
99
+ if (registered) return true
100
+ registered = await registerProvider(ctx, providerId, providerName, paths)
101
+ return registered
61
102
  }
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
103
+
104
+ // /connect integration first: it is the only way to connect when no
105
+ // provider is registered yet, so it must exist even while disconnected.
77
106
  const stopConnect = await registerConnect({
78
107
  ctx,
79
108
  paths,
80
109
  secrets: new KeyringStore(),
81
110
  log,
111
+ register: registerProviderFn,
112
+ providerRegistered: false,
82
113
  }).catch((e) => {
83
114
  log(`/connect integration failed: ${(e as Error).message}`)
84
115
  return { stop: () => {}, applyNow: async () => false }
85
116
  })
86
- const beat = setInterval(() => void heartbeat(conn, paths), 60_000)
117
+
118
+ // The provider is registered only while the integration is connected:
119
+ // removing it in opencode must remove the models too.
120
+ if (!hasIntegration(ctx)) {
121
+ await registerProviderFn() // old opencode without the integration API — register as before
122
+ } else {
123
+ const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID).catch(() => undefined)
124
+ if (connection) await registerProviderFn()
125
+ else log(`${providerId} not connected — open /connect in opencode to set it up`)
126
+ }
127
+
128
+ const beat = setInterval(() => void heartbeat(ctx, paths), 60_000)
87
129
  ;(beat as { unref?: () => void }).unref?.()
88
130
  return () => {
89
131
  clearInterval(beat)
@@ -43,6 +43,8 @@ export type ConnectApplied = {
43
43
  aliases: { alias: string; key: string }[]
44
44
  removedAliases: string[]
45
45
  loginStored: boolean
46
+ /** False when the submission was byte-identical to what is already stored — no gateway restart needed. */
47
+ changed: boolean
46
48
  errors: string[]
47
49
  }
48
50
 
@@ -60,15 +62,16 @@ export async function applyConnect(
60
62
  const keys = splitKeys(input.keys)
61
63
  if (keys.length === 0) {
62
64
  errors.push("no keys given")
63
- return { aliases: [], removedAliases: [], loginStored: false, errors }
65
+ return { aliases: [], removedAliases: [], loginStored: false, changed: false, errors }
64
66
  }
65
67
  if (input.login && !EMAIL_RE.test(input.login.trim())) {
66
68
  errors.push(`"${input.login}" does not look like a uni login`)
67
- return { aliases: [], removedAliases: [], loginStored: false, errors }
69
+ return { aliases: [], removedAliases: [], loginStored: false, changed: false, errors }
68
70
  }
69
71
 
70
72
  const cfg = await loadConfig(o.paths.configFile)
71
73
  const custom = cfg.keys.filter((k) => !/^key\d+$/.test(k))
74
+ const beforeKeys = cfg.keys.join("\n")
72
75
 
73
76
  // replace the managed key1…keyN aliases
74
77
  const removedAliases: string[] = []
@@ -80,15 +83,21 @@ export async function applyConnect(
80
83
  }
81
84
  }
82
85
  const aliases: { alias: string; key: string }[] = []
86
+ let changed = beforeKeys !== [...custom, ...wanted].join("\n") || removedAliases.length > 0
83
87
  for (let i = 0; i < keys.length; i++) {
84
88
  const alias = wanted[i]!
89
+ if ((await o.secrets.get(alias)) !== keys[i]!) changed = true
85
90
  await o.secrets.set(alias, keys[i]!)
86
91
  aliases.push({ alias, key: keys[i]! })
87
92
  }
88
93
 
89
94
  let loginStored = false
90
95
  if (input.login?.trim()) {
91
- await o.secrets.set(VPN_USER, input.login.trim())
96
+ const login = input.login.trim()
97
+ if ((await o.secrets.get(VPN_USER)) !== login || (input.password && (await o.secrets.get(VPN_PASS)) !== input.password)) {
98
+ changed = true
99
+ }
100
+ await o.secrets.set(VPN_USER, login)
92
101
  if (input.password) await o.secrets.set(VPN_PASS, input.password)
93
102
  loginStored = true
94
103
  }
@@ -96,5 +105,5 @@ export async function applyConnect(
96
105
  cfg.keys = [...custom, ...wanted]
97
106
  await saveConfig(o.paths.configFile, cfg)
98
107
  log(`connect applied: ${aliases.length} key(s), login ${loginStored ? "stored" : "unchanged"}`)
99
- return { aliases, removedAliases, loginStored, errors }
108
+ return { aliases, removedAliases, loginStored, changed, errors }
100
109
  }