@pellux/goodvibes-tui 0.28.0 → 1.0.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -0,0 +1,203 @@
1
+ // ---------------------------------------------------------------------------
2
+ // provider-health-routes.ts
3
+ //
4
+ // Single-source auth-route posture for the provider console (WO-112 merge of
5
+ // the retired accounts panel into provider-health). All route data flows from
6
+ // ProviderRuntimeInspectionQuery.inspectAll() snapshots; this module only
7
+ // normalizes descriptors (synthesizing them for providers that do not declare
8
+ // routes) and derives the active/preferred route, freshness, issues, and
9
+ // repair hints. It intentionally keeps the full descriptor list so the panel
10
+ // can render per-route detail (absorbed from the accounts panel) instead of a
11
+ // lossy summary record.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import type { ProviderAuthRouteDescriptor } from '@pellux/goodvibes-sdk/platform/providers';
15
+
16
+ export type ProviderPanelAuthRoute = ProviderAuthRouteDescriptor['route'] | 'unconfigured';
17
+ export type ProviderPanelAuthFreshness = NonNullable<ProviderAuthRouteDescriptor['freshness']> | 'unconfigured';
18
+
19
+ /** Auth metadata shape carried by ProviderRuntimeInspectionQuery snapshots. */
20
+ export interface ProviderRuntimeAuthMetadata {
21
+ readonly mode: 'api-key' | 'oauth' | 'anonymous' | 'none';
22
+ readonly configured: boolean;
23
+ readonly detail?: string;
24
+ readonly envVars?: readonly string[];
25
+ readonly routes?: readonly ProviderAuthRouteDescriptor[];
26
+ }
27
+
28
+ /** Minimal inspectAll() snapshot shape consumed by the provider console. */
29
+ export interface ProviderRuntimeSnapshotLike {
30
+ readonly providerId: string;
31
+ readonly active: boolean;
32
+ readonly modelCount: number;
33
+ readonly runtime: {
34
+ readonly auth?: ProviderRuntimeAuthMetadata;
35
+ };
36
+ }
37
+
38
+ /** Derived per-provider account/auth posture for console rendering. */
39
+ export interface ProviderAccountPosture {
40
+ readonly providerId: string;
41
+ readonly active: boolean;
42
+ readonly modelCount: number;
43
+ /** Normalized route descriptors (declared or synthesized). */
44
+ readonly routes: readonly ProviderAuthRouteDescriptor[];
45
+ readonly activeRoute: ProviderPanelAuthRoute;
46
+ readonly preferredRoute: ProviderPanelAuthRoute;
47
+ readonly activeRouteReason: string;
48
+ readonly authFreshness: ProviderPanelAuthFreshness;
49
+ /** True when any route is expiring (or already expired/pending). */
50
+ readonly expiringSoon: boolean;
51
+ readonly issues: readonly string[];
52
+ /** Repair hints sourced from ProviderAuthRouteDescriptor.repairHints. */
53
+ readonly repairHints: readonly string[];
54
+ }
55
+
56
+ const AUTH_ROUTE_PRIORITY: readonly ProviderPanelAuthRoute[] = [
57
+ 'subscription-oauth',
58
+ 'service-oauth',
59
+ 'secret-ref',
60
+ 'api-key',
61
+ 'anonymous',
62
+ 'none',
63
+ 'unconfigured',
64
+ ] as const;
65
+
66
+ export function routePriority(route: ProviderPanelAuthRoute): number {
67
+ const priority = AUTH_ROUTE_PRIORITY.indexOf(route);
68
+ return priority >= 0 ? priority : AUTH_ROUTE_PRIORITY.length;
69
+ }
70
+
71
+ /**
72
+ * Synthesize route descriptors for providers that only declare legacy auth
73
+ * metadata. Keeps the console on a single ProviderAuthRouteDescriptor shape.
74
+ */
75
+ export function buildSyntheticAuthRoutes(
76
+ auth: ProviderRuntimeAuthMetadata | undefined,
77
+ ): readonly ProviderAuthRouteDescriptor[] {
78
+ if (!auth) return [];
79
+ switch (auth.mode) {
80
+ case 'none':
81
+ return [{
82
+ route: 'none',
83
+ label: 'No auth required',
84
+ configured: true,
85
+ usable: true,
86
+ freshness: 'healthy',
87
+ detail: auth.detail ?? 'Provider does not require interactive credentials.',
88
+ }];
89
+ case 'anonymous':
90
+ return [{
91
+ route: 'anonymous',
92
+ label: 'Anonymous / local access',
93
+ configured: auth.configured,
94
+ usable: auth.configured,
95
+ freshness: auth.configured ? 'healthy' : 'unconfigured',
96
+ detail: auth.detail ?? 'Provider can be used without stored credentials.',
97
+ }];
98
+ case 'api-key':
99
+ return [{
100
+ route: 'api-key',
101
+ label: 'Ambient API key',
102
+ configured: auth.configured,
103
+ usable: auth.configured,
104
+ freshness: auth.configured ? 'healthy' : 'unconfigured',
105
+ detail: auth.detail ?? 'Provider expects a configured API key.',
106
+ ...(auth.envVars?.length ? { envVars: auth.envVars } : {}),
107
+ ...(auth.envVars?.length
108
+ ? { repairHints: [`Set ${auth.envVars.join(' or ')} in the environment or secrets store.`] }
109
+ : {}),
110
+ }];
111
+ case 'oauth':
112
+ return [{
113
+ route: 'service-oauth',
114
+ label: 'OAuth session',
115
+ configured: auth.configured,
116
+ usable: auth.configured,
117
+ freshness: auth.configured ? 'healthy' : 'unconfigured',
118
+ detail: auth.detail ?? 'Provider expects an OAuth-backed credential.',
119
+ repairHints: ['Refresh or repair the provider OAuth session before relying on it.'],
120
+ }];
121
+ default:
122
+ return [];
123
+ }
124
+ }
125
+
126
+ export function isRouteUsable(route: ProviderAuthRouteDescriptor): boolean {
127
+ return route.usable ?? route.configured;
128
+ }
129
+
130
+ function pickRoute(
131
+ routes: readonly ProviderAuthRouteDescriptor[],
132
+ ): ProviderAuthRouteDescriptor | null {
133
+ if (routes.length === 0) return null;
134
+ return [...routes].sort((left, right) => routePriority(left.route) - routePriority(right.route))[0] ?? null;
135
+ }
136
+
137
+ /**
138
+ * Derive the console's account/auth posture for one inspectAll() snapshot.
139
+ * This is the only route-posture derivation in the TUI (the retired accounts
140
+ * panel's parallel snapshot model was deleted with WO-112).
141
+ */
142
+ export function buildAccountPosture(snapshot: ProviderRuntimeSnapshotLike): ProviderAccountPosture {
143
+ const auth = snapshot.runtime.auth;
144
+ const routes = auth?.routes?.length ? auth.routes : buildSyntheticAuthRoutes(auth);
145
+ const configuredRoutes = routes.filter((route) => route.configured);
146
+ const usableRoutes = routes.filter(isRouteUsable);
147
+ const preferredRoute = pickRoute(configuredRoutes.length > 0 ? configuredRoutes : routes);
148
+ const activeRoute = pickRoute(usableRoutes.length > 0 ? usableRoutes : (preferredRoute ? [preferredRoute] : []));
149
+ const activeRouteId: ProviderPanelAuthRoute = activeRoute?.route ?? 'unconfigured';
150
+ const preferredRouteId: ProviderPanelAuthRoute = preferredRoute?.route ?? activeRouteId;
151
+ const authFreshness: ProviderPanelAuthFreshness =
152
+ activeRoute?.freshness ?? (activeRouteId === 'none' ? 'healthy' : 'unconfigured');
153
+
154
+ const issueSet = new Set<string>();
155
+ const hintSet = new Set<string>();
156
+
157
+ if (activeRouteId === 'unconfigured' && auth?.mode !== 'none') {
158
+ issueSet.add('Provider has no usable auth route configured.');
159
+ }
160
+
161
+ for (const route of routes) {
162
+ if (route.freshness === 'expired') {
163
+ issueSet.add(route.detail ?? `${route.label} is expired.`);
164
+ } else if (route.freshness === 'pending') {
165
+ issueSet.add(route.detail ?? `${route.label} is pending completion.`);
166
+ } else if (route.configured && !isRouteUsable(route)) {
167
+ issueSet.add(route.detail ?? `${route.label} is configured but not currently usable.`);
168
+ }
169
+ for (const hint of route.repairHints ?? []) {
170
+ if (hint.trim().length > 0) hintSet.add(hint);
171
+ }
172
+ }
173
+
174
+ if (usableRoutes.length > 1) {
175
+ issueSet.add('Multiple auth routes are simultaneously usable; verify route priority before switching providers.');
176
+ }
177
+ if (issueSet.size > 0 && hintSet.size === 0 && activeRouteId !== 'none') {
178
+ hintSet.add(`Review ${snapshot.providerId} provider credentials and routing metadata.`);
179
+ }
180
+
181
+ const expiringSoon = authFreshness === 'expiring'
182
+ || authFreshness === 'expired'
183
+ || authFreshness === 'pending'
184
+ || routes.some((route) => route.freshness === 'expiring' || route.freshness === 'expired' || route.freshness === 'pending');
185
+
186
+ return {
187
+ providerId: snapshot.providerId,
188
+ active: snapshot.active,
189
+ modelCount: snapshot.modelCount,
190
+ routes,
191
+ activeRoute: activeRouteId,
192
+ preferredRoute: preferredRouteId,
193
+ activeRouteReason: activeRoute?.detail
194
+ ?? auth?.detail
195
+ ?? (activeRouteId === 'none'
196
+ ? 'Provider does not require interactive credentials.'
197
+ : 'No usable auth route is configured for this provider.'),
198
+ authFreshness,
199
+ expiringSoon,
200
+ issues: [...issueSet],
201
+ repairHints: [...hintSet],
202
+ };
203
+ }
@@ -1,8 +1,23 @@
1
1
  // ProviderStatus is the shared SDK type — imported from the runtime barrel
2
2
  // to eliminate the duplicate local definition that diverged from the SDK shape.
3
- import type { ProviderStatus } from '@/runtime/index.ts';
3
+ import type {
4
+ ProviderHealthDomainState,
5
+ ProviderHealthRecord,
6
+ ProviderStatus,
7
+ } from '@/runtime/index.ts';
8
+ import { calcSessionCost } from '../export/cost-utils.ts';
9
+
4
10
  export type { ProviderStatus };
5
11
 
12
+ /** Token/cost usage delta carried by one LLM_RESPONSE_RECEIVED event. */
13
+ export interface ProviderUsageDelta {
14
+ readonly model?: string;
15
+ readonly inputTokens?: number;
16
+ readonly outputTokens?: number;
17
+ readonly cacheReadTokens?: number;
18
+ readonly cacheWriteTokens?: number;
19
+ }
20
+
6
21
  export interface ProviderHealth {
7
22
  name: string;
8
23
  status: ProviderStatus;
@@ -11,16 +26,55 @@ export interface ProviderHealth {
11
26
  lastSuccessAt?: number;
12
27
  lastErrorAt?: number;
13
28
  rateLimitExpiresAt: number;
29
+ /** Most recent model id seen for this provider (from LLM responses). */
30
+ lastModelId?: string;
31
+ /** Session request counter (successes + errors). */
32
+ requests: number;
33
+ /** Session error counter. */
34
+ errors: number;
35
+ /** Session token counters (absorbed from the retired provider-stats panel). */
36
+ inputTokens: number;
37
+ outputTokens: number;
38
+ cacheReadTokens: number;
39
+ cacheWriteTokens: number;
40
+ totalTokens: number;
41
+ /** Session USD cost accumulated per call via calcSessionCost. */
42
+ totalCostUsd: number;
43
+ /** Ring buffer of recent request latencies in ms (most-recent last). */
44
+ latencies: number[];
45
+ }
46
+
47
+ /** Per-provider identity/config metadata used to build SDK domain state. */
48
+ export interface ProviderHealthMeta {
49
+ readonly providerId: string;
50
+ readonly displayName?: string;
51
+ readonly isActive: boolean;
52
+ readonly isConfigured: boolean;
53
+ }
54
+
55
+ const LATENCY_RING_SIZE = 20;
56
+
57
+ function avg(values: readonly number[]): number {
58
+ if (values.length === 0) return 0;
59
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
14
60
  }
15
61
 
16
62
  /**
17
63
  * Tracks provider request posture from shell-facing turn and provider events.
18
64
  * The panel owns event subscriptions and feeds those events into this tracker.
65
+ *
66
+ * WO-112: the tracker also accumulates the session metrics the retired
67
+ * provider-stats panel used to duplicate (latency ring, request/error/token
68
+ * counters, per-call cost) and can project its records into the SDK
69
+ * ProviderHealthDomainState shape so the orphaned ProviderHealthDataProvider
70
+ * (60-point timelines, success/error rates, cache metrics) is the single
71
+ * derivation engine for the provider console.
19
72
  */
20
73
  export class ProviderHealthTracker {
21
74
  private records = new Map<string, ProviderHealth>();
22
75
  private streamStartMs: number | null = null;
23
76
  private turnStartMs: number | null = null;
77
+ private revision = 0;
24
78
 
25
79
  private static readonly DEFAULT_COOLDOWN_MS = 60_000;
26
80
 
@@ -32,7 +86,7 @@ export class ProviderHealthTracker {
32
86
  this.streamStartMs = Date.now();
33
87
  }
34
88
 
35
- onLlmResponse(providerName: string): void {
89
+ onLlmResponse(providerName: string, usage?: ProviderUsageDelta): void {
36
90
  const now = Date.now();
37
91
  const latencyMs =
38
92
  this.streamStartMs !== null
@@ -42,10 +96,15 @@ export class ProviderHealthTracker {
42
96
  : undefined;
43
97
  this.streamStartMs = null;
44
98
 
45
- this.recordSuccess(providerName, latencyMs);
99
+ this.recordSuccess(providerName, latencyMs, usage);
46
100
  }
47
101
 
48
- onTurnError(error: string, providerName = 'unknown'): void {
102
+ /**
103
+ * Record a turn error against a concrete provider. The panel resolves the
104
+ * active provider (in-flight request, model domain, config, last response)
105
+ * before calling this — the tracker never invents an 'unknown' row.
106
+ */
107
+ onTurnError(error: string, providerName: string): void {
49
108
  this.streamStartMs = null;
50
109
  this.turnStartMs = null;
51
110
  const isRateLimit = this.isRateLimitMessage(error);
@@ -73,22 +132,149 @@ export class ProviderHealthTracker {
73
132
  return this.records.get(name);
74
133
  }
75
134
 
135
+ /**
136
+ * Project tracked records into the SDK ProviderHealthDomainState shape so
137
+ * ProviderHealthDataProvider can derive timelines, rates, and sort order.
138
+ * Providers present in `meta` but never seen by the tracker are emitted as
139
+ * zero-stat 'unknown' records; tracked providers missing from `meta` are
140
+ * kept so attribution/table rows never silently disappear.
141
+ */
142
+ buildHealthDomainState(meta: readonly ProviderHealthMeta[]): ProviderHealthDomainState {
143
+ const providers = new Map<string, ProviderHealthRecord>();
144
+ const metaById = new Map(meta.map((entry) => [entry.providerId, entry] as const));
145
+ const ids = new Set<string>([...metaById.keys(), ...this.records.keys()]);
146
+
147
+ let degradedCount = 0;
148
+ let unavailableCount = 0;
149
+ let healthySeen = 0;
150
+
151
+ for (const id of ids) {
152
+ const record = this.records.get(id);
153
+ const entry = metaById.get(id);
154
+ const latencies = record?.latencies ?? [];
155
+ const requests = record?.requests ?? 0;
156
+ const errors = record?.errors ?? 0;
157
+ const status: ProviderStatus = record?.status ?? 'unknown';
158
+
159
+ if (status === 'degraded' || status === 'rate_limited' || status === 'auth_error') degradedCount++;
160
+ if (status === 'unavailable') unavailableCount++;
161
+ if (status === 'healthy') healthySeen++;
162
+
163
+ const cacheRead = record?.cacheReadTokens ?? 0;
164
+ const cacheWrite = record?.cacheWriteTokens ?? 0;
165
+ const promptTokens = record?.inputTokens ?? 0;
166
+
167
+ providers.set(id, {
168
+ providerId: id,
169
+ displayName: entry?.displayName ?? id,
170
+ status,
171
+ isActive: entry?.isActive ?? false,
172
+ isConfigured: entry?.isConfigured ?? true,
173
+ stats: {
174
+ totalCalls: requests,
175
+ successCalls: Math.max(0, requests - errors),
176
+ errorCalls: errors,
177
+ avgLatencyMs: Math.round(avg(latencies)),
178
+ minLatencyMs: latencies.length > 0 ? Math.min(...latencies) : 0,
179
+ maxLatencyMs: latencies.length > 0 ? Math.max(...latencies) : 0,
180
+ lastSuccessAt: record?.lastSuccessAt,
181
+ lastErrorAt: record?.lastErrorAt,
182
+ lastErrorMessage: record?.lastErrorMessage,
183
+ },
184
+ ...(cacheRead + cacheWrite > 0
185
+ ? {
186
+ cacheMetrics: {
187
+ cacheReadTokens: cacheRead,
188
+ cacheWriteTokens: cacheWrite,
189
+ hitRate: promptTokens + cacheRead > 0 ? cacheRead / (promptTokens + cacheRead) : 0,
190
+ },
191
+ }
192
+ : {}),
193
+ lastCheckedAt: Date.now(),
194
+ ...(record && record.rateLimitExpiresAt > Date.now()
195
+ ? { rateLimitResetAt: record.rateLimitExpiresAt }
196
+ : {}),
197
+ });
198
+ }
199
+
200
+ const compositeStatus = unavailableCount > 0
201
+ ? 'critical'
202
+ : degradedCount > 0
203
+ ? 'degraded'
204
+ : healthySeen > 0 && healthySeen === ids.size
205
+ ? 'healthy'
206
+ : ids.size === 0
207
+ ? 'unknown'
208
+ : healthySeen > 0
209
+ ? 'healthy'
210
+ : 'unknown';
211
+
212
+ this.revision += 1;
213
+ return {
214
+ revision: this.revision,
215
+ lastUpdatedAt: Date.now(),
216
+ source: 'provider-health-panel',
217
+ providers,
218
+ compositeStatus,
219
+ degradedCount,
220
+ unavailableCount,
221
+ warnings: [],
222
+ };
223
+ }
224
+
76
225
  private ensureRecord(name: string): ProviderHealth {
77
226
  let record = this.records.get(name);
78
227
  if (!record) {
79
- record = { name, status: 'unknown', rateLimitExpiresAt: 0 };
228
+ record = {
229
+ name,
230
+ status: 'unknown',
231
+ rateLimitExpiresAt: 0,
232
+ requests: 0,
233
+ errors: 0,
234
+ inputTokens: 0,
235
+ outputTokens: 0,
236
+ cacheReadTokens: 0,
237
+ cacheWriteTokens: 0,
238
+ totalTokens: 0,
239
+ totalCostUsd: 0,
240
+ latencies: [],
241
+ };
80
242
  this.records.set(name, record);
81
243
  }
82
244
  return record;
83
245
  }
84
246
 
85
- private recordSuccess(name: string, latencyMs?: number): void {
247
+ private applyUsage(record: ProviderHealth, usage: ProviderUsageDelta | undefined): void {
248
+ if (!usage) return;
249
+ const input = usage.inputTokens ?? 0;
250
+ const output = usage.outputTokens ?? 0;
251
+ const cacheRead = usage.cacheReadTokens ?? 0;
252
+ const cacheWrite = usage.cacheWriteTokens ?? 0;
253
+ record.inputTokens += input;
254
+ record.outputTokens += output;
255
+ record.cacheReadTokens += cacheRead;
256
+ record.cacheWriteTokens += cacheWrite;
257
+ record.totalTokens += input + output + cacheRead + cacheWrite;
258
+ if (usage.model) record.lastModelId = usage.model;
259
+ const model = usage.model ?? record.lastModelId;
260
+ if (model) {
261
+ record.totalCostUsd += calcSessionCost(input, output, cacheRead, cacheWrite, model);
262
+ }
263
+ }
264
+
265
+ private recordSuccess(name: string, latencyMs: number | undefined, usage?: ProviderUsageDelta): void {
86
266
  const record = this.ensureRecord(name);
87
267
  record.status = 'healthy';
88
268
  record.lastSuccessAt = Date.now();
89
269
  record.lastErrorMessage = undefined;
270
+ record.requests += 1;
271
+ this.applyUsage(record, usage);
90
272
  if (latencyMs !== undefined) {
91
273
  record.lastLatencyMs = latencyMs;
274
+ if (latencyMs > 0) {
275
+ record.latencies.push(latencyMs);
276
+ if (record.latencies.length > LATENCY_RING_SIZE) record.latencies.shift();
277
+ }
92
278
  }
93
279
  if (record.rateLimitExpiresAt > 0 && record.rateLimitExpiresAt <= Date.now()) {
94
280
  record.rateLimitExpiresAt = 0;
@@ -99,6 +285,8 @@ export class ProviderHealthTracker {
99
285
  const record = this.ensureRecord(name);
100
286
  record.lastErrorAt = Date.now();
101
287
  record.lastErrorMessage = message.slice(0, 120);
288
+ record.requests += 1;
289
+ record.errors += 1;
102
290
  if (isRateLimit) {
103
291
  record.status = 'rate_limited';
104
292
  record.rateLimitExpiresAt = Date.now() + ProviderHealthTracker.DEFAULT_COOLDOWN_MS;