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 CHANGED
@@ -1,10 +1,13 @@
1
1
  // @bun
2
2
  // src/plugin/tui.staged.build.js
3
- import { createComponent as _$createComponent } from "@opentui/solid";
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, Show } from "solid-js";
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 [rates, setRates] = createSignal(null);
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
- setRates(null);
134
+ setStatus(null);
110
135
  return;
111
136
  }
112
- const j = await res.json();
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 = rates();
149
+ const r = status()?.rates;
126
150
  if (!r)
127
151
  return null;
128
- return `UFR ${r.reqPerSec} req/s \xB7 ${fmtTokens(r.tokensOutPerSec)} tok/s out \xB7 ${fmtTokens(r.tokensInPerSec)} tok/s in`;
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 label();
157
+ return status();
133
158
  },
134
159
  get children() {
135
- var _el$ = _$createElement("text");
136
- _$insert(_el$, label);
137
- return _el$;
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-05",
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.11",
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",
@@ -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 = { alias: string; used: number; cap: number; blockedForMs: number; invalid: boolean }
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
- s.blockedUntil = Math.max(s.blockedUntil, (s.window.oldest() ?? now) + this.o.windowMs, now + 1_000)
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
  }
@@ -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`, k.retryAfterMs)
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
- keys.onRateLimited(k.alias, model)
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
@@ -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 }
@@ -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 one line to the right
7
- * panel: req/s and tok/s over the gateway's rolling 60 s window. Status
8
- * polls don't reset the daemon's idle timer (see server.ts), so leaving the
9
- * panel open never keeps the gateway alive.
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, Show } from "solid-js"
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 [rates, setRates] = createSignal<Rates | null>(null)
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
- setRates(null) // daemon down, restarting or new token — the line hides
103
+ setStatus(null) // daemon down, restarting or new token — the lines hide
42
104
  return
43
105
  }
44
- const j = (await res.json()) as { rates?: Rates }
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 = rates()
118
+ const r = status()?.rates
58
119
  if (!r) return null
59
- return `UFR ${r.reqPerSec} req/s · ${fmtTokens(r.tokensOutPerSec)} tok/s out · ${fmtTokens(r.tokensInPerSec)} tok/s in`
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={label()}>
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
  )