opencode-ufr 0.2.2 → 0.2.4

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
@@ -23,6 +23,23 @@ opencode plugin add opencode-ufr # registers the plugin
23
23
 
24
24
  (or add `"plugins": ["opencode-ufr"]` to your `opencode.json` manually.)
25
25
 
26
+ ## Updating
27
+
28
+ opencode loads plugins inside a background server (`opencode serve --service`)
29
+ that keeps running after you close the TUI, and it keeps using the plugin
30
+ version it loaded when it started — a new release only arrives once that server
31
+ restarts:
32
+
33
+ ```bash
34
+ bun add -g opencode-ufr@latest # update the `ufr` CLI
35
+ pkill -f "opencode serve --service" # stop opencode's background server
36
+ ```
37
+
38
+ The next opencode start installs the latest plugin. If it still loads the old
39
+ one, delete opencode's cached copy (`~/.cache/opencode/npm/opencode-ufr@latest`)
40
+ and start opencode again. On Windows, end the `opencode` server process in Task
41
+ Manager instead of `pkill`.
42
+
26
43
  ## Setup
27
44
 
28
45
  **Inside opencode — the normal way:** open `/connect`, pick **Uni Freiburg**,
@@ -60,8 +77,16 @@ ufr stats # requests, tokens and cost
60
77
  ufr keys list # stored aliases
61
78
  ufr keys test # check your keys against UFR
62
79
  ufr login show # the stored uni login
80
+ ufr disconnect # remove everything: keys, uni login, config, gateway
63
81
  ```
64
82
 
83
+ Removing the **unifreiburg** integration in opencode's `/connect` panel does the
84
+ same as `ufr disconnect`: the stored keys and uni login are wiped, the gateway
85
+ stops, and the models disappear from the picker on the next opencode start.
86
+ While opencode runs, connecting and removing are noticed within seconds. If
87
+ opencode was closed when you removed the integration, run `ufr disconnect` once
88
+ to wipe what is left.
89
+
65
90
  ## The built-in VPN
66
91
 
67
92
  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.4",
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,15 +9,25 @@
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
 
16
+ /** opencode has no connection events for plugins, so the watcher polls; seconds keep /connect feeling immediate. */
17
+ export const CONNECT_POLL_MS = 5_000
18
+
16
19
  export type ConnectDeps = {
17
20
  ctx: any // opencode plugin context (integration + storage domains)
18
21
  paths: Paths
19
22
  secrets: { get(alias: string): Promise<string | null>; set(alias: string, value: string): Promise<void>; delete(alias: string): Promise<boolean> }
20
23
  log: (m: string) => void
24
+ /** (Re-)registers the provider with opencode — called on first connect and after a removal+reconnect.
25
+ * Returns false when the registration did not succeed (e.g. no models yet); the watcher retries. */
26
+ register?: () => Promise<boolean | void> | boolean | void
27
+ /** Whether setup() already registered the provider (it does when a connection exists at startup). */
28
+ providerRegistered?: boolean
29
+ /** Poll interval override (tests). */
30
+ pollMs?: number
21
31
  }
22
32
 
23
33
  type CredentialValue = { type?: string; key?: string; configuration?: Record<string, unknown> } | undefined
@@ -73,28 +83,52 @@ export async function registerConnect(d: ConnectDeps): Promise<ConnectRegistrati
73
83
  })
74
84
  })
75
85
 
76
- // Apply a submitted credential once, then watch for changes.
86
+ // Apply a submitted credential once, then watch for changes. Phases:
87
+ // "registered" (provider live), "unregistered" (no provider yet), and
88
+ // "disconnected" (the connection was removed in opencode — everything
89
+ // wiped; a later connection re-applies and re-registers).
90
+ type Phase = "registered" | "unregistered" | "disconnected"
91
+ let phase: Phase = d.providerRegistered ? "registered" : "unregistered"
77
92
  let lastApplied = ""
78
93
  const applyOnce = async (): Promise<boolean> => {
79
94
  try {
80
95
  const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID)
81
- if (!connection) return false
96
+ if (!connection) {
97
+ if (phase === "registered") {
98
+ phase = "disconnected"
99
+ lastApplied = ""
100
+ const r = await disconnectAll({ paths: d.paths, secrets: d.secrets, fetch: (u, i) => fetch(u, i), log: d.log })
101
+ d.log(`unifreiburg removed in opencode — wiped from keyring: ${r.removed.length > 0 ? r.removed.join(", ") : "nothing"}${r.loginRemoved ? " + uni login" : ""}`)
102
+ return true // the disconnect cleaned something up — a state change
103
+ }
104
+ return false
105
+ }
82
106
  const cred = (await ctx.integration.connection.resolve(connection)) as CredentialValue
83
107
  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
108
+ let changed = false
109
+ if (sig !== lastApplied) {
110
+ lastApplied = sig
111
+ const input = credentialToInput(cred)
112
+ if (!input) {
113
+ d.log("/connect credential has no keys — nothing applied")
114
+ return false
115
+ }
116
+ const r = await applyConnect(input, { paths: d.paths, secrets: d.secrets, log: d.log })
117
+ if (r.errors.length > 0) {
118
+ for (const e of r.errors) d.log(`/connect: ${e}`)
119
+ lastApplied = "" // retry on the next poll
120
+ return false
121
+ }
122
+ if (r.changed) await applyKeyChange({ paths: d.paths, fetch: (u, i) => fetch(u, i), log: d.log })
123
+ changed = true
90
124
  }
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
125
+ if (phase !== "registered") {
126
+ // first connect mid-session, or a reconnect after a removal
127
+ const ok = await d.register?.()
128
+ if (ok !== false) phase = "registered" // false = registration failed — retry on the next poll
129
+ changed = true
95
130
  }
96
- await applyKeyChange({ paths: d.paths, fetch: (u, i) => fetch(u, i), log: d.log })
97
- return true
131
+ return changed
98
132
  } catch (e) {
99
133
  d.log(`/connect apply failed: ${(e as Error).message}`)
100
134
  return false
@@ -102,7 +136,18 @@ export async function registerConnect(d: ConnectDeps): Promise<ConnectRegistrati
102
136
  }
103
137
 
104
138
  await applyOnce()
105
- const timer = setInterval(() => void applyOnce(), 60_000)
139
+ // A registration can take ~40 s (gateway boot) — never start a second one while it runs.
140
+ let busy = false
141
+ const tick = async () => {
142
+ if (busy) return
143
+ busy = true
144
+ try {
145
+ await applyOnce()
146
+ } finally {
147
+ busy = false
148
+ }
149
+ }
150
+ const timer = setInterval(() => void tick(), d.pollMs ?? CONNECT_POLL_MS)
106
151
  ;(timer as { unref?: () => void }).unref?.()
107
152
  return {
108
153
  stop: () => clearInterval(timer),
@@ -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
  }