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.
- package/package.json +1 -1
- package/tui.tsx +63 -37
package/package.json
CHANGED
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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(
|
|
104
|
+
const [err, setErr] = createSignal(false)
|
|
105
|
+
const [configured, setConfigured] = createSignal(true)
|
|
82
106
|
|
|
83
107
|
const poll = async () => {
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
}}
|