@genee/omp-opsx-addon 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 +83 -0
- package/index.ts +1327 -0
- package/lib/agent-defs.ts +291 -0
- package/lib/change-scanner.ts +72 -0
- package/lib/cursor-auth.ts +230 -0
- package/lib/direct-fetchers.ts +155 -0
- package/lib/edit-variant-pin.ts +156 -0
- package/lib/error-scan.ts +97 -0
- package/lib/family-filter.ts +174 -0
- package/lib/model-selector.ts +307 -0
- package/lib/model-tiers.ts +164 -0
- package/lib/notify.ts +4 -0
- package/lib/openspec-tasks.ts +28 -0
- package/lib/peak-detector.ts +44 -0
- package/lib/reachability-cache.ts +288 -0
- package/lib/reachability-probe.ts +459 -0
- package/lib/reachability-refresh.ts +84 -0
- package/lib/selection-filters.ts +100 -0
- package/lib/selection-lifecycle.ts +17 -0
- package/lib/session-title.ts +186 -0
- package/lib/session-usage.ts +252 -0
- package/lib/state.ts +95 -0
- package/lib/system-prompt.ts +160 -0
- package/lib/tiers-data.ts +136 -0
- package/lib/tiers-updater.ts +146 -0
- package/lib/unified-config.ts +463 -0
- package/lib/usage-estimator.ts +154 -0
- package/lib/usage-poller.ts +1200 -0
- package/lib/usage-poller.worker.ts +31 -0
- package/lib/usage-redis-client.ts +594 -0
- package/lib/usage-redis-multiproc.worker.ts +80 -0
- package/lib/usage-redis-probe.ts +389 -0
- package/lib/usage-render.ts +526 -0
- package/lib/usage-resolver.ts +477 -0
- package/lib/usage-sampler.ts +77 -0
- package/lib/usage-widget.ts +112 -0
- package/package.json +47 -0
- package/types/bun.d.ts +21 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider usage health resolver.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `ctx.modelRegistry.authStorage.fetchUsageReports` and (optionally) a
|
|
5
|
+
* list of direct fetchers (mirroring `omp-plan-usage`) into a normalized
|
|
6
|
+
* `Map<provider, ProviderHealth>` so the model-selector can filter / rank by
|
|
7
|
+
* quota state without knowing the OMP auth-storage API.
|
|
8
|
+
*
|
|
9
|
+
* Direct fetchers are injected so the plugin stays decoupled from
|
|
10
|
+
* `omp-plan-usage`'s internal list. If none are provided, the resolver falls
|
|
11
|
+
* back to whatever `fetchUsageReports` returns (which already includes
|
|
12
|
+
* OMP-managed providers).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { UsageReport, UsageStatus } from '@oh-my-pi/pi-ai';
|
|
16
|
+
|
|
17
|
+
export type ApiKeyResolver = (provider: string) => Promise<string | undefined>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A provider-bound key resolver: the caller (gatherReports) has already bound a
|
|
21
|
+
* fetcher's `provider`, so each fetcher asks for "my key" with no argument.
|
|
22
|
+
* This keeps provider id strings out of fetcher bodies — a fetcher's identity
|
|
23
|
+
* lives only in its `DIRECT_FETCHERS` registration, never hardcoded inside it.
|
|
24
|
+
*/
|
|
25
|
+
export type BoundApiKeyResolver = () => Promise<string | undefined>;
|
|
26
|
+
|
|
27
|
+
export interface DirectFetcher {
|
|
28
|
+
provider: string;
|
|
29
|
+
fetch(signal?: AbortSignal, getApiKey?: BoundApiKeyResolver): Promise<UsageReport | null>;
|
|
30
|
+
/**
|
|
31
|
+
* Sync, local-only credential probe. When true, this provider is treated as
|
|
32
|
+
* logged-in for gather/widget even if absent from authStorage.list().
|
|
33
|
+
*/
|
|
34
|
+
credentialsPresent?: () => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Provider id aliases (currently empty). All fetchers and reports use the same
|
|
39
|
+
* provider id as the auth/model registry (e.g. `zhipu-coding-plan`,
|
|
40
|
+
* `minimax-code-cn`), so no canonicalization is needed.
|
|
41
|
+
*/
|
|
42
|
+
const PROVIDER_ALIAS: Record<string, string> = {};
|
|
43
|
+
|
|
44
|
+
/** Normalize a provider id to its canonical form (no-op if already canonical). */
|
|
45
|
+
export function canonicalizeProvider(id: string): string {
|
|
46
|
+
return PROVIDER_ALIAS[id] ?? id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Providers whose direct fetcher reports locally-detectable credentials (no network). */
|
|
50
|
+
export function listDetectableProviders(directFetchers: readonly DirectFetcher[]): string[] {
|
|
51
|
+
const out: string[] = [];
|
|
52
|
+
for (const f of directFetchers) {
|
|
53
|
+
try {
|
|
54
|
+
if (f.credentialsPresent?.()) out.push(canonicalizeProvider(f.provider));
|
|
55
|
+
} catch {
|
|
56
|
+
/* ignore probe errors */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...new Set(out)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type QuotaPolicy = 'conjunctive' | 'bucket' | 'balance';
|
|
63
|
+
|
|
64
|
+
export interface WindowHealth {
|
|
65
|
+
id: string;
|
|
66
|
+
remaining?: number;
|
|
67
|
+
exhausted: boolean;
|
|
68
|
+
status: UsageStatus;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ProviderHealth {
|
|
72
|
+
provider: string;
|
|
73
|
+
policy: QuotaPolicy;
|
|
74
|
+
/** Provider-wide shortcut: conjunctive any required window; bucket no usable bucket; balance remaining ≤ 0. */
|
|
75
|
+
exhausted: boolean;
|
|
76
|
+
windows: WindowHealth[];
|
|
77
|
+
autoBucketModels?: string[];
|
|
78
|
+
/** Best (lowest) usedFraction across all limit windows. 0..1+. Undefined = unknown. */
|
|
79
|
+
bestUsedFraction?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Conjunctive/balance: min remaining. Cursor: max remaining among still-usable buckets.
|
|
82
|
+
*/
|
|
83
|
+
worstRemainingFraction?: number;
|
|
84
|
+
/** Overall status: ok / warning / exhausted / unknown. */
|
|
85
|
+
status: UsageStatus;
|
|
86
|
+
/** Window labels whose status is exhausted. */
|
|
87
|
+
exhaustedWindows: string[];
|
|
88
|
+
fetchedAt: number;
|
|
89
|
+
notes?: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const HEALTHY_REMAINING_THRESHOLD = 0.1;
|
|
93
|
+
|
|
94
|
+
const REQUIRED_WINDOWS: Record<string, { policy: QuotaPolicy; required?: readonly string[] }> = {
|
|
95
|
+
'zhipu-coding-plan': { policy: 'conjunctive', required: ['5h', '1w'] },
|
|
96
|
+
'minimax-code-cn': { policy: 'conjunctive', required: ['5h', '1w'] },
|
|
97
|
+
'opencode-go': { policy: 'conjunctive', required: ['5h', '1w', '1mo'] },
|
|
98
|
+
kimi: { policy: 'conjunctive' },
|
|
99
|
+
'openai-codex': { policy: 'conjunctive' },
|
|
100
|
+
cursor: { policy: 'bucket' },
|
|
101
|
+
deepseek: { policy: 'balance' },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function quotaPolicyFor(provider: string): QuotaPolicy {
|
|
105
|
+
return REQUIRED_WINDOWS[provider]?.policy ?? 'conjunctive';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractFraction(amount: {
|
|
109
|
+
usedFraction?: number;
|
|
110
|
+
remainingFraction?: number;
|
|
111
|
+
used?: number;
|
|
112
|
+
limit?: number;
|
|
113
|
+
remaining?: number;
|
|
114
|
+
}): number | undefined {
|
|
115
|
+
if (typeof amount.usedFraction === 'number') return amount.usedFraction;
|
|
116
|
+
if (typeof amount.remainingFraction === 'number') return 1 - amount.remainingFraction;
|
|
117
|
+
if (typeof amount.used === 'number' && typeof amount.limit === 'number' && amount.limit > 0) {
|
|
118
|
+
return amount.used / amount.limit;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function inferCursorBucket(limit: UsageReport['limits'][number]): 'builtin' | 'other' | 'ondemand' | undefined {
|
|
124
|
+
const hay = `${limit.id ?? ''} ${limit.label ?? ''} ${limit.window?.id ?? ''} ${limit.scope?.windowId ?? ''}`.toLowerCase();
|
|
125
|
+
if (
|
|
126
|
+
hay.includes('ondemand') ||
|
|
127
|
+
hay.includes('on-demand') ||
|
|
128
|
+
hay.includes('spendlimit') ||
|
|
129
|
+
hay.includes('按需')
|
|
130
|
+
) {
|
|
131
|
+
return 'ondemand';
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
hay.includes('cursor models') ||
|
|
135
|
+
hay.includes('planusage') ||
|
|
136
|
+
hay.includes('autopercent') ||
|
|
137
|
+
hay.includes('builtin') ||
|
|
138
|
+
hay.includes('内置')
|
|
139
|
+
) {
|
|
140
|
+
return 'builtin';
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
hay.includes('other models') ||
|
|
144
|
+
hay.includes('named') ||
|
|
145
|
+
hay.includes('apipercent') ||
|
|
146
|
+
hay.includes(':api') ||
|
|
147
|
+
hay.includes('其他')
|
|
148
|
+
) {
|
|
149
|
+
return 'other';
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function inferWindowId(limit: UsageReport['limits'][number]): string {
|
|
155
|
+
const cursorBucket = inferCursorBucket(limit);
|
|
156
|
+
if (cursorBucket) return cursorBucket;
|
|
157
|
+
|
|
158
|
+
const raw = limit.window?.id;
|
|
159
|
+
if (raw) {
|
|
160
|
+
if (raw === 'auto') return 'builtin';
|
|
161
|
+
if (raw === 'api') return 'other';
|
|
162
|
+
if (raw === 'rolling-5h' || raw === 'rolling') return '5h';
|
|
163
|
+
if (raw === 'weekly' || raw === 'week') return '1w';
|
|
164
|
+
if (raw === 'monthly') return '1mo';
|
|
165
|
+
if (raw === 'daily' || raw === 'day') return '1d';
|
|
166
|
+
}
|
|
167
|
+
// Opaque ids with a recognizable duration (OpenCode Go reports its weekly
|
|
168
|
+
// window as "7d"): classify by duration so conjunctive required-window
|
|
169
|
+
// matching sees the same canonical id the 周 chip renders — otherwise an
|
|
170
|
+
// exhausted "7d" window is invisible to the health check and the column
|
|
171
|
+
// keeps showing chips instead of the reset countdown.
|
|
172
|
+
const dur = limit.window?.durationMs;
|
|
173
|
+
if (dur === 18_000_000) return '5h';
|
|
174
|
+
if (dur === 86_400_000) return '1d';
|
|
175
|
+
if (dur === 604_800_000) return '1w';
|
|
176
|
+
if (dur === 2_592_000_000) return '1mo';
|
|
177
|
+
if (raw) return raw;
|
|
178
|
+
const id = limit.id ?? '';
|
|
179
|
+
const after = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
|
|
180
|
+
const known = new Set(['5h', '1w', '1mo', '1d', 'builtin', 'other', 'ondemand', 'default', 'auto', 'api']);
|
|
181
|
+
if (known.has(after)) {
|
|
182
|
+
if (after === 'auto') return 'builtin';
|
|
183
|
+
if (after === 'api') return 'other';
|
|
184
|
+
return after;
|
|
185
|
+
}
|
|
186
|
+
const label = (limit.label ?? '').toLowerCase();
|
|
187
|
+
if (label.includes('5h') || label.includes('5小时')) return '5h';
|
|
188
|
+
if (label.includes('周') || label.includes('week')) return '1w';
|
|
189
|
+
if (label.includes('月') || label.includes('month')) return '1mo';
|
|
190
|
+
return after || 'default';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseAutoBucketModels(notes?: string[]): string[] | undefined {
|
|
194
|
+
if (!notes) return undefined;
|
|
195
|
+
for (const n of notes) {
|
|
196
|
+
if (n.startsWith('autoBucketModels=')) {
|
|
197
|
+
const ids = n
|
|
198
|
+
.slice('autoBucketModels='.length)
|
|
199
|
+
.split(',')
|
|
200
|
+
.map((s) => s.trim())
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
return ids.length > 0 ? ids : undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function windowIsExhausted(limit: UsageReport['limits'][number], frac: number | undefined): boolean {
|
|
209
|
+
if (limit.status === 'warning') return false;
|
|
210
|
+
if (limit.status === 'exhausted') return true;
|
|
211
|
+
if (frac !== undefined && frac >= 1) return true;
|
|
212
|
+
const remaining = (limit.amount as { remaining?: number }).remaining;
|
|
213
|
+
if (typeof remaining === 'number' && remaining <= 0 && frac === undefined) return true;
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function summarizeReport(report: UsageReport): ProviderHealth {
|
|
218
|
+
const policy = quotaPolicyFor(report.provider);
|
|
219
|
+
const windows: WindowHealth[] = [];
|
|
220
|
+
const exhaustedWindows: string[] = [];
|
|
221
|
+
let bestUsed: number | undefined;
|
|
222
|
+
let status: UsageStatus = report.limits.length === 0 ? 'unknown' : 'ok';
|
|
223
|
+
|
|
224
|
+
for (const limit of report.limits) {
|
|
225
|
+
const frac = extractFraction(limit.amount);
|
|
226
|
+
const remaining =
|
|
227
|
+
frac !== undefined
|
|
228
|
+
? 1 - frac
|
|
229
|
+
: typeof (limit.amount as { remainingFraction?: number }).remainingFraction === 'number'
|
|
230
|
+
? (limit.amount as { remainingFraction: number }).remainingFraction
|
|
231
|
+
: undefined;
|
|
232
|
+
const winExhausted = windowIsExhausted(limit, frac);
|
|
233
|
+
const winId = inferWindowId(limit);
|
|
234
|
+
windows.push({
|
|
235
|
+
id: winId,
|
|
236
|
+
remaining,
|
|
237
|
+
exhausted: winExhausted,
|
|
238
|
+
status: (limit.status ?? (winExhausted ? 'exhausted' : 'ok')) as UsageStatus,
|
|
239
|
+
});
|
|
240
|
+
if (frac !== undefined) {
|
|
241
|
+
if (frac >= 1 - HEALTHY_REMAINING_THRESHOLD && !winExhausted && status === 'ok') {
|
|
242
|
+
status = 'warning';
|
|
243
|
+
}
|
|
244
|
+
if (bestUsed === undefined || frac < bestUsed) bestUsed = frac;
|
|
245
|
+
}
|
|
246
|
+
if (winExhausted) exhaustedWindows.push(limit.label);
|
|
247
|
+
else if (limit.status === 'warning' && status === 'ok') status = 'warning';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let exhausted = false;
|
|
251
|
+
let worstRemaining: number | undefined;
|
|
252
|
+
|
|
253
|
+
if (policy === 'bucket') {
|
|
254
|
+
const byId = new Map(windows.map((w) => [w.id, w]));
|
|
255
|
+
const builtin = byId.get('builtin');
|
|
256
|
+
const other = byId.get('other');
|
|
257
|
+
const ondemand = byId.get('ondemand');
|
|
258
|
+
const builtinDead = !builtin || builtin.exhausted;
|
|
259
|
+
const otherDead = !other || other.exhausted;
|
|
260
|
+
const ondemandOk = ondemand && !ondemand.exhausted;
|
|
261
|
+
exhausted = builtinDead && otherDead && !ondemandOk;
|
|
262
|
+
const usable = windows.filter((w) => !w.exhausted && (w.id === 'builtin' || w.id === 'other' || w.id === 'ondemand'));
|
|
263
|
+
for (const w of usable) {
|
|
264
|
+
if (w.remaining === undefined) continue;
|
|
265
|
+
if (worstRemaining === undefined || w.remaining > worstRemaining) worstRemaining = w.remaining;
|
|
266
|
+
}
|
|
267
|
+
} else if (policy === 'balance') {
|
|
268
|
+
const win = windows[0];
|
|
269
|
+
exhausted = windows.some((w) => w.exhausted) || (win?.remaining !== undefined && win.remaining <= 0);
|
|
270
|
+
worstRemaining = windows.reduce<number | undefined>((min, w) => {
|
|
271
|
+
if (w.remaining === undefined) return min;
|
|
272
|
+
return min === undefined ? w.remaining : Math.min(min, w.remaining);
|
|
273
|
+
}, undefined);
|
|
274
|
+
} else {
|
|
275
|
+
const spec = REQUIRED_WINDOWS[report.provider];
|
|
276
|
+
const required = spec?.required;
|
|
277
|
+
const relevant = required ? windows.filter((w) => required.includes(w.id)) : windows;
|
|
278
|
+
exhausted = relevant.some((w) => w.exhausted);
|
|
279
|
+
worstRemaining = relevant.reduce<number | undefined>((min, w) => {
|
|
280
|
+
if (w.remaining === undefined) return min;
|
|
281
|
+
return min === undefined ? w.remaining : Math.min(min, w.remaining);
|
|
282
|
+
}, undefined);
|
|
283
|
+
if (worstRemaining === undefined) {
|
|
284
|
+
worstRemaining = windows.reduce<number | undefined>((min, w) => {
|
|
285
|
+
if (w.remaining === undefined) return min;
|
|
286
|
+
return min === undefined ? w.remaining : Math.min(min, w.remaining);
|
|
287
|
+
}, undefined);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (exhausted) status = 'exhausted';
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
provider: report.provider,
|
|
295
|
+
policy,
|
|
296
|
+
exhausted,
|
|
297
|
+
windows,
|
|
298
|
+
autoBucketModels: parseAutoBucketModels(report.notes),
|
|
299
|
+
bestUsedFraction: bestUsed,
|
|
300
|
+
worstRemainingFraction: worstRemaining,
|
|
301
|
+
status,
|
|
302
|
+
exhaustedWindows,
|
|
303
|
+
fetchedAt: report.fetchedAt,
|
|
304
|
+
notes: report.notes,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Stable key for selection cache invalidation. */
|
|
309
|
+
export function healthCacheKey(health: Map<string, ProviderHealth> | null | undefined): string {
|
|
310
|
+
if (!health || health.size === 0) return '';
|
|
311
|
+
return [...health.keys()]
|
|
312
|
+
.sort()
|
|
313
|
+
.map((p) => {
|
|
314
|
+
const h = health.get(p)!;
|
|
315
|
+
const wins = h.windows.map((w) => `${w.id}:${w.exhausted ? 1 : 0}:${w.remaining ?? ''}`).join(',');
|
|
316
|
+
const ab = (h.autoBucketModels ?? []).join('+');
|
|
317
|
+
return `${p}=${h.exhausted ? 1 : 0};${wins};${ab}`;
|
|
318
|
+
})
|
|
319
|
+
.join('|');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Build a provider → health map from a list of reports, preserving the most recent. */
|
|
323
|
+
export function buildHealthMap(reports: UsageReport[]): Map<string, ProviderHealth> {
|
|
324
|
+
const map = new Map<string, ProviderHealth>();
|
|
325
|
+
for (const r of reports) {
|
|
326
|
+
const next = summarizeReport(r);
|
|
327
|
+
const prev = map.get(r.provider);
|
|
328
|
+
if (!prev || next.fetchedAt > prev.fetchedAt) {
|
|
329
|
+
map.set(r.provider, next);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return map;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Minimal interface for the auth-storage call site we depend on. */
|
|
336
|
+
|
|
337
|
+
export interface AuthStorageLike {
|
|
338
|
+
fetchUsageReports?: (params?: { signal?: AbortSignal }) => Promise<UsageReport[] | undefined | null>;
|
|
339
|
+
/** List all providers with credentials configured. */
|
|
340
|
+
list?: () => string[];
|
|
341
|
+
/** Check if a specific provider has any auth configured. */
|
|
342
|
+
hasAuth?: (provider: string) => boolean;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Resolve usage for all configured providers.
|
|
347
|
+
*
|
|
348
|
+
* @param authStorage AuthStorage-like (in practice `ctx.modelRegistry.authStorage`).
|
|
349
|
+
* @param directFetchers Optional list of direct fetchers (mirrors omp-plan-usage's
|
|
350
|
+
* `DIRECT_FETCHERS`). Useful when the host plugin does not register them.
|
|
351
|
+
* @param signal Optional abort signal forwarded to all fetches.
|
|
352
|
+
* @param getApiKey Optional provider→apiKey resolver (from `modelRegistry.getApiKeyForProvider`).
|
|
353
|
+
*/
|
|
354
|
+
export async function resolveProviderHealth(
|
|
355
|
+
authStorage: AuthStorageLike | undefined,
|
|
356
|
+
directFetchers: DirectFetcher[] = [],
|
|
357
|
+
signal?: AbortSignal,
|
|
358
|
+
getApiKey?: ApiKeyResolver,
|
|
359
|
+
providers?: readonly string[],
|
|
360
|
+
): Promise<Map<string, ProviderHealth>> {
|
|
361
|
+
const reports = await gatherReports(authStorage, directFetchers, signal, getApiKey, providers);
|
|
362
|
+
return buildHealthMap(reports);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface ProviderReports {
|
|
366
|
+
reports: UsageReport[];
|
|
367
|
+
health: Map<string, ProviderHealth>;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Like resolveProviderHealth but also returns the raw UsageReport[]. */
|
|
371
|
+
export async function resolveProviderReports(
|
|
372
|
+
authStorage: AuthStorageLike | undefined,
|
|
373
|
+
directFetchers: DirectFetcher[] = [],
|
|
374
|
+
signal?: AbortSignal,
|
|
375
|
+
getApiKey?: ApiKeyResolver,
|
|
376
|
+
providers?: readonly string[],
|
|
377
|
+
): Promise<ProviderReports> {
|
|
378
|
+
const reports = await gatherReports(authStorage, directFetchers, signal, getApiKey, providers);
|
|
379
|
+
return { reports, health: buildHealthMap(reports) };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Gather raw reports (shared impl).
|
|
384
|
+
*
|
|
385
|
+
* Providers are split by whether a direct fetcher covers them:
|
|
386
|
+
* - direct providers (covered by `directFetchers`) are fetched individually via
|
|
387
|
+
* their fetcher — fully per-provider independent, never touching the bulk API.
|
|
388
|
+
* - non-direct providers fall back to OMP's bulk `fetchUsageReports` (OAuth
|
|
389
|
+
* providers like anthropic/openai). Bulk runs at most once per call.
|
|
390
|
+
*
|
|
391
|
+
* `providers?` (canonical ids) narrows the scope: only those providers are
|
|
392
|
+
* fetched, the rest skipped. When omitted, all logged-in providers are fetched.
|
|
393
|
+
* Logged-in providers come from `authStorage.list()` (canonicalized); when
|
|
394
|
+
* authStorage is unavailable the direct fetchers' providers are used as the
|
|
395
|
+
* fallback universe (for tests / offline mode).
|
|
396
|
+
*/
|
|
397
|
+
async function gatherReports(
|
|
398
|
+
authStorage: AuthStorageLike | undefined,
|
|
399
|
+
directFetchers: DirectFetcher[],
|
|
400
|
+
signal?: AbortSignal,
|
|
401
|
+
getApiKey?: ApiKeyResolver,
|
|
402
|
+
providers?: readonly string[],
|
|
403
|
+
): Promise<UsageReport[]> {
|
|
404
|
+
const reports: UsageReport[] = [];
|
|
405
|
+
// Map direct fetchers by their CANONICAL provider id (PROVIDER_ALIAS merges a
|
|
406
|
+
// login-name registration like `zhipu-coding-plan` with the canonical id
|
|
407
|
+
// `zai-coding-cn` a fetcher returns, so dedup/override keys are stable).
|
|
408
|
+
const directFetcherByProvider = new Map(directFetchers.map((f) => [canonicalizeProvider(f.provider), f]));
|
|
409
|
+
const directIds = new Set(directFetcherByProvider.keys());
|
|
410
|
+
const loggedInSet = authStorage?.list ? new Set(authStorage.list().map(canonicalizeProvider)) : null;
|
|
411
|
+
|
|
412
|
+
// Classify target providers into direct (have a fetcher) vs bulk (need
|
|
413
|
+
// fetchUsageReports). `bulkTargets === null` means "discover via bulk" — used
|
|
414
|
+
// when no provider list is available, so fetchUsageReports is the only way to
|
|
415
|
+
// surface non-direct providers (OAuth providers like anthropic/openai).
|
|
416
|
+
let directTargets: string[];
|
|
417
|
+
let bulkTargets: string[] | null;
|
|
418
|
+
if (providers && providers.length > 0) {
|
|
419
|
+
// Per-provider mode: classify each requested provider directly.
|
|
420
|
+
const canonical = [...new Set(providers.map(canonicalizeProvider))];
|
|
421
|
+
directTargets = canonical.filter((p) => directFetcherByProvider.has(p));
|
|
422
|
+
bulkTargets = canonical.filter((p) => !directFetcherByProvider.has(p));
|
|
423
|
+
} else if (loggedInSet) {
|
|
424
|
+
for (const p2 of listDetectableProviders(directFetchers)) loggedInSet.add(p2);
|
|
425
|
+
directTargets = [...loggedInSet].filter((p) => directFetcherByProvider.has(p));
|
|
426
|
+
bulkTargets = [...loggedInSet].filter((p) => !directFetcherByProvider.has(p));
|
|
427
|
+
} else {
|
|
428
|
+
// No list (tests / offline): direct fetchers are known; bulk discovers the rest.
|
|
429
|
+
directTargets = [...directIds];
|
|
430
|
+
bulkTargets = null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Step 1: bulk fetchUsageReports — ONLY when there are bulk targets to fetch.
|
|
434
|
+
// A direct-fetcher provider never triggers this. Discovery mode (null) calls
|
|
435
|
+
// bulk unconditionally but excludes providers that have direct fetchers.
|
|
436
|
+
if (authStorage?.fetchUsageReports && (bulkTargets === null || bulkTargets.length > 0)) {
|
|
437
|
+
const filterSet = bulkTargets ? new Set(bulkTargets) : null;
|
|
438
|
+
try {
|
|
439
|
+
const fetched = await authStorage.fetchUsageReports({ signal });
|
|
440
|
+
if (Array.isArray(fetched)) {
|
|
441
|
+
for (const r of fetched) {
|
|
442
|
+
// Canonicalize BEFORE filtering so a login-name id merges
|
|
443
|
+
// with the canonical id under the same account.
|
|
444
|
+
const c = canonicalizeProvider(r.provider);
|
|
445
|
+
r.provider = c;
|
|
446
|
+
// filterSet mode: only keep requested bulk providers.
|
|
447
|
+
// null (discovery) mode: keep all except direct-fetcher providers
|
|
448
|
+
// (they'll be fetched directly and would be overridden anyway).
|
|
449
|
+
if (filterSet ? filterSet.has(c) : !directIds.has(c)) reports.push(r);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
/* ignore */
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Step 2: direct fetchers — each provider fetched independently. With no
|
|
458
|
+
// providers filter this is the full direct set; with a filter it is just the
|
|
459
|
+
// requested direct provider(s), enabling per-provider turn_end refresh.
|
|
460
|
+
for (const provider of directTargets) {
|
|
461
|
+
const fetcher = directFetcherByProvider.get(provider)!;
|
|
462
|
+
// Bind the key resolver to this fetcher's own provider so fetchers don't
|
|
463
|
+
// hardcode their id — they just ask for "my key" via the bound resolver.
|
|
464
|
+
const direct = await fetcher.fetch(signal, getApiKey ? () => getApiKey(fetcher.provider) : undefined);
|
|
465
|
+
if (direct) {
|
|
466
|
+
// Dedup against the canonical id: a fetcher registered under a login
|
|
467
|
+
// alias must still override the built-in report for the same account.
|
|
468
|
+
const canonical = canonicalizeProvider(direct.provider);
|
|
469
|
+
direct.provider = canonical;
|
|
470
|
+
const idx = reports.findIndex((r) => r.provider === canonical);
|
|
471
|
+
if (idx >= 0) reports[idx] = direct;
|
|
472
|
+
else reports.push(direct);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return reports;
|
|
477
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level per-provider ring buffers of recent consumption increments,
|
|
3
|
+
* driving each usage widget column's own Braille mini waveform (design
|
|
4
|
+
* Decision 5, revised: per-provider charts, usage-RATE scale, sessionUsage
|
|
5
|
+
* source).
|
|
6
|
+
*
|
|
7
|
+
* Each sample is the provider's consumption DELTA over the sampling interval,
|
|
8
|
+
* not its stock level — a stock-mapped chart barely moves (quota drains slowly)
|
|
9
|
+
* or pins at full the moment a window is exhausted, which carries no signal.
|
|
10
|
+
* The stock the deltas are taken against is the SHARED CACHE's aggregated
|
|
11
|
+
* `sessionUsage` total for the provider (assistant `usage.totalTokens` summed
|
|
12
|
+
* across every session and process — subagents included via cache backflow;
|
|
13
|
+
* see usage-poller.ts / session-usage.ts). The sampler records
|
|
14
|
+
* `max(0, stock - prevStock)` so a counter reset reads as 0, never a spike.
|
|
15
|
+
* Each provider keeps an independent ring buffer. The buffers are strictly
|
|
16
|
+
* read-only wrt selection state: they never touch `selection`, role overrides,
|
|
17
|
+
* or the primary model.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Max samples retained per provider. 40 samples = 40 seconds = the full
|
|
22
|
+
* 40-pixel waveform window (`WAVEFORM_COLS` × 2 half-columns), so the ring
|
|
23
|
+
* is exactly one window: every appended second shifts the chart 1px left,
|
|
24
|
+
* keeping the slide uniform and the strip filled edge to edge.
|
|
25
|
+
*/
|
|
26
|
+
export const RING_SIZE = 40;
|
|
27
|
+
|
|
28
|
+
const buffers = new Map<string, number[]>();
|
|
29
|
+
/** Last observed cumulative-consumption stock per provider (for delta math). */
|
|
30
|
+
const prevStock = new Map<string, number>();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Record one consumption increment for `provider`.
|
|
34
|
+
* - `stock === undefined` → skip entirely (unknown this tick; baseline unchanged,
|
|
35
|
+
* so a later value deltas across the whole gap).
|
|
36
|
+
* - first observation → increment 0 (no interval yet).
|
|
37
|
+
* - stock decrease (window reset) → increment 0, never a spike.
|
|
38
|
+
*/
|
|
39
|
+
export function sampleConsumption(provider: string, stock: number | undefined): void {
|
|
40
|
+
if (stock === undefined) return;
|
|
41
|
+
const prev = prevStock.get(provider);
|
|
42
|
+
const increment = prev === undefined ? 0 : Math.max(0, stock - prev);
|
|
43
|
+
prevStock.set(provider, stock);
|
|
44
|
+
const arr = buffers.get(provider) ?? [];
|
|
45
|
+
arr.push(increment);
|
|
46
|
+
buffers.set(provider, arr.length > RING_SIZE ? arr.slice(-RING_SIZE) : arr);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Append one per-second increment (newest last). The caller is responsible
|
|
51
|
+
* for sampling EVERY elapsed second — including zeros for seconds with no
|
|
52
|
+
* consumption and for ticks the timer skipped — so all providers' buffers
|
|
53
|
+
* stay aligned on the same 1px-per-second time axis.
|
|
54
|
+
*/
|
|
55
|
+
export function appendSample(provider: string, increment: number): void {
|
|
56
|
+
if (!Number.isFinite(increment)) return;
|
|
57
|
+
const arr = buffers.get(provider) ?? [];
|
|
58
|
+
arr.push(increment);
|
|
59
|
+
buffers.set(provider, arr.length > RING_SIZE ? arr.slice(-RING_SIZE) : arr);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Recent consumption increments for a provider, newest last. Read-only. */
|
|
63
|
+
export function getConsumptionSamples(provider: string): readonly number[] {
|
|
64
|
+
return buffers.get(provider) ?? [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
/** Clear every provider's ring buffer and stock baseline. */
|
|
69
|
+
export function clearConsumptionSamples(): void {
|
|
70
|
+
buffers.clear();
|
|
71
|
+
prevStock.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Test seam: fully reset module state. Production code never calls this. */
|
|
75
|
+
export function _resetForTest(): void {
|
|
76
|
+
clearConsumptionSamples();
|
|
77
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { truncateToWidth, type Component } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import type { ExtensionUiComponentFactory } from "@oh-my-pi/pi-coding-agent";
|
|
3
|
+
import type { UsageReport } from "@oh-my-pi/pi-ai";
|
|
4
|
+
import { buildColumn, renderUsageReports, themePainter, type Painter, type ConsumptionTrack } from "./usage-render.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A single provider rendered as a standalone, reusable pi-tui `Component`.
|
|
8
|
+
*
|
|
9
|
+
* `render(width)` returns the compact 2-line column (header + horizontal
|
|
10
|
+
* window chips / reset countdown), each clamped to ≤ `width` via
|
|
11
|
+
* `truncateToWidth`. Today the host drives the whole status strip through
|
|
12
|
+
* `UsageTable`, which reuses the shared `renderUsageReports` flex-wrap kernel
|
|
13
|
+
* rather than assembling cards directly — but this seam lets a future
|
|
14
|
+
* per-provider overlay (expand / detail / quick action) build on a unit that
|
|
15
|
+
* already renders in isolation, without reweaving the render tree.
|
|
16
|
+
*
|
|
17
|
+
* No keyboard interaction is implemented this round: `handleInput` / `dispose`
|
|
18
|
+
* are kept as empty extension points per the design non-goal.
|
|
19
|
+
*/
|
|
20
|
+
export class ProviderCard implements Component {
|
|
21
|
+
readonly #report: UsageReport;
|
|
22
|
+
readonly #painter: Painter;
|
|
23
|
+
|
|
24
|
+
constructor(report: UsageReport, painter: Painter) {
|
|
25
|
+
this.#report = report;
|
|
26
|
+
this.#painter = painter;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
render(width: number): readonly string[] {
|
|
30
|
+
const lines = buildColumn(this.#report, this.#painter);
|
|
31
|
+
if (!lines) return [];
|
|
32
|
+
const w = Math.max(0, Math.trunc(width));
|
|
33
|
+
return lines.map((l) => truncateToWidth(l, w, ""));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Placeholder for future overlay interaction — intentionally a no-op. */
|
|
37
|
+
handleInput(_data: string): void {}
|
|
38
|
+
|
|
39
|
+
/** Placeholder for future overlay teardown — intentionally a no-op. */
|
|
40
|
+
dispose(): void {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Root pi-tui `Component` the host mounts in its hook-widget tree. A single
|
|
45
|
+
* `UsageTable` owns the whole multi-provider layout (header + window rows for
|
|
46
|
+
* every report): the host only vertically stacks hook widgets, so per-provider
|
|
47
|
+
* widgets would collapse into a vertical list and lose the compact flex-wrap
|
|
48
|
+
* columns. The layout brain is the validated `renderUsageReports` kernel — the
|
|
49
|
+
* same code the RPC/print `string[]` fallback runs — only the `Painter` differs.
|
|
50
|
+
*/
|
|
51
|
+
export class UsageTable implements Component {
|
|
52
|
+
readonly #reports: UsageReport[];
|
|
53
|
+
readonly #painter: Painter;
|
|
54
|
+
readonly #placeholderIds?: readonly string[];
|
|
55
|
+
readonly #loadingProviders?: ReadonlySet<string>;
|
|
56
|
+
readonly #consumptionTracks?: Map<string, ConsumptionTrack>;
|
|
57
|
+
readonly #usageEstimates?: Map<string, string>;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
reports: UsageReport[],
|
|
61
|
+
painter: Painter,
|
|
62
|
+
placeholderIds?: readonly string[],
|
|
63
|
+
loadingProviders?: ReadonlySet<string>,
|
|
64
|
+
consumptionTracks?: Map<string, ConsumptionTrack>,
|
|
65
|
+
usageEstimates?: Map<string, string>,
|
|
66
|
+
) {
|
|
67
|
+
this.#reports = reports;
|
|
68
|
+
this.#painter = painter;
|
|
69
|
+
this.#placeholderIds = placeholderIds;
|
|
70
|
+
this.#loadingProviders = loadingProviders;
|
|
71
|
+
this.#consumptionTracks = consumptionTracks;
|
|
72
|
+
this.#usageEstimates = usageEstimates;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
render(width: number): readonly string[] {
|
|
76
|
+
// Per-provider consumption tracks are threaded into the layout kernel,
|
|
77
|
+
// which embeds each chart inside its own provider column.
|
|
78
|
+
return renderUsageReports(
|
|
79
|
+
this.#reports,
|
|
80
|
+
Math.max(1, Math.trunc(width)),
|
|
81
|
+
this.#painter,
|
|
82
|
+
this.#placeholderIds,
|
|
83
|
+
this.#loadingProviders,
|
|
84
|
+
this.#consumptionTracks,
|
|
85
|
+
this.#usageEstimates,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** No cached state to release; provided so the host can propagate teardown. */
|
|
90
|
+
dispose(): void {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the `ExtensionUiComponentFactory` the interactive host mounts. Colors are
|
|
95
|
+
* resolved lazily against the theme the host passes at mount time, so the widget
|
|
96
|
+
* follows the active palette; a later poll/resize re-runs `setWidget` with the
|
|
97
|
+
* current reports (mirroring the dashboard precedent), which re-injects the then-
|
|
98
|
+
* current theme. Returns a `Container`-free `UsageTable` directly: the validated
|
|
99
|
+
* layout already yields per-line `visibleWidth ≤ width`, and wrapping each row in
|
|
100
|
+
* `pi-tui`'s `Text` would both drop the flex-wrap blank separators (Text renders
|
|
101
|
+
* empty input as `[]`) and repad every line to the full width — losing byte
|
|
102
|
+
* parity with the RPC/print path for zero structural benefit.
|
|
103
|
+
*/
|
|
104
|
+
export function createUsageWidget(
|
|
105
|
+
reports: UsageReport[],
|
|
106
|
+
placeholderIds?: readonly string[],
|
|
107
|
+
loadingProviders?: ReadonlySet<string>,
|
|
108
|
+
consumptionTracks?: Map<string, ConsumptionTrack>,
|
|
109
|
+
usageEstimates?: Map<string, string>,
|
|
110
|
+
): ExtensionUiComponentFactory {
|
|
111
|
+
return (_tui, theme) => new UsageTable(reports, themePainter(theme), placeholderIds, loadingProviders, consumptionTracks, usageEstimates);
|
|
112
|
+
}
|