oc-plugin-litellm-budget 0.1.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 (3) hide show
  1. package/package.json +45 -0
  2. package/server.ts +5 -0
  3. package/tui.tsx +162 -0
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "oc-plugin-litellm-budget",
4
+ "version": "0.1.0",
5
+ "description": "Display LiteLLM budget usage in OpenCode TUI sidebar and footer",
6
+ "type": "module",
7
+ "exports": {
8
+ "./server": {
9
+ "import": "./server.ts",
10
+ "config": {
11
+ "enabled": true
12
+ }
13
+ },
14
+ "./tui": {
15
+ "import": "./tui.tsx",
16
+ "config": {
17
+ "enabled": true,
18
+ "sidebar": true,
19
+ "footer": true
20
+ }
21
+ }
22
+ },
23
+ "engines": {
24
+ "opencode": ">=1.3.13"
25
+ },
26
+ "peerDependencies": {
27
+ "@opencode-ai/plugin": "*",
28
+ "@opentui/core": "*",
29
+ "@opentui/solid": "*",
30
+ "solid-js": "*"
31
+ },
32
+ "keywords": [
33
+ "opencode",
34
+ "opencode-plugin",
35
+ "litellm",
36
+ "budget"
37
+ ],
38
+ "author": "Binetz",
39
+ "license": "MIT",
40
+ "files": [
41
+ "package.json",
42
+ "server.ts",
43
+ "tui.tsx"
44
+ ]
45
+ }
package/server.ts ADDED
@@ -0,0 +1,5 @@
1
+ // @ts-nocheck
2
+ import type { PluginModule } from "@opencode-ai/plugin"
3
+
4
+ const plugin: PluginModule = {}
5
+ export default plugin
package/tui.tsx ADDED
@@ -0,0 +1,162 @@
1
+ // @ts-nocheck
2
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
+ import { createSignal, onCleanup, Show } from "solid-js"
4
+
5
+ const POLL_MS = 60_000
6
+
7
+ type BudgetData = {
8
+ spend: number
9
+ max: number
10
+ resetAt: string
11
+ alias: string
12
+ }
13
+
14
+ const getConfig = () => {
15
+ const url = process.env.LITELLM_BASE_URL || process.env.OPENAI_BASE_URL || ""
16
+ const key = process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || ""
17
+ return { url: url.replace(/\/+$/, ""), key }
18
+ }
19
+
20
+ const fetchBudget = async (url: string, key: string): Promise<BudgetData | null> => {
21
+ if (!url || !key) return null
22
+ try {
23
+ const res = await fetch(`${url}/key/info`, {
24
+ headers: { Authorization: `Bearer ${key}` },
25
+ signal: AbortSignal.timeout(10_000),
26
+ })
27
+ if (!res.ok) return null
28
+ const d = await res.json()
29
+ const info = d?.info
30
+ if (!info) return null
31
+ const bt = info.litellm_budget_table || {}
32
+ return {
33
+ spend: info.spend ?? 0,
34
+ max: bt.max_budget ?? 0,
35
+ resetAt: info.budget_reset_at ?? "",
36
+ alias: info.key_alias ?? "",
37
+ }
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ const daysUntil = (iso: string): number => {
44
+ if (!iso) return 0
45
+ const ms = new Date(iso).getTime() - Date.now()
46
+ return isNaN(ms) ? 0 : Math.max(0, Math.ceil(ms / 86_400_000))
47
+ }
48
+
49
+ const pctUsed = (b: BudgetData): number => {
50
+ if (b.max <= 0) return 0
51
+ return Math.min(100, Math.round((b.spend / b.max) * 100))
52
+ }
53
+
54
+ const bar = (pct: number, w: number): string =>
55
+ "\u2588".repeat(Math.round((pct / 100) * w)) + "\u2591".repeat(w - Math.round((pct / 100) * w))
56
+
57
+ const useBudget = () => {
58
+ const { url, key } = getConfig()
59
+ const [data, setData] = createSignal<BudgetData | null>(null)
60
+ const [err, setErr] = createSignal(!url || !key)
61
+
62
+ const poll = async () => {
63
+ if (!url || !key) { setErr(true); return }
64
+ const b = await fetchBudget(url, key)
65
+ if (b) { setData(b); setErr(false) }
66
+ else setErr(true)
67
+ }
68
+
69
+ poll()
70
+ const timer = setInterval(poll, POLL_MS)
71
+ onCleanup(() => clearInterval(timer))
72
+
73
+ return { data, err, configured: !!url && !!key }
74
+ }
75
+
76
+ const Sidebar = (props: { theme: any }) => {
77
+ const { data, err, configured } = useBudget()
78
+
79
+ return (
80
+ <box paddingTop={1} width="100%" flexDirection="column">
81
+ <text bold fg={props.theme.current.primary}>Budget</text>
82
+ <Show when={configured} fallback={
83
+ <text fg={props.theme.current.textMuted}>Not configured</text>
84
+ }>
85
+ <Show when={!err()} fallback={
86
+ <text fg={props.theme.current.textMuted}>...</text>
87
+ }>
88
+ <Show when={data()}>
89
+ {(b) => {
90
+ const remaining = () => Math.max(0, b().max - b().spend).toFixed(2)
91
+ const pct = () => pctUsed(b())
92
+ const days = () => daysUntil(b().resetAt)
93
+ const color = () => pct() >= 80 ? props.theme.current.error : pct() >= 60 ? props.theme.current.warning : props.theme.current.success
94
+ return (
95
+ <box flexDirection="column">
96
+ <text fg={color()}>${remaining()}/${b().max.toFixed(0)}</text>
97
+ <text fg={color()}>{bar(pct(), 10)} {pct()}%</text>
98
+ <text fg={props.theme.current.textMuted}>Reset {days()}d</text>
99
+ </box>
100
+ )
101
+ }}
102
+ </Show>
103
+ </Show>
104
+ </Show>
105
+ </box>
106
+ )
107
+ }
108
+
109
+ const FooterBudget = (props: { theme: any }) => {
110
+ const { data, err, configured } = useBudget()
111
+
112
+ return (
113
+ <box>
114
+ <Show when={configured} fallback={
115
+ <text fg={props.theme.current.textMuted}>Budget: not configured</text>
116
+ }>
117
+ <Show when={!err() && data()} fallback={
118
+ <text fg={props.theme.current.textMuted}>Budget: loading...</text>
119
+ }>
120
+ {(b) => {
121
+ const remaining = () => Math.max(0, b().max - b().spend).toFixed(2)
122
+ const pct = () => pctUsed(b())
123
+ const days = () => daysUntil(b().resetAt)
124
+ const color = () => pct() >= 80 ? props.theme.current.error : pct() >= 60 ? props.theme.current.warning : props.theme.current.success
125
+ return (
126
+ <text fg={color()}>
127
+ Budget: ${remaining()}/${b().max.toFixed(0)} [{bar(pct(), 10)}] {pct()}% ({days()}d)
128
+ </text>
129
+ )
130
+ }}
131
+ </Show>
132
+ </Show>
133
+ </box>
134
+ )
135
+ }
136
+
137
+ const tui: TuiPlugin = async (api) => {
138
+ api.slots.register({
139
+ order: 52,
140
+ slots: {
141
+ sidebar_content(ctx) {
142
+ return <Sidebar theme={ctx.theme} />
143
+ },
144
+ },
145
+ })
146
+
147
+ api.slots.register({
148
+ order: 10,
149
+ slots: {
150
+ home_footer(ctx) {
151
+ return <FooterBudget theme={ctx.theme} />
152
+ },
153
+ },
154
+ })
155
+ }
156
+
157
+ const plugin: TuiPluginModule & { id: string } = {
158
+ id: "litellm-budget",
159
+ tui,
160
+ }
161
+
162
+ export default plugin