opencode-ufr 0.2.11 → 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 +49 -12
- package/models.json +33 -1
- package/package.json +1 -1
- package/src/daemon/keypool.ts +56 -2
- package/src/daemon/router.ts +43 -3
- package/src/daemon/upstream.ts +12 -0
- package/src/plugin/tui.tsx +82 -12
package/dist/tui.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/plugin/tui.staged.build.js
|
|
3
|
-
import {
|
|
3
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
4
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
4
5
|
import { insert as _$insert } from "@opentui/solid";
|
|
5
6
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
7
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
8
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
6
9
|
import { Plugin } from "@opencode/plugin/tui";
|
|
7
|
-
import { createSignal, onCleanup, onMount
|
|
10
|
+
import { For, Show, createSignal, onCleanup, onMount } from "solid-js";
|
|
8
11
|
|
|
9
12
|
// src/shared/fs.ts
|
|
10
13
|
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
@@ -96,8 +99,30 @@ var POLL_MS = 1000;
|
|
|
96
99
|
function fmtTokens(v) {
|
|
97
100
|
return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v);
|
|
98
101
|
}
|
|
102
|
+
function fmtMoney(v) {
|
|
103
|
+
return `$${v.toFixed(2)}`;
|
|
104
|
+
}
|
|
105
|
+
function keyLine(k, s) {
|
|
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)
|
|
112
|
+
line += " invalid";
|
|
113
|
+
else if (k.blockedBy === "budget")
|
|
114
|
+
line += " budget";
|
|
115
|
+
else if (k.blockedBy === "rate")
|
|
116
|
+
line += " rate";
|
|
117
|
+
if (k.budgetSpend !== undefined && k.invalid)
|
|
118
|
+
line += " invalid";
|
|
119
|
+
return line;
|
|
120
|
+
}
|
|
121
|
+
function keyColor(k) {
|
|
122
|
+
return k.blockedBy === "budget" ? "red" : undefined;
|
|
123
|
+
}
|
|
99
124
|
function RatesLine(props) {
|
|
100
|
-
const [
|
|
125
|
+
const [status, setStatus] = createSignal(null);
|
|
101
126
|
let busy = false;
|
|
102
127
|
const poll = async () => {
|
|
103
128
|
if (busy)
|
|
@@ -106,11 +131,10 @@ function RatesLine(props) {
|
|
|
106
131
|
try {
|
|
107
132
|
const res = await daemonRequest(props.paths, (u, i) => fetch(u, i), "/v1/_status");
|
|
108
133
|
if (!res?.ok) {
|
|
109
|
-
|
|
134
|
+
setStatus(null);
|
|
110
135
|
return;
|
|
111
136
|
}
|
|
112
|
-
|
|
113
|
-
setRates(j.rates ?? null);
|
|
137
|
+
setStatus(await res.json());
|
|
114
138
|
} finally {
|
|
115
139
|
busy = false;
|
|
116
140
|
}
|
|
@@ -122,19 +146,32 @@ function RatesLine(props) {
|
|
|
122
146
|
});
|
|
123
147
|
onCleanup(() => clearInterval(timer));
|
|
124
148
|
const label = () => {
|
|
125
|
-
const r =
|
|
149
|
+
const r = status()?.rates;
|
|
126
150
|
if (!r)
|
|
127
151
|
return null;
|
|
128
|
-
|
|
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`;
|
|
129
154
|
};
|
|
130
155
|
return _$createComponent(Show, {
|
|
131
156
|
get when() {
|
|
132
|
-
return
|
|
157
|
+
return status();
|
|
133
158
|
},
|
|
134
159
|
get children() {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
return [_$createComponent(For, {
|
|
161
|
+
get each() {
|
|
162
|
+
return status().keys ?? [];
|
|
163
|
+
},
|
|
164
|
+
children: (k) => (() => {
|
|
165
|
+
var _el$2 = _$createElement("text");
|
|
166
|
+
_$insert(_el$2, () => keyLine(k, status()));
|
|
167
|
+
_$effect((_$p) => _$setProp(_el$2, "fg", keyColor(k), _$p));
|
|
168
|
+
return _el$2;
|
|
169
|
+
})()
|
|
170
|
+
}), (() => {
|
|
171
|
+
var _el$ = _$createElement("text");
|
|
172
|
+
_$insert(_el$, label);
|
|
173
|
+
return _el$;
|
|
174
|
+
})()];
|
|
138
175
|
}
|
|
139
176
|
});
|
|
140
177
|
}
|
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
|
@@ -1,9 +1,21 @@
|
|
|
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
|
+
/** Parsed from UFR's budget body, only while budget-blocked and only when UFR named the numbers. */
|
|
15
|
+
budgetSpend?: number
|
|
16
|
+
budgetLimit?: number
|
|
17
|
+
invalid: boolean
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
/** A 401 can be transient (portal hiccup, short-lived token rotation): the key
|
|
9
21
|
* is skipped for this long, then retried — instead of being dead until restart. */
|
|
@@ -12,6 +24,11 @@ export const INVALID_KEY_TTL_MS = 10 * 60_000
|
|
|
12
24
|
type Slot = KeyInfo & {
|
|
13
25
|
window: SlidingWindow
|
|
14
26
|
blockedUntil: number
|
|
27
|
+
/** What blocked the key: a daily-budget 429 or a bucket 429 (null until one fires). */
|
|
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
|
|
15
32
|
/** Until when the key is considered invalid (0 = valid); a transient 401 expires. */
|
|
16
33
|
invalidUntil: number
|
|
17
34
|
recent429: { at: number; model: string }[]
|
|
@@ -29,6 +46,7 @@ export class KeyPool {
|
|
|
29
46
|
...k,
|
|
30
47
|
window: new SlidingWindow({ cap: o.cap, windowMs: o.windowMs, maxWaitMs: o.maxWaitMs, now: o.now }),
|
|
31
48
|
blockedUntil: 0,
|
|
49
|
+
blockedBy: null,
|
|
32
50
|
invalidUntil: 0,
|
|
33
51
|
recent429: [],
|
|
34
52
|
}))
|
|
@@ -92,10 +110,38 @@ export class KeyPool {
|
|
|
92
110
|
const acrossModels = new Set(s.recent429.map((e) => e.model)).size >= 2
|
|
93
111
|
if (usedHalf || acrossModels) {
|
|
94
112
|
// UFR frees a slot at first admission + window; rejected calls do not count.
|
|
95
|
-
|
|
113
|
+
const until = Math.max(s.blockedUntil, (s.window.oldest() ?? now) + this.o.windowMs, now + 1_000)
|
|
114
|
+
// Only the block that actually extends the deadline owns the cause: a rate
|
|
115
|
+
// block on top of a longer budget block must not relabel it "rate".
|
|
116
|
+
if (until > s.blockedUntil) {
|
|
117
|
+
s.blockedUntil = until
|
|
118
|
+
s.blockedBy = "rate"
|
|
119
|
+
}
|
|
96
120
|
}
|
|
97
121
|
}
|
|
98
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.
|
|
132
|
+
*/
|
|
133
|
+
onBudgetExhausted(alias: string, spend?: number, limit?: number): void {
|
|
134
|
+
const s = this.find(alias)
|
|
135
|
+
if (!s) return
|
|
136
|
+
const now = this.o.now()
|
|
137
|
+
// startOfLocalDay(now) at exactly midnight would be now itself — +24h always
|
|
138
|
+
// lands in the future, so the block can never be already expired.
|
|
139
|
+
s.blockedUntil = Math.max(s.blockedUntil, startOfLocalDay(now) + 24 * 3_600_000)
|
|
140
|
+
s.blockedBy = "budget"
|
|
141
|
+
s.budgetSpend = spend
|
|
142
|
+
s.budgetLimit = limit
|
|
143
|
+
}
|
|
144
|
+
|
|
99
145
|
onInvalid(alias: string): void {
|
|
100
146
|
const s = this.find(alias)
|
|
101
147
|
if (s) s.invalidUntil = this.o.now() + INVALID_KEY_TTL_MS
|
|
@@ -108,6 +154,14 @@ export class KeyPool {
|
|
|
108
154
|
used: s.window.inWindow(),
|
|
109
155
|
cap: this.o.cap,
|
|
110
156
|
blockedForMs: Math.max(0, s.blockedUntil - now),
|
|
157
|
+
// The cause is derived from the current time, not stored state: a block
|
|
158
|
+
// whose deadline has passed reports as unblocked even if the slot still
|
|
159
|
+
// carries a stale blockedBy. The parsed spend/limit are display values of
|
|
160
|
+
// the block — they must not outlive it either.
|
|
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
|
+
: {}),
|
|
111
165
|
invalid: s.invalidUntil > now,
|
|
112
166
|
}))
|
|
113
167
|
}
|
package/src/daemon/router.ts
CHANGED
|
@@ -238,11 +238,22 @@ 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" } })
|
|
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" } })
|
|
246
257
|
case "auth_invalid":
|
|
247
258
|
this.d.keys.onInvalid(k.alias)
|
|
248
259
|
this.d.breakers.get(group).onOtherFailure()
|
|
@@ -318,8 +329,23 @@ export class Router {
|
|
|
318
329
|
if (k.reason === "exhausted") {
|
|
319
330
|
breakers.get(model).onOtherFailure() // model's probe (if any) got no outcome
|
|
320
331
|
if (rateLimitedHere) giveUp(model) // the wall counts toward the breaker threshold even here
|
|
332
|
+
// The two messages tell the user very different remedies: wait a minute
|
|
333
|
+
// vs. wait for midnight. The pool alone cannot tell why a key is
|
|
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
|
+
}
|
|
321
346
|
return fail(429, "key_pool_exhausted",
|
|
322
|
-
`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`,
|
|
348
|
+
k.retryAfterMs)
|
|
323
349
|
}
|
|
324
350
|
// all_tried: every usable key already failed on this model
|
|
325
351
|
if (rateLimitedHere) giveUp(model)
|
|
@@ -376,7 +402,12 @@ export class Router {
|
|
|
376
402
|
this.d.onUpstream?.(true, "")
|
|
377
403
|
return { ok: true, response: r.response, model, keyAlias: k.alias, attempts, upstreamAbort: upstreamAbort ?? undefined }
|
|
378
404
|
case "rate_limited":
|
|
379
|
-
|
|
405
|
+
// UFR's daily-budget 429 and its bucket 429 share one body — the
|
|
406
|
+
// `budget_exceeded` marker is the only tell (text/plain, 2026-09-28).
|
|
407
|
+
// A bucket 429 carrying it budget-blocks a merely rate-limited key:
|
|
408
|
+
// acceptable, that clears at midnight too.
|
|
409
|
+
if (r.body.includes("budget_exceeded")) keys.onBudgetExhausted(k.alias)
|
|
410
|
+
else keys.onRateLimited(k.alias, model)
|
|
380
411
|
rateLimitedHere = true
|
|
381
412
|
if (tried.size >= Math.min(2, keys.size)) {
|
|
382
413
|
giveUp(model)
|
|
@@ -388,6 +419,15 @@ export class Router {
|
|
|
388
419
|
}
|
|
389
420
|
}
|
|
390
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
|
|
391
431
|
case "auth_invalid":
|
|
392
432
|
keys.onInvalid(k.alias)
|
|
393
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,27 +1,54 @@
|
|
|
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
|
-
* Polls GET /v1/_status every second and appends
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Polls GET /v1/_status every second and appends lines to the right panel:
|
|
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),
|
|
13
|
+
* so leaving the panel open never keeps the gateway alive.
|
|
10
14
|
*/
|
|
11
15
|
import { Plugin } from "@opencode/plugin/tui"
|
|
12
|
-
import { createSignal, onCleanup, onMount
|
|
16
|
+
import { For, Show, createSignal, onCleanup, onMount } from "solid-js"
|
|
13
17
|
import { daemonRequest } from "../shared/daemon-client"
|
|
14
18
|
import { resolvePaths, type Paths } from "../shared/paths"
|
|
15
19
|
|
|
16
20
|
/** The rates block of GET /v1/_status (see StatusJson in daemon.ts). */
|
|
17
21
|
type Rates = {
|
|
18
22
|
windowMs: number
|
|
23
|
+
/** Raw window counts — a 60 s window, so these ARE the per-minute numbers. */
|
|
19
24
|
requests: number
|
|
20
25
|
reqPerSec: number
|
|
21
26
|
tokensInPerSec: number
|
|
22
27
|
tokensOutPerSec: number
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
/** Per-key line of GET /v1/_status (KeySnapshot in keypool.ts). */
|
|
31
|
+
type KeyStatus = {
|
|
32
|
+
alias: string
|
|
33
|
+
used: number
|
|
34
|
+
cap: number
|
|
35
|
+
blockedForMs: number
|
|
36
|
+
blockedBy: "budget" | "rate" | null
|
|
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
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** GET /v1/_status — only the fields the sidebar consumes. */
|
|
45
|
+
type Status = {
|
|
46
|
+
rates?: Rates
|
|
47
|
+
keys?: KeyStatus[]
|
|
48
|
+
spendToday?: Record<string, number>
|
|
49
|
+
dailyBudgetUsd?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
25
52
|
const POLL_MS = 1_000
|
|
26
53
|
|
|
27
54
|
/** 6743 → "6.7k" — prompt traffic through opencode dwarfs output. */
|
|
@@ -29,8 +56,43 @@ function fmtTokens(v: number): string {
|
|
|
29
56
|
return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v)
|
|
30
57
|
}
|
|
31
58
|
|
|
59
|
+
/** 0.4166 → "$0.42" — money is always shown with 2 decimals. */
|
|
60
|
+
function fmtMoney(v: number): string {
|
|
61
|
+
return `$${v.toFixed(2)}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One compact line per pool key: `k1 $0.42/$20.00`, with the reason appended when
|
|
66
|
+
* the key is unusable — `budget` (daily cap hit, resets at local midnight),
|
|
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
|
|
72
|
+
* asked to see every key.
|
|
73
|
+
*/
|
|
74
|
+
function keyLine(k: KeyStatus, s: Status): string {
|
|
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"
|
|
82
|
+
else if (k.blockedBy === "budget") line += " budget"
|
|
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"
|
|
86
|
+
return line
|
|
87
|
+
}
|
|
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
|
+
|
|
32
94
|
function RatesLine(props: { paths: Paths }) {
|
|
33
|
-
const [
|
|
95
|
+
const [status, setStatus] = createSignal<Status | null>(null)
|
|
34
96
|
let busy = false
|
|
35
97
|
const poll = async () => {
|
|
36
98
|
if (busy) return
|
|
@@ -38,11 +100,10 @@ function RatesLine(props: { paths: Paths }) {
|
|
|
38
100
|
try {
|
|
39
101
|
const res = await daemonRequest(props.paths, (u, i) => fetch(u, i), "/v1/_status")
|
|
40
102
|
if (!res?.ok) {
|
|
41
|
-
|
|
103
|
+
setStatus(null) // daemon down, restarting or new token — the lines hide
|
|
42
104
|
return
|
|
43
105
|
}
|
|
44
|
-
|
|
45
|
-
setRates(j.rates ?? null)
|
|
106
|
+
setStatus((await res.json()) as Status)
|
|
46
107
|
} finally {
|
|
47
108
|
busy = false
|
|
48
109
|
}
|
|
@@ -54,12 +115,21 @@ function RatesLine(props: { paths: Paths }) {
|
|
|
54
115
|
})
|
|
55
116
|
onCleanup(() => clearInterval(timer))
|
|
56
117
|
const label = () => {
|
|
57
|
-
const r =
|
|
118
|
+
const r = status()?.rates
|
|
58
119
|
if (!r) return null
|
|
59
|
-
|
|
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`
|
|
60
125
|
}
|
|
61
126
|
return (
|
|
62
|
-
<Show when={
|
|
127
|
+
<Show when={status()}>
|
|
128
|
+
<For each={status()!.keys ?? []}>
|
|
129
|
+
{(k) => (
|
|
130
|
+
<text fg={keyColor(k)}>{keyLine(k, status()!)}</text>
|
|
131
|
+
)}
|
|
132
|
+
</For>
|
|
63
133
|
<text>{label()}</text>
|
|
64
134
|
</Show>
|
|
65
135
|
)
|