@genee/omp-opsx-addon 0.1.0 → 0.2.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/index.ts +34 -7
- package/lib/provider-status.ts +55 -0
- package/lib/usage-render.ts +25 -9
- package/lib/usage-resolver.ts +9 -2
- package/lib/usage-widget.ts +6 -1
- package/package.json +7 -3
package/index.ts
CHANGED
|
@@ -101,8 +101,7 @@ function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
|
|
|
101
101
|
// This prevents cross-stream contamination while keeping global totals intact
|
|
102
102
|
const subagentStreamKey = `${primarySessionId}:subagent:${p.id}`;
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
sessionUsageRecorder.record(provider, usage, subagentStreamKey);
|
|
104
|
+
sessionUsageRecorder.record(canonicalizeProvider(provider), usage, subagentStreamKey);
|
|
106
105
|
} catch (err) {
|
|
107
106
|
// Silently fail to avoid breaking the plugin
|
|
108
107
|
console.error('Failed to handle subagent event:', err);
|
|
@@ -121,6 +120,7 @@ import { scoreModelWithProvider } from './lib/model-tiers.js';
|
|
|
121
120
|
import { DIRECT_FETCHERS } from './lib/direct-fetchers.js';
|
|
122
121
|
import { createUsageWidget } from './lib/usage-widget.js';
|
|
123
122
|
import { renderUsageReports, type ConsumptionTrack } from './lib/usage-render.js';
|
|
123
|
+
import { markExhausted, markAvailable, getExhaustedProviders } from './lib/provider-status.js';
|
|
124
124
|
import {
|
|
125
125
|
startSharedPoller,
|
|
126
126
|
getUnconfirmedProviders,
|
|
@@ -692,11 +692,30 @@ export default (pi: ExtensionAPI) => {
|
|
|
692
692
|
const reports = state?.reports ?? [];
|
|
693
693
|
let loggedIn: string[] = [];
|
|
694
694
|
let authAvailable = false;
|
|
695
|
+
// Three-way dedup union of the "logged in" universe:
|
|
696
|
+
// 1. providers derived from modelRegistry.getAvailable() — models
|
|
697
|
+
// whose provider has auth and is not disabled (covers models.yml
|
|
698
|
+
// config-override apiKeys that authStorage.list() never sees);
|
|
699
|
+
// 2. authStorage.list() — SQLite credential keys (incl. non-catalog
|
|
700
|
+
// providers getAvailable() doesn't surface);
|
|
701
|
+
// 3. locally-detectable direct-fetcher credentials.
|
|
702
|
+
// All ids canonicalized (lowercase) before dedup so one provider
|
|
703
|
+
// never surfaces as two columns / tracks.
|
|
704
|
+
try {
|
|
705
|
+
if (typeof ctx.modelRegistry?.getAvailable === 'function') {
|
|
706
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
707
|
+
authAvailable = true;
|
|
708
|
+
loggedIn = [...new Set(available.map((m) => canonicalizeProvider(m.provider)).filter(Boolean))];
|
|
709
|
+
}
|
|
710
|
+
} catch {
|
|
711
|
+
/* modelRegistry unavailable → fall back to auth.list() + detectable */
|
|
712
|
+
}
|
|
695
713
|
try {
|
|
696
714
|
if (auth?.list) {
|
|
697
715
|
authAvailable = true;
|
|
698
716
|
const list = auth.list();
|
|
699
|
-
|
|
717
|
+
const ids = Array.isArray(list) ? list : [];
|
|
718
|
+
loggedIn = [...new Set([...loggedIn, ...ids.map(canonicalizeProvider)])];
|
|
700
719
|
}
|
|
701
720
|
} catch {
|
|
702
721
|
/* treat authStorage as unavailable */
|
|
@@ -759,13 +778,13 @@ export default (pi: ExtensionAPI) => {
|
|
|
759
778
|
consumptionTracks.set(p, { samples: getConsumptionSamples(p) });
|
|
760
779
|
}
|
|
761
780
|
if (ctx.hasUI) {
|
|
762
|
-
ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates), {
|
|
781
|
+
ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders()), {
|
|
763
782
|
placement: 'belowEditor',
|
|
764
783
|
});
|
|
765
784
|
} else {
|
|
766
785
|
if (ctx.ui) {
|
|
767
786
|
const cols = (process.stdout.columns ?? 120) - 3;
|
|
768
|
-
const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates);
|
|
787
|
+
const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders());
|
|
769
788
|
ctx.ui.setWidget(key, lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
|
|
770
789
|
}
|
|
771
790
|
}
|
|
@@ -868,7 +887,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
868
887
|
const ev = event.assistantMessageEvent;
|
|
869
888
|
if ('partial' in ev) {
|
|
870
889
|
const usage = assistantUsageOf(ev.partial);
|
|
871
|
-
if (usage) sessionUsageRecorder.record(usage.provider, usage.usage);
|
|
890
|
+
if (usage) sessionUsageRecorder.record(canonicalizeProvider(usage.provider), usage.usage);
|
|
872
891
|
}
|
|
873
892
|
} catch {
|
|
874
893
|
/* usage recording is best-effort; stay silent */
|
|
@@ -879,7 +898,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
879
898
|
pi.on('message_end', async (event) => {
|
|
880
899
|
try {
|
|
881
900
|
const usage = assistantUsageOf(event.message);
|
|
882
|
-
if (usage) sessionUsageRecorder.record(usage.provider, usage.usage);
|
|
901
|
+
if (usage) sessionUsageRecorder.record(canonicalizeProvider(usage.provider), usage.usage);
|
|
883
902
|
} catch {
|
|
884
903
|
/* usage recording is best-effort; stay silent */
|
|
885
904
|
}
|
|
@@ -973,6 +992,10 @@ export default (pi: ExtensionAPI) => {
|
|
|
973
992
|
const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
|
|
974
993
|
? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
|
|
975
994
|
: undefined;
|
|
995
|
+
// 429/403 is the only observable exhaustion evidence for providers
|
|
996
|
+
// without a usage fetcher — record it in the process-local binary status
|
|
997
|
+
// so the placeholder column reads 耗尽 (see lib/provider-status.ts).
|
|
998
|
+
markExhausted(canonical);
|
|
976
999
|
// Force-refresh just this provider (bypassing the 10s floor) so the
|
|
977
1000
|
// diagnosis reflects current quota.
|
|
978
1001
|
if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { providers: [canonical], force: true });
|
|
@@ -989,6 +1012,10 @@ export default (pi: ExtensionAPI) => {
|
|
|
989
1012
|
await diagnoseProviderError(ctx, `HTTP ${event.status}`, '429');
|
|
990
1013
|
} else if (event.status === 403) {
|
|
991
1014
|
await diagnoseProviderError(ctx, `HTTP ${event.status}`, '403');
|
|
1015
|
+
} else if (event.status >= 200 && event.status < 300) {
|
|
1016
|
+
// Strict 2xx restores the provider's binary status. 5xx / other
|
|
1017
|
+
// 4xx are NOT recovery evidence and must not clear exhaustion.
|
|
1018
|
+
if (ctx.model) markAvailable(canonicalizeProvider(ctx.model.provider));
|
|
992
1019
|
}
|
|
993
1020
|
} catch (e) {
|
|
994
1021
|
warn(`[omp-opsx-addon] after_provider_response error: ${e}`);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local binary status for providers WITHOUT a usage report.
|
|
3
|
+
*
|
|
4
|
+
* A provider that has no usage fetcher has no authoritative quota source; the
|
|
5
|
+
* only observable exhaustion evidence is a 429/403 signal on the provider
|
|
6
|
+
* response or tool-execution paths. This module keeps that signal as a
|
|
7
|
+
* process-local `Map` (mirroring the `regionBlockedModels` in-process precedent
|
|
8
|
+
* in index.ts): absence of an entry means "可用", an explicit `markExhausted`
|
|
9
|
+
* flips it to "耗尽", and a subsequent strict-2xx provider response restores
|
|
10
|
+
* "可用". Nothing is persisted and nothing crosses processes — a 429 observed
|
|
11
|
+
* in one process is not broadcast to another, which is an accepted trade-off
|
|
12
|
+
* for a best-effort local hint.
|
|
13
|
+
*
|
|
14
|
+
* Keys are canonicalized (lowercase) so `ArkCodingPlan` and `arkcodingplan`
|
|
15
|
+
* share one entry.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { canonicalizeProvider } from './usage-resolver.js';
|
|
19
|
+
|
|
20
|
+
interface ProviderStatus {
|
|
21
|
+
exhausted: boolean;
|
|
22
|
+
observedAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const state = new Map<string, ProviderStatus>();
|
|
26
|
+
|
|
27
|
+
/** Mark a provider exhausted (429/403 observed). */
|
|
28
|
+
export function markExhausted(provider: string): void {
|
|
29
|
+
const canonical = canonicalizeProvider(provider);
|
|
30
|
+
state.set(canonical, { exhausted: true, observedAt: Date.now() });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Clear the exhausted marker (strict 2xx response observed). */
|
|
34
|
+
export function markAvailable(provider: string): void {
|
|
35
|
+
state.delete(canonicalizeProvider(provider));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True iff the provider has an explicit exhaustion marker (default: available). */
|
|
39
|
+
export function isExhausted(provider: string): boolean {
|
|
40
|
+
return state.get(canonicalizeProvider(provider))?.exhausted ?? false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Canonical ids of all currently-exhausted providers. */
|
|
44
|
+
export function getExhaustedProviders(): ReadonlySet<string> {
|
|
45
|
+
const out = new Set<string>();
|
|
46
|
+
for (const [id, s] of state) {
|
|
47
|
+
if (s.exhausted) out.add(id);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Test seam: clear all process-local state. */
|
|
53
|
+
export function _resetForTest(): void {
|
|
54
|
+
state.clear();
|
|
55
|
+
}
|
package/lib/usage-render.ts
CHANGED
|
@@ -289,18 +289,33 @@ export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, load
|
|
|
289
289
|
};
|
|
290
290
|
|
|
291
291
|
/**
|
|
292
|
-
* Render a
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* logged-in-but-unfetched provider.
|
|
292
|
+
* Render a real report whose `limits` array is empty — a fetch succeeded but
|
|
293
|
+
* returned no usable windows. This is NOT a logged-in-but-unfetched provider:
|
|
294
|
+
* it keeps the existing `—` marker (never the binary 可用/耗尽 body) so its
|
|
295
|
+
* rendering is byte-identical to the pre-binary-status behavior.
|
|
297
296
|
*/
|
|
298
|
-
export const
|
|
297
|
+
export const buildEmptyReportColumn = (provider: string, painter: Painter = ansiPainter, loading = false): string[] => {
|
|
299
298
|
const { label, color } = providerLabel(provider);
|
|
300
|
-
// Loading placeholder: body shows ⟳ (fetch in flight) instead of the idle —.
|
|
301
299
|
return [painter.header(loading ? `${label}⟳` : label, color), painter.balance(loading ? "⟳" : "—")];
|
|
302
300
|
}
|
|
303
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Render a logged-in provider that has no report yet as a header + a single
|
|
304
|
+
* binary-status row (`可用` / `耗尽` / `⟳`). Placeholder identity is always
|
|
305
|
+
* carried by an explicit id (see `renderUsageReports` `placeholderIds`); it is
|
|
306
|
+
* NEVER inferred from an empty `limits` array, so a real fetch that returns no
|
|
307
|
+
* windows is not mistaken for a logged-in-but-unfetched provider. `exhausted`
|
|
308
|
+
* reflects the process-local 429/403 signal (see `lib/provider-status.ts`).
|
|
309
|
+
*/
|
|
310
|
+
export const buildPlaceholderColumn = (provider: string, painter: Painter = ansiPainter, loading = false, exhausted = false): string[] => {
|
|
311
|
+
const { label, color } = providerLabel(provider);
|
|
312
|
+
const body = loading ? "⟳" : exhausted ? "耗尽" : "可用";
|
|
313
|
+
return [
|
|
314
|
+
painter.header(loading ? `${label}⟳` : label, color),
|
|
315
|
+
exhausted ? painter.exhausted(body) : painter.balance(body),
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
|
|
304
319
|
// ── flex-wrap table layout ───────────────────────────────────────────
|
|
305
320
|
|
|
306
321
|
const SEP_VISUAL = 3;
|
|
@@ -314,6 +329,7 @@ export function renderUsageReports(
|
|
|
314
329
|
loadingProviders?: ReadonlySet<string>,
|
|
315
330
|
consumptionTracks?: Map<string, ConsumptionTrack>,
|
|
316
331
|
usageEstimates?: Map<string, string>,
|
|
332
|
+
exhaustedProviders?: ReadonlySet<string>,
|
|
317
333
|
): string[] {
|
|
318
334
|
// Placeholder providers are logged-in but unfetched this round. Their ids are
|
|
319
335
|
// explicit (never inferred from `limits.length === 0`); dedupe against reports
|
|
@@ -329,7 +345,7 @@ export function renderUsageReports(
|
|
|
329
345
|
interface ProviderColumn { provider: string; col: string[] }
|
|
330
346
|
const cols: ProviderColumn[] = (reports ?? []).map((r) => {
|
|
331
347
|
const col = r.limits.length === 0
|
|
332
|
-
?
|
|
348
|
+
? buildEmptyReportColumn(r.provider, painter, loading.has(r.provider))
|
|
333
349
|
: buildColumn(r, painter, loading.has(r.provider));
|
|
334
350
|
if (!col) return { provider: r.provider, col: [] };
|
|
335
351
|
// Second-level estimate override replaces the usage line (balance style).
|
|
@@ -338,7 +354,7 @@ export function renderUsageReports(
|
|
|
338
354
|
return { provider: r.provider, col };
|
|
339
355
|
});
|
|
340
356
|
for (const id of placeholders) {
|
|
341
|
-
cols.push({ provider: id, col: buildPlaceholderColumn(id, painter, loading.has(id)) });
|
|
357
|
+
cols.push({ provider: id, col: buildPlaceholderColumn(id, painter, loading.has(id), exhaustedProviders?.has(id)) });
|
|
342
358
|
}
|
|
343
359
|
const valid = cols.filter((c) => c.col.length > 0);
|
|
344
360
|
if (valid.length === 0) return [];
|
package/lib/usage-resolver.ts
CHANGED
|
@@ -41,9 +41,16 @@ export interface DirectFetcher {
|
|
|
41
41
|
*/
|
|
42
42
|
const PROVIDER_ALIAS: Record<string, string> = {};
|
|
43
43
|
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Normalize a provider id to its canonical form: lowercase first, then alias
|
|
46
|
+
* mapping. models.yml config keys are mixed-case (`ArkCodingPlan`), DB
|
|
47
|
+
* credentials and message events are lowercase (`arkcodingplan`), and the
|
|
48
|
+
* harness compares ids case-insensitively — lowercase unifies all three so a
|
|
49
|
+
* provider never surfaces as two columns or two waveform tracks.
|
|
50
|
+
*/
|
|
45
51
|
export function canonicalizeProvider(id: string): string {
|
|
46
|
-
|
|
52
|
+
const k = id.toLowerCase();
|
|
53
|
+
return PROVIDER_ALIAS[k] ?? k;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/** Providers whose direct fetcher reports locally-detectable credentials (no network). */
|
package/lib/usage-widget.ts
CHANGED
|
@@ -55,6 +55,7 @@ export class UsageTable implements Component {
|
|
|
55
55
|
readonly #loadingProviders?: ReadonlySet<string>;
|
|
56
56
|
readonly #consumptionTracks?: Map<string, ConsumptionTrack>;
|
|
57
57
|
readonly #usageEstimates?: Map<string, string>;
|
|
58
|
+
readonly #exhaustedProviders?: ReadonlySet<string>;
|
|
58
59
|
|
|
59
60
|
constructor(
|
|
60
61
|
reports: UsageReport[],
|
|
@@ -63,6 +64,7 @@ export class UsageTable implements Component {
|
|
|
63
64
|
loadingProviders?: ReadonlySet<string>,
|
|
64
65
|
consumptionTracks?: Map<string, ConsumptionTrack>,
|
|
65
66
|
usageEstimates?: Map<string, string>,
|
|
67
|
+
exhaustedProviders?: ReadonlySet<string>,
|
|
66
68
|
) {
|
|
67
69
|
this.#reports = reports;
|
|
68
70
|
this.#painter = painter;
|
|
@@ -70,6 +72,7 @@ export class UsageTable implements Component {
|
|
|
70
72
|
this.#loadingProviders = loadingProviders;
|
|
71
73
|
this.#consumptionTracks = consumptionTracks;
|
|
72
74
|
this.#usageEstimates = usageEstimates;
|
|
75
|
+
this.#exhaustedProviders = exhaustedProviders;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
render(width: number): readonly string[] {
|
|
@@ -83,6 +86,7 @@ export class UsageTable implements Component {
|
|
|
83
86
|
this.#loadingProviders,
|
|
84
87
|
this.#consumptionTracks,
|
|
85
88
|
this.#usageEstimates,
|
|
89
|
+
this.#exhaustedProviders,
|
|
86
90
|
);
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -107,6 +111,7 @@ export function createUsageWidget(
|
|
|
107
111
|
loadingProviders?: ReadonlySet<string>,
|
|
108
112
|
consumptionTracks?: Map<string, ConsumptionTrack>,
|
|
109
113
|
usageEstimates?: Map<string, string>,
|
|
114
|
+
exhaustedProviders?: ReadonlySet<string>,
|
|
110
115
|
): ExtensionUiComponentFactory {
|
|
111
|
-
return (_tui, theme) => new UsageTable(reports, themePainter(theme), placeholderIds, loadingProviders, consumptionTracks, usageEstimates);
|
|
116
|
+
return (_tui, theme) => new UsageTable(reports, themePainter(theme), placeholderIds, loadingProviders, consumptionTracks, usageEstimates, exhaustedProviders);
|
|
112
117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genee/omp-opsx-addon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi Extension: OpenSpec workflow orchestration - coder/reviewer/planner agents, session title & progress",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -25,10 +25,14 @@
|
|
|
25
25
|
"opsx"
|
|
26
26
|
],
|
|
27
27
|
"pi": {
|
|
28
|
-
"extensions": [
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./index.ts"
|
|
30
|
+
]
|
|
29
31
|
},
|
|
30
32
|
"omp": {
|
|
31
|
-
"extensions": [
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./index.ts"
|
|
35
|
+
]
|
|
32
36
|
},
|
|
33
37
|
"license": "MIT",
|
|
34
38
|
"peerDependencies": {
|