opencode-ufr 0.2.10 → 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 ADDED
@@ -0,0 +1,180 @@
1
+ // @bun
2
+ // src/plugin/tui.staged.build.js
3
+ import { insert as _$insert } from "@opentui/solid";
4
+ import { createElement as _$createElement } from "@opentui/solid";
5
+ import { createComponent as _$createComponent } from "@opentui/solid";
6
+ import { memo as _$memo } from "@opentui/solid";
7
+ import { Plugin } from "@opencode/plugin/tui";
8
+ import { For, Show, createSignal, onCleanup, onMount } from "solid-js";
9
+
10
+ // src/shared/fs.ts
11
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
12
+ async function readText(file) {
13
+ try {
14
+ return await readFile(file, "utf8");
15
+ } catch (e) {
16
+ if (e.code === "ENOENT")
17
+ return null;
18
+ throw e;
19
+ }
20
+ }
21
+ async function readJson(file) {
22
+ const text = await readText(file);
23
+ if (text === null)
24
+ return null;
25
+ try {
26
+ return JSON.parse(text);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ // src/shared/daemon-client.ts
33
+ async function readDaemonInfo(paths) {
34
+ const j = await readJson(paths.daemonFile);
35
+ if (j === null || typeof j.port !== "number" || typeof j.version !== "string")
36
+ return null;
37
+ return { port: j.port, pid: typeof j.pid === "number" ? j.pid : 0, version: j.version };
38
+ }
39
+ async function daemonRequest(paths, f, path, method = "GET") {
40
+ const info = await readDaemonInfo(paths);
41
+ const token = (await readText(paths.tokenFile))?.trim();
42
+ if (!info?.port || !token)
43
+ return null;
44
+ try {
45
+ return await f(`http://127.0.0.1:${info.port}${path}`, {
46
+ method,
47
+ headers: { Authorization: `Bearer ${token}` },
48
+ signal: AbortSignal.timeout(5000)
49
+ });
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ // src/shared/paths.ts
56
+ import { homedir } from "os";
57
+ import { join } from "path";
58
+ var APP = "opencode-ufr";
59
+ function resolvePaths(env = process.env, platform = process.platform, home = homedir()) {
60
+ let configDir, stateDir, cacheDir, dataDir;
61
+ if (env.OPENCODE_UFR_HOME) {
62
+ const base = env.OPENCODE_UFR_HOME;
63
+ configDir = join(base, "config");
64
+ stateDir = join(base, "state");
65
+ cacheDir = join(base, "cache");
66
+ dataDir = join(base, "data");
67
+ } else if (platform === "win32") {
68
+ const roaming = env.APPDATA ?? join(home, "AppData", "Roaming");
69
+ const local = env.LOCALAPPDATA ?? join(home, "AppData", "Local");
70
+ configDir = join(roaming, APP);
71
+ stateDir = join(local, APP, "state");
72
+ cacheDir = join(local, APP, "cache");
73
+ dataDir = join(local, APP, "data");
74
+ } else {
75
+ configDir = join(env.XDG_CONFIG_HOME ?? join(home, ".config"), APP);
76
+ stateDir = join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), APP);
77
+ cacheDir = join(env.XDG_CACHE_HOME ?? join(home, ".cache"), APP);
78
+ dataDir = join(env.XDG_DATA_HOME ?? join(home, ".local", "share"), APP);
79
+ }
80
+ return {
81
+ configDir,
82
+ stateDir,
83
+ cacheDir,
84
+ dataDir,
85
+ configFile: join(configDir, "config.json"),
86
+ daemonFile: join(stateDir, "daemon.json"),
87
+ tokenFile: join(stateDir, "token"),
88
+ lockFile: join(stateDir, "daemon.lock"),
89
+ logFile: join(stateDir, "daemon.log"),
90
+ statsDb: join(dataDir, "stats.db"),
91
+ ufrModelsCache: join(cacheDir, "ufr-models.json")
92
+ };
93
+ }
94
+
95
+ // src/plugin/tui.staged.build.js
96
+ var POLL_MS = 1000;
97
+ function fmtTokens(v) {
98
+ return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v);
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
+ }
114
+ function RatesLine(props) {
115
+ const [status, setStatus] = createSignal(null);
116
+ let busy = false;
117
+ const poll = async () => {
118
+ if (busy)
119
+ return;
120
+ busy = true;
121
+ try {
122
+ const res = await daemonRequest(props.paths, (u, i) => fetch(u, i), "/v1/_status");
123
+ if (!res?.ok) {
124
+ setStatus(null);
125
+ return;
126
+ }
127
+ setStatus(await res.json());
128
+ } finally {
129
+ busy = false;
130
+ }
131
+ };
132
+ let timer;
133
+ onMount(() => {
134
+ poll();
135
+ timer = setInterval(() => void poll(), POLL_MS);
136
+ });
137
+ onCleanup(() => clearInterval(timer));
138
+ const label = () => {
139
+ const r = status()?.rates;
140
+ if (!r)
141
+ return null;
142
+ return `UFR ${r.reqPerSec} req/s \xB7 ${fmtTokens(r.tokensOutPerSec)} tok/s out \xB7 ${fmtTokens(r.tokensInPerSec)} tok/s in`;
143
+ };
144
+ return _$createComponent(Show, {
145
+ get when() {
146
+ return status();
147
+ },
148
+ get children() {
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
+ })()];
163
+ }
164
+ });
165
+ }
166
+ var tui_staged_build_default = Plugin.define({
167
+ id: "opencode-ufr.stats",
168
+ setup(context) {
169
+ const paths = resolvePaths();
170
+ return context.ui.slot({
171
+ append: "sidebar.content",
172
+ render: () => _$createComponent(RatesLine, {
173
+ paths
174
+ })
175
+ });
176
+ }
177
+ });
178
+ export {
179
+ tui_staged_build_default as default
180
+ };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "opencode-ufr",
3
- "version": "0.2.10",
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",
7
7
  "main": "./src/plugin/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/plugin/index.ts",
10
- "./tui": "./src/plugin/tui.tsx"
10
+ "./tui": "./dist/tui.js"
11
11
  },
12
12
  "dependencies": {
13
13
  "@opencode/plugin": "^2.0.23"
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "files": [
21
21
  "src",
22
+ "dist",
22
23
  "skills",
23
24
  "models.json",
24
25
  "README.md",
@@ -38,14 +39,18 @@
38
39
  "open-webui"
39
40
  ],
40
41
  "scripts": {
42
+ "build:tui": "bun scripts/build-tui.ts",
41
43
  "test": "bun test",
42
44
  "typecheck": "tsc --noEmit",
43
- "prepublishOnly": "bun run typecheck && bun test"
45
+ "prepublishOnly": "bun run build:tui && bun run typecheck && bun test"
44
46
  },
45
47
  "devDependencies": {
48
+ "@babel/core": "7.28.0",
49
+ "@babel/preset-typescript": "7.27.1",
46
50
  "@opentui/core": "^0.5.14",
47
51
  "@opentui/solid": "^0.5.14",
48
52
  "@types/bun": "^1.3.0",
53
+ "babel-preset-solid": "1.9.12",
49
54
  "solid-js": "^1.9.15",
50
55
  "typescript": "^5.9.0"
51
56
  }
@@ -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 = { 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
+ 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
- s.blockedUntil = Math.max(s.blockedUntil, (s.window.oldest() ?? now) + this.o.windowMs, now + 1_000)
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
  }
@@ -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
- `every UFR key is at its limit of ${config.limits.keyRpm} requests per ${config.limits.keyWindowS} s`, k.retryAfterMs)
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
- keys.onRateLimited(k.alias, model)
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)
@@ -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 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.
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, Show } from "solid-js"
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 [rates, setRates] = createSignal<Rates | null>(null)
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
- setRates(null) // daemon down, restarting or new token — the line hides
81
+ setStatus(null) // daemon down, restarting or new token — the lines hide
42
82
  return
43
83
  }
44
- const j = (await res.json()) as { rates?: Rates }
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 = rates()
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={label()}>
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
  )