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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Finley Leo Lämpe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # opencode-ufr
2
+
3
+ An [opencode](https://opencode.ai) provider for Uni Freiburg's Open WebUI
4
+ models. A small local gateway runs on your machine, pools your UFR API
5
+ key(s), and handles UFR's rate limits and outages so opencode doesn't have to.
6
+
7
+ ## Requirements
8
+
9
+ - opencode ≥ 2.0
10
+ - A UFR account and API key (Open WebUI → Settings → Account → API keys)
11
+ - [Bun](https://bun.sh) on your `PATH` for the `ufr` CLI. The plugin itself
12
+ needs no Bun — it runs inside opencode.
13
+ - Off campus, the **built-in VPN** connects to UFR on its own: store your uni
14
+ login once (`ufr login add <user>`) and the plugin tunnels its UFR calls
15
+ through `fortivpn.uni-freiburg.de` — with no TUN device, no admin rights, no
16
+ openconnect and no changes to your routing. Your own VPNs keep working
17
+ untouched in parallel. See the RZ guide if you prefer a system VPN:
18
+ https://wiki.uni-freiburg.de/rz/doku.php?id=vpn
19
+
20
+ ## Install
21
+
22
+ Not on npm yet — install straight from GitHub:
23
+
24
+ ```bash
25
+ bun add -g github:FinleyLaempe/opencode-ufr # puts `ufr` on PATH
26
+ ufr connect
27
+ ```
28
+
29
+ `ufr connect` asks for your UFR API key(s) — comma-separated, whitespace
30
+ around keys is filtered, aliases (key1, key2, …) are assigned automatically —
31
+ and optionally your uni login for the built-in VPN. Everything is verified
32
+ against UFR and stored in the OS keyring (never on disk).
33
+
34
+ Inside opencode you never need the CLI: the plugin takes over the built-in
35
+ `/connect` panel's **Uni Freiburg** entry. It shows the API-key(s) field plus
36
+ **Uni login (optional)** and **Uni password (optional)** — both only needed on
37
+ a machine outside the uni network, where the built-in VPN uses them. Submitted
38
+ credentials land in the OS keyring automatically and the gateway restarts with
39
+ them.
40
+
41
+ Manual alternative — add to `opencode.json`:
42
+
43
+ ```json
44
+ { "plugins": ["github:FinleyLaempe/opencode-ufr"] }
45
+ ```
46
+
47
+ or run `opencode plugin add github:FinleyLaempe/opencode-ufr` yourself, then
48
+ `ufr connect --keys "<key1>,<key2>"`.
49
+
50
+ npm publication is planned; once it lands, `bunx opencode-ufr …` /
51
+ `npx opencode-ufr …` will work without a global install.
52
+
53
+ ## Use
54
+
55
+ Models show up in opencode as `unifreiburg/<model>`, e.g.
56
+ `unifreiburg/ufr/coding-complex`. Everything else is the CLI:
57
+
58
+ ```bash
59
+ ufr status # gateway, keys, limits, breakers, spend today
60
+ ufr stats # requests, tokens and cost
61
+ ufr keys test # check your stored keys against UFR
62
+ ufr keys list # aliases
63
+ ufr login show # the stored uni login
64
+ ```
65
+
66
+ The gateway starts itself the first time opencode needs it (the plugin waits
67
+ up to 40 s for it to come up) and shuts down again after 5 minutes idle. You
68
+ don't run or manage it directly.
69
+
70
+ ## What the gateway does
71
+
72
+ - Keeps each key under 18 of UFR's 20 requests-per-rolling-minute, waiting
73
+ instead of failing when a key is close to its limit.
74
+ - Enforces a pool-wide cap of 800 requests/hour across all keys and models —
75
+ UFR walls a model group for hours once it sees sustained traffic above
76
+ roughly 900/hour.
77
+ - **Connects to UFR on its own when you are off campus.** It logs in to the
78
+ uni's Fortinet gateway with your stored uni login, negotiates PPP/IPCP and
79
+ runs a userspace TCP/IP stack inside the gateway process: no TUN device, no
80
+ routes, no admin rights, identical on Linux, macOS and Windows. On campus it
81
+ talks to UFR directly and never opens the tunnel; `vpn.mode: "always"`
82
+ forces the tunnel.
83
+ - Runs a circuit breaker per model: after repeated failures it backs off from
84
+ 30 s up to 60 minutes before probing again, instead of hammering a walled
85
+ model.
86
+ - Falls back, when a model is rate-limited or walled, only to models listed
87
+ in `free_escape_order` (in `models.json`), in that order — and never to
88
+ UFR's external (paid-tier) models unless you set `allowPaid`. Some "local"
89
+ UFR models (glm-5.x) are still billed per token.
90
+ - Caps every client request at 4 upstream calls to UFR in total, across
91
+ retries and fallbacks.
92
+ - Retries once with reasoning turned off if a `glm` model spends its whole
93
+ token budget thinking and returns no text.
94
+ - Keeps local stats in the same "% of $20/day" unit UFR's own portal uses, so
95
+ the numbers in `ufr status` line up with what you see there.
96
+ - Survives being killed: if the gateway's lock file is left behind by an
97
+ unclean shutdown, the next start detects it's stale and takes it over
98
+ rather than refusing to start.
99
+
100
+ ## More than one key
101
+
102
+ UFR allows one API key per account, so one key is one account's worth of
103
+ throughput. Adding more keys only helps if they belong to different UFR
104
+ accounts — it does not raise any one account's limit.
105
+
106
+ ## Configuration
107
+
108
+ Non-secret settings live in a JSON file; keys always stay in the OS keyring.
109
+
110
+ - **Linux / macOS:** `~/.config/opencode-ufr/config.json` (state in
111
+ `~/.local/state/opencode-ufr`, cache in `~/.cache/opencode-ufr`, data in
112
+ `~/.local/share/opencode-ufr` — or the matching `$XDG_*` variable if you
113
+ set one)
114
+ - **Windows:** `%APPDATA%\opencode-ufr\config.json` (state, cache and data
115
+ under `%LOCALAPPDATA%\opencode-ufr`)
116
+ - Set `OPENCODE_UFR_HOME` to point everything (config, state, cache, data)
117
+ at one directory instead — useful for testing or a fully portable setup.
118
+
119
+ Reference `config.json` (all fields optional; shown here at their defaults
120
+ except `port`, which is written on first start):
121
+
122
+ ```json
123
+ {
124
+ "schema": 1,
125
+ "port": 47300,
126
+ "transport": { "type": "auto" },
127
+ "vpn": { "gateway": "https://fortivpn.uni-freiburg.de", "mode": "auto" },
128
+ "keys": ["a", "b"],
129
+ "limits": {
130
+ "keyRpm": 18,
131
+ "keyWindowS": 60,
132
+ "poolPerHour": 800,
133
+ "poolWindowS": 3600,
134
+ "keyMaxWaitS": 60,
135
+ "poolMaxWaitS": 20,
136
+ "maxUpstreamAttempts": 4
137
+ },
138
+ "dailyBudgetUsd": 20,
139
+ "breaker": {
140
+ "tripThreshold": 3,
141
+ "ladderS": [30, 120, 300, 900, 1800, 3600],
142
+ "probeTimeoutS": 120
143
+ },
144
+ "allowPaid": false,
145
+ "catalog": {
146
+ "url": "https://raw.githubusercontent.com/FinleyLaempe/opencode-ufr/main/models.json",
147
+ "refreshHours": 6
148
+ },
149
+ "idleShutdownMin": 5
150
+ }
151
+ ```
152
+
153
+ `port` is chosen once (preferred `47300`, next free port if taken) and then
154
+ stays fixed across restarts. `poolPerHour: 0` disables the pool limiter.
155
+ `transport.type: "direct"` never opens the tunnel; `"auto"` (the default)
156
+ tunnels only when UFR is not reachable directly. `vpn.mode: "always"` tunnels
157
+ every UFR call, useful behind firewalls that block the campus route.
158
+
159
+ ## Model data
160
+
161
+ The gateway fetches `models.json` from this repository every 6 hours —
162
+ context windows, prices, vision/tool flags, alias spellings and fallback
163
+ order that UFR's own API doesn't expose or gets wrong. Fixes reach every
164
+ user without a release.
165
+
166
+ To contribute a measurement (a price, a context window, a missing alias),
167
+ open a PR against `models.json` with the source of the measurement in a
168
+ `note` field.
169
+
170
+ ## Privacy
171
+
172
+ - UFR API keys and the uni VPN login live only in your OS keyring (Keychain,
173
+ Credential Manager, or libsecret/KWallet on Linux) — never in a config file
174
+ or in this repository.
175
+ - The gateway listens on `127.0.0.1` only, behind a random local token; it
176
+ is not reachable from the network or by other local users without that
177
+ token.
178
+ - Local stats record token counts, cost and latency per request — never
179
+ prompt or response content.
180
+
181
+ ## Not affiliated
182
+
183
+ This is an independent project, not an official service of the University
184
+ of Freiburg's Rechenzentrum (RZ).
package/bin/ufr.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from "../src/cli/index"
3
+
4
+ process.exit(await main(process.argv.slice(2)))
package/models.json ADDED
@@ -0,0 +1,206 @@
1
+ {
2
+ "schema": 1,
3
+ "updated": "2026-09-28",
4
+ "defaults": {
5
+ "context": 131072,
6
+ "max_output": 16384
7
+ },
8
+ "fallbacks": {
9
+ "free_escape_order": [
10
+ "gemma-4-31b-llmlb",
11
+ "mistral-small-4-llmlb",
12
+ "glm-5.2-llmlb"
13
+ ],
14
+ "max_targets": 4,
15
+ "context_hub": "openai/gpt-5.6-llmlb"
16
+ },
17
+ "exclude": [
18
+ "gpt-oss-120b-llmlb",
19
+ "openai/gpt-5.4-llmlb",
20
+ "standard-chat-ufr",
21
+ "standard-reasoning-ufr",
22
+ "standard-bild-ufr"
23
+ ],
24
+ "aliases": {
25
+ "gpt-5.6-llmlb": "openai/gpt-5.6-llmlb",
26
+ "gpt-5.6-sol-llmlb": "openai/gpt-5.6-sol-llmlb",
27
+ "gpt-5.6-terra-llmlb": "openai/gpt-5.6-terra-llmlb",
28
+ "mistral-large-latest-llmlb": "mistral/mistral-large-latest-llmlb",
29
+ "codestral-latest-llmlb": "mistral/codestral-latest-llmlb"
30
+ },
31
+ "models": {
32
+ "glm-5.2-llmlb": {
33
+ "context": 1048576,
34
+ "price": {
35
+ "input": 0.4,
36
+ "output": 0.4
37
+ }
38
+ },
39
+ "glm-5.3-flash-llmlb": {
40
+ "context": 1048576,
41
+ "price": {
42
+ "input": 0.4,
43
+ "output": 0.4
44
+ },
45
+ "note": "portal-measured 2026-09-28: 100 267 input tokens moved a test key 1.58% -> 1.78% of $20 (= $0.40/Mtok)"
46
+ },
47
+ "gemma-4-31b-llmlb": {
48
+ "context": 262144
49
+ },
50
+ "gemma-4-12b-llmlb": {
51
+ "context": 131072
52
+ },
53
+ "gemma-3-27b-llmlb": {
54
+ "context": 128000
55
+ },
56
+ "gpt-oss-120b-llmlb": {
57
+ "context": 128000
58
+ },
59
+ "mistral-small-4-llmlb": {
60
+ "context": 256000
61
+ },
62
+ "qwen-3.5-397b-llmlb": {
63
+ "context": 262144
64
+ },
65
+ "qwen-3.6-27b-llmlb": {
66
+ "context": 262144
67
+ },
68
+ "qwen-3.8-27b-llmlb": {
69
+ "context": 262144
70
+ },
71
+ "qwen3.5-9b-llmlb": {
72
+ "context": 128000
73
+ },
74
+ "nuextract3-llmlb": {
75
+ "context": 131072,
76
+ "tools": false
77
+ },
78
+ "numarkdown-8b-thinking-llmlb": {
79
+ "context": 24768,
80
+ "tools": false
81
+ },
82
+ "standard-chat-ufr": {
83
+ "context": 262144
84
+ },
85
+ "standard-coding-ufr": {
86
+ "context": 1048576,
87
+ "price": {
88
+ "input": 0.4,
89
+ "output": 0.4
90
+ }
91
+ },
92
+ "standard-reasoning-ufr": {
93
+ "context": 262144,
94
+ "price": {
95
+ "input": 0.1,
96
+ "output": 0.1
97
+ }
98
+ },
99
+ "standard-bild-ufr": {
100
+ "context": 262144
101
+ },
102
+ "ufr/chat": {
103
+ "context": 262144,
104
+ "vision": true
105
+ },
106
+ "ufr/chat-fast": {
107
+ "context": 131044
108
+ },
109
+ "ufr/chat-standard": {
110
+ "context": 262144
111
+ },
112
+ "ufr/coding-complex": {
113
+ "context": 1048576,
114
+ "vision": false,
115
+ "price": {
116
+ "input": 0.4,
117
+ "output": 0.4
118
+ }
119
+ },
120
+ "ufr/coding-fast": {
121
+ "context": 262144,
122
+ "vision": true
123
+ },
124
+ "ufr/reasoning-complex": {
125
+ "context": 262144,
126
+ "price": {
127
+ "input": 0.1,
128
+ "output": 0.1
129
+ }
130
+ },
131
+ "ufr/reasoning-fast": {
132
+ "context": 262144
133
+ },
134
+ "ufr/text": {
135
+ "context": 256000
136
+ },
137
+ "ufr/vision-complex": {
138
+ "context": 262144
139
+ },
140
+ "ufr/vision-fast": {
141
+ "context": 131044
142
+ },
143
+ "ufr/vision-standard": {
144
+ "context": 262144
145
+ },
146
+ "openai/gpt-5.6-llmlb": {
147
+ "context": 1050000,
148
+ "price": {
149
+ "input": 0.2,
150
+ "output": 1.2,
151
+ "cache_read": 0.02,
152
+ "cache_write": 0.25
153
+ }
154
+ },
155
+ "openai/gpt-5.6-sol-llmlb": {
156
+ "context": 922000,
157
+ "price": {
158
+ "input": 5,
159
+ "output": 30,
160
+ "cache_read": 0.5,
161
+ "cache_write": 6.25
162
+ }
163
+ },
164
+ "openai/gpt-5.6-terra-llmlb": {
165
+ "context": 1000000,
166
+ "price": {
167
+ "input": 2,
168
+ "output": 12,
169
+ "cache_read": 0.2,
170
+ "cache_write": 2.5
171
+ }
172
+ },
173
+ "openai/gpt-5.4-llmlb": {
174
+ "context": 272000,
175
+ "price": {
176
+ "input": 2.5,
177
+ "output": 15,
178
+ "cache_read": 0.25,
179
+ "cache_write": 3.125
180
+ }
181
+ },
182
+ "openai/gpt-5.3-codex-llmlb": {
183
+ "context": 1000000,
184
+ "price": {
185
+ "input": 1.75,
186
+ "output": 14,
187
+ "cache_read": 0.175,
188
+ "cache_write": 2.1875
189
+ }
190
+ },
191
+ "mistral/mistral-large-latest-llmlb": {
192
+ "context": 262144,
193
+ "price": {
194
+ "input": 2,
195
+ "output": 6
196
+ }
197
+ },
198
+ "mistral/codestral-latest-llmlb": {
199
+ "context": 128000,
200
+ "price": {
201
+ "input": 0.3,
202
+ "output": 0.9
203
+ }
204
+ }
205
+ }
206
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "opencode-ufr",
3
+ "version": "0.2.0",
4
+ "description": "Uni Freiburg (UFR) Open WebUI models in opencode — local gateway with key pool, UFR-aware rate limiting, circuit breaker and fallbacks",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/plugin/index.ts",
8
+ "exports": { ".": "./src/plugin/index.ts" },
9
+ "bin": { "ufr": "bin/ufr.ts", "opencode-ufr": "bin/ufr.ts" },
10
+ "files": ["bin", "src", "models.json", "README.md", "LICENSE"],
11
+ "engines": { "bun": ">=1.3.0" },
12
+ "repository": { "type": "git", "url": "git+https://github.com/FinleyLaempe/opencode-ufr.git" },
13
+ "keywords": ["opencode", "opencode-plugin", "uni-freiburg", "open-webui"],
14
+ "scripts": { "test": "bun test", "typecheck": "tsc --noEmit", "prepublishOnly": "bun run typecheck && bun test" },
15
+ "devDependencies": { "@types/bun": "^1.3.0", "typescript": "^5.9.0" }
16
+ }
@@ -0,0 +1,45 @@
1
+ import type { UfrModel } from "../daemon/catalog"
2
+ import { BUNDLED_MODELS, loadModelsFile, loadUfrModels } from "../daemon/catalog-source"
3
+ import { loadConfig } from "../shared/config"
4
+ import type { ModelsFile } from "../shared/models-file"
5
+ import type { CliDeps } from "./index"
6
+
7
+ export function diffCatalog(ufr: UfrModel[], file: ModelsFile) {
8
+ const served = new Set(ufr.map((u) => u.id))
9
+ const aliasSpellings = new Set(Object.keys(file.aliases))
10
+ const canonical = ufr.filter((u) => !aliasSpellings.has(u.id))
11
+ return {
12
+ added: canonical.filter((u) => !file.models[u.id]).map((u) => u.id),
13
+ gone: Object.keys(file.models).filter((id) => !served.has(id)),
14
+ vision: canonical
15
+ .filter((u) => file.models[u.id]?.vision !== undefined && file.models[u.id]!.vision !== u.vision)
16
+ .map((u) => ({ id: u.id, ufr: u.vision, ours: file.models[u.id]!.vision! })),
17
+ unpriced: canonical.filter((u) => !file.models[u.id]?.price).map((u) => u.id),
18
+ }
19
+ }
20
+
21
+ export async function cmdCatalogDiff(d: CliDeps): Promise<number> {
22
+ const cfg = await loadConfig(d.paths.configFile)
23
+ const alias = cfg.keys[0]
24
+ const key = alias ? await d.secrets.get(alias) : null
25
+ if (!key) {
26
+ d.io.err("catalog diff needs a key — run `ufr keys add <alias>`\n")
27
+ return 1
28
+ }
29
+ const log = (m: string) => d.io.err(`${m}\n`)
30
+ const mf = await loadModelsFile({ url: cfg.catalog.url, cachePath: d.paths.modelsCache, etagPath: d.paths.modelsEtag,
31
+ bundledPath: BUNDLED_MODELS, fetch: d.fetch, log })
32
+ const ufr = await loadUfrModels({ baseUrl: cfg.upstream.baseUrl, key, cachePath: d.paths.ufrModelsCache, fetch: d.fetch, log })
33
+ if (ufr.source !== "remote") {
34
+ d.io.err(`cannot read UFR's live model list: ${ufr.error}\n`)
35
+ return 1
36
+ }
37
+ const diff = diffCatalog(ufr.models, mf.file)
38
+ d.io.out(`models.json from ${mf.source}, ${ufr.models.length} models at UFR\n`)
39
+ for (const id of diff.added) d.io.out(`+ ${id} (new at UFR, no models.json entry)\n`)
40
+ for (const id of diff.gone) d.io.out(`- ${id} (in models.json, no longer at UFR)\n`)
41
+ for (const v of diff.vision) d.io.out(`~ ${v.id} vision: UFR says ${v.ufr}, models.json says ${v.ours}\n`)
42
+ for (const id of diff.unpriced) d.io.out(`$ ${id} (no price — cost unknown)\n`)
43
+ if (!diff.added.length && !diff.gone.length) d.io.out("no models added or removed\n")
44
+ return diff.added.length || diff.gone.length ? 1 : 0
45
+ }
@@ -0,0 +1,146 @@
1
+ import { parseArgs } from "node:util"
2
+ import { ALIAS_RE, loadConfig, saveConfig } from "../shared/config"
3
+ import { VPN_PASS, VPN_USER } from "../shared/secrets"
4
+ import { checkKey, type Check } from "./ufr-check"
5
+ import { applyKeyChange } from "./keys"
6
+ import type { CliDeps } from "./index"
7
+
8
+ const LABEL: Record<Check | "missing", string> = {
9
+ ok: "ok",
10
+ invalid: "INVALID (UFR says 401)",
11
+ vpn: "not verified — not on the uni VPN",
12
+ unreachable: "not verified — UFR unreachable",
13
+ rate_limited: "ok (currently rate-limited)",
14
+ missing: "MISSING in keyring",
15
+ }
16
+
17
+ const PLUGIN_SPEC = "github:FinleyLaempe/opencode-ufr"
18
+
19
+ /** Splits a key list on commas AND newlines, trims whitespace, drops empties. */
20
+ export function splitKeys(spec: string): string[] {
21
+ return spec
22
+ .split(/[,\n\r]+/)
23
+ .map((k) => k.trim())
24
+ .filter((k) => k.length > 0)
25
+ }
26
+
27
+ /** Auto-aliases key1…keyN, skipping numbers already in use. */
28
+ export function nextAliases(existing: string[], count: number): string[] {
29
+ const used = new Set(existing)
30
+ const out: string[] = []
31
+ for (let n = 1; out.length < count; n++) {
32
+ const alias = `key${n}`
33
+ if (!used.has(alias)) {
34
+ used.add(alias)
35
+ out.push(alias)
36
+ }
37
+ }
38
+ return out
39
+ }
40
+
41
+ async function storeKey(d: CliDeps, alias: string, key: string): Promise<{ stored: boolean; check: Check }> {
42
+ const cfg = await loadConfig(d.paths.configFile)
43
+ const check = await checkKey(d.fetch, cfg.upstream.baseUrl, key)
44
+ if (check === "invalid") return { stored: false, check }
45
+ if (check !== "ok" && check !== "rate_limited") {
46
+ const why = check === "vpn" ? "not on the uni VPN" : "UFR unreachable"
47
+ if (!(await d.io.confirm(`could not verify this key (${why}). Store it anyway?`))) return { stored: false, check }
48
+ }
49
+ await d.secrets.set(alias, key)
50
+ if (!cfg.keys.includes(alias)) {
51
+ cfg.keys.push(alias)
52
+ await saveConfig(d.paths.configFile, cfg)
53
+ }
54
+ return { stored: true, check }
55
+ }
56
+
57
+ async function stopIdleGateway(d: CliDeps): Promise<void> {
58
+ await applyKeyChange({ paths: d.paths, fetch: d.fetch, log: (m) => d.io.out(m + "\n") })
59
+ }
60
+
61
+ /**
62
+ * ufr connect — one command for everything: uni login (optional) + API keys.
63
+ * Interactive when arguments are missing; fully non-interactive with flags
64
+ * (what the /connect slash command uses).
65
+ */
66
+ export async function cmdConnect(d: CliDeps, args: string[]): Promise<number> {
67
+ const { values } = parseArgs({
68
+ args,
69
+ options: {
70
+ login: { type: "string" },
71
+ password: { type: "string" },
72
+ keys: { type: "string" },
73
+ },
74
+ allowPositionals: true,
75
+ })
76
+
77
+ const io = d.io
78
+ const cfg = await loadConfig(d.paths.configFile)
79
+
80
+ // keys -------------------------------------------------------------------
81
+ let keySpec = values.keys
82
+ if (keySpec === undefined && d.io.isTTY) {
83
+ keySpec = await io.prompt("UFR API keys (comma-separated or one per line; Open WebUI → Settings → Account → API keys): ")
84
+ }
85
+ const keys = splitKeys(keySpec ?? "")
86
+ if (keys.length === 0) {
87
+ io.err("no keys given — usage: ufr connect [--login <user> --password <pass>] --keys \"<key1>,<key2>\"\n")
88
+ return 2
89
+ }
90
+ const aliases = nextAliases(cfg.keys, keys.length)
91
+
92
+ // --- login (optional) ------------------------------------------------------
93
+ let user = values.login
94
+ let pass = values.password
95
+ if (user === undefined && d.io.isTTY) {
96
+ user = (await io.prompt("Uni login for the built-in VPN, e.g. fl240@uni-freiburg.de (empty to skip): ")).trim()
97
+ }
98
+ if (user) {
99
+ if (!/^[^\s@]+@[^\s@]+$/.test(user)) {
100
+ io.err(`"${user}" does not look like a uni login (expected e.g. fl240@uni-freiburg.de)\n`)
101
+ return 2
102
+ }
103
+ if (pass === undefined) {
104
+ if (d.io.isTTY) pass = await io.prompt(`Uni password for ${user}: `, { secret: true })
105
+ else {
106
+ io.err("--password is required when --login is given non-interactively\n")
107
+ return 2
108
+ }
109
+ }
110
+ if (!pass) {
111
+ io.err("no password entered\n")
112
+ return 2
113
+ }
114
+ }
115
+
116
+ // --- store everything -------------------------------------------------------
117
+ let bad = 0
118
+ for (let i = 0; i < keys.length; i++) {
119
+ const alias = aliases[i]!
120
+ const r = await storeKey(d, alias, keys[i]!)
121
+ if (r.stored) io.out(`${alias}\t${LABEL[r.check]}\n`)
122
+ else {
123
+ bad++
124
+ io.err(`${alias}\t${LABEL[r.check]} — not stored\n`)
125
+ }
126
+ }
127
+ if (bad === keys.length) return 1
128
+
129
+ if (user && pass) {
130
+ await d.secrets.set(VPN_USER, user)
131
+ await d.secrets.set(VPN_PASS, pass)
132
+ io.out(`login\tstored for ${user} (used by the built-in VPN)\n`)
133
+ }
134
+
135
+ await stopIdleGateway(d)
136
+ if (d.io.isTTY) {
137
+ if (await io.confirm(`register the plugin with opencode now (\`opencode plugin add ${PLUGIN_SPEC}\`)?`)) {
138
+ const code = await d.runOpencode(["plugin", "add", PLUGIN_SPEC])
139
+ if (code !== 0) io.err(`opencode plugin add exited with ${code} — add "${PLUGIN_SPEC}" to "plugins" in opencode.json yourself\n`)
140
+ }
141
+ } else {
142
+ io.out(`register the provider in opencode if not done yet: opencode plugin add ${PLUGIN_SPEC}\n`)
143
+ }
144
+ io.out(`done — ${keys.length - bad}/${keys.length} key(s) stored${user ? ", uni login stored" : ""}\n`)
145
+ return bad > 0 ? 1 : 0
146
+ }
@@ -0,0 +1,19 @@
1
+ import type { FetchLike } from "../daemon/catalog-source"
2
+ import { readJson, readText } from "../shared/fs"
3
+ import type { Paths } from "../shared/paths"
4
+
5
+ /** A request to the running daemon, or null if none answers. */
6
+ export async function daemonRequest(paths: Paths, f: FetchLike, path: string, method = "GET"): Promise<Response | null> {
7
+ const info = (await readJson(paths.daemonFile)) as { port?: number } | null
8
+ const token = (await readText(paths.tokenFile))?.trim()
9
+ if (!info?.port || !token) return null
10
+ try {
11
+ return await f(`http://127.0.0.1:${info.port}${path}`, {
12
+ method,
13
+ headers: { Authorization: `Bearer ${token}` },
14
+ signal: AbortSignal.timeout(5_000),
15
+ })
16
+ } catch {
17
+ return null
18
+ }
19
+ }