usage-panel-opencode 0.0.0-stage → 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/README.md +70 -2
- package/package.json +56 -4
- package/src/config.ts +62 -0
- package/src/core/account.ts +74 -0
- package/src/core/backoff.ts +32 -0
- package/src/core/claims.ts +49 -0
- package/src/core/redact.ts +151 -0
- package/src/core/snapshot.ts +276 -0
- package/src/core/state.ts +42 -0
- package/src/core/usage-client.ts +168 -0
- package/src/core/usage-headers.ts +32 -0
- package/src/core/windows.ts +102 -0
- package/src/index.ts +761 -0
- package/src/rpc.ts +313 -0
- package/src/tui.tsx +158 -0
- package/src/ui/detail.tsx +253 -0
- package/src/ui/format.ts +13 -0
- package/src/ui/sidebar.tsx +228 -0
- package/src/ui/theme.ts +20 -0
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { Rpc } from '@opencode/plugin/rpc';
|
|
2
|
+
import type { PanelPreferences } from './config';
|
|
3
|
+
|
|
4
|
+
export type PanelState = 'loading' | 'ready' | 'stale' | 'no_subscription' | 'needs_auth' | 'error';
|
|
5
|
+
|
|
6
|
+
export type CredentialKind = 'oauth' | 'key' | 'none';
|
|
7
|
+
export type WindowKind = 'five_hour' | 'weekly' | 'other';
|
|
8
|
+
export type WindowSource = 'service' | 'headers';
|
|
9
|
+
export type WindowAvailability = 'applicable' | 'not_applicable' | 'unknown';
|
|
10
|
+
export type OriginKind = 'service' | 'headers' | 'mixed' | 'none';
|
|
11
|
+
export type PanelErrorKind =
|
|
12
|
+
| 'network'
|
|
13
|
+
| 'rate_limited'
|
|
14
|
+
| 'unauthorized'
|
|
15
|
+
| 'forbidden'
|
|
16
|
+
| 'malformed'
|
|
17
|
+
| 'unknown';
|
|
18
|
+
|
|
19
|
+
export interface SubscriptionAccount {
|
|
20
|
+
credentialKind: CredentialKind;
|
|
21
|
+
label?: string | null;
|
|
22
|
+
/** A masked identifier: the last four characters only. */
|
|
23
|
+
accountId?: string | null;
|
|
24
|
+
planHint?: string | null;
|
|
25
|
+
organization?: boolean | null;
|
|
26
|
+
expiresAt?: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UsageWindow {
|
|
30
|
+
kind: WindowKind;
|
|
31
|
+
label: string;
|
|
32
|
+
applicable: boolean;
|
|
33
|
+
/** Missing for older snapshots; new snapshots distinguish no evidence from not-applicable. */
|
|
34
|
+
availability?: WindowAvailability;
|
|
35
|
+
usedPercent: number | null;
|
|
36
|
+
remainingPercent: number | null;
|
|
37
|
+
resetsAt: number | null;
|
|
38
|
+
source: WindowSource;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SnapshotOrigin {
|
|
42
|
+
kind: OriginKind;
|
|
43
|
+
fetchedAt: number | null;
|
|
44
|
+
detail: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UsageSnapshot {
|
|
48
|
+
account: SubscriptionAccount;
|
|
49
|
+
plan: { type: string; label: string } | null;
|
|
50
|
+
windows: UsageWindow[];
|
|
51
|
+
origin: SnapshotOrigin;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PanelError {
|
|
55
|
+
kind: PanelErrorKind;
|
|
56
|
+
message: string;
|
|
57
|
+
action?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RefreshNotice {
|
|
61
|
+
kind: 'limited' | 'in_progress';
|
|
62
|
+
retryAt: number;
|
|
63
|
+
message: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PanelSnapshot {
|
|
67
|
+
state: PanelState;
|
|
68
|
+
snapshot: UsageSnapshot | null;
|
|
69
|
+
error?: PanelError | null;
|
|
70
|
+
refreshNotice?: RefreshNotice | null;
|
|
71
|
+
preferences: PanelPreferences;
|
|
72
|
+
ageMs?: number;
|
|
73
|
+
/** Next automatic retry, when a recoverable failure has scheduled one. */
|
|
74
|
+
retryAt?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface UsageUpdateEvent {
|
|
78
|
+
instanceId: string;
|
|
79
|
+
data: PanelSnapshot;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PreferenceUpdate {
|
|
83
|
+
enabled?: boolean;
|
|
84
|
+
intervalSeconds?: number;
|
|
85
|
+
visible?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const emptyObjectSchema = {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {},
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
const instanceInputSchema = {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: { instanceId: { type: 'string', minLength: 1, maxLength: 128 } },
|
|
97
|
+
required: ['instanceId'],
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
} as const;
|
|
100
|
+
|
|
101
|
+
const nullableStringSchema = {
|
|
102
|
+
anyOf: [{ type: 'string' }, { type: 'null' }],
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
const nullableNumberSchema = {
|
|
106
|
+
anyOf: [{ type: 'number' }, { type: 'null' }],
|
|
107
|
+
} as const;
|
|
108
|
+
|
|
109
|
+
const panelSnapshotSchema = {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
state: {
|
|
113
|
+
type: 'string',
|
|
114
|
+
enum: ['loading', 'ready', 'stale', 'no_subscription', 'needs_auth', 'error'],
|
|
115
|
+
},
|
|
116
|
+
snapshot: {
|
|
117
|
+
anyOf: [
|
|
118
|
+
{
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
account: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
credentialKind: { type: 'string', enum: ['oauth', 'key', 'none'] },
|
|
125
|
+
label: nullableStringSchema,
|
|
126
|
+
accountId: {
|
|
127
|
+
// OpenCode intentionally disables JSON Schema `pattern` validation for RPCs.
|
|
128
|
+
// The server masks identifiers before returning snapshots.
|
|
129
|
+
anyOf: [{ type: 'string' }, { type: 'null' }],
|
|
130
|
+
},
|
|
131
|
+
planHint: nullableStringSchema,
|
|
132
|
+
organization: {
|
|
133
|
+
anyOf: [{ type: 'boolean' }, { type: 'null' }],
|
|
134
|
+
},
|
|
135
|
+
expiresAt: nullableNumberSchema,
|
|
136
|
+
},
|
|
137
|
+
required: ['credentialKind'],
|
|
138
|
+
additionalProperties: false,
|
|
139
|
+
},
|
|
140
|
+
plan: {
|
|
141
|
+
anyOf: [
|
|
142
|
+
{
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
type: { type: 'string' },
|
|
146
|
+
label: { type: 'string' },
|
|
147
|
+
},
|
|
148
|
+
required: ['type', 'label'],
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
},
|
|
151
|
+
{ type: 'null' },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
windows: {
|
|
155
|
+
type: 'array',
|
|
156
|
+
minItems: 0,
|
|
157
|
+
maxItems: 8,
|
|
158
|
+
items: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
kind: { type: 'string', enum: ['five_hour', 'weekly', 'other'] },
|
|
162
|
+
label: { type: 'string', minLength: 1 },
|
|
163
|
+
applicable: { type: 'boolean' },
|
|
164
|
+
availability: {
|
|
165
|
+
type: 'string',
|
|
166
|
+
enum: ['applicable', 'not_applicable', 'unknown'],
|
|
167
|
+
},
|
|
168
|
+
usedPercent: {
|
|
169
|
+
anyOf: [{ type: 'number', minimum: 0, maximum: 100 }, { type: 'null' }],
|
|
170
|
+
},
|
|
171
|
+
remainingPercent: {
|
|
172
|
+
anyOf: [{ type: 'number', minimum: 0, maximum: 100 }, { type: 'null' }],
|
|
173
|
+
},
|
|
174
|
+
resetsAt: nullableNumberSchema,
|
|
175
|
+
source: { type: 'string', enum: ['service', 'headers'] },
|
|
176
|
+
},
|
|
177
|
+
required: [
|
|
178
|
+
'kind',
|
|
179
|
+
'label',
|
|
180
|
+
'applicable',
|
|
181
|
+
'usedPercent',
|
|
182
|
+
'remainingPercent',
|
|
183
|
+
'resetsAt',
|
|
184
|
+
'source',
|
|
185
|
+
],
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
origin: {
|
|
190
|
+
type: 'object',
|
|
191
|
+
properties: {
|
|
192
|
+
kind: { type: 'string', enum: ['service', 'headers', 'mixed', 'none'] },
|
|
193
|
+
fetchedAt: nullableNumberSchema,
|
|
194
|
+
detail: nullableStringSchema,
|
|
195
|
+
},
|
|
196
|
+
required: ['kind', 'fetchedAt', 'detail'],
|
|
197
|
+
additionalProperties: false,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
required: ['account', 'plan', 'windows', 'origin'],
|
|
201
|
+
additionalProperties: false,
|
|
202
|
+
},
|
|
203
|
+
{ type: 'null' },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
error: {
|
|
207
|
+
anyOf: [
|
|
208
|
+
{
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: {
|
|
211
|
+
kind: {
|
|
212
|
+
type: 'string',
|
|
213
|
+
enum: [
|
|
214
|
+
'network',
|
|
215
|
+
'rate_limited',
|
|
216
|
+
'unauthorized',
|
|
217
|
+
'forbidden',
|
|
218
|
+
'malformed',
|
|
219
|
+
'unknown',
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
message: { type: 'string' },
|
|
223
|
+
action: nullableStringSchema,
|
|
224
|
+
},
|
|
225
|
+
required: ['kind', 'message'],
|
|
226
|
+
additionalProperties: false,
|
|
227
|
+
},
|
|
228
|
+
{ type: 'null' },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
refreshNotice: {
|
|
232
|
+
anyOf: [
|
|
233
|
+
{
|
|
234
|
+
type: 'object',
|
|
235
|
+
properties: {
|
|
236
|
+
kind: { type: 'string', enum: ['limited', 'in_progress'] },
|
|
237
|
+
retryAt: { type: 'number', minimum: 0 },
|
|
238
|
+
message: { type: 'string' },
|
|
239
|
+
},
|
|
240
|
+
required: ['kind', 'retryAt', 'message'],
|
|
241
|
+
additionalProperties: false,
|
|
242
|
+
},
|
|
243
|
+
{ type: 'null' },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
preferences: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
properties: {
|
|
249
|
+
enabled: { type: 'boolean' },
|
|
250
|
+
intervalSeconds: { type: 'integer', minimum: 60 },
|
|
251
|
+
visible: { type: 'boolean' },
|
|
252
|
+
},
|
|
253
|
+
required: ['enabled', 'intervalSeconds', 'visible'],
|
|
254
|
+
additionalProperties: false,
|
|
255
|
+
},
|
|
256
|
+
ageMs: { type: 'number', minimum: 0 },
|
|
257
|
+
retryAt: { type: 'number', minimum: 0 },
|
|
258
|
+
},
|
|
259
|
+
required: ['state', 'snapshot', 'preferences'],
|
|
260
|
+
additionalProperties: false,
|
|
261
|
+
} as const;
|
|
262
|
+
|
|
263
|
+
const preferenceUpdateSchema = {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
enabled: { type: 'boolean' },
|
|
267
|
+
// Deliberately no minimum here: the server normalizes integer values below 60 to 60.
|
|
268
|
+
intervalSeconds: { type: 'integer' },
|
|
269
|
+
visible: { type: 'boolean' },
|
|
270
|
+
},
|
|
271
|
+
additionalProperties: false,
|
|
272
|
+
} as const;
|
|
273
|
+
|
|
274
|
+
export const usageRpcDefinition = {
|
|
275
|
+
id: 'openai-usage',
|
|
276
|
+
methods: {
|
|
277
|
+
snapshot: {
|
|
278
|
+
input: instanceInputSchema,
|
|
279
|
+
output: panelSnapshotSchema,
|
|
280
|
+
},
|
|
281
|
+
refresh: {
|
|
282
|
+
input: instanceInputSchema,
|
|
283
|
+
output: panelSnapshotSchema,
|
|
284
|
+
},
|
|
285
|
+
setPreferences: {
|
|
286
|
+
input: preferenceUpdateSchema,
|
|
287
|
+
output: panelSnapshotSchema,
|
|
288
|
+
},
|
|
289
|
+
start: {
|
|
290
|
+
input: instanceInputSchema,
|
|
291
|
+
output: emptyObjectSchema,
|
|
292
|
+
},
|
|
293
|
+
stop: {
|
|
294
|
+
input: instanceInputSchema,
|
|
295
|
+
output: emptyObjectSchema,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
events: {
|
|
299
|
+
updated: {
|
|
300
|
+
schema: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
properties: {
|
|
303
|
+
instanceId: { type: 'string', minLength: 1, maxLength: 128 },
|
|
304
|
+
data: panelSnapshotSchema,
|
|
305
|
+
},
|
|
306
|
+
required: ['instanceId', 'data'],
|
|
307
|
+
additionalProperties: false,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
} as const;
|
|
312
|
+
|
|
313
|
+
export const Usage = Rpc.define(usageRpcDefinition);
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Plugin } from '@opencode/plugin/tui';
|
|
2
|
+
import { createSignal } from 'solid-js';
|
|
3
|
+
import { DEFAULT_PREFERENCES } from './config';
|
|
4
|
+
import type { PanelError, PanelSnapshot } from './rpc';
|
|
5
|
+
import { Usage } from './rpc';
|
|
6
|
+
import { Detail } from './ui/detail';
|
|
7
|
+
import { Sidebar, sidebarRequiredRows, sidebarWidthForTerminal } from './ui/sidebar';
|
|
8
|
+
|
|
9
|
+
const DETAIL_ROUTE_NAME = 'openai-usage-detail';
|
|
10
|
+
|
|
11
|
+
const initialPanel: PanelSnapshot = {
|
|
12
|
+
state: 'loading',
|
|
13
|
+
snapshot: null,
|
|
14
|
+
preferences: { ...DEFAULT_PREFERENCES },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function describeRefreshError(_error: unknown): PanelError {
|
|
18
|
+
return {
|
|
19
|
+
kind: 'unknown',
|
|
20
|
+
message: 'No se pudo actualizar el consumo de OpenAI.',
|
|
21
|
+
action: 'Comprueba la conexión y vuelve a intentarlo.',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function locationKey(directory: string): string {
|
|
26
|
+
const normalized = directory.replaceAll('\\', '/').replace(/\/+$/, '') || '/';
|
|
27
|
+
return /^[A-Za-z]:\//.test(normalized) ? normalized.toLowerCase() : normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default Plugin.define({
|
|
31
|
+
id: 'openai-usage.tui',
|
|
32
|
+
setup(context) {
|
|
33
|
+
const instanceId = globalThis.crypto.randomUUID();
|
|
34
|
+
const [panel, setPanel] = createSignal<PanelSnapshot>(initialPanel);
|
|
35
|
+
const [refreshing, setRefreshing] = createSignal(false);
|
|
36
|
+
const usage = context.client.rpc(Usage);
|
|
37
|
+
const location = context.location ?? context.data.location.default();
|
|
38
|
+
const rpcOptions = { location };
|
|
39
|
+
const unsubscribe = usage.events.on('updated', (event) => {
|
|
40
|
+
if (locationKey(event.location.directory) !== locationKey(location.directory)) return;
|
|
41
|
+
const update = event.data as unknown as { instanceId?: string; data?: PanelSnapshot };
|
|
42
|
+
if (update.instanceId !== instanceId || !update.data) return;
|
|
43
|
+
setPanel(update.data);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const openDetail = () => {
|
|
47
|
+
context.ui.router.navigate({ type: 'plugin', name: DETAIL_ROUTE_NAME });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const sidebarWidth = () => {
|
|
51
|
+
const terminalWidth = context.renderer.terminalWidth;
|
|
52
|
+
const terminalHeight = context.renderer.terminalHeight;
|
|
53
|
+
const availableWidth = sidebarWidthForTerminal(terminalWidth, terminalHeight, 0);
|
|
54
|
+
if (availableWidth === null) return null;
|
|
55
|
+
return sidebarWidthForTerminal(
|
|
56
|
+
terminalWidth,
|
|
57
|
+
terminalHeight,
|
|
58
|
+
sidebarRequiredRows(panel(), refreshing(), availableWidth),
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const refreshNow = async () => {
|
|
63
|
+
if (refreshing()) return;
|
|
64
|
+
setRefreshing(true);
|
|
65
|
+
try {
|
|
66
|
+
const snapshot = await usage.refresh({ instanceId }, rpcOptions);
|
|
67
|
+
setPanel(snapshot as unknown as PanelSnapshot);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
setPanel((current) => ({
|
|
70
|
+
...current,
|
|
71
|
+
state: current.snapshot ? 'stale' : 'error',
|
|
72
|
+
error: describeRefreshError(error),
|
|
73
|
+
}));
|
|
74
|
+
} finally {
|
|
75
|
+
setRefreshing(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const unregisterRoute = context.ui.router.register({
|
|
80
|
+
name: DETAIL_ROUTE_NAME,
|
|
81
|
+
render: () => (
|
|
82
|
+
<Detail
|
|
83
|
+
panel={panel()}
|
|
84
|
+
refreshing={refreshing()}
|
|
85
|
+
onRefresh={() => void refreshNow()}
|
|
86
|
+
onBack={() => context.ui.router.navigate({ type: 'home' })}
|
|
87
|
+
theme={context.theme}
|
|
88
|
+
/>
|
|
89
|
+
),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
context.keymap.layer(() => ({
|
|
93
|
+
mode: 'global',
|
|
94
|
+
commands: [
|
|
95
|
+
{
|
|
96
|
+
id: 'openai-usage.detail',
|
|
97
|
+
title: 'Abrir detalle del consumo de OpenAI',
|
|
98
|
+
description: 'Muestra el desglose y el origen de las cuotas.',
|
|
99
|
+
group: 'Consumo de OpenAI',
|
|
100
|
+
bind: false,
|
|
101
|
+
palette: true,
|
|
102
|
+
slash: { name: 'usage-detail', aliases: ['openai-usage-detail'] },
|
|
103
|
+
enabled: () => panel().preferences.enabled,
|
|
104
|
+
run: openDetail,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'openai-usage.refresh',
|
|
108
|
+
title: 'Actualizar consumo de OpenAI',
|
|
109
|
+
description: 'Consulta ahora el consumo respetando el intervalo mínimo.',
|
|
110
|
+
group: 'Consumo de OpenAI',
|
|
111
|
+
bind: false,
|
|
112
|
+
palette: true,
|
|
113
|
+
slash: { name: 'usage-refresh', aliases: ['openai-usage-refresh'] },
|
|
114
|
+
enabled: () => panel().preferences.enabled,
|
|
115
|
+
run: refreshNow,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const unregisterSlot = context.ui.slot({
|
|
121
|
+
append: 'sidebar.content',
|
|
122
|
+
render: () => (
|
|
123
|
+
<Sidebar
|
|
124
|
+
panel={panel()}
|
|
125
|
+
refreshing={refreshing()}
|
|
126
|
+
theme={context.theme}
|
|
127
|
+
width={sidebarWidth()}
|
|
128
|
+
onOpenDetail={openDetail}
|
|
129
|
+
/>
|
|
130
|
+
),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
void usage
|
|
134
|
+
.start({ instanceId }, rpcOptions)
|
|
135
|
+
.then(() => usage.snapshot({ instanceId }, rpcOptions))
|
|
136
|
+
.then((snapshot) => {
|
|
137
|
+
setPanel(snapshot as unknown as PanelSnapshot);
|
|
138
|
+
})
|
|
139
|
+
.catch(() => {
|
|
140
|
+
setPanel((current) => ({
|
|
141
|
+
...current,
|
|
142
|
+
state: current.snapshot ? 'stale' : 'error',
|
|
143
|
+
error: {
|
|
144
|
+
kind: 'unknown',
|
|
145
|
+
message: 'No se pudo iniciar el panel de consumo.',
|
|
146
|
+
action: 'Comprueba que el plugin de servidor esté cargado.',
|
|
147
|
+
},
|
|
148
|
+
}));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return () => {
|
|
152
|
+
unsubscribe();
|
|
153
|
+
unregisterRoute();
|
|
154
|
+
unregisterSlot();
|
|
155
|
+
void usage.stop({ instanceId }, rpcOptions).catch(() => undefined);
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
});
|