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