oc-plugin-litellm-budget 0.4.0 → 0.6.0

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/tui.tsx +63 -37
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "oc-plugin-litellm-budget",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
5
  "description": "Display LiteLLM budget usage in OpenCode TUI sidebar and footer",
6
6
  "type": "module",
7
7
  "exports": {
package/tui.tsx CHANGED
@@ -1,9 +1,6 @@
1
1
  // @ts-nocheck
2
2
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
3
  import { createSignal, onCleanup, Show } from "solid-js"
4
- import { readFileSync } from "fs"
5
- import { join } from "path"
6
- import { homedir } from "os"
7
4
 
8
5
  const POLL_MS = 60_000
9
6
 
@@ -12,32 +9,53 @@ type QuotaData = {
12
9
  resetAt: string
13
10
  rpm: number
14
11
  tpm: number
12
+ periodDays: number
13
+ daysIn: number
15
14
  }
16
15
 
17
- const readKeyFromConfig = (): string => {
18
- const paths = [
19
- join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode", "opencode.json"),
20
- join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode", "opencode.jsonc"),
21
- ]
22
- for (const p of paths) {
23
- try {
24
- const raw = readFileSync(p, "utf8")
25
- const clean = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "")
26
- const d = JSON.parse(clean)
27
- const k = d?.provider?.binetz?.options?.apiKey
28
- if (k && typeof k === "string" && k !== "{env:BINETZ_REMOTE_CONFIG_TOKEN}") return k
29
- } catch {}
30
- }
16
+ const readKeyFromConfig = async (): Promise<string> => {
17
+ try {
18
+ const home = process.env.HOME || process.env.USERPROFILE || ""
19
+ if (!home) return ""
20
+ const base = process.env.XDG_CONFIG_HOME || `${home}/.config`
21
+ for (const name of ["opencode.json", "opencode.jsonc"]) {
22
+ try {
23
+ const raw = await Bun.file(`${base}/opencode/${name}`).text()
24
+ if (!raw) continue
25
+ const d = JSON.parse(raw)
26
+ const k = d?.provider?.binetz?.options?.apiKey
27
+ if (k && typeof k === "string" && !k.startsWith("{env:")) return k
28
+ } catch {
29
+ try {
30
+ const raw = await Bun.file(`${base}/opencode/${name}`).text()
31
+ const clean = raw.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1")
32
+ const d = JSON.parse(clean)
33
+ const k = d?.provider?.binetz?.options?.apiKey
34
+ if (k && typeof k === "string" && !k.startsWith("{env:")) return k
35
+ } catch {}
36
+ }
37
+ }
38
+ } catch {}
31
39
  return ""
32
40
  }
33
41
 
34
- const getConfig = () => {
42
+ const getConfig = async () => {
35
43
  const envKey = process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || process.env.BINETZ_REMOTE_CONFIG_TOKEN || ""
36
- const key = envKey || readKeyFromConfig()
44
+ const key = envKey || await readKeyFromConfig()
37
45
  const url = process.env.LITELLM_BASE_URL || process.env.OPENAI_BASE_URL || (key ? "https://litellm.binetz.com" : "")
38
46
  return { url: url.replace(/\/+$/, ""), key }
39
47
  }
40
48
 
49
+ const parseDurationDays = (d: string): number => {
50
+ if (!d) return 7
51
+ const m = d.match(/^(\d+)([dhms])$/)
52
+ if (!m) return 7
53
+ const n = parseInt(m[1], 10)
54
+ if (m[2] === "d") return n
55
+ if (m[2] === "h") return Math.max(1, Math.round(n / 24))
56
+ return 7
57
+ }
58
+
41
59
  const fetchQuota = async (url: string, key: string): Promise<QuotaData | null> => {
42
60
  if (!url || !key) return null
43
61
  try {
@@ -52,11 +70,17 @@ const fetchQuota = async (url: string, key: string): Promise<QuotaData | null> =
52
70
  const bt = info.litellm_budget_table || {}
53
71
  const max = bt.max_budget ?? 0
54
72
  const spend = info.spend ?? 0
73
+ const resetAt = bt.budget_reset_at ?? ""
74
+ const periodDays = bt.budget_duration ? parseDurationDays(bt.budget_duration) : 7
75
+ const daysLeft = resetAt ? Math.max(0, Math.ceil((new Date(resetAt).getTime() - Date.now()) / 86_400_000)) : 0
76
+ const daysIn = Math.max(1, periodDays - daysLeft + 1)
55
77
  return {
56
78
  usedPct: max > 0 ? Math.min(100, Math.round((spend / max) * 100)) : 0,
57
- resetAt: bt.budget_reset_at ?? "",
79
+ resetAt,
58
80
  rpm: info.rpm_limit ?? 0,
59
81
  tpm: info.tpm_limit ?? 0,
82
+ periodDays,
83
+ daysIn: Math.min(daysIn, periodDays),
60
84
  }
61
85
  } catch {
62
86
  return null
@@ -76,12 +100,14 @@ const fmtTpm = (n: number): string =>
76
100
  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(0)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(0)}K` : `${n}`
77
101
 
78
102
  const useQuota = () => {
79
- const { url, key } = getConfig()
80
103
  const [data, setData] = createSignal<QuotaData | null>(null)
81
- const [err, setErr] = createSignal(!url || !key)
104
+ const [err, setErr] = createSignal(false)
105
+ const [configured, setConfigured] = createSignal(true)
82
106
 
83
107
  const poll = async () => {
84
- if (!url || !key) { setErr(true); return }
108
+ const { url, key } = await getConfig()
109
+ if (!url || !key) { setConfigured(false); setErr(true); return }
110
+ setConfigured(true)
85
111
  const q = await fetchQuota(url, key)
86
112
  if (q) { setData(q); setErr(false) }
87
113
  else setErr(true)
@@ -91,7 +117,7 @@ const useQuota = () => {
91
117
  const timer = setInterval(poll, POLL_MS)
92
118
  onCleanup(() => clearInterval(timer))
93
119
 
94
- return { data, err, configured: !!url && !!key }
120
+ return { data, err, configured }
95
121
  }
96
122
 
97
123
  const colorForPct = (pct: number, theme: any) =>
@@ -103,7 +129,7 @@ const Sidebar = (props: { theme: any }) => {
103
129
  return (
104
130
  <box paddingTop={1} width="100%" flexDirection="column">
105
131
  <text bold fg={props.theme.current.primary}>Quota</text>
106
- <Show when={configured} fallback={
132
+ <Show when={configured()} fallback={
107
133
  <text fg={props.theme.current.textMuted}>Not configured</text>
108
134
  }>
109
135
  <Show when={!err()} fallback={
@@ -114,16 +140,16 @@ const Sidebar = (props: { theme: any }) => {
114
140
  const free = () => 100 - q().usedPct
115
141
  const days = () => daysUntil(q().resetAt)
116
142
  const color = () => colorForPct(q().usedPct, props.theme)
117
- return (
118
- <box flexDirection="column">
119
- <text fg={color()}>{bar(q().usedPct, 10)} {free()}% libre</text>
120
- <text fg={props.theme.current.textMuted}>Reset {days()}d</text>
121
- <Show when={q().rpm > 0 || q().tpm > 0}>
122
- <text fg={props.theme.current.textMuted}>
123
- {q().rpm > 0 ? `${q().rpm} rpm` : ""}{q().rpm > 0 && q().tpm > 0 ? " | " : ""}{q().tpm > 0 ? `${fmtTpm(q().tpm)} tpm` : ""}
124
- </text>
125
- </Show>
126
- </box>
143
+ return (
144
+ <box flexDirection="column">
145
+ <text fg={color()}>{bar(q().usedPct, 10)} {free()}% libre</text>
146
+ <text fg={props.theme.current.textMuted}>Dia {q().daysIn}/{q().periodDays} | Reset {days()}d</text>
147
+ <Show when={q().rpm > 0 || q().tpm > 0}>
148
+ <text fg={props.theme.current.textMuted}>
149
+ {q().rpm > 0 ? `${q().rpm} rpm` : ""}{q().rpm > 0 && q().tpm > 0 ? " | " : ""}{q().tpm > 0 ? `${fmtTpm(q().tpm)} tpm` : ""}
150
+ </text>
151
+ </Show>
152
+ </box>
127
153
  )
128
154
  }}
129
155
  </Show>
@@ -138,7 +164,7 @@ const FooterQuota = (props: { theme: any }) => {
138
164
 
139
165
  return (
140
166
  <box>
141
- <Show when={configured} fallback={
167
+ <Show when={configured()} fallback={
142
168
  <text fg={props.theme.current.textMuted}>Quota: not configured</text>
143
169
  }>
144
170
  <Show when={!err() && data()} fallback={
@@ -156,7 +182,7 @@ const FooterQuota = (props: { theme: any }) => {
156
182
  }
157
183
  return (
158
184
  <text fg={color()}>
159
- Quota: {free()}% libre [{bar(q().usedPct, 10)}] Reset {days()}d{limits()}
185
+ Quota: {free()}% libre [{bar(q().usedPct, 10)}] Dia {q().daysIn}/{q().periodDays} | Reset {days()}d{limits()}
160
186
  </text>
161
187
  )
162
188
  }}