oc-usage-limits-plugin 0.0.1 → 1.0.1
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/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +848 -0
- package/examples/usage-limits.jsonc +21 -0
- package/package.json +59 -9
- package/usage-limits.schema.json +36 -0
- package/index.ts +0 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mynameistito
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# oc-usage-limits-plugin
|
|
2
|
+
|
|
3
|
+
OpenCode TUI plugin that shows Codex and ZAI usage limits in the sidebar and prompt footer.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Adds a `Usage Limits` block under the sidebar `Context` section.
|
|
8
|
+
- Shows current Codex usage windows from OpenAI/Codex auth.
|
|
9
|
+
- Shows current ZAI quota windows from ZAI Coding Plan auth.
|
|
10
|
+
- Adds compact prompt-footer usage when the current session uses an OpenAI or ZAI Coding Plan model.
|
|
11
|
+
- Providers are toggled from `~/.config/opencode/usage-limits.jsonc`.
|
|
12
|
+
- Reads OpenCode-connected credentials first, then falls back to explicit config/env credentials.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Add the TUI plugin to `~/.config/opencode/tui.json`:
|
|
17
|
+
|
|
18
|
+
```jsonc
|
|
19
|
+
{
|
|
20
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
21
|
+
"plugin": ["oc-usage-limits-plugin"],
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
OpenCode TUI plugins are configured in `tui.json`, not `opencode.jsonc`.
|
|
26
|
+
|
|
27
|
+
Restart OpenCode after changing TUI plugin config.
|
|
28
|
+
|
|
29
|
+
## Usage Config
|
|
30
|
+
|
|
31
|
+
Create `~/.config/opencode/usage-limits.jsonc`:
|
|
32
|
+
|
|
33
|
+
```jsonc
|
|
34
|
+
{
|
|
35
|
+
"$schema": "https://raw.githubusercontent.com/mynameistito/oc-usage-limits-plugin/main/usage-limits.schema.json",
|
|
36
|
+
"enabled": true,
|
|
37
|
+
"refreshIntervalSeconds": 60,
|
|
38
|
+
"requestTimeoutMs": 10000,
|
|
39
|
+
"showErrors": true,
|
|
40
|
+
"providers": {
|
|
41
|
+
"codex": {
|
|
42
|
+
"enabled": true,
|
|
43
|
+
"label": "Codex",
|
|
44
|
+
"authPath": "~/.codex/auth.json",
|
|
45
|
+
},
|
|
46
|
+
"zai": {
|
|
47
|
+
"enabled": true,
|
|
48
|
+
"label": "ZAI",
|
|
49
|
+
"authPath": "~/.local/share/opencode/auth.json",
|
|
50
|
+
"apiKey": "{env:OC_ZAI_API_KEY}",
|
|
51
|
+
"authorizationScheme": "raw",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Disabled providers are hidden:
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
"providers": {
|
|
61
|
+
"codex": { "enabled": true },
|
|
62
|
+
"zai": { "enabled": false }
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Credential Lookup
|
|
67
|
+
|
|
68
|
+
Codex lookup order:
|
|
69
|
+
|
|
70
|
+
1. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `openai`.
|
|
71
|
+
2. Codex auth file from `authPath`, default `~/.codex/auth.json`.
|
|
72
|
+
|
|
73
|
+
ZAI lookup order:
|
|
74
|
+
|
|
75
|
+
1. Config `authPath`, which can point at OpenCode auth JSON or a simple `{ "key": "..." }` / `{ "apiKey": "..." }` JSON file.
|
|
76
|
+
2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `zai-coding-plan`.
|
|
77
|
+
3. OpenCode auth provider `zai`.
|
|
78
|
+
4. Config `apiKey`, including `{env:OC_ZAI_API_KEY}` references.
|
|
79
|
+
|
|
80
|
+
## Display
|
|
81
|
+
|
|
82
|
+
Sidebar rows look like:
|
|
83
|
+
|
|
84
|
+
```txt
|
|
85
|
+
Usage Limits
|
|
86
|
+
codex
|
|
87
|
+
5h: 42% used resets 1h 2m
|
|
88
|
+
weekly: 12% used resets 3d 4h
|
|
89
|
+
ZAI
|
|
90
|
+
tokens: 18% used resets 2h
|
|
91
|
+
MCP: 6% used
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Prompt footer shows compact usage when the current session model belongs to a supported provider:
|
|
95
|
+
|
|
96
|
+
```txt
|
|
97
|
+
5h: 42% used resets 1h 2m
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Provider mapping:
|
|
101
|
+
|
|
102
|
+
- OpenCode provider `openai` -> Codex usage.
|
|
103
|
+
- OpenCode provider `zai-coding-plan` -> ZAI token usage.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```powershell
|
|
108
|
+
bun install
|
|
109
|
+
bun run typecheck
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The package exposes a TUI entrypoint at `oc-usage-limits-plugin/tui` for OpenCode's package plugin loader.
|
|
113
|
+
|
|
114
|
+
## Notes
|
|
115
|
+
|
|
116
|
+
- The refresh interval defaults to 60 seconds.
|
|
117
|
+
- The effective minimum refresh interval is 15 seconds.
|
|
118
|
+
- Errors are intentionally short and do not include auth tokens or response bodies.
|
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
import { For, Show, createSignal } from "solid-js";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "@opentui/solid/jsx-runtime";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
//#region src/format.ts
|
|
7
|
+
/**
|
|
8
|
+
* Formats a positive duration in seconds into the compact label used by the TUI.
|
|
9
|
+
*
|
|
10
|
+
* Values that are missing, invalid, or already elapsed are displayed as `now`.
|
|
11
|
+
* Positive values are rounded up to the next minute so near-future resets never
|
|
12
|
+
* appear as zero minutes remaining.
|
|
13
|
+
*
|
|
14
|
+
* @param seconds - Duration, in seconds, until the event occurs.
|
|
15
|
+
* @returns A short human-readable duration such as `3m`, `2h 15m`, or `1d 4h`.
|
|
16
|
+
*/
|
|
17
|
+
const duration = (seconds) => {
|
|
18
|
+
if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) return "now";
|
|
19
|
+
const minutes = Math.ceil(seconds / 60);
|
|
20
|
+
if (minutes < 60) return `${minutes}m`;
|
|
21
|
+
const hours = Math.floor(minutes / 60);
|
|
22
|
+
const remainder = minutes % 60;
|
|
23
|
+
if (hours < 24) return remainder === 0 ? `${hours}h` : `${hours}h ${remainder}m`;
|
|
24
|
+
const days = Math.floor(hours / 24);
|
|
25
|
+
const hourRemainder = hours % 24;
|
|
26
|
+
return hourRemainder === 0 ? `${days}d` : `${days}d ${hourRemainder}h`;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Formats a nullable usage percentage for display.
|
|
30
|
+
*
|
|
31
|
+
* @param value - The percentage to render, or `null` when the provider did not
|
|
32
|
+
* report a percentage.
|
|
33
|
+
* @returns A rounded usage string, or `? used` when usage is unknown.
|
|
34
|
+
*/
|
|
35
|
+
const formatPercent = (value) => value === null ? "? used" : `${Math.round(value)}% used`;
|
|
36
|
+
/**
|
|
37
|
+
* Builds the primary line of text for a usage window in the sidebar panel.
|
|
38
|
+
*
|
|
39
|
+
* @param window - The provider usage window to summarize.
|
|
40
|
+
* @returns A label and percentage pair such as `daily: 42% used`.
|
|
41
|
+
*/
|
|
42
|
+
const windowMainText = (window) => `${window.label}: ${formatPercent(window.usedPercent)}`;
|
|
43
|
+
/**
|
|
44
|
+
* Builds the compact prompt-footer text for the active provider's primary window.
|
|
45
|
+
*
|
|
46
|
+
* @param window - The active provider usage window to summarize.
|
|
47
|
+
* @returns A compact percentage label such as `daily: 42% used`.
|
|
48
|
+
*/
|
|
49
|
+
const bottomWindowMainText = (window) => `${window.label}: ${formatPercent(window.usedPercent)}`;
|
|
50
|
+
/**
|
|
51
|
+
* Formats the reset suffix for a usage window.
|
|
52
|
+
*
|
|
53
|
+
* @param window - The usage window whose reset time should be rendered.
|
|
54
|
+
* @returns A leading-space suffix such as ` resets 12m`, or an empty string when
|
|
55
|
+
* the provider did not report a reset countdown.
|
|
56
|
+
*/
|
|
57
|
+
const windowResetText = (window) => window.resetAfterSeconds === null ? "" : ` resets ${duration(window.resetAfterSeconds)}`;
|
|
58
|
+
/**
|
|
59
|
+
* Converts a provider-reported rolling-window size into a stable display label.
|
|
60
|
+
*
|
|
61
|
+
* Provider APIs can return slightly imprecise window lengths, so expected
|
|
62
|
+
* windows are matched within a 5% tolerance before falling back to the caller's
|
|
63
|
+
* label.
|
|
64
|
+
*
|
|
65
|
+
* @param seconds - The provider-reported limit window length in seconds.
|
|
66
|
+
* @param fallback - Label to use when the window does not match a known size.
|
|
67
|
+
* @returns A normalized label such as `5h`, `daily`, `weekly`, or `monthly`.
|
|
68
|
+
*/
|
|
69
|
+
const limitLabelForWindow = (seconds, fallback) => {
|
|
70
|
+
const minutes = Math.ceil(seconds / 60);
|
|
71
|
+
const roughly = (expected) => minutes >= expected * .95 && minutes <= expected * 1.05;
|
|
72
|
+
const hour = 60;
|
|
73
|
+
const day = 24 * hour;
|
|
74
|
+
if (roughly(5 * hour)) return "5h";
|
|
75
|
+
if (roughly(day)) return "daily";
|
|
76
|
+
if (roughly(7 * day)) return "weekly";
|
|
77
|
+
if (roughly(30 * day)) return "monthly";
|
|
78
|
+
return fallback;
|
|
79
|
+
};
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/components.tsx
|
|
82
|
+
/**
|
|
83
|
+
* Chooses the status-dot color for a usage percentage.
|
|
84
|
+
*
|
|
85
|
+
* @param usedPercent - Percentage consumed, or `null` when unknown.
|
|
86
|
+
* @param theme - Active OpenCode TUI theme.
|
|
87
|
+
* @returns A theme color indicating healthy, warning, error, or unknown usage.
|
|
88
|
+
*/
|
|
89
|
+
const dotColor = (usedPercent, theme) => {
|
|
90
|
+
if (usedPercent === null) return theme.textMuted;
|
|
91
|
+
if (usedPercent >= 90) return theme.error;
|
|
92
|
+
if (usedPercent >= 70) return theme.warning;
|
|
93
|
+
return theme.success;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Renders the sidebar usage-limits panel.
|
|
97
|
+
*
|
|
98
|
+
* The panel lists every enabled provider, shows loading and stale states, and can
|
|
99
|
+
* optionally display provider fetch errors.
|
|
100
|
+
*
|
|
101
|
+
* @param props - Provider states, error visibility, and active TUI theme.
|
|
102
|
+
* @returns Solid/OpenTUI JSX for the sidebar content slot.
|
|
103
|
+
*/
|
|
104
|
+
const UsageLimitsPanel = (props) => /* @__PURE__ */ jsx(Show, {
|
|
105
|
+
when: props.states.some((state) => state.status !== "disabled"),
|
|
106
|
+
children: /* @__PURE__ */ jsxs("box", { children: [/* @__PURE__ */ jsx("text", {
|
|
107
|
+
fg: props.theme.text,
|
|
108
|
+
children: /* @__PURE__ */ jsx("b", { children: "Usage Limits" })
|
|
109
|
+
}), /* @__PURE__ */ jsx(For, {
|
|
110
|
+
each: props.states,
|
|
111
|
+
children: (state) => /* @__PURE__ */ jsx(Show, {
|
|
112
|
+
when: state.status !== "disabled",
|
|
113
|
+
children: /* @__PURE__ */ jsxs("box", { children: [
|
|
114
|
+
/* @__PURE__ */ jsxs("text", {
|
|
115
|
+
fg: props.theme.text,
|
|
116
|
+
children: [state.label, /* @__PURE__ */ jsxs(Show, {
|
|
117
|
+
when: state.status === "ready" && state.stale,
|
|
118
|
+
children: [" ", "stale"]
|
|
119
|
+
})]
|
|
120
|
+
}),
|
|
121
|
+
/* @__PURE__ */ jsx(Show, {
|
|
122
|
+
when: state.status === "loading",
|
|
123
|
+
children: /* @__PURE__ */ jsx("text", {
|
|
124
|
+
fg: props.theme.textMuted,
|
|
125
|
+
children: " loading..."
|
|
126
|
+
})
|
|
127
|
+
}),
|
|
128
|
+
/* @__PURE__ */ jsx(Show, {
|
|
129
|
+
when: state.status === "ready",
|
|
130
|
+
children: /* @__PURE__ */ jsx(For, {
|
|
131
|
+
each: state.status === "ready" ? state.data.windows : [],
|
|
132
|
+
children: (window) => /* @__PURE__ */ jsxs("box", {
|
|
133
|
+
flexDirection: "row",
|
|
134
|
+
gap: 1,
|
|
135
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
136
|
+
flexShrink: 0,
|
|
137
|
+
fg: dotColor(window.usedPercent, props.theme),
|
|
138
|
+
children: "•"
|
|
139
|
+
}), /* @__PURE__ */ jsxs("text", {
|
|
140
|
+
fg: props.theme.text,
|
|
141
|
+
children: [windowMainText(window), /* @__PURE__ */ jsx("span", {
|
|
142
|
+
style: { fg: props.theme.textMuted },
|
|
143
|
+
children: windowResetText(window)
|
|
144
|
+
})]
|
|
145
|
+
})]
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
}),
|
|
149
|
+
/* @__PURE__ */ jsx(Show, {
|
|
150
|
+
when: state.status === "error" && props.showErrors,
|
|
151
|
+
children: /* @__PURE__ */ jsx(Show, {
|
|
152
|
+
fallback: /* @__PURE__ */ jsxs("text", {
|
|
153
|
+
fg: props.theme.error,
|
|
154
|
+
children: [" ", state.status === "error" ? state.message : "error"]
|
|
155
|
+
}),
|
|
156
|
+
when: state.status === "error" ? state.previous : void 0,
|
|
157
|
+
children: (previous) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(For, {
|
|
158
|
+
each: previous().windows,
|
|
159
|
+
children: (window) => /* @__PURE__ */ jsxs("box", {
|
|
160
|
+
flexDirection: "row",
|
|
161
|
+
gap: 1,
|
|
162
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
163
|
+
flexShrink: 0,
|
|
164
|
+
fg: dotColor(window.usedPercent, props.theme),
|
|
165
|
+
children: "•"
|
|
166
|
+
}), /* @__PURE__ */ jsxs("text", {
|
|
167
|
+
fg: props.theme.text,
|
|
168
|
+
children: [windowMainText(window), /* @__PURE__ */ jsx("span", {
|
|
169
|
+
style: { fg: props.theme.textMuted },
|
|
170
|
+
children: windowResetText(window)
|
|
171
|
+
})]
|
|
172
|
+
})]
|
|
173
|
+
})
|
|
174
|
+
}), /* @__PURE__ */ jsxs("text", {
|
|
175
|
+
fg: props.theme.error,
|
|
176
|
+
children: [" ", state.status === "error" ? state.message : "error"]
|
|
177
|
+
})] })
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
] })
|
|
181
|
+
})
|
|
182
|
+
})] })
|
|
183
|
+
});
|
|
184
|
+
/**
|
|
185
|
+
* Renders the compact active-provider usage indicator in the prompt footer.
|
|
186
|
+
*
|
|
187
|
+
* @param props - Active usage window and active TUI theme.
|
|
188
|
+
* @returns Solid/OpenTUI JSX for the prompt footer slot.
|
|
189
|
+
*/
|
|
190
|
+
const BottomUsage = (props) => /* @__PURE__ */ jsx(Show, {
|
|
191
|
+
when: props.window,
|
|
192
|
+
children: (window) => /* @__PURE__ */ jsxs("text", {
|
|
193
|
+
fg: props.theme.text,
|
|
194
|
+
children: [bottomWindowMainText(window()), /* @__PURE__ */ jsx("span", {
|
|
195
|
+
style: { fg: props.theme.textMuted },
|
|
196
|
+
children: windowResetText(window())
|
|
197
|
+
})]
|
|
198
|
+
})
|
|
199
|
+
});
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/utils.ts
|
|
202
|
+
/**
|
|
203
|
+
* Normalizes an arbitrary number into the inclusive percentage range used by UI.
|
|
204
|
+
*
|
|
205
|
+
* @param value - Provider-reported percentage value.
|
|
206
|
+
* @returns A finite number clamped between `0` and `100`.
|
|
207
|
+
*/
|
|
208
|
+
const clampPercent = (value) => Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0));
|
|
209
|
+
/**
|
|
210
|
+
* Checks whether a value is a plain object-like record.
|
|
211
|
+
*
|
|
212
|
+
* This intentionally excludes arrays because provider API payloads are parsed as
|
|
213
|
+
* `unknown` and object fields are accessed only after this guard succeeds.
|
|
214
|
+
*
|
|
215
|
+
* @param value - Value to narrow.
|
|
216
|
+
* @returns `true` when the value can be safely indexed as a record.
|
|
217
|
+
*/
|
|
218
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
219
|
+
const stripTrailingCommas = (input) => {
|
|
220
|
+
let output = "";
|
|
221
|
+
let inString = false;
|
|
222
|
+
let quote = "";
|
|
223
|
+
let escaped = false;
|
|
224
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
225
|
+
const char = input[index];
|
|
226
|
+
if (inString) {
|
|
227
|
+
output += char;
|
|
228
|
+
if (escaped) escaped = false;
|
|
229
|
+
else if (char === "\\") escaped = true;
|
|
230
|
+
else if (char === quote) inString = false;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (char === "\"" || char === "'") {
|
|
234
|
+
inString = true;
|
|
235
|
+
quote = char;
|
|
236
|
+
output += char;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (char === ",") {
|
|
240
|
+
let lookahead = index + 1;
|
|
241
|
+
while (/\s/u.test(input[lookahead] ?? "")) lookahead += 1;
|
|
242
|
+
if (input[lookahead] === "}" || input[lookahead] === "]") continue;
|
|
243
|
+
}
|
|
244
|
+
output += char;
|
|
245
|
+
}
|
|
246
|
+
return output;
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Removes JSONC comments and trailing commas while preserving string contents.
|
|
250
|
+
*
|
|
251
|
+
* The plugin accepts small user-authored config files without adding a JSONC
|
|
252
|
+
* dependency. Both line comments and block comments are stripped, but comment
|
|
253
|
+
* markers inside quoted strings are left untouched.
|
|
254
|
+
*
|
|
255
|
+
* @param input - Raw JSONC text.
|
|
256
|
+
* @returns JSON-compatible text suitable for `JSON.parse`.
|
|
257
|
+
*/
|
|
258
|
+
const stripJsonComments = (input) => {
|
|
259
|
+
let output = "";
|
|
260
|
+
let inString = false;
|
|
261
|
+
let quote = "";
|
|
262
|
+
let escaped = false;
|
|
263
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
264
|
+
const char = input[index];
|
|
265
|
+
const next = input[index + 1];
|
|
266
|
+
if (inString) {
|
|
267
|
+
output += char;
|
|
268
|
+
if (escaped) escaped = false;
|
|
269
|
+
else if (char === "\\") escaped = true;
|
|
270
|
+
else if (char === quote) inString = false;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (char === "\"" || char === "'") {
|
|
274
|
+
inString = true;
|
|
275
|
+
quote = char;
|
|
276
|
+
output += char;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (char === "/" && next === "/") {
|
|
280
|
+
while (index < input.length && input[index] !== "\n") index += 1;
|
|
281
|
+
output += "\n";
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (char === "/" && next === "*") {
|
|
285
|
+
index += 2;
|
|
286
|
+
while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) index += 1;
|
|
287
|
+
index += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
output += char;
|
|
291
|
+
}
|
|
292
|
+
return stripTrailingCommas(output);
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* Expands a leading home-directory marker in a filesystem path.
|
|
296
|
+
*
|
|
297
|
+
* @param value - Path that may start with `~`, `~/`, or `~\`.
|
|
298
|
+
* @returns The path with a leading home marker replaced by the user's home path.
|
|
299
|
+
*/
|
|
300
|
+
const expandHome = (value) => value === "~" || value.startsWith("~/") || value.startsWith("~\\") ? path.join(homedir(), value.slice(2)) : value;
|
|
301
|
+
/**
|
|
302
|
+
* Reads and parses a JSON or JSONC file.
|
|
303
|
+
*
|
|
304
|
+
* A leading `~` in the path is expanded before reading. The generic type is a
|
|
305
|
+
* caller-provided assertion; callers should still validate external data before
|
|
306
|
+
* relying on specific fields.
|
|
307
|
+
*
|
|
308
|
+
* @param filePath - Absolute path, relative path, or home-relative path to read.
|
|
309
|
+
* @returns The parsed JSON value typed as `T`.
|
|
310
|
+
*/
|
|
311
|
+
const readJsonFile = async (filePath) => {
|
|
312
|
+
const raw = await readFile(expandHome(filePath), "utf-8");
|
|
313
|
+
return JSON.parse(stripJsonComments(raw));
|
|
314
|
+
};
|
|
315
|
+
/**
|
|
316
|
+
* Resolves a config value that may reference an environment variable.
|
|
317
|
+
*
|
|
318
|
+
* Values in the form `{env:NAME}` are replaced with `process.env.NAME`. Any
|
|
319
|
+
* other non-empty string is returned unchanged.
|
|
320
|
+
*
|
|
321
|
+
* @param value - Raw config value or environment reference.
|
|
322
|
+
* @returns The resolved value, unchanged literal, or `undefined` when absent.
|
|
323
|
+
*/
|
|
324
|
+
const resolveEnvReference = (value) => {
|
|
325
|
+
if (!value) return;
|
|
326
|
+
const envMatch = /^\{env:(?<name>[^}]+)\}$/iu.exec(value.trim());
|
|
327
|
+
if (envMatch?.groups?.name) return process.env[envMatch.groups.name];
|
|
328
|
+
return value;
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* Fetches a JSON endpoint with a timeout and normalized provider-facing errors.
|
|
332
|
+
*
|
|
333
|
+
* HTTP status codes that commonly matter for auth and quota diagnostics are
|
|
334
|
+
* mapped to stable messages, while successful non-JSON responses are rejected as
|
|
335
|
+
* invalid provider payloads.
|
|
336
|
+
*
|
|
337
|
+
* @param url - Endpoint URL to request.
|
|
338
|
+
* @param init - Fetch options such as method and headers.
|
|
339
|
+
* @param timeoutMs - Timeout in milliseconds when `init.signal` is not supplied.
|
|
340
|
+
* @returns The parsed JSON payload as `unknown` for caller-side validation.
|
|
341
|
+
* @throws {Error} When the response is unsuccessful or cannot be parsed as JSON.
|
|
342
|
+
*/
|
|
343
|
+
const fetchJson = async (url, init, timeoutMs) => {
|
|
344
|
+
const response = await fetch(url, {
|
|
345
|
+
...init,
|
|
346
|
+
signal: init.signal ?? AbortSignal.timeout(timeoutMs)
|
|
347
|
+
});
|
|
348
|
+
const body = await response.text();
|
|
349
|
+
if (!response.ok) {
|
|
350
|
+
if (response.status === 401) throw new Error("unauthorized");
|
|
351
|
+
if (response.status === 403) throw new Error("forbidden");
|
|
352
|
+
if (response.status === 429) throw new Error("rate limited");
|
|
353
|
+
throw new Error(`HTTP ${response.status}`);
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
return JSON.parse(body);
|
|
357
|
+
} catch {
|
|
358
|
+
throw new Error("invalid JSON");
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/config.ts
|
|
363
|
+
/** Default user configuration path for this plugin. */
|
|
364
|
+
const CONFIG_PATH = path.join(homedir(), ".config", "opencode", "usage-limits.jsonc");
|
|
365
|
+
/** Default OpenCode auth path shared by installed providers. */
|
|
366
|
+
const OPENCODE_AUTH_PATH = path.join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
367
|
+
const numericConfigValue = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
368
|
+
/**
|
|
369
|
+
* Loads the usage-limits plugin configuration from OpenCode's config directory.
|
|
370
|
+
*
|
|
371
|
+
* Missing files, unreadable files, and invalid JSONC all resolve to conservative
|
|
372
|
+
* defaults so the plugin can start without interrupting the TUI. Partial config
|
|
373
|
+
* files are merged with the same defaults.
|
|
374
|
+
*
|
|
375
|
+
* @returns The fully-populated plugin configuration.
|
|
376
|
+
*/
|
|
377
|
+
const loadConfig = async () => {
|
|
378
|
+
const fallback = {
|
|
379
|
+
enabled: true,
|
|
380
|
+
providers: {},
|
|
381
|
+
refreshIntervalSeconds: 60,
|
|
382
|
+
requestTimeoutMs: 1e4,
|
|
383
|
+
showErrors: true
|
|
384
|
+
};
|
|
385
|
+
try {
|
|
386
|
+
const config = await readJsonFile(CONFIG_PATH);
|
|
387
|
+
return {
|
|
388
|
+
enabled: config.enabled ?? fallback.enabled,
|
|
389
|
+
providers: config.providers ?? fallback.providers,
|
|
390
|
+
refreshIntervalSeconds: numericConfigValue(config.refreshIntervalSeconds, fallback.refreshIntervalSeconds),
|
|
391
|
+
requestTimeoutMs: numericConfigValue(config.requestTimeoutMs, fallback.requestTimeoutMs),
|
|
392
|
+
showErrors: config.showErrors ?? fallback.showErrors
|
|
393
|
+
};
|
|
394
|
+
} catch {
|
|
395
|
+
return fallback;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* Loads OpenCode's shared auth file for provider credentials.
|
|
400
|
+
*
|
|
401
|
+
* This file may not exist for every installation or provider. Auth loading is
|
|
402
|
+
* therefore best-effort and returns an empty object when credentials are absent
|
|
403
|
+
* or unreadable.
|
|
404
|
+
*
|
|
405
|
+
* @returns The parsed OpenCode auth payload, or an empty auth object.
|
|
406
|
+
*/
|
|
407
|
+
const loadOpenCodeAuth = async () => {
|
|
408
|
+
try {
|
|
409
|
+
return await readJsonFile(OPENCODE_AUTH_PATH);
|
|
410
|
+
} catch {
|
|
411
|
+
return {};
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region src/providers/codex.ts
|
|
416
|
+
/** Default ChatGPT backend base URL used for Codex usage requests. */
|
|
417
|
+
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
418
|
+
/**
|
|
419
|
+
* Reads Codex credentials from the Codex CLI auth file.
|
|
420
|
+
*
|
|
421
|
+
* @param authPath - Optional path override. Defaults to `~/.codex/auth.json`.
|
|
422
|
+
* @returns Access token and ChatGPT account ID required by the Codex usage API.
|
|
423
|
+
* @throws {Error} When the auth file is missing or does not contain credentials.
|
|
424
|
+
* @throws {TypeError} When the auth file contains credentials with invalid types.
|
|
425
|
+
*/
|
|
426
|
+
const readCodexAuthFile = async (authPath) => {
|
|
427
|
+
const auth = await readJsonFile(authPath ?? "~/.codex/auth.json");
|
|
428
|
+
if (!isRecord(auth) || !isRecord(auth.tokens)) throw new Error("missing Codex auth");
|
|
429
|
+
const access = auth.tokens.access_token;
|
|
430
|
+
const accountId = auth.tokens.account_id;
|
|
431
|
+
if (typeof access !== "string" || typeof accountId !== "string") throw new TypeError("invalid Codex credential types");
|
|
432
|
+
return {
|
|
433
|
+
access,
|
|
434
|
+
accountId
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* Converts a raw Codex rate-limit window into the plugin's normalized shape.
|
|
439
|
+
*
|
|
440
|
+
* @param value - Unknown `primary_window` or `secondary_window` payload.
|
|
441
|
+
* @param fallback - Label used when the provider does not report a known window
|
|
442
|
+
* length.
|
|
443
|
+
* @returns A normalized usage window, or `null` for invalid payloads.
|
|
444
|
+
*/
|
|
445
|
+
const codexWindow = (value, fallback) => {
|
|
446
|
+
if (!isRecord(value)) return null;
|
|
447
|
+
const used = typeof value.used_percent === "number" ? clampPercent(value.used_percent) : null;
|
|
448
|
+
const resetAfter = typeof value.reset_after_seconds === "number" ? value.reset_after_seconds : null;
|
|
449
|
+
const windowSeconds = typeof value.limit_window_seconds === "number" ? value.limit_window_seconds : 0;
|
|
450
|
+
const resetAt = typeof value.reset_at === "number" && value.reset_at > 0 ? /* @__PURE__ */ new Date(value.reset_at * 1e3) : null;
|
|
451
|
+
return {
|
|
452
|
+
label: windowSeconds > 0 ? limitLabelForWindow(windowSeconds, fallback) : fallback,
|
|
453
|
+
remainingPercent: used === null ? null : 100 - used,
|
|
454
|
+
resetAfterSeconds: resetAfter,
|
|
455
|
+
resetsAt: resetAt,
|
|
456
|
+
usedPercent: used
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
/**
|
|
460
|
+
* Fetches and normalizes Codex usage limits.
|
|
461
|
+
*
|
|
462
|
+
* Credentials are read from OpenCode auth when available, otherwise from the
|
|
463
|
+
* Codex CLI auth file. The returned windows represent the primary and secondary
|
|
464
|
+
* Codex rate-limit windows reported by ChatGPT's backend API.
|
|
465
|
+
*
|
|
466
|
+
* @param config - Optional Codex provider configuration.
|
|
467
|
+
* @param openCodeAuth - Shared OpenCode auth payload.
|
|
468
|
+
* @param timeoutMs - Request timeout in milliseconds.
|
|
469
|
+
* @returns Normalized Codex usage data.
|
|
470
|
+
* @throws {Error} When credentials are missing or the provider response is invalid.
|
|
471
|
+
*/
|
|
472
|
+
const fetchCodexUsage = async (config, openCodeAuth, timeoutMs) => {
|
|
473
|
+
const { openai } = openCodeAuth;
|
|
474
|
+
const credentials = typeof openai?.access === "string" && openai.access.trim() !== "" && typeof openai.accountId === "string" && openai.accountId.trim() !== "" ? {
|
|
475
|
+
access: openai.access,
|
|
476
|
+
accountId: openai.accountId
|
|
477
|
+
} : await readCodexAuthFile(config?.authPath);
|
|
478
|
+
const payload = await fetchJson(`${(config?.baseUrl ?? DEFAULT_CODEX_BASE_URL).replace(/\/$/u, "")}/wham/usage`, {
|
|
479
|
+
headers: {
|
|
480
|
+
Authorization: `Bearer ${credentials.access}`,
|
|
481
|
+
"ChatGPT-Account-Id": credentials.accountId,
|
|
482
|
+
"User-Agent": "opencode-usage-limits"
|
|
483
|
+
},
|
|
484
|
+
method: "GET"
|
|
485
|
+
}, timeoutMs);
|
|
486
|
+
if (!isRecord(payload)) throw new Error("invalid Codex usage");
|
|
487
|
+
const rateLimit = isRecord(payload.rate_limit) ? payload.rate_limit : void 0;
|
|
488
|
+
const windows = [codexWindow(rateLimit?.primary_window, "usage"), codexWindow(rateLimit?.secondary_window, "secondary")].filter((item) => item !== null);
|
|
489
|
+
const resetCredits = isRecord(payload.rate_limit_reset_credits) && typeof payload.rate_limit_reset_credits.available_count === "number" ? payload.rate_limit_reset_credits.available_count : null;
|
|
490
|
+
return {
|
|
491
|
+
capturedAt: /* @__PURE__ */ new Date(),
|
|
492
|
+
id: "codex",
|
|
493
|
+
label: config?.label ?? "Codex",
|
|
494
|
+
metadata: { resetCredits },
|
|
495
|
+
tierName: typeof payload.plan_type === "string" ? payload.plan_type : void 0,
|
|
496
|
+
windows
|
|
497
|
+
};
|
|
498
|
+
};
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/providers/zai-coding-plan.ts
|
|
501
|
+
/** ZAI Coding Plan quota endpoint used to fetch usage limits. */
|
|
502
|
+
const ZAI_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
|
|
503
|
+
/**
|
|
504
|
+
* Infers the ZAI plan tier from the provider's prompt/time quota total.
|
|
505
|
+
*
|
|
506
|
+
* @param total - Total quota reported by the ZAI time-limit payload.
|
|
507
|
+
* @returns The inferred tier name, or `undefined` when it cannot be inferred.
|
|
508
|
+
*/
|
|
509
|
+
const inferZaiTier = (total) => {
|
|
510
|
+
if (total === null) return;
|
|
511
|
+
if (total >= 1400) return "Max";
|
|
512
|
+
if (total >= 300) return "Pro";
|
|
513
|
+
if (total > 0) return "Lite";
|
|
514
|
+
};
|
|
515
|
+
/**
|
|
516
|
+
* Extracts a ZAI API key from any supported auth object shape.
|
|
517
|
+
*
|
|
518
|
+
* The plugin accepts both direct `{ key }`/`{ apiKey }` objects and the nested
|
|
519
|
+
* shapes used by OpenCode auth.
|
|
520
|
+
*
|
|
521
|
+
* @param value - Unknown auth payload to inspect.
|
|
522
|
+
* @returns The first recognized API key.
|
|
523
|
+
*/
|
|
524
|
+
const keyFromZaiAuth = (value) => {
|
|
525
|
+
if (!isRecord(value)) return;
|
|
526
|
+
if (typeof value.key === "string") return value.key;
|
|
527
|
+
if (typeof value.apiKey === "string") return value.apiKey;
|
|
528
|
+
const zaiCodingPlan = value["zai-coding-plan"];
|
|
529
|
+
if (isRecord(zaiCodingPlan) && typeof zaiCodingPlan.key === "string") return zaiCodingPlan.key;
|
|
530
|
+
if (isRecord(value.zai) && typeof value.zai.key === "string") return value.zai.key;
|
|
531
|
+
};
|
|
532
|
+
/**
|
|
533
|
+
* Attempts to load a ZAI API key from a configured auth path.
|
|
534
|
+
*
|
|
535
|
+
* Missing or invalid files are ignored so other credential sources can still be
|
|
536
|
+
* tried by the provider adapter.
|
|
537
|
+
*
|
|
538
|
+
* @param authPath - Optional auth file path.
|
|
539
|
+
* @returns A ZAI API key when the file exists and contains one.
|
|
540
|
+
*/
|
|
541
|
+
const readZaiAuthPathKey = async (authPath) => {
|
|
542
|
+
if (!authPath) return;
|
|
543
|
+
try {
|
|
544
|
+
return keyFromZaiAuth(await readJsonFile(authPath));
|
|
545
|
+
} catch {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
/**
|
|
550
|
+
* Converts one raw ZAI limit entry into a normalized usage window.
|
|
551
|
+
*
|
|
552
|
+
* Token limits become the primary `tokens` quota window. Time limits become an
|
|
553
|
+
* `MCP` count-based window and also expose the total prompt quota used to infer
|
|
554
|
+
* the user's ZAI tier.
|
|
555
|
+
*
|
|
556
|
+
* @param limit - Raw limit object from the ZAI quota API.
|
|
557
|
+
* @returns The normalized window plus any prompt total discovered on the entry.
|
|
558
|
+
*/
|
|
559
|
+
const zaiWindowFromLimit = (limit) => {
|
|
560
|
+
const usedPercent = typeof limit.percentage === "number" ? clampPercent(limit.percentage) : null;
|
|
561
|
+
const resetsAt = typeof limit.nextResetTime === "number" ? new Date(limit.nextResetTime) : null;
|
|
562
|
+
const current = typeof limit.currentValue === "number" ? limit.currentValue : void 0;
|
|
563
|
+
const total = typeof limit.usage === "number" ? limit.usage : void 0;
|
|
564
|
+
if (limit.type === "TOKENS_LIMIT") return {
|
|
565
|
+
promptTotal: null,
|
|
566
|
+
window: {
|
|
567
|
+
label: "tokens",
|
|
568
|
+
remainingPercent: usedPercent === null ? null : 100 - usedPercent,
|
|
569
|
+
resetAfterSeconds: resetsAt ? Math.max(0, Math.ceil((resetsAt.getTime() - Date.now()) / 1e3)) : null,
|
|
570
|
+
resetsAt,
|
|
571
|
+
usedPercent
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
if (limit.type === "TIME_LIMIT") return {
|
|
575
|
+
promptTotal: total ?? null,
|
|
576
|
+
window: {
|
|
577
|
+
current,
|
|
578
|
+
label: "MCP",
|
|
579
|
+
remainingPercent: usedPercent === null ? null : 100 - usedPercent,
|
|
580
|
+
resetAfterSeconds: null,
|
|
581
|
+
resetsAt: null,
|
|
582
|
+
total,
|
|
583
|
+
usedPercent
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
return {
|
|
587
|
+
promptTotal: null,
|
|
588
|
+
window: null
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
/**
|
|
592
|
+
* Fetches and normalizes ZAI Coding Plan usage limits.
|
|
593
|
+
*
|
|
594
|
+
* Credential lookup checks, in order, the configured auth path, OpenCode auth,
|
|
595
|
+
* and a configured literal or environment-backed API key.
|
|
596
|
+
*
|
|
597
|
+
* @param config - Optional ZAI provider configuration.
|
|
598
|
+
* @param openCodeAuth - Shared OpenCode auth payload.
|
|
599
|
+
* @param timeoutMs - Request timeout in milliseconds.
|
|
600
|
+
* @returns Normalized ZAI usage data.
|
|
601
|
+
* @throws {Error} When no API key is available or the provider response is invalid.
|
|
602
|
+
*/
|
|
603
|
+
const fetchZaiCodingPlanUsage = async (config, openCodeAuth, timeoutMs) => {
|
|
604
|
+
const configuredKey = resolveEnvReference(config?.apiKey);
|
|
605
|
+
const apiKey = await readZaiAuthPathKey(config?.authPath) ?? keyFromZaiAuth(openCodeAuth) ?? configuredKey;
|
|
606
|
+
if (!apiKey) throw new Error("missing ZAI key");
|
|
607
|
+
const payload = await fetchJson(ZAI_QUOTA_URL, {
|
|
608
|
+
headers: {
|
|
609
|
+
"Accept-Language": "en-US,en",
|
|
610
|
+
Authorization: (config?.authorizationScheme ?? "raw") === "bearer" ? `Bearer ${apiKey}` : apiKey,
|
|
611
|
+
"Content-Type": "application/json"
|
|
612
|
+
},
|
|
613
|
+
method: "GET"
|
|
614
|
+
}, timeoutMs);
|
|
615
|
+
if (!isRecord(payload) || !isRecord(payload.data) || !Array.isArray(payload.data.limits)) throw new Error("invalid ZAI usage");
|
|
616
|
+
const windows = [];
|
|
617
|
+
let promptTotal = null;
|
|
618
|
+
for (const limit of payload.data.limits) {
|
|
619
|
+
if (!isRecord(limit) || typeof limit.type !== "string") continue;
|
|
620
|
+
const usage = zaiWindowFromLimit(limit);
|
|
621
|
+
if (usage.window) windows.push(usage.window);
|
|
622
|
+
if (usage.promptTotal !== null) ({promptTotal} = usage);
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
capturedAt: /* @__PURE__ */ new Date(),
|
|
626
|
+
id: "zai",
|
|
627
|
+
label: config?.label ?? "ZAI",
|
|
628
|
+
tierName: inferZaiTier(promptTotal),
|
|
629
|
+
windows
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/providers.ts
|
|
634
|
+
/**
|
|
635
|
+
* Fetches usage data for a configured provider.
|
|
636
|
+
*
|
|
637
|
+
* This dispatches to the provider-specific adapter while keeping plugin refresh
|
|
638
|
+
* code independent of each provider's authentication and response format.
|
|
639
|
+
*
|
|
640
|
+
* @param id - Provider adapter to fetch.
|
|
641
|
+
* @param config - Optional provider-specific configuration.
|
|
642
|
+
* @param openCodeAuth - Shared OpenCode auth payload.
|
|
643
|
+
* @param timeoutMs - Request timeout in milliseconds.
|
|
644
|
+
* @returns Normalized provider usage data.
|
|
645
|
+
*/
|
|
646
|
+
const fetchProvider = (id, config, openCodeAuth, timeoutMs) => {
|
|
647
|
+
if (id === "codex") return fetchCodexUsage(config, openCodeAuth, timeoutMs);
|
|
648
|
+
if (id === "zai") return fetchZaiCodingPlanUsage(config, openCodeAuth, timeoutMs);
|
|
649
|
+
throw new Error(`unknown provider: ${id}`);
|
|
650
|
+
};
|
|
651
|
+
/**
|
|
652
|
+
* Returns enabled provider configurations in the order they should appear in UI.
|
|
653
|
+
*
|
|
654
|
+
* Providers are opt-in: a provider is included only when its config sets
|
|
655
|
+
* `enabled: true`.
|
|
656
|
+
*
|
|
657
|
+
* @param config - Fully resolved plugin configuration.
|
|
658
|
+
* @returns Tuples of provider IDs and their config objects.
|
|
659
|
+
*/
|
|
660
|
+
const getProviderConfigs = (config) => ["codex", "zai"].flatMap((id) => {
|
|
661
|
+
const provider = config.providers[id];
|
|
662
|
+
if (provider?.enabled !== true) return [];
|
|
663
|
+
return [[id, provider]];
|
|
664
|
+
});
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region src/session.ts
|
|
667
|
+
/**
|
|
668
|
+
* Extracts an OpenCode provider identifier from a session message-like value.
|
|
669
|
+
*
|
|
670
|
+
* OpenCode message shapes have changed over time, so the provider may be present
|
|
671
|
+
* either directly on the message or nested under `message.model`.
|
|
672
|
+
*
|
|
673
|
+
* @param message - Unknown message payload from OpenCode session state.
|
|
674
|
+
* @returns The provider identifier when present.
|
|
675
|
+
*/
|
|
676
|
+
const getProviderFromMessage = (message) => {
|
|
677
|
+
if (!isRecord(message)) return;
|
|
678
|
+
if (typeof message.providerID === "string") return message.providerID;
|
|
679
|
+
if (isRecord(message.model) && typeof message.model.providerID === "string") return message.model.providerID;
|
|
680
|
+
};
|
|
681
|
+
/**
|
|
682
|
+
* Finds the provider currently represented by a session's latest messages.
|
|
683
|
+
*
|
|
684
|
+
* Messages are scanned from newest to oldest so the returned provider reflects
|
|
685
|
+
* the most recent model/provider selection in the active conversation.
|
|
686
|
+
*
|
|
687
|
+
* @param messages - OpenCode session messages.
|
|
688
|
+
* @returns The latest provider identifier, or `undefined` when unavailable.
|
|
689
|
+
*/
|
|
690
|
+
const currentProviderID = (messages) => {
|
|
691
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
692
|
+
const providerID = getProviderFromMessage(messages[index]);
|
|
693
|
+
if (providerID) return providerID;
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* Selects the usage window that should be shown in the prompt footer.
|
|
698
|
+
*
|
|
699
|
+
* OpenCode provider IDs are mapped to this plugin's provider IDs, then the most
|
|
700
|
+
* useful window is selected from the current provider state. If the latest fetch
|
|
701
|
+
* failed, the last successful data attached to the error state is used.
|
|
702
|
+
*
|
|
703
|
+
* @param states - Current provider states maintained by the plugin.
|
|
704
|
+
* @param providerID - OpenCode provider identifier for the active session.
|
|
705
|
+
* @returns The best usage window for the active provider, or `null` if none can
|
|
706
|
+
* be shown.
|
|
707
|
+
*/
|
|
708
|
+
const usageForProvider = (states, providerID) => {
|
|
709
|
+
let usageID = null;
|
|
710
|
+
if (providerID === "openai") usageID = "codex";
|
|
711
|
+
if (providerID === "zai-coding-plan") usageID = "zai";
|
|
712
|
+
if (!usageID) return null;
|
|
713
|
+
const state = states.find((item) => item.id === usageID);
|
|
714
|
+
let data;
|
|
715
|
+
if (state?.status === "ready") ({data} = state);
|
|
716
|
+
if (state?.status === "error") data = state.previous;
|
|
717
|
+
if (!data) return null;
|
|
718
|
+
if (usageID === "zai") return data.windows.find((window) => window.label === "tokens") ?? data.windows[0] ?? null;
|
|
719
|
+
return data.windows.find((window) => window.label === "5h") ?? data.windows[0] ?? null;
|
|
720
|
+
};
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region src/plugin.tsx
|
|
723
|
+
/**
|
|
724
|
+
* OpenCode TUI plugin entry point.
|
|
725
|
+
*
|
|
726
|
+
* The plugin periodically loads configuration, fetches enabled provider usage,
|
|
727
|
+
* stores the latest successful result for stale/error fallback, and registers UI
|
|
728
|
+
* slots for both the sidebar panel and prompt-footer indicator.
|
|
729
|
+
*
|
|
730
|
+
* @param api - OpenCode TUI plugin API supplied at plugin initialization.
|
|
731
|
+
*/
|
|
732
|
+
const tui = async (api) => {
|
|
733
|
+
const [states, setStates] = createSignal([]);
|
|
734
|
+
const [showErrors, setShowErrors] = createSignal(true);
|
|
735
|
+
let lastSuccess = /* @__PURE__ */ new Map();
|
|
736
|
+
let refreshIntervalSeconds = 60;
|
|
737
|
+
/**
|
|
738
|
+
* Refreshes configuration and usage data for every enabled provider.
|
|
739
|
+
*
|
|
740
|
+
* Existing ready or error states are kept visible while new requests are in
|
|
741
|
+
* flight. Failed refreshes retain the last successful usage payload so the UI
|
|
742
|
+
* can still show stale usage alongside the error message.
|
|
743
|
+
*/
|
|
744
|
+
const refresh = async () => {
|
|
745
|
+
const config = await loadConfig();
|
|
746
|
+
setShowErrors(config.showErrors);
|
|
747
|
+
({refreshIntervalSeconds} = config);
|
|
748
|
+
if (!config.enabled) {
|
|
749
|
+
setStates([]);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const effectiveRefreshIntervalSeconds = Math.max(15, refreshIntervalSeconds);
|
|
753
|
+
const providers = getProviderConfigs(config);
|
|
754
|
+
const previous = new Map(states().map((state) => [state.id, state]));
|
|
755
|
+
setStates(providers.map(([id, provider]) => {
|
|
756
|
+
const label = provider.label ?? (id === "codex" ? "Codex" : "ZAI");
|
|
757
|
+
const current = previous.get(id);
|
|
758
|
+
if (current?.status === "ready" || current?.status === "error") return current;
|
|
759
|
+
return {
|
|
760
|
+
id,
|
|
761
|
+
label,
|
|
762
|
+
status: "loading"
|
|
763
|
+
};
|
|
764
|
+
}));
|
|
765
|
+
const openCodeAuth = await loadOpenCodeAuth();
|
|
766
|
+
const nextStates = await Promise.all(providers.map(async ([id, provider]) => {
|
|
767
|
+
const label = provider.label ?? (id === "codex" ? "Codex" : "ZAI");
|
|
768
|
+
try {
|
|
769
|
+
const data = await fetchProvider(id, provider, openCodeAuth, config.requestTimeoutMs);
|
|
770
|
+
lastSuccess.set(id, data);
|
|
771
|
+
return {
|
|
772
|
+
data,
|
|
773
|
+
id,
|
|
774
|
+
label,
|
|
775
|
+
stale: false,
|
|
776
|
+
status: "ready"
|
|
777
|
+
};
|
|
778
|
+
} catch (error) {
|
|
779
|
+
const message = error instanceof Error ? error.message : "usage unavailable";
|
|
780
|
+
const previousData = lastSuccess.get(id);
|
|
781
|
+
if (previousData) return {
|
|
782
|
+
id,
|
|
783
|
+
label,
|
|
784
|
+
message,
|
|
785
|
+
previous: previousData,
|
|
786
|
+
status: "error"
|
|
787
|
+
};
|
|
788
|
+
return {
|
|
789
|
+
id,
|
|
790
|
+
label,
|
|
791
|
+
message,
|
|
792
|
+
status: "error"
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
}));
|
|
796
|
+
const staleAfterMs = effectiveRefreshIntervalSeconds * 2 * 1e3;
|
|
797
|
+
setStates(nextStates.map((state) => {
|
|
798
|
+
if (state.status !== "ready") return state;
|
|
799
|
+
return {
|
|
800
|
+
...state,
|
|
801
|
+
stale: Date.now() - state.data.capturedAt.getTime() > staleAfterMs
|
|
802
|
+
};
|
|
803
|
+
}));
|
|
804
|
+
};
|
|
805
|
+
await refresh();
|
|
806
|
+
let disposed = false;
|
|
807
|
+
let timer;
|
|
808
|
+
const scheduleRefresh = () => {
|
|
809
|
+
timer = setTimeout(async () => {
|
|
810
|
+
await refresh();
|
|
811
|
+
if (!disposed) scheduleRefresh();
|
|
812
|
+
}, Math.max(15, refreshIntervalSeconds) * 1e3);
|
|
813
|
+
};
|
|
814
|
+
scheduleRefresh();
|
|
815
|
+
api.lifecycle.onDispose(() => {
|
|
816
|
+
disposed = true;
|
|
817
|
+
if (timer) clearTimeout(timer);
|
|
818
|
+
lastSuccess = /* @__PURE__ */ new Map();
|
|
819
|
+
});
|
|
820
|
+
api.slots.register({
|
|
821
|
+
order: 101,
|
|
822
|
+
slots: {
|
|
823
|
+
session_prompt_right(ctx, props) {
|
|
824
|
+
const providerID = currentProviderID(api.state.session.messages(props.session_id));
|
|
825
|
+
return /* @__PURE__ */ jsx(BottomUsage, {
|
|
826
|
+
theme: ctx.theme.current,
|
|
827
|
+
window: usageForProvider(states(), providerID)
|
|
828
|
+
});
|
|
829
|
+
},
|
|
830
|
+
sidebar_content(ctx) {
|
|
831
|
+
return /* @__PURE__ */ jsx(UsageLimitsPanel, {
|
|
832
|
+
showErrors: showErrors(),
|
|
833
|
+
states: states(),
|
|
834
|
+
theme: ctx.theme.current
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region src/index.ts
|
|
842
|
+
/** OpenCode plugin module exported for the `oc-usage-limits-plugin/tui` entry. */
|
|
843
|
+
var src_default = {
|
|
844
|
+
id: "mynameistito.usage-limits",
|
|
845
|
+
tui
|
|
846
|
+
};
|
|
847
|
+
//#endregion
|
|
848
|
+
export { src_default as default };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/mynameistito/oc-usage-limits-plugin/main/usage-limits.schema.json",
|
|
3
|
+
"enabled": true,
|
|
4
|
+
"refreshIntervalSeconds": 60,
|
|
5
|
+
"requestTimeoutMs": 10000,
|
|
6
|
+
"showErrors": true,
|
|
7
|
+
"providers": {
|
|
8
|
+
"codex": {
|
|
9
|
+
"enabled": true,
|
|
10
|
+
"label": "Codex",
|
|
11
|
+
"authPath": "~/.codex/auth.json",
|
|
12
|
+
},
|
|
13
|
+
"zai": {
|
|
14
|
+
"enabled": true,
|
|
15
|
+
"label": "ZAI",
|
|
16
|
+
"authPath": "~/.local/share/opencode/auth.json",
|
|
17
|
+
"apiKey": "{env:OC_ZAI_API_KEY}",
|
|
18
|
+
"authorizationScheme": "raw",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,24 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oc-usage-limits-plugin",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "OpenCode TUI plugin that shows usage limits in the sidebar and prompt footer.",
|
|
5
|
-
"keywords": [
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "OpenCode TUI plugin that shows Codex and ZAI usage limits in the sidebar and prompt footer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"codex",
|
|
7
|
+
"opencode",
|
|
8
|
+
"opencode-plugin",
|
|
9
|
+
"tui",
|
|
10
|
+
"usage-limits",
|
|
11
|
+
"zai"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/mynameistito/oc-usage-limits-plugin#readme",
|
|
7
14
|
"bugs": {
|
|
8
|
-
"url": "https://github.com/mynameistito/oc-usage-limits/issues"
|
|
15
|
+
"url": "https://github.com/mynameistito/oc-usage-limits-plugin/issues"
|
|
9
16
|
},
|
|
10
17
|
"license": "MIT",
|
|
11
18
|
"repository": {
|
|
12
19
|
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/mynameistito/oc-usage-limits.git"
|
|
20
|
+
"url": "git+https://github.com/mynameistito/oc-usage-limits-plugin.git"
|
|
14
21
|
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"examples",
|
|
25
|
+
"usage-limits.schema.json",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
15
29
|
"type": "module",
|
|
16
30
|
"exports": {
|
|
17
|
-
"
|
|
18
|
-
|
|
31
|
+
"./tui": {
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"import": "./dist/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./schema": "./usage-limits.schema.json"
|
|
19
36
|
},
|
|
20
|
-
"files": ["index.ts"],
|
|
21
37
|
"publishConfig": {
|
|
22
38
|
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"changeset": "changeset",
|
|
42
|
+
"changeset-add": "bun ./scripts/changeset-add.ts",
|
|
43
|
+
"build": "tsdown",
|
|
44
|
+
"check": "ultracite check",
|
|
45
|
+
"knip": "bunx knip@latest",
|
|
46
|
+
"prepack": "bun run build",
|
|
47
|
+
"release": "changeset publish",
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"typecheck": "tsgo --noEmit",
|
|
50
|
+
"version": "changeset version",
|
|
51
|
+
"fix": "ultracite fix",
|
|
52
|
+
"prepare": "lefthook install"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@changesets/cli": "^2.31.0",
|
|
56
|
+
"@opencode-ai/plugin": "1.17.9",
|
|
57
|
+
"@opentui/core": "0.4.1",
|
|
58
|
+
"@opentui/solid": "0.4.1",
|
|
59
|
+
"@types/bun": "^1.3.14",
|
|
60
|
+
"@types/node": "^26.0.0",
|
|
61
|
+
"@typescript/native-preview": "^7.0.0-dev.20260622.1",
|
|
62
|
+
"lefthook": "^2.1.9",
|
|
63
|
+
"oxfmt": "^0.56.0",
|
|
64
|
+
"oxlint": "^1.71.0",
|
|
65
|
+
"solid-js": "1.9.12",
|
|
66
|
+
"tsdown": "^0.22.3",
|
|
67
|
+
"ultracite": "7.8.3"
|
|
68
|
+
},
|
|
69
|
+
"peerDependencies": {
|
|
70
|
+
"@opencode-ai/plugin": ">=1.17.9",
|
|
71
|
+
"@opentui/solid": ">=0.4.1",
|
|
72
|
+
"solid-js": ">=1.9.12"
|
|
23
73
|
}
|
|
24
74
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/mynameistito/oc-usage-limits-plugin/main/usage-limits.schema.json",
|
|
4
|
+
"title": "OpenCode Usage Limits Plugin Config",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"$schema": { "type": "string" },
|
|
9
|
+
"enabled": { "type": "boolean" },
|
|
10
|
+
"refreshIntervalSeconds": { "type": "number", "minimum": 15 },
|
|
11
|
+
"requestTimeoutMs": { "type": "number", "minimum": 1000 },
|
|
12
|
+
"showErrors": { "type": "boolean" },
|
|
13
|
+
"providers": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"properties": {
|
|
17
|
+
"codex": { "$ref": "#/$defs/provider" },
|
|
18
|
+
"zai": { "$ref": "#/$defs/provider" }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"$defs": {
|
|
23
|
+
"provider": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"properties": {
|
|
27
|
+
"enabled": { "type": "boolean" },
|
|
28
|
+
"label": { "type": "string" },
|
|
29
|
+
"authPath": { "type": "string" },
|
|
30
|
+
"apiKey": { "type": "string" },
|
|
31
|
+
"authorizationScheme": { "enum": ["raw", "bearer"] },
|
|
32
|
+
"baseUrl": { "type": "string" }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|