opencode-ufr 0.2.9 → 0.2.10
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 +55 -62
- package/package.json +14 -6
- package/skills/pdf2md/SKILL.md +32 -0
- package/src/client/gateway.ts +101 -0
- package/src/client/pdf2md.ts +747 -0
- package/src/daemon/catalog-source.ts +9 -0
- package/src/daemon/daemon.ts +70 -18
- package/src/daemon/keypool.ts +12 -7
- package/src/daemon/main.ts +7 -1
- package/src/daemon/probe-all.ts +60 -38
- package/src/daemon/router.ts +215 -17
- package/src/daemon/server.ts +33 -1
- package/src/daemon/stats.ts +13 -1
- package/src/daemon/upstream.ts +14 -3
- package/src/daemon/vpn/fortinet.ts +42 -29
- package/src/daemon/vpn/manager.ts +50 -16
- package/src/daemon/vpn/proxy.ts +3 -1
- package/src/plugin/connect.ts +4 -4
- package/src/plugin/ensure-daemon.ts +48 -19
- package/src/plugin/index.ts +121 -46
- package/src/plugin/tui.tsx +77 -0
- package/src/shared/config.ts +53 -4
- package/src/shared/connect.ts +3 -4
- package/src/shared/daemon-client.ts +29 -0
- package/src/shared/errors.ts +1 -1
- package/src/shared/fs.ts +8 -3
- package/src/shared/keys.ts +65 -0
- package/src/shared/models-file.ts +1 -1
- package/src/shared/secrets.ts +2 -6
- package/bin/ufr.ts +0 -4
- package/src/cli/catalog.ts +0 -44
- package/src/cli/connect.ts +0 -146
- package/src/cli/context-probe.ts +0 -96
- package/src/cli/daemon-client.ts +0 -19
- package/src/cli/index.ts +0 -105
- package/src/cli/io.ts +0 -98
- package/src/cli/keys.ts +0 -130
- package/src/cli/stats.ts +0 -37
- package/src/cli/status.ts +0 -48
- package/src/cli/stop.ts +0 -8
- 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
|
|
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 (
|
|
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
|
-
##
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
opencode was closed
|
|
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
|
-
##
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
128
|
-
instead of failing when
|
|
129
|
-
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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;
|
|
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
|
-
"
|
|
167
|
+
"upstream": { "baseUrl": "https://openwebui.uni-freiburg.de/api", "requestTimeoutS": 600 },
|
|
168
|
+
"keys": [],
|
|
173
169
|
"limits": {
|
|
174
|
-
"keyRpm":
|
|
170
|
+
"keyRpm": 19,
|
|
175
171
|
"keyWindowS": 60,
|
|
176
|
-
"poolPerHour":
|
|
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.
|
|
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.
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
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/package.json
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ufr",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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": "./src/plugin/tui.tsx"
|
|
10
11
|
},
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
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
|
+
"skills",
|
|
18
23
|
"models.json",
|
|
19
24
|
"README.md",
|
|
20
25
|
"LICENSE"
|
|
@@ -38,7 +43,10 @@
|
|
|
38
43
|
"prepublishOnly": "bun run typecheck && bun test"
|
|
39
44
|
},
|
|
40
45
|
"devDependencies": {
|
|
46
|
+
"@opentui/core": "^0.5.14",
|
|
47
|
+
"@opentui/solid": "^0.5.14",
|
|
41
48
|
"@types/bun": "^1.3.0",
|
|
49
|
+
"solid-js": "^1.9.15",
|
|
42
50
|
"typescript": "^5.9.0"
|
|
43
51
|
}
|
|
44
52
|
}
|
|
@@ -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
|
+
}
|