opencode-ufr 0.2.11 → 0.2.12
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 +36 -11
- package/package.json +1 -1
- package/src/daemon/keypool.ts +42 -2
- package/src/daemon/router.ts +19 -3
- package/src/plugin/tui.tsx +51 -11
package/dist/tui.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/plugin/tui.staged.build.js
|
|
3
|
-
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
4
3
|
import { insert as _$insert } from "@opentui/solid";
|
|
5
4
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
5
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
6
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
6
7
|
import { Plugin } from "@opencode/plugin/tui";
|
|
7
|
-
import { createSignal, onCleanup, onMount
|
|
8
|
+
import { For, Show, createSignal, onCleanup, onMount } from "solid-js";
|
|
8
9
|
|
|
9
10
|
// src/shared/fs.ts
|
|
10
11
|
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
@@ -96,8 +97,22 @@ var POLL_MS = 1000;
|
|
|
96
97
|
function fmtTokens(v) {
|
|
97
98
|
return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v);
|
|
98
99
|
}
|
|
100
|
+
function fmtMoney(v) {
|
|
101
|
+
return `$${v.toFixed(2)}`;
|
|
102
|
+
}
|
|
103
|
+
function keyLine(k, s) {
|
|
104
|
+
const spend = s.spendToday?.[k.alias] ?? 0;
|
|
105
|
+
let line = `${k.alias} ${fmtMoney(spend)}/${fmtMoney(s.dailyBudgetUsd ?? 0)}`;
|
|
106
|
+
if (k.invalid)
|
|
107
|
+
line += " invalid";
|
|
108
|
+
else if (k.blockedBy === "budget")
|
|
109
|
+
line += " budget";
|
|
110
|
+
else if (k.blockedBy === "rate")
|
|
111
|
+
line += " rate";
|
|
112
|
+
return line;
|
|
113
|
+
}
|
|
99
114
|
function RatesLine(props) {
|
|
100
|
-
const [
|
|
115
|
+
const [status, setStatus] = createSignal(null);
|
|
101
116
|
let busy = false;
|
|
102
117
|
const poll = async () => {
|
|
103
118
|
if (busy)
|
|
@@ -106,11 +121,10 @@ function RatesLine(props) {
|
|
|
106
121
|
try {
|
|
107
122
|
const res = await daemonRequest(props.paths, (u, i) => fetch(u, i), "/v1/_status");
|
|
108
123
|
if (!res?.ok) {
|
|
109
|
-
|
|
124
|
+
setStatus(null);
|
|
110
125
|
return;
|
|
111
126
|
}
|
|
112
|
-
|
|
113
|
-
setRates(j.rates ?? null);
|
|
127
|
+
setStatus(await res.json());
|
|
114
128
|
} finally {
|
|
115
129
|
busy = false;
|
|
116
130
|
}
|
|
@@ -122,19 +136,30 @@ function RatesLine(props) {
|
|
|
122
136
|
});
|
|
123
137
|
onCleanup(() => clearInterval(timer));
|
|
124
138
|
const label = () => {
|
|
125
|
-
const r =
|
|
139
|
+
const r = status()?.rates;
|
|
126
140
|
if (!r)
|
|
127
141
|
return null;
|
|
128
142
|
return `UFR ${r.reqPerSec} req/s \xB7 ${fmtTokens(r.tokensOutPerSec)} tok/s out \xB7 ${fmtTokens(r.tokensInPerSec)} tok/s in`;
|
|
129
143
|
};
|
|
130
144
|
return _$createComponent(Show, {
|
|
131
145
|
get when() {
|
|
132
|
-
return
|
|
146
|
+
return status();
|
|
133
147
|
},
|
|
134
148
|
get children() {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
return [_$createComponent(For, {
|
|
150
|
+
get each() {
|
|
151
|
+
return status().keys ?? [];
|
|
152
|
+
},
|
|
153
|
+
children: (k) => (() => {
|
|
154
|
+
var _el$2 = _$createElement("text");
|
|
155
|
+
_$insert(_el$2, () => keyLine(k, status()));
|
|
156
|
+
return _el$2;
|
|
157
|
+
})()
|
|
158
|
+
}), (() => {
|
|
159
|
+
var _el$ = _$createElement("text");
|
|
160
|
+
_$insert(_el$, label);
|
|
161
|
+
return _el$;
|
|
162
|
+
})()];
|
|
138
163
|
}
|
|
139
164
|
});
|
|
140
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ufr",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
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
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
import { startOfLocalDay } from "../shared/time"
|
|
1
2
|
import { SlidingWindow } from "./window"
|
|
2
3
|
|
|
3
4
|
export type KeyInfo = { alias: string; secret: string }
|
|
4
5
|
export type Acquired = { kind: "ok"; alias: string; secret: string; waitMs: number; at: number }
|
|
5
6
|
export type NotAcquired = { kind: "none"; reason: "no_keys" | "all_tried" | "exhausted"; retryAfterMs: number }
|
|
6
|
-
export type KeySnapshot = {
|
|
7
|
+
export type KeySnapshot = {
|
|
8
|
+
alias: string
|
|
9
|
+
used: number
|
|
10
|
+
cap: number
|
|
11
|
+
blockedForMs: number
|
|
12
|
+
/** Why the key is blocked right now: a daily-budget 429 or a bucket 429; null when usable. */
|
|
13
|
+
blockedBy: "budget" | "rate" | null
|
|
14
|
+
invalid: boolean
|
|
15
|
+
}
|
|
7
16
|
|
|
8
17
|
/** A 401 can be transient (portal hiccup, short-lived token rotation): the key
|
|
9
18
|
* is skipped for this long, then retried — instead of being dead until restart. */
|
|
@@ -12,6 +21,8 @@ export const INVALID_KEY_TTL_MS = 10 * 60_000
|
|
|
12
21
|
type Slot = KeyInfo & {
|
|
13
22
|
window: SlidingWindow
|
|
14
23
|
blockedUntil: number
|
|
24
|
+
/** What blocked the key: a daily-budget 429 or a bucket 429 (null until one fires). */
|
|
25
|
+
blockedBy: "budget" | "rate" | null
|
|
15
26
|
/** Until when the key is considered invalid (0 = valid); a transient 401 expires. */
|
|
16
27
|
invalidUntil: number
|
|
17
28
|
recent429: { at: number; model: string }[]
|
|
@@ -29,6 +40,7 @@ export class KeyPool {
|
|
|
29
40
|
...k,
|
|
30
41
|
window: new SlidingWindow({ cap: o.cap, windowMs: o.windowMs, maxWaitMs: o.maxWaitMs, now: o.now }),
|
|
31
42
|
blockedUntil: 0,
|
|
43
|
+
blockedBy: null,
|
|
32
44
|
invalidUntil: 0,
|
|
33
45
|
recent429: [],
|
|
34
46
|
}))
|
|
@@ -92,10 +104,34 @@ export class KeyPool {
|
|
|
92
104
|
const acrossModels = new Set(s.recent429.map((e) => e.model)).size >= 2
|
|
93
105
|
if (usedHalf || acrossModels) {
|
|
94
106
|
// UFR frees a slot at first admission + window; rejected calls do not count.
|
|
95
|
-
|
|
107
|
+
const until = Math.max(s.blockedUntil, (s.window.oldest() ?? now) + this.o.windowMs, now + 1_000)
|
|
108
|
+
// Only the block that actually extends the deadline owns the cause: a rate
|
|
109
|
+
// block on top of a longer budget block must not relabel it "rate".
|
|
110
|
+
if (until > s.blockedUntil) {
|
|
111
|
+
s.blockedUntil = until
|
|
112
|
+
s.blockedBy = "rate"
|
|
113
|
+
}
|
|
96
114
|
}
|
|
97
115
|
}
|
|
98
116
|
|
|
117
|
+
/**
|
|
118
|
+
* UFR's daily-budget 429 (the `budget_exceeded` marker) means this key's $ cap
|
|
119
|
+
* is spent for the day: block it until the next local midnight — UFR's own
|
|
120
|
+
* reset time is unknown, and midnight is the same assumption the spend-today
|
|
121
|
+
* stats already make. A bucket 429 carries the identical body (2026-10-07), so
|
|
122
|
+
* a merely rate-limited key can end up budget-blocked; that only costs until
|
|
123
|
+
* midnight and is safe. Never shortens an existing block.
|
|
124
|
+
*/
|
|
125
|
+
onBudgetExhausted(alias: string): void {
|
|
126
|
+
const s = this.find(alias)
|
|
127
|
+
if (!s) return
|
|
128
|
+
const now = this.o.now()
|
|
129
|
+
// startOfLocalDay(now) at exactly midnight would be now itself — +24h always
|
|
130
|
+
// lands in the future, so the block can never be already expired.
|
|
131
|
+
s.blockedUntil = Math.max(s.blockedUntil, startOfLocalDay(now) + 24 * 3_600_000)
|
|
132
|
+
s.blockedBy = "budget"
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
onInvalid(alias: string): void {
|
|
100
136
|
const s = this.find(alias)
|
|
101
137
|
if (s) s.invalidUntil = this.o.now() + INVALID_KEY_TTL_MS
|
|
@@ -108,6 +144,10 @@ export class KeyPool {
|
|
|
108
144
|
used: s.window.inWindow(),
|
|
109
145
|
cap: this.o.cap,
|
|
110
146
|
blockedForMs: Math.max(0, s.blockedUntil - now),
|
|
147
|
+
// The cause is derived from the current time, not stored state: a block
|
|
148
|
+
// whose deadline has passed reports as unblocked even if the slot still
|
|
149
|
+
// carries a stale blockedBy.
|
|
150
|
+
blockedBy: s.blockedUntil > now ? s.blockedBy : null,
|
|
111
151
|
invalid: s.invalidUntil > now,
|
|
112
152
|
}))
|
|
113
153
|
}
|
package/src/daemon/router.ts
CHANGED
|
@@ -238,8 +238,12 @@ export class Router {
|
|
|
238
238
|
return Response.json(json)
|
|
239
239
|
}
|
|
240
240
|
case "rate_limited":
|
|
241
|
+
// Same body as a bucket 429 — the `budget_exceeded` marker is the only
|
|
242
|
+
// tell (text/plain, 2026-09-28); a merely rate-limited key that carries
|
|
243
|
+
// it is budget-blocked until midnight, which is safe.
|
|
244
|
+
if (r.body.includes("budget_exceeded")) this.d.keys.onBudgetExhausted(k.alias)
|
|
245
|
+
else this.d.keys.onRateLimited(k.alias, group)
|
|
241
246
|
// A wall on the exact model the script asked for — honest breaker signal.
|
|
242
|
-
this.d.keys.onRateLimited(k.alias, group)
|
|
243
247
|
this.d.breakers.get(group).onRateLimited()
|
|
244
248
|
this.record(t0, group, k.alias, 429, null, 1, "upstream_rate_limited", true)
|
|
245
249
|
return new Response(r.body, { status: 429, headers: { "content-type": "application/json" } })
|
|
@@ -318,8 +322,15 @@ export class Router {
|
|
|
318
322
|
if (k.reason === "exhausted") {
|
|
319
323
|
breakers.get(model).onOtherFailure() // model's probe (if any) got no outcome
|
|
320
324
|
if (rateLimitedHere) giveUp(model) // the wall counts toward the breaker threshold even here
|
|
325
|
+
// The two messages tell the user very different remedies: wait a minute
|
|
326
|
+
// 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
|
+
const budgeted = keys.snapshot().some((s) => s.blockedBy === "budget")
|
|
321
329
|
return fail(429, "key_pool_exhausted",
|
|
322
|
-
|
|
330
|
+
budgeted
|
|
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`,
|
|
333
|
+
k.retryAfterMs)
|
|
323
334
|
}
|
|
324
335
|
// all_tried: every usable key already failed on this model
|
|
325
336
|
if (rateLimitedHere) giveUp(model)
|
|
@@ -376,7 +387,12 @@ export class Router {
|
|
|
376
387
|
this.d.onUpstream?.(true, "")
|
|
377
388
|
return { ok: true, response: r.response, model, keyAlias: k.alias, attempts, upstreamAbort: upstreamAbort ?? undefined }
|
|
378
389
|
case "rate_limited":
|
|
379
|
-
|
|
390
|
+
// UFR's daily-budget 429 and its bucket 429 share one body — the
|
|
391
|
+
// `budget_exceeded` marker is the only tell (text/plain, 2026-09-28).
|
|
392
|
+
// A bucket 429 carrying it budget-blocks a merely rate-limited key:
|
|
393
|
+
// acceptable, that clears at midnight too.
|
|
394
|
+
if (r.body.includes("budget_exceeded")) keys.onBudgetExhausted(k.alias)
|
|
395
|
+
else keys.onRateLimited(k.alias, model)
|
|
380
396
|
rateLimitedHere = true
|
|
381
397
|
if (tried.size >= Math.min(2, keys.size)) {
|
|
382
398
|
giveUp(model)
|
package/src/plugin/tui.tsx
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* opencode's CLI because package.json exports "./tui" — the server plugin
|
|
4
4
|
* (index.ts) needs no changes for this to load.
|
|
5
5
|
*
|
|
6
|
-
* Polls GET /v1/_status every second and appends
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Polls GET /v1/_status every second and appends lines to the right panel:
|
|
7
|
+
* one compact line per pool key (alias, $ spent today, daily budget in USD,
|
|
8
|
+
* why it is blocked) above the req/s and tok/s line over the gateway's rolling 60 s
|
|
9
|
+
* window. Status polls don't reset the daemon's idle timer (see server.ts),
|
|
10
|
+
* so leaving the panel open never keeps the gateway alive.
|
|
10
11
|
*/
|
|
11
12
|
import { Plugin } from "@opencode/plugin/tui"
|
|
12
|
-
import { createSignal, onCleanup, onMount
|
|
13
|
+
import { For, Show, createSignal, onCleanup, onMount } from "solid-js"
|
|
13
14
|
import { daemonRequest } from "../shared/daemon-client"
|
|
14
15
|
import { resolvePaths, type Paths } from "../shared/paths"
|
|
15
16
|
|
|
@@ -22,6 +23,24 @@ type Rates = {
|
|
|
22
23
|
tokensOutPerSec: number
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Per-key line of GET /v1/_status (KeySnapshot in keypool.ts). */
|
|
27
|
+
type KeyStatus = {
|
|
28
|
+
alias: string
|
|
29
|
+
used: number
|
|
30
|
+
cap: number
|
|
31
|
+
blockedForMs: number
|
|
32
|
+
blockedBy: "budget" | "rate" | null
|
|
33
|
+
invalid: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** GET /v1/_status — only the fields the sidebar consumes. */
|
|
37
|
+
type Status = {
|
|
38
|
+
rates?: Rates
|
|
39
|
+
keys?: KeyStatus[]
|
|
40
|
+
spendToday?: Record<string, number>
|
|
41
|
+
dailyBudgetUsd?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
const POLL_MS = 1_000
|
|
26
45
|
|
|
27
46
|
/** 6743 → "6.7k" — prompt traffic through opencode dwarfs output. */
|
|
@@ -29,8 +48,29 @@ function fmtTokens(v: number): string {
|
|
|
29
48
|
return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v)
|
|
30
49
|
}
|
|
31
50
|
|
|
51
|
+
/** 0.4166 → "$0.42" — money is always shown with 2 decimals. */
|
|
52
|
+
function fmtMoney(v: number): string {
|
|
53
|
+
return `$${v.toFixed(2)}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One compact line per pool key: `k1 $0.42/$20.00`, with the reason appended when
|
|
58
|
+
* the key is unusable — `budget` (daily cap hit, resets at local midnight),
|
|
59
|
+
* `rate` (bucket 429 cool-down) or `invalid` (transient 401). A key with no
|
|
60
|
+
* entry in spendToday renders as $0.00; healthy keys render too, the user
|
|
61
|
+
* asked to see every key.
|
|
62
|
+
*/
|
|
63
|
+
function keyLine(k: KeyStatus, s: Status): string {
|
|
64
|
+
const spend = s.spendToday?.[k.alias] ?? 0
|
|
65
|
+
let line = `${k.alias} ${fmtMoney(spend)}/${fmtMoney(s.dailyBudgetUsd ?? 0)}`
|
|
66
|
+
if (k.invalid) line += " invalid"
|
|
67
|
+
else if (k.blockedBy === "budget") line += " budget"
|
|
68
|
+
else if (k.blockedBy === "rate") line += " rate"
|
|
69
|
+
return line
|
|
70
|
+
}
|
|
71
|
+
|
|
32
72
|
function RatesLine(props: { paths: Paths }) {
|
|
33
|
-
const [
|
|
73
|
+
const [status, setStatus] = createSignal<Status | null>(null)
|
|
34
74
|
let busy = false
|
|
35
75
|
const poll = async () => {
|
|
36
76
|
if (busy) return
|
|
@@ -38,11 +78,10 @@ function RatesLine(props: { paths: Paths }) {
|
|
|
38
78
|
try {
|
|
39
79
|
const res = await daemonRequest(props.paths, (u, i) => fetch(u, i), "/v1/_status")
|
|
40
80
|
if (!res?.ok) {
|
|
41
|
-
|
|
81
|
+
setStatus(null) // daemon down, restarting or new token — the lines hide
|
|
42
82
|
return
|
|
43
83
|
}
|
|
44
|
-
|
|
45
|
-
setRates(j.rates ?? null)
|
|
84
|
+
setStatus((await res.json()) as Status)
|
|
46
85
|
} finally {
|
|
47
86
|
busy = false
|
|
48
87
|
}
|
|
@@ -54,12 +93,13 @@ function RatesLine(props: { paths: Paths }) {
|
|
|
54
93
|
})
|
|
55
94
|
onCleanup(() => clearInterval(timer))
|
|
56
95
|
const label = () => {
|
|
57
|
-
const r =
|
|
96
|
+
const r = status()?.rates
|
|
58
97
|
if (!r) return null
|
|
59
98
|
return `UFR ${r.reqPerSec} req/s · ${fmtTokens(r.tokensOutPerSec)} tok/s out · ${fmtTokens(r.tokensInPerSec)} tok/s in`
|
|
60
99
|
}
|
|
61
100
|
return (
|
|
62
|
-
<Show when={
|
|
101
|
+
<Show when={status()}>
|
|
102
|
+
<For each={status()!.keys ?? []}>{(k) => <text>{keyLine(k, status()!)}</text>}</For>
|
|
63
103
|
<text>{label()}</text>
|
|
64
104
|
</Show>
|
|
65
105
|
)
|