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.
- package/package.json +45 -0
- package/server.ts +5 -0
- 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
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
|