@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 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
- // Record usage with subagent-specific stream key (NO setSessionId call)
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
- loggedIn = Array.isArray(list) ? list : [];
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
+ }
@@ -289,18 +289,33 @@ export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, load
289
289
  };
290
290
 
291
291
  /**
292
- * Render a logged-in provider that has no report yet as a header + a single
293
- * placeholder row (`—`). Placeholder identity is always carried by an explicit id
294
- * (see `renderUsageReports` `placeholderIds`); it is NEVER inferred from an empty
295
- * `limits` array, so a real fetch that returns no windows is not mistaken for a
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 buildPlaceholderColumn = (provider: string, painter: Painter = ansiPainter, loading = false): string[] => {
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
- ? buildPlaceholderColumn(r.provider, painter, loading.has(r.provider))
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 [];
@@ -41,9 +41,16 @@ export interface DirectFetcher {
41
41
  */
42
42
  const PROVIDER_ALIAS: Record<string, string> = {};
43
43
 
44
- /** Normalize a provider id to its canonical form (no-op if already canonical). */
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
- return PROVIDER_ALIAS[id] ?? id;
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). */
@@ -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.1.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": ["./index.ts"]
28
+ "extensions": [
29
+ "./index.ts"
30
+ ]
29
31
  },
30
32
  "omp": {
31
- "extensions": ["./index.ts"]
33
+ "extensions": [
34
+ "./index.ts"
35
+ ]
32
36
  },
33
37
  "license": "MIT",
34
38
  "peerDependencies": {