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 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
- let line = `${k.alias} ${fmtMoney(spend)}/${fmtMoney(s.dailyBudgetUsd ?? 0)}`;
106
- if (k.invalid)
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
- 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`;
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-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.12",
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",
@@ -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 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
+ * 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
  }
@@ -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
- const budgeted = keys.snapshot().some((s) => s.blockedBy === "budget")
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
- 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`,
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
@@ -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,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, 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),
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 with no
60
- * entry in spendToday renders as $0.00; healthy keys render too, the user
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
- const spend = s.spendToday?.[k.alias] ?? 0
65
- let line = `${k.alias} ${fmtMoney(spend)}/${fmtMoney(s.dailyBudgetUsd ?? 0)}`
66
- if (k.invalid) line += " invalid"
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
- 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`
99
125
  }
100
126
  return (
101
127
  <Show when={status()}>
102
- <For each={status()!.keys ?? []}>{(k) => <text>{keyLine(k, status()!)}</text>}</For>
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
  )