opencode-ufr 0.2.12 → 0.2.14
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/dist/tui.js +16 -4
- package/models.json +33 -1
- package/package.json +1 -1
- package/src/daemon/keypool.ts +22 -8
- package/src/daemon/router.ts +29 -5
- package/src/daemon/upstream.ts +12 -0
- package/src/plugin/tui.tsx +40 -10
package/dist/tui.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/plugin/tui.staged.build.js
|
|
3
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
4
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
3
5
|
import { insert as _$insert } from "@opentui/solid";
|
|
4
6
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
5
7
|
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
@@ -101,16 +103,24 @@ function fmtMoney(v) {
|
|
|
101
103
|
return `$${v.toFixed(2)}`;
|
|
102
104
|
}
|
|
103
105
|
function keyLine(k, s) {
|
|
104
|
-
const spend = s.spendToday?.[k.alias] ?? 0;
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
const spend = k.budgetSpend ?? s.spendToday?.[k.alias] ?? 0;
|
|
107
|
+
const limit = k.budgetLimit ?? s.dailyBudgetUsd ?? 0;
|
|
108
|
+
let line = `${k.alias} ${fmtMoney(spend)}/${fmtMoney(limit)}`;
|
|
109
|
+
if (k.budgetSpend !== undefined)
|
|
110
|
+
line += " budget";
|
|
111
|
+
else if (k.invalid)
|
|
107
112
|
line += " invalid";
|
|
108
113
|
else if (k.blockedBy === "budget")
|
|
109
114
|
line += " budget";
|
|
110
115
|
else if (k.blockedBy === "rate")
|
|
111
116
|
line += " rate";
|
|
117
|
+
if (k.budgetSpend !== undefined && k.invalid)
|
|
118
|
+
line += " invalid";
|
|
112
119
|
return line;
|
|
113
120
|
}
|
|
121
|
+
function keyColor(k) {
|
|
122
|
+
return k.blockedBy === "budget" ? "red" : undefined;
|
|
123
|
+
}
|
|
114
124
|
function RatesLine(props) {
|
|
115
125
|
const [status, setStatus] = createSignal(null);
|
|
116
126
|
let busy = false;
|
|
@@ -139,7 +149,8 @@ function RatesLine(props) {
|
|
|
139
149
|
const r = status()?.rates;
|
|
140
150
|
if (!r)
|
|
141
151
|
return null;
|
|
142
|
-
|
|
152
|
+
const perMin = (perSec) => Math.round(perSec * 60);
|
|
153
|
+
return `UFR ${r.requests} req/min \xB7 ${fmtTokens(perMin(r.tokensOutPerSec))} tok/min out \xB7 ${fmtTokens(perMin(r.tokensInPerSec))} tok/min in`;
|
|
143
154
|
};
|
|
144
155
|
return _$createComponent(Show, {
|
|
145
156
|
get when() {
|
|
@@ -153,6 +164,7 @@ function RatesLine(props) {
|
|
|
153
164
|
children: (k) => (() => {
|
|
154
165
|
var _el$2 = _$createElement("text");
|
|
155
166
|
_$insert(_el$2, () => keyLine(k, status()));
|
|
167
|
+
_$effect((_$p) => _$setProp(_el$2, "fg", keyColor(k), _$p));
|
|
156
168
|
return _el$2;
|
|
157
169
|
})()
|
|
158
170
|
}), (() => {
|
package/models.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"updated": "2026-10-
|
|
3
|
+
"updated": "2026-10-07",
|
|
4
4
|
"defaults": {
|
|
5
5
|
"context": 131072,
|
|
6
6
|
"max_output": 16384
|
|
@@ -190,6 +190,38 @@
|
|
|
190
190
|
"cache_write": 3.125
|
|
191
191
|
}
|
|
192
192
|
},
|
|
193
|
+
"openai/gpt-6-astra-llmlb": {
|
|
194
|
+
"context": 1050000,
|
|
195
|
+
"max_output": 128000,
|
|
196
|
+
"price": {
|
|
197
|
+
"input": 10,
|
|
198
|
+
"output": 50,
|
|
199
|
+
"cache_write": 12.5
|
|
200
|
+
},
|
|
201
|
+
"note": "context/max_output from OpenAI's model page (2026-10-07), not probed at UFR; cache_read unknown — billed at full input"
|
|
202
|
+
},
|
|
203
|
+
"openai/gpt-6.1-sol-llmlb": {
|
|
204
|
+
"context": 1050000,
|
|
205
|
+
"max_output": 128000,
|
|
206
|
+
"price": {
|
|
207
|
+
"input": 2,
|
|
208
|
+
"output": 10,
|
|
209
|
+
"cache_read": 0.1,
|
|
210
|
+
"cache_write": 2.5
|
|
211
|
+
},
|
|
212
|
+
"note": "context/max_output from OpenAI's model page (2026-10-07), not probed at UFR"
|
|
213
|
+
},
|
|
214
|
+
"openai/gpt-6-luna-llmlb": {
|
|
215
|
+
"context": 1050000,
|
|
216
|
+
"max_output": 128000,
|
|
217
|
+
"price": {
|
|
218
|
+
"input": 0.1,
|
|
219
|
+
"output": 0.5,
|
|
220
|
+
"cache_read": 0.01,
|
|
221
|
+
"cache_write": 0.125
|
|
222
|
+
},
|
|
223
|
+
"note": "context/max_output from OpenAI's model page (2026-10-07), not probed at UFR"
|
|
224
|
+
},
|
|
193
225
|
"openai/gpt-5.3-codex-llmlb": {
|
|
194
226
|
"context": 1000000,
|
|
195
227
|
"price": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ufr",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"description": "Uni Freiburg (UFR) Open WebUI models in opencode — local gateway with key pool, UFR-aware rate limiting, circuit breaker and fallbacks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/daemon/keypool.ts
CHANGED
|
@@ -11,6 +11,9 @@ export type KeySnapshot = {
|
|
|
11
11
|
blockedForMs: number
|
|
12
12
|
/** Why the key is blocked right now: a daily-budget 429 or a bucket 429; null when usable. */
|
|
13
13
|
blockedBy: "budget" | "rate" | null
|
|
14
|
+
/** Parsed from UFR's budget body, only while budget-blocked and only when UFR named the numbers. */
|
|
15
|
+
budgetSpend?: number
|
|
16
|
+
budgetLimit?: number
|
|
14
17
|
invalid: boolean
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -23,6 +26,9 @@ type Slot = KeyInfo & {
|
|
|
23
26
|
blockedUntil: number
|
|
24
27
|
/** What blocked the key: a daily-budget 429 or a bucket 429 (null until one fires). */
|
|
25
28
|
blockedBy: "budget" | "rate" | null
|
|
29
|
+
/** Spend/limit parsed from the last budget error, for the display; cleared with the block. */
|
|
30
|
+
budgetSpend?: number
|
|
31
|
+
budgetLimit?: number
|
|
26
32
|
/** Until when the key is considered invalid (0 = valid); a transient 401 expires. */
|
|
27
33
|
invalidUntil: number
|
|
28
34
|
recent429: { at: number; model: string }[]
|
|
@@ -115,14 +121,16 @@ export class KeyPool {
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
/**
|
|
118
|
-
* UFR's daily-budget
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
+
* UFR's daily-budget error (the `budget_exceeded` marker 429, or the 400
|
|
125
|
+
* `ExceededBudget` body since 2026-10-07) means this key's $ cap is spent for
|
|
126
|
+
* the day: block it until the next local midnight — UFR's own reset time is
|
|
127
|
+
* unknown, and midnight is the same assumption the spend-today stats already
|
|
128
|
+
* make. A bucket 429 carries the identical body (2026-10-07), so a merely
|
|
129
|
+
* rate-limited key can end up budget-blocked; that only costs until midnight
|
|
130
|
+
* and is safe. Never shortens an existing block. When UFR's body named the
|
|
131
|
+
* numbers, they are kept for the display — and expire with the block.
|
|
124
132
|
*/
|
|
125
|
-
onBudgetExhausted(alias: string): void {
|
|
133
|
+
onBudgetExhausted(alias: string, spend?: number, limit?: number): void {
|
|
126
134
|
const s = this.find(alias)
|
|
127
135
|
if (!s) return
|
|
128
136
|
const now = this.o.now()
|
|
@@ -130,6 +138,8 @@ export class KeyPool {
|
|
|
130
138
|
// lands in the future, so the block can never be already expired.
|
|
131
139
|
s.blockedUntil = Math.max(s.blockedUntil, startOfLocalDay(now) + 24 * 3_600_000)
|
|
132
140
|
s.blockedBy = "budget"
|
|
141
|
+
s.budgetSpend = spend
|
|
142
|
+
s.budgetLimit = limit
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
onInvalid(alias: string): void {
|
|
@@ -146,8 +156,12 @@ export class KeyPool {
|
|
|
146
156
|
blockedForMs: Math.max(0, s.blockedUntil - now),
|
|
147
157
|
// The cause is derived from the current time, not stored state: a block
|
|
148
158
|
// whose deadline has passed reports as unblocked even if the slot still
|
|
149
|
-
// carries a stale blockedBy.
|
|
159
|
+
// carries a stale blockedBy. The parsed spend/limit are display values of
|
|
160
|
+
// the block — they must not outlive it either.
|
|
150
161
|
blockedBy: s.blockedUntil > now ? s.blockedBy : null,
|
|
162
|
+
...(s.blockedUntil > now && s.blockedBy === "budget" && s.budgetSpend !== undefined
|
|
163
|
+
? { budgetSpend: s.budgetSpend, budgetLimit: s.budgetLimit }
|
|
164
|
+
: {}),
|
|
151
165
|
invalid: s.invalidUntil > now,
|
|
152
166
|
}))
|
|
153
167
|
}
|
package/src/daemon/router.ts
CHANGED
|
@@ -247,6 +247,13 @@ export class Router {
|
|
|
247
247
|
this.d.breakers.get(group).onRateLimited()
|
|
248
248
|
this.record(t0, group, k.alias, 429, null, 1, "upstream_rate_limited", true)
|
|
249
249
|
return new Response(r.body, { status: 429, headers: { "content-type": "application/json" } })
|
|
250
|
+
case "budget_exhausted":
|
|
251
|
+
// UFR named this key over budget (400 ExceededBudget, 2026-10-07): block
|
|
252
|
+
// it until midnight and hand the raw 400 back — the relay is a raw pipe.
|
|
253
|
+
this.d.keys.onBudgetExhausted(k.alias, r.spend, r.limit)
|
|
254
|
+
this.d.breakers.get(group).onOtherFailure()
|
|
255
|
+
this.record(t0, group, k.alias, r.status, null, 1, "upstream_budget_exhausted", true)
|
|
256
|
+
return new Response(r.body, { status: r.status, headers: { "content-type": "application/json" } })
|
|
250
257
|
case "auth_invalid":
|
|
251
258
|
this.d.keys.onInvalid(k.alias)
|
|
252
259
|
this.d.breakers.get(group).onOtherFailure()
|
|
@@ -324,12 +331,20 @@ export class Router {
|
|
|
324
331
|
if (rateLimitedHere) giveUp(model) // the wall counts toward the breaker threshold even here
|
|
325
332
|
// The two messages tell the user very different remedies: wait a minute
|
|
326
333
|
// vs. wait for midnight. The pool alone cannot tell why a key is
|
|
327
|
-
// blocked, so read it off the snapshot's budget marker.
|
|
328
|
-
|
|
334
|
+
// blocked, so read it off the snapshot's budget marker. When UFR's
|
|
335
|
+
// budget bodies named the numbers, show per-key spend — the reset time
|
|
336
|
+
// is still unknown here (the probe, not the error body, knows it).
|
|
337
|
+
const blocked = keys.snapshot().filter((s) => s.blockedBy === "budget")
|
|
338
|
+
if (blocked.length > 0) {
|
|
339
|
+
const withSpend = blocked.filter((s) => s.budgetSpend !== undefined)
|
|
340
|
+
return fail(429, "key_pool_exhausted",
|
|
341
|
+
withSpend.length > 0
|
|
342
|
+
? `UFR key(s) over budget: ${withSpend.map((s) => `${s.alias} $${s.budgetSpend!.toFixed(2)}`).join(", ")} (limit $${(withSpend[0]!.budgetLimit ?? 0).toFixed(2)}) — reset time unknown, probing`
|
|
343
|
+
: "a UFR key has exhausted its daily budget (resets at local midnight)",
|
|
344
|
+
k.retryAfterMs)
|
|
345
|
+
}
|
|
329
346
|
return fail(429, "key_pool_exhausted",
|
|
330
|
-
|
|
331
|
-
? "a UFR key has exhausted its daily budget (resets at local midnight)"
|
|
332
|
-
: `every UFR key is at its limit of ${config.limits.keyRpm} requests per ${config.limits.keyWindowS} s`,
|
|
347
|
+
`every UFR key is at its limit of ${config.limits.keyRpm} requests per ${config.limits.keyWindowS} s`,
|
|
333
348
|
k.retryAfterMs)
|
|
334
349
|
}
|
|
335
350
|
// all_tried: every usable key already failed on this model
|
|
@@ -404,6 +419,15 @@ export class Router {
|
|
|
404
419
|
}
|
|
405
420
|
}
|
|
406
421
|
continue
|
|
422
|
+
case "budget_exhausted":
|
|
423
|
+
// A daily-budget error (400 ExceededBudget, 2026-10-07) is key-specific:
|
|
424
|
+
// do NOT giveUp(model) — the breaker must not wall a model every other
|
|
425
|
+
// key can still answer — and do not advance the model either: the next
|
|
426
|
+
// acquire picks an untried key, and once every key is budget-blocked
|
|
427
|
+
// acquire reports exhausted, which surfaces the budget message below.
|
|
428
|
+
breakers.get(model).onOtherFailure() // a budget 400 is no wall evidence
|
|
429
|
+
keys.onBudgetExhausted(k.alias, r.spend, r.limit)
|
|
430
|
+
continue
|
|
407
431
|
case "auth_invalid":
|
|
408
432
|
keys.onInvalid(k.alias)
|
|
409
433
|
continue
|
package/src/daemon/upstream.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Transport } from "./transport"
|
|
|
4
4
|
export type UpstreamResult =
|
|
5
5
|
| { kind: "ok"; response: Response }
|
|
6
6
|
| { kind: "rate_limited"; status: number; body: string }
|
|
7
|
+
| { kind: "budget_exhausted"; status: number; body: string; spend?: number; limit?: number }
|
|
7
8
|
| { kind: "context_overflow"; status: number; body: string }
|
|
8
9
|
| { kind: "auth_invalid"; status: number; body: string }
|
|
9
10
|
| { kind: "unreachable"; message: string }
|
|
@@ -11,6 +12,9 @@ export type UpstreamResult =
|
|
|
11
12
|
|
|
12
13
|
const CONTEXT_RE = /maximum context length|max input tokens|context length|context window|too many tokens|prompt is too long/i
|
|
13
14
|
|
|
15
|
+
/** UFR's budget body names the numbers: `... Spend=24.0425389, Budget=20.0`. */
|
|
16
|
+
const BUDGET_RE = /Spend=([0-9.]+),\s*Budget=([0-9.]+)/
|
|
17
|
+
|
|
14
18
|
/** One call to UFR. Throws only if the caller's own signal aborted. */
|
|
15
19
|
export async function callUpstream(o: {
|
|
16
20
|
transport: Transport
|
|
@@ -72,6 +76,14 @@ export async function callUpstream(o: {
|
|
|
72
76
|
}
|
|
73
77
|
if (res.ok) return { kind: "ok", response: res }
|
|
74
78
|
const body = await res.text()
|
|
79
|
+
// The daily-budget error carries the `ExceededBudget` marker in its detail
|
|
80
|
+
// (400 application/json since 2026-10-07, previously a 429 marker). It must
|
|
81
|
+
// win over the generic error fallthrough on any status, and over context
|
|
82
|
+
// overflow — a budget body never names a context limit.
|
|
83
|
+
if (body.includes("ExceededBudget")) {
|
|
84
|
+
const m = BUDGET_RE.exec(body)
|
|
85
|
+
return { kind: "budget_exhausted", status: res.status, body, spend: m?.[1] ? Number(m[1]) : undefined, limit: m?.[2] ? Number(m[2]) : undefined }
|
|
86
|
+
}
|
|
75
87
|
if (res.status === 429) return { kind: "rate_limited", status: 429, body }
|
|
76
88
|
if (res.status === 401 || res.status === 403) return { kind: "auth_invalid", status: res.status, body }
|
|
77
89
|
if ((res.status === 400 || res.status === 413) && CONTEXT_RE.test(body)) return { kind: "context_overflow", status: res.status, body }
|
package/src/plugin/tui.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* @jsxImportSource @opentui/solid
|
|
3
|
+
*
|
|
2
4
|
* TUI contribution: live gateway throughput in opencode's sidebar. Loaded by
|
|
3
5
|
* opencode's CLI because package.json exports "./tui" — the server plugin
|
|
4
6
|
* (index.ts) needs no changes for this to load.
|
|
5
7
|
*
|
|
6
8
|
* Polls GET /v1/_status every second and appends lines to the right panel:
|
|
7
|
-
* one compact line per pool key (alias, $ spent today
|
|
8
|
-
* why it is blocked) above the
|
|
9
|
-
*
|
|
9
|
+
* one compact line per pool key (alias, $ spent today or the real spend from
|
|
10
|
+
* UFR's budget error, daily budget in USD, why it is blocked) above the
|
|
11
|
+
* per-minute rates line (raw counts of the gateway's rolling 60 s window).
|
|
12
|
+
* Status polls don't reset the daemon's idle timer (see server.ts),
|
|
10
13
|
* so leaving the panel open never keeps the gateway alive.
|
|
11
14
|
*/
|
|
12
15
|
import { Plugin } from "@opencode/plugin/tui"
|
|
@@ -17,6 +20,7 @@ import { resolvePaths, type Paths } from "../shared/paths"
|
|
|
17
20
|
/** The rates block of GET /v1/_status (see StatusJson in daemon.ts). */
|
|
18
21
|
type Rates = {
|
|
19
22
|
windowMs: number
|
|
23
|
+
/** Raw window counts — a 60 s window, so these ARE the per-minute numbers. */
|
|
20
24
|
requests: number
|
|
21
25
|
reqPerSec: number
|
|
22
26
|
tokensInPerSec: number
|
|
@@ -31,6 +35,10 @@ type KeyStatus = {
|
|
|
31
35
|
blockedForMs: number
|
|
32
36
|
blockedBy: "budget" | "rate" | null
|
|
33
37
|
invalid: boolean
|
|
38
|
+
/** Real spend/limit parsed from UFR's ExceededBudget error; present only
|
|
39
|
+
* while a budget block with parsed data is active (see keypool.ts). */
|
|
40
|
+
budgetSpend?: number
|
|
41
|
+
budgetLimit?: number
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
/** GET /v1/_status — only the fields the sidebar consumes. */
|
|
@@ -56,19 +64,33 @@ function fmtMoney(v: number): string {
|
|
|
56
64
|
/**
|
|
57
65
|
* One compact line per pool key: `k1 $0.42/$20.00`, with the reason appended when
|
|
58
66
|
* the key is unusable — `budget` (daily cap hit, resets at local midnight),
|
|
59
|
-
* `rate` (bucket 429 cool-down) or `invalid` (transient 401). A key
|
|
60
|
-
*
|
|
67
|
+
* `rate` (bucket 429 cool-down) or `invalid` (transient 401). A key blocked by
|
|
68
|
+
* the real ExceededBudget error shows UFR's parsed numbers instead of today's
|
|
69
|
+
* estimate: `k1 $24.04/$20.00 budget` — spend data is the more actionable info,
|
|
70
|
+
* so it wins over the `invalid` suffix (which is then just appended). A key with
|
|
71
|
+
* no entry in spendToday renders as $0.00; healthy keys render too, the user
|
|
61
72
|
* asked to see every key.
|
|
62
73
|
*/
|
|
63
74
|
function keyLine(k: KeyStatus, s: Status): string {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
// Parsed budget numbers come straight from UFR's error body; the fallback is
|
|
76
|
+
// today's local spend estimate against the configured daily budget.
|
|
77
|
+
const spend = k.budgetSpend ?? s.spendToday?.[k.alias] ?? 0
|
|
78
|
+
const limit = k.budgetLimit ?? s.dailyBudgetUsd ?? 0
|
|
79
|
+
let line = `${k.alias} ${fmtMoney(spend)}/${fmtMoney(limit)}`
|
|
80
|
+
if (k.budgetSpend !== undefined) line += " budget"
|
|
81
|
+
else if (k.invalid) line += " invalid"
|
|
67
82
|
else if (k.blockedBy === "budget") line += " budget"
|
|
68
83
|
else if (k.blockedBy === "rate") line += " rate"
|
|
84
|
+
// Spend data outranks the transient-invalid suffix, but invalid stays visible.
|
|
85
|
+
if (k.budgetSpend !== undefined && k.invalid) line += " invalid"
|
|
69
86
|
return line
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
/** Budget-exhausted keys render red; everything else keeps the default color. */
|
|
90
|
+
function keyColor(k: KeyStatus): string | undefined {
|
|
91
|
+
return k.blockedBy === "budget" ? "red" : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
72
94
|
function RatesLine(props: { paths: Paths }) {
|
|
73
95
|
const [status, setStatus] = createSignal<Status | null>(null)
|
|
74
96
|
let busy = false
|
|
@@ -95,11 +117,19 @@ function RatesLine(props: { paths: Paths }) {
|
|
|
95
117
|
const label = () => {
|
|
96
118
|
const r = status()?.rates
|
|
97
119
|
if (!r) return null
|
|
98
|
-
|
|
120
|
+
// The daemon's rates window is exactly 60 s, so the raw request count IS
|
|
121
|
+
// req/min; the per-second token numbers ×60 give the same window counts
|
|
122
|
+
// (daemon.ts rounds them, invisible at fmtTokens precision).
|
|
123
|
+
const perMin = (perSec: number) => Math.round(perSec * 60)
|
|
124
|
+
return `UFR ${r.requests} req/min · ${fmtTokens(perMin(r.tokensOutPerSec))} tok/min out · ${fmtTokens(perMin(r.tokensInPerSec))} tok/min in`
|
|
99
125
|
}
|
|
100
126
|
return (
|
|
101
127
|
<Show when={status()}>
|
|
102
|
-
<For each={status()!.keys ?? []}>
|
|
128
|
+
<For each={status()!.keys ?? []}>
|
|
129
|
+
{(k) => (
|
|
130
|
+
<text fg={keyColor(k)}>{keyLine(k, status()!)}</text>
|
|
131
|
+
)}
|
|
132
|
+
</For>
|
|
103
133
|
<text>{label()}</text>
|
|
104
134
|
</Show>
|
|
105
135
|
)
|