opencode-ufr 0.2.9 → 0.2.11

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 (42) hide show
  1. package/README.md +55 -62
  2. package/dist/tui.js +155 -0
  3. package/package.json +20 -7
  4. package/skills/pdf2md/SKILL.md +32 -0
  5. package/src/client/gateway.ts +101 -0
  6. package/src/client/pdf2md.ts +747 -0
  7. package/src/daemon/catalog-source.ts +9 -0
  8. package/src/daemon/daemon.ts +70 -18
  9. package/src/daemon/keypool.ts +12 -7
  10. package/src/daemon/main.ts +7 -1
  11. package/src/daemon/probe-all.ts +60 -38
  12. package/src/daemon/router.ts +215 -17
  13. package/src/daemon/server.ts +33 -1
  14. package/src/daemon/stats.ts +13 -1
  15. package/src/daemon/upstream.ts +14 -3
  16. package/src/daemon/vpn/fortinet.ts +42 -29
  17. package/src/daemon/vpn/manager.ts +50 -16
  18. package/src/daemon/vpn/proxy.ts +3 -1
  19. package/src/plugin/connect.ts +4 -4
  20. package/src/plugin/ensure-daemon.ts +48 -19
  21. package/src/plugin/index.ts +121 -46
  22. package/src/plugin/tui.tsx +77 -0
  23. package/src/shared/config.ts +53 -4
  24. package/src/shared/connect.ts +3 -4
  25. package/src/shared/daemon-client.ts +29 -0
  26. package/src/shared/errors.ts +1 -1
  27. package/src/shared/fs.ts +8 -3
  28. package/src/shared/keys.ts +65 -0
  29. package/src/shared/models-file.ts +1 -1
  30. package/src/shared/secrets.ts +2 -6
  31. package/bin/ufr.ts +0 -4
  32. package/src/cli/catalog.ts +0 -44
  33. package/src/cli/connect.ts +0 -146
  34. package/src/cli/context-probe.ts +0 -96
  35. package/src/cli/daemon-client.ts +0 -19
  36. package/src/cli/index.ts +0 -105
  37. package/src/cli/io.ts +0 -98
  38. package/src/cli/keys.ts +0 -130
  39. package/src/cli/stats.ts +0 -37
  40. package/src/cli/status.ts +0 -48
  41. package/src/cli/stop.ts +0 -8
  42. package/src/cli/ufr-check.ts +0 -43
package/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  An [opencode](https://opencode.ai) provider for **Uni Freiburg's Open WebUI
4
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.
5
+ key, and handles UFR's rate limits and outages so opencode doesn't have to.
6
6
  Off campus, it connects through the uni's Fortinet VPN **by itself** — with no
7
7
  TUN device, no admin rights and no external tools.
8
8
 
9
9
  ```
10
10
  opencode ⇄ gateway (127.0.0.1) ⇄ [Fortinet tunnel] ⇄ openwebui.uni-freiburg.de
11
11
  │
12
- ├─ key pool (1…n UFR accounts)
12
+ ├─ key pool (your UFR account)
13
13
  ├─ rate-limit pacing, circuit breakers, fallbacks
14
14
  └─ VPN when off campus (userspace TCP/IP stack)
15
15
  ```
@@ -17,13 +17,12 @@ opencode ⇄ gateway (127.0.0.1) ⇄ [Fortinet tunnel] ⇄ openwebui.uni-freibur
17
17
  ## Install
18
18
 
19
19
  ```bash
20
- bun add -g opencode-ufr # puts `ufr` on PATH
21
20
  opencode plugin add opencode-ufr # registers the plugin
22
21
  ```
23
22
 
24
23
  (or add `"plugins": ["opencode-ufr"]` to your `opencode.json` manually.)
25
24
 
26
- ## Install
25
+ ## Update
27
26
 
28
27
  opencode installs the plugin once into its cache and does not look for a newer
29
28
  version while that copy exists — restarting opencode alone keeps the old one.
@@ -32,9 +31,8 @@ that keeps running after you close the TUI. To update, remove the cached copy
32
31
  and stop that server:
33
32
 
34
33
  ```bash
35
- bun add -g opencode-ufr@latest # update the `ufr` CLI
36
34
  rm -rf ~/.cache/opencode/npm/opencode-ufr@latest # opencode's cached plugin copy
37
- /restart in opencode eintippen # opencode's background server
35
+ /restart in opencode # stops opencode's background server
38
36
  ```
39
37
 
40
38
  The next opencode start installs the latest version and replaces the old
@@ -43,28 +41,17 @@ Manager and delete the `opencode-ufr@latest` folder in opencode's cache.
43
41
 
44
42
  ## Setup
45
43
 
46
- **Inside opencode — the normal way:** open `/connect`, pick **Uni Freiburg**,
47
- and fill in:
44
+ Open `/connect` in opencode, pick **Uni Freiburg**, and fill in:
48
45
 
49
46
  | Field | Needed? |
50
47
  |---|---|
51
- | **API keys** | always — paste one key per UFR account, comma-separated (whitespace is filtered) |
48
+ | **API key** | always — your UFR API key (Open WebUI → Settings → Account → API keys) |
52
49
  | **Uni login** | only off campus — e.g. `xx0000@uni-freiburg.de`, empty on the uni network |
53
50
  | **Uni password** | only with a login above |
54
51
 
55
52
  Submitted credentials land in your OS keyring (never on disk), the gateway
56
53
  restarts with them, and the `unifreiburg/…` models appear in the model picker.
57
54
 
58
- **In a terminal — the fallback:** works before opencode ever starts.
59
-
60
- ```bash
61
- ufr connect --keys "<key1>,<key2>" # keys only
62
- ufr connect --login xx0000@uni-freiburg.de --password … --keys "…" # + VPN login
63
- ```
64
-
65
- `ufr connect` verifies every key against UFR, assigns aliases (`key1`, `key2`,
66
- …) automatically and accepts keys comma- or line-separated.
67
-
68
55
  ## Use
69
56
 
70
57
  Models show up in opencode as `unifreiburg/<model>`, e.g.
@@ -72,21 +59,17 @@ Models show up in opencode as `unifreiburg/<model>`, e.g.
72
59
  gateway starts itself the first time opencode needs it (the plugin waits up to
73
60
  40 s) and shuts down after 5 minutes idle. You never manage it directly.
74
61
 
75
- ```bash
76
- ufr status # gateway, keys, limits, breakers, vpn, spend today
77
- ufr stats # requests, tokens and cost
78
- ufr keys list # stored aliases
79
- ufr keys test # check your keys against UFR
80
- ufr login show # the stored uni login
81
- ufr disconnect # remove everything: keys, uni login, config, gateway
82
- ```
62
+ The plugin adds a line to opencode's sidebar with live gateway throughput —
63
+ requests/s and tokens/s (in and out) over the gateway's rolling 60 s window.
64
+ It hides while the gateway is off, and polling it never keeps the gateway
65
+ alive. The same numbers are served on `GET /v1/_status` under `rates` for
66
+ anything else that wants them.
83
67
 
84
- Removing the **unifreiburg** integration in opencode's `/connect` panel does the
85
- same as `ufr disconnect`: the stored keys and uni login are wiped, the gateway
86
- stops, and the models disappear from the picker on the next opencode start.
87
- While opencode runs, connecting and removing are noticed within seconds. If
88
- opencode was closed when you removed the integration, run `ufr disconnect` once
89
- to wipe what is left.
68
+ Removing the **unifreiburg** integration in opencode's `/connect` panel wipes
69
+ everything: the stored key and uni login are deleted, the gateway stops, and
70
+ the models disappear from the picker on the next opencode start. While
71
+ opencode runs, connecting and removing are noticed within seconds — and even
72
+ if opencode was closed during the removal, the next start wipes what is left.
90
73
 
91
74
  ## The built-in VPN
92
75
 
@@ -115,20 +98,32 @@ What that means in practice:
115
98
  - If the tunnel dies, it reconnects with backoff — no session is lost, the
116
99
  gateway just waits until the path is back.
117
100
 
118
- ## More than one key
119
-
120
- UFR allows one API key per account, so one key is one account's worth of
121
- throughput. The key pool round-robins across all stored keys and keeps each
122
- under UFR's per-key limits; adding more keys only helps if they belong to
123
- different UFR accounts.
101
+ ## PDF to Markdown (skill)
102
+
103
+ The plugin registers a **ufr-pdf2md** skill with opencode: an agentic
104
+ PDF-to-Markdown pipeline on the UFR vision models — page classification,
105
+ OCR with tables and LaTeX, and for circuit diagrams and flowcharts a
106
+ three-model ensemble (glm-5.3-flash, deepseek-v4.1-flash, qwen-3.5-397b)
107
+ with quadrant zoom and an adjudication pass that resolves disagreements
108
+ against the page image. Flowcharts and node graphs additionally come out as
109
+ node lists, edge lists and Mermaid graphs; a refinement loop and cross-page
110
+ table merging finish the document. opencode's agent loads the skill on its
111
+ own when you ask it to convert a PDF — you never run anything by hand.
112
+
113
+ Every model call goes through the gateway's relay (`POST /v1/_relay`): the
114
+ same central key rotation and soft rate limiting as chat. Pages run with up
115
+ to 16 parallel workers (auto-tuned), the key pool queues them and waits for
116
+ a free slot — nothing hammers UFR. The relay is also usable by your own
117
+ scripts (`src/client/gateway.ts`), and the context probe uses it too: the
118
+ old fixed pause between probe calls is gone, the key pool paces instead.
124
119
 
125
120
  ## What the gateway does
126
121
 
127
- - Keeps each key under 18 of UFR's 20 requests-per-rolling-minute, waiting
128
- instead of failing when a key is close to its limit.
129
- - Enforces a pool-wide cap of 800 requests/hour across all keys and models —
130
- UFR walls a model group for hours once it sees sustained traffic above
131
- roughly 900/hour.
122
+ - Keeps the key under 19 of UFR's 20 requests-per-rolling-minute, waiting
123
+ instead of failing when it is close to its limit.
124
+ - Can enforce a pool-wide cap on requests/hour across all models (off by
125
+ default; `limits.poolPerHour`) — the old ~900/h model-group wall is gone,
126
+ so nothing throttles beyond the per-key bucket unless you set a cap.
132
127
  - Runs a circuit breaker per model: after repeated failures it backs off from
133
128
  30 s up to 60 minutes before probing again, instead of hammering a walled
134
129
  model.
@@ -141,15 +136,15 @@ different UFR accounts.
141
136
  - Retries once with reasoning turned off if a `glm` model spends its whole
142
137
  token budget thinking and returns no text.
143
138
  - Keeps local stats in the same "% of $20/day" unit UFR's own portal uses, so
144
- the numbers in `ufr status` line up with what you see there.
139
+ the recorded spend lines up with what you see there.
145
140
  - Survives being killed: if the gateway's lock file is left behind by an
146
141
  unclean shutdown, the next start detects it's stale and takes it over
147
142
  rather than refusing to start.
148
143
 
149
144
  ## Configuration
150
145
 
151
- Non-secret settings live in a JSON file; keys and the uni login always stay in
152
- the OS keyring.
146
+ Non-secret settings live in a JSON file; the key and the uni login always stay
147
+ in the OS keyring.
153
148
 
154
149
  - **Linux / macOS:** `~/.config/opencode-ufr/config.json` (state in
155
150
  `~/.local/state/opencode-ufr`, cache in `~/.cache/opencode-ufr`, data in
@@ -169,11 +164,12 @@ except `port`, which is written on first start):
169
164
  "port": 47300,
170
165
  "transport": { "type": "auto" },
171
166
  "vpn": { "gateway": "https://fortivpn.uni-freiburg.de", "mode": "auto" },
172
- "keys": ["a", "b"],
167
+ "upstream": { "baseUrl": "https://openwebui.uni-freiburg.de/api", "requestTimeoutS": 600 },
168
+ "keys": [],
173
169
  "limits": {
174
- "keyRpm": 18,
170
+ "keyRpm": 19,
175
171
  "keyWindowS": 60,
176
- "poolPerHour": 800,
172
+ "poolPerHour": 0,
177
173
  "poolWindowS": 3600,
178
174
  "keyMaxWaitS": 60,
179
175
  "poolMaxWaitS": 20,
@@ -186,21 +182,19 @@ except `port`, which is written on first start):
186
182
  "probeTimeoutS": 120
187
183
  },
188
184
  "allowPaid": false,
189
- "catalog": {
190
- "url": "https://raw.githubusercontent.com/FinleyLaempe/opencode-ufr/main/models.json",
191
- "refreshHours": 6
192
- },
193
185
  "idleShutdownMin": 5
194
186
  }
195
187
  ```
196
188
 
197
189
  `port` is chosen once (preferred `47300`, next free port if taken) and then
198
- stays fixed across restarts. `poolPerHour: 0` disables the pool limiter.
199
- `transport.type: "direct"` never opens the tunnel.
190
+ stays fixed across restarts. `poolPerHour: 0` (the default) disables the pool limiter.
191
+ `transport.type: "direct"` never opens the tunnel. `upstream.requestTimeoutS`
192
+ is the per-request timeout in seconds. `keys` lists keyring aliases and is
193
+ filled in by the plugin when you connect — you normally never edit it by hand.
200
194
 
201
195
  ## Releases
202
196
 
203
- Releases are tag-driven: `scripts/release.sh 0.2.1` bumps, commits, tags and
197
+ Releases are tag-driven: `scripts/release.sh 0.2.9` bumps, commits, tags and
204
198
  pushes; the workflow then verifies on Ubuntu, macOS and Windows, stages the
205
199
  package on npm (trusted publishing via OIDC — no stored tokens), and creates
206
200
  the GitHub release after a maintainer approves the staged version with 2FA.
@@ -214,12 +208,11 @@ doesn't expose or gets wrong. Fixes reach users with the next plugin update.
214
208
  Context windows are measured, not guessed: the `probe contexts` workflow
215
209
  (weekly, on demand, and after every release tag) sends one oversized — and
216
210
  therefore free, since UFR rejects it before pricing — request per model and
217
- commits the exact limit the server names back to `models.json` on main. Run
218
- it yourself with a stored key:
211
+ commits the exact limit the server names back to `models.json` on main.
212
+ Maintainers run the same measurement locally:
219
213
 
220
214
  ```bash
221
- ufr context-probe # report: measured context vs. models.json
222
- ufr context-probe --write # also patch your local models.json
215
+ bun scripts/probe-all-contexts.ts --keyring key1 --write # report + patch models.json
223
216
  ```
224
217
 
225
218
  To contribute a measurement (a price, a context window, a missing alias),
@@ -228,7 +221,7 @@ open a PR against `models.json` with the source of the measurement in a
228
221
 
229
222
  ## Privacy
230
223
 
231
- - UFR API keys and the uni VPN login live only in your OS keyring (Keychain,
224
+ - Your UFR API key and the uni VPN login live only in your OS keyring (Keychain,
232
225
  Credential Manager, or libsecret/KWallet on Linux) — never in a config file
233
226
  or in this repository.
234
227
  - The gateway listens on `127.0.0.1` only, behind a random local token; it
package/dist/tui.js ADDED
@@ -0,0 +1,155 @@
1
+ // @bun
2
+ // src/plugin/tui.staged.build.js
3
+ import { createComponent as _$createComponent } from "@opentui/solid";
4
+ import { insert as _$insert } from "@opentui/solid";
5
+ import { createElement as _$createElement } from "@opentui/solid";
6
+ import { Plugin } from "@opencode/plugin/tui";
7
+ import { createSignal, onCleanup, onMount, Show } from "solid-js";
8
+
9
+ // src/shared/fs.ts
10
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
11
+ async function readText(file) {
12
+ try {
13
+ return await readFile(file, "utf8");
14
+ } catch (e) {
15
+ if (e.code === "ENOENT")
16
+ return null;
17
+ throw e;
18
+ }
19
+ }
20
+ async function readJson(file) {
21
+ const text = await readText(file);
22
+ if (text === null)
23
+ return null;
24
+ try {
25
+ return JSON.parse(text);
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ // src/shared/daemon-client.ts
32
+ async function readDaemonInfo(paths) {
33
+ const j = await readJson(paths.daemonFile);
34
+ if (j === null || typeof j.port !== "number" || typeof j.version !== "string")
35
+ return null;
36
+ return { port: j.port, pid: typeof j.pid === "number" ? j.pid : 0, version: j.version };
37
+ }
38
+ async function daemonRequest(paths, f, path, method = "GET") {
39
+ const info = await readDaemonInfo(paths);
40
+ const token = (await readText(paths.tokenFile))?.trim();
41
+ if (!info?.port || !token)
42
+ return null;
43
+ try {
44
+ return await f(`http://127.0.0.1:${info.port}${path}`, {
45
+ method,
46
+ headers: { Authorization: `Bearer ${token}` },
47
+ signal: AbortSignal.timeout(5000)
48
+ });
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ // src/shared/paths.ts
55
+ import { homedir } from "os";
56
+ import { join } from "path";
57
+ var APP = "opencode-ufr";
58
+ function resolvePaths(env = process.env, platform = process.platform, home = homedir()) {
59
+ let configDir, stateDir, cacheDir, dataDir;
60
+ if (env.OPENCODE_UFR_HOME) {
61
+ const base = env.OPENCODE_UFR_HOME;
62
+ configDir = join(base, "config");
63
+ stateDir = join(base, "state");
64
+ cacheDir = join(base, "cache");
65
+ dataDir = join(base, "data");
66
+ } else if (platform === "win32") {
67
+ const roaming = env.APPDATA ?? join(home, "AppData", "Roaming");
68
+ const local = env.LOCALAPPDATA ?? join(home, "AppData", "Local");
69
+ configDir = join(roaming, APP);
70
+ stateDir = join(local, APP, "state");
71
+ cacheDir = join(local, APP, "cache");
72
+ dataDir = join(local, APP, "data");
73
+ } else {
74
+ configDir = join(env.XDG_CONFIG_HOME ?? join(home, ".config"), APP);
75
+ stateDir = join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), APP);
76
+ cacheDir = join(env.XDG_CACHE_HOME ?? join(home, ".cache"), APP);
77
+ dataDir = join(env.XDG_DATA_HOME ?? join(home, ".local", "share"), APP);
78
+ }
79
+ return {
80
+ configDir,
81
+ stateDir,
82
+ cacheDir,
83
+ dataDir,
84
+ configFile: join(configDir, "config.json"),
85
+ daemonFile: join(stateDir, "daemon.json"),
86
+ tokenFile: join(stateDir, "token"),
87
+ lockFile: join(stateDir, "daemon.lock"),
88
+ logFile: join(stateDir, "daemon.log"),
89
+ statsDb: join(dataDir, "stats.db"),
90
+ ufrModelsCache: join(cacheDir, "ufr-models.json")
91
+ };
92
+ }
93
+
94
+ // src/plugin/tui.staged.build.js
95
+ var POLL_MS = 1000;
96
+ function fmtTokens(v) {
97
+ return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v);
98
+ }
99
+ function RatesLine(props) {
100
+ const [rates, setRates] = createSignal(null);
101
+ let busy = false;
102
+ const poll = async () => {
103
+ if (busy)
104
+ return;
105
+ busy = true;
106
+ try {
107
+ const res = await daemonRequest(props.paths, (u, i) => fetch(u, i), "/v1/_status");
108
+ if (!res?.ok) {
109
+ setRates(null);
110
+ return;
111
+ }
112
+ const j = await res.json();
113
+ setRates(j.rates ?? null);
114
+ } finally {
115
+ busy = false;
116
+ }
117
+ };
118
+ let timer;
119
+ onMount(() => {
120
+ poll();
121
+ timer = setInterval(() => void poll(), POLL_MS);
122
+ });
123
+ onCleanup(() => clearInterval(timer));
124
+ const label = () => {
125
+ const r = rates();
126
+ if (!r)
127
+ return null;
128
+ return `UFR ${r.reqPerSec} req/s \xB7 ${fmtTokens(r.tokensOutPerSec)} tok/s out \xB7 ${fmtTokens(r.tokensInPerSec)} tok/s in`;
129
+ };
130
+ return _$createComponent(Show, {
131
+ get when() {
132
+ return label();
133
+ },
134
+ get children() {
135
+ var _el$ = _$createElement("text");
136
+ _$insert(_el$, label);
137
+ return _el$;
138
+ }
139
+ });
140
+ }
141
+ var tui_staged_build_default = Plugin.define({
142
+ id: "opencode-ufr.stats",
143
+ setup(context) {
144
+ const paths = resolvePaths();
145
+ return context.ui.slot({
146
+ append: "sidebar.content",
147
+ render: () => _$createComponent(RatesLine, {
148
+ paths
149
+ })
150
+ });
151
+ }
152
+ });
153
+ export {
154
+ tui_staged_build_default as default
155
+ };
package/package.json CHANGED
@@ -1,20 +1,26 @@
1
1
  {
2
2
  "name": "opencode-ufr",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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",
7
7
  "main": "./src/plugin/index.ts",
8
8
  "exports": {
9
- ".": "./src/plugin/index.ts"
9
+ ".": "./src/plugin/index.ts",
10
+ "./tui": "./dist/tui.js"
10
11
  },
11
- "bin": {
12
- "ufr": "bin/ufr.ts",
13
- "opencode-ufr": "bin/ufr.ts"
12
+ "dependencies": {
13
+ "@opencode/plugin": "^2.0.23"
14
+ },
15
+ "peerDependencies": {
16
+ "@opentui/core": ">=0.5.8",
17
+ "@opentui/solid": ">=0.5.8",
18
+ "solid-js": ">=1.9.0"
14
19
  },
15
20
  "files": [
16
- "bin",
17
21
  "src",
22
+ "dist",
23
+ "skills",
18
24
  "models.json",
19
25
  "README.md",
20
26
  "LICENSE"
@@ -33,12 +39,19 @@
33
39
  "open-webui"
34
40
  ],
35
41
  "scripts": {
42
+ "build:tui": "bun scripts/build-tui.ts",
36
43
  "test": "bun test",
37
44
  "typecheck": "tsc --noEmit",
38
- "prepublishOnly": "bun run typecheck && bun test"
45
+ "prepublishOnly": "bun run build:tui && bun run typecheck && bun test"
39
46
  },
40
47
  "devDependencies": {
48
+ "@babel/core": "7.28.0",
49
+ "@babel/preset-typescript": "7.27.1",
50
+ "@opentui/core": "^0.5.14",
51
+ "@opentui/solid": "^0.5.14",
41
52
  "@types/bun": "^1.3.0",
53
+ "babel-preset-solid": "1.9.12",
54
+ "solid-js": "^1.9.15",
42
55
  "typescript": "^5.9.0"
43
56
  }
44
57
  }
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: PDF to Markdown
3
+ description: Convert a PDF to clean Markdown using UFR vision models — full OCR with tables, math (LaTeX), circuit diagrams (component tables via quadrant zoom), a refinement loop and cross-page table merging. Use whenever the user asks to convert a PDF or scanned document to Markdown, especially with tables, formulas or technical drawings where plain text extraction fails.
4
+ ---
5
+
6
+ Convert a PDF to Markdown with the UFR vision pipeline:
7
+
8
+ ```
9
+ bun {{PDF2MD_SCRIPT}} <input.pdf> [output.md] [options]
10
+ ```
11
+
12
+ Options: `--workers N` (0 = auto-tune, default), `--dpi 300`, `--no-dedup`, `--dedup-threshold 0.98`, `--no-refine`, `--no-merge`, `--verbose` / `-v`.
13
+
14
+ ## Workflow
15
+
16
+ 1. Check the prerequisites are on PATH: `pdftoppm`/`pdfinfo` (poppler-utils) and `magick` (ImageMagick). If missing, tell the user to install them (`poppler-utils` and `imagemagick` packages) instead of trying another conversion path.
17
+ 2. Run the command. Prefer giving an explicit `output.md` next to the input (stdout is the fallback); report the output path and page count.
18
+ 3. For large documents (> 20 pages), start with `--no-refine --no-merge` for a fast first pass, then refine only the pages the user cares about.
19
+
20
+ ## What the pipeline does
21
+
22
+ - Renders each page at 300 DPI and drops PowerPoint-style incremental-reveal duplicates.
23
+ - Classifies each page (TEXT / TABLE_MATH / DIAGRAM / MIXED) and routes it to the best model: glm-5.3-flash for text and tables (structure + LaTeX), and for diagrams an ensemble of three vision models (glm-5.3-flash, deepseek-v4.1-flash, qwen-3.5-397b) that each read the full page and every quadrant crop, followed by an adjudication pass that resolves disagreements against the image. Flowcharts and node graphs additionally get a node/edge list and a Mermaid rendering.
24
+ - Runs a refinement loop that compares the markdown against the page image (3 rounds for tables/diagrams, 1 for text).
25
+ - Merges tables that span page breaks at the end.
26
+
27
+ ## Notes
28
+
29
+ - Every model call goes through the local opencode-ufr gateway, which rotates keys and paces requests automatically — **never add artificial sleeps between runs**; parallelism is handled and safe.
30
+ - The first run with > 2 pages auto-tunes concurrency with a few lightweight probe requests.
31
+ - Progress and page logs go to stderr; only the final markdown goes to stdout/the output file.
32
+ - The script needs the gateway running (it starts one automatically if the plugin is installed); model availability depends on the Uni Freiburg connection like every other UFR model.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The one door scripts knock on: a connection to the local opencode-ufr
3
+ * gateway. Every model call made through here rides the gateway's central
4
+ * key rotation and soft rate limiting — the caller waits for a key slot
5
+ * instead of being told to back off, and never has to pace itself with
6
+ * artificial sleeps. Two primitives:
7
+ *
8
+ * - relay(): one raw upstream call to exactly the requested model (no
9
+ * fallbacks, no context hub, no reasoning retry) with UFR's status and
10
+ * body passed through verbatim. For context probes and tools that must
11
+ * know which model answered.
12
+ * - chat(): a full chat completion with the gateway's fallback chain and
13
+ * reasoning retry, for tools that just want text.
14
+ */
15
+ import { fileURLToPath } from "node:url"
16
+ import { type Conn, ensureDaemon, spawnDaemon } from "../plugin/ensure-daemon"
17
+ import { resolvePaths } from "../shared/paths"
18
+ import { VERSION } from "../shared/version"
19
+
20
+ const DAEMON_ENTRY = fileURLToPath(new URL("../daemon/main.ts", import.meta.url))
21
+
22
+ export type Gateway = { port: number; token: string; baseUrl: string }
23
+
24
+ /** Find the running gateway or start one (waits up to ~40 s on a cold start). */
25
+ export async function connectGateway(): Promise<Gateway> {
26
+ const paths = resolvePaths()
27
+ const conn: Conn = await ensureDaemon({
28
+ paths,
29
+ version: VERSION,
30
+ spawn: () => spawnDaemon(DAEMON_ENTRY, paths.stateDir),
31
+ })
32
+ return { port: conn.port, token: conn.token, baseUrl: `http://127.0.0.1:${conn.port}` }
33
+ }
34
+
35
+ export type RawResult = { status: number; body: string; retryAfterMs?: number; contentType: string }
36
+
37
+ function retryAfterOf(res: Response): number | undefined {
38
+ const s = Number(res.headers.get("retry-after"))
39
+ return Number.isFinite(s) && s > 0 ? s * 1000 : undefined
40
+ }
41
+
42
+ /** One raw upstream call through the gateway (POST /v1/_relay). */
43
+ export async function relay(
44
+ gw: Gateway,
45
+ body: Record<string, unknown>,
46
+ o: { signal?: AbortSignal; timeoutMs?: number } = {},
47
+ ): Promise<RawResult> {
48
+ const signal = o.timeoutMs
49
+ ? AbortSignal.any([o.signal, AbortSignal.timeout(o.timeoutMs)].filter((s): s is AbortSignal => s !== undefined))
50
+ : o.signal
51
+ const res = await fetch(`${gw.baseUrl}/v1/_relay`, {
52
+ method: "POST",
53
+ headers: { Authorization: `Bearer ${gw.token}`, "Content-Type": "application/json" },
54
+ body: JSON.stringify(body),
55
+ signal,
56
+ }).catch((e) => {
57
+ throw new Error(`cannot reach the gateway at ${gw.baseUrl} (${(e as Error).message})`)
58
+ })
59
+ return {
60
+ status: res.status,
61
+ body: await res.text(),
62
+ retryAfterMs: retryAfterOf(res),
63
+ contentType: res.headers.get("content-type") ?? "application/json",
64
+ }
65
+ }
66
+
67
+ export type ChatResult = { status: number; json: unknown }
68
+
69
+ /** A full chat completion through the gateway (POST /v1/chat/completions). */
70
+ export async function chat(
71
+ gw: Gateway,
72
+ body: Record<string, unknown>,
73
+ o: { signal?: AbortSignal } = {},
74
+ ): Promise<ChatResult> {
75
+ const res = await fetch(`${gw.baseUrl}/v1/chat/completions`, {
76
+ method: "POST",
77
+ headers: { Authorization: `Bearer ${gw.token}`, "Content-Type": "application/json" },
78
+ body: JSON.stringify(body),
79
+ signal: o.signal,
80
+ }).catch((e) => {
81
+ throw new Error(`cannot reach the gateway at ${gw.baseUrl} (${(e as Error).message})`)
82
+ })
83
+ const text = await res.text()
84
+ let json: unknown = null
85
+ try {
86
+ json = JSON.parse(text)
87
+ } catch {
88
+ json = { error: { message: text.slice(0, 500), type: "invalid_response", code: res.status } }
89
+ }
90
+ return { status: res.status, json }
91
+ }
92
+
93
+ /** The gateway's raw catalog inputs: UFR's model list plus the bundled models.json. */
94
+ export async function catalogData(gw: Gateway): Promise<{ ufr: unknown; file: unknown } | null> {
95
+ const res = await fetch(`${gw.baseUrl}/v1/_catalog`, {
96
+ headers: { Authorization: `Bearer ${gw.token}` },
97
+ signal: AbortSignal.timeout(10_000),
98
+ }).catch(() => null)
99
+ if (!res?.ok) return null
100
+ return (await res.json()) as { ufr: unknown; file: unknown } | null
101
+ }