@tokentop/ttop 0.5.0 → 0.6.1

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 (60) hide show
  1. package/README.md +38 -15
  2. package/package.json +8 -6
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +40 -5
  5. package/src/cli.ts +14 -0
  6. package/src/config/schema.ts +1 -1
  7. package/src/demo/simulator.ts +1 -1
  8. package/src/plugins/agents/index.ts +1 -0
  9. package/src/plugins/lifecycle.ts +1 -1
  10. package/src/plugins/notification-bus.ts +27 -0
  11. package/src/plugins/notifications/terminal-bell.ts +80 -16
  12. package/src/plugins/notifications/visual-flash.ts +5 -18
  13. package/src/plugins/plugin-host.test.ts +197 -0
  14. package/src/plugins/plugin-host.ts +45 -1
  15. package/src/plugins/providers/anthropic.test.ts +202 -0
  16. package/src/plugins/providers/anthropic.ts +19 -0
  17. package/src/plugins/providers/antigravity.ts +16 -0
  18. package/src/plugins/providers/chutes.ts +16 -0
  19. package/src/plugins/providers/codex.ts +16 -0
  20. package/src/plugins/providers/gemini.ts +16 -0
  21. package/src/plugins/providers/github-copilot.ts +16 -0
  22. package/src/plugins/providers/minimax.ts +16 -0
  23. package/src/plugins/providers/openai-api.ts +52 -0
  24. package/src/plugins/providers/perplexity.ts +10 -1
  25. package/src/plugins/providers/zai.ts +16 -0
  26. package/src/pricing/fallback.ts +4 -0
  27. package/src/tui/App.tsx +22 -8
  28. package/src/tui/components/HelpOverlay.tsx +1 -1
  29. package/src/tui/components/LimitGauge.tsx +12 -4
  30. package/src/tui/components/NotificationFlash.tsx +141 -0
  31. package/src/tui/components/ProviderCard.tsx +38 -22
  32. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  33. package/src/tui/components/SessionsTable.tsx +54 -36
  34. package/src/tui/components/SettingsModal.tsx +20 -4
  35. package/src/tui/components/Skeleton.tsx +9 -6
  36. package/src/tui/components/SortOverlay.tsx +167 -0
  37. package/src/tui/components/Spinner.tsx +16 -11
  38. package/src/tui/components/Toast.tsx +1 -1
  39. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  40. package/src/tui/contexts/PluginContext.tsx +72 -10
  41. package/src/tui/contexts/ThemeContext.tsx +4 -1
  42. package/src/tui/createApp.tsx +2 -0
  43. package/src/tui/hooks/index.ts +1 -0
  44. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  45. package/src/tui/hooks/useAnimationTick.ts +93 -0
  46. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  47. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  48. package/src/tui/hooks/useNotificationBridge.ts +29 -0
  49. package/src/tui/hooks/usePulse.ts +13 -7
  50. package/src/tui/hooks/useValueFlash.ts +25 -33
  51. package/src/tui/index.tsx +4 -0
  52. package/src/tui/types/projectSort.ts +35 -0
  53. package/src/tui/types/sort.ts +61 -0
  54. package/src/tui/views/ProjectsView.tsx +104 -38
  55. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  56. package/src/tui/views/SettingsView.tsx +26 -8
  57. package/src/utils/__tests__/error-category.test.ts +147 -0
  58. package/src/utils/currency.ts +4 -2
  59. package/src/utils/error-category.ts +91 -0
  60. package/src/version.ts +1 -1
@@ -2,11 +2,13 @@ import type { ScrollBoxRenderable } from "@opentui/core";
2
2
  import { useTerminalDimensions } from "@opentui/react";
3
3
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { notificationBus } from "@/plugins/notification-bus.ts";
5
+ import { classifyProviderError } from "@/utils/error-category.ts";
5
6
  import { HelpOverlay } from "../components/HelpOverlay.tsx";
6
7
  import { KpiStrip } from "../components/KpiStrip.tsx";
7
8
  import { ProviderLimitsPanel } from "../components/ProviderLimitsPanel.tsx";
8
9
  import { SessionsTable } from "../components/SessionsTable.tsx";
9
10
  import { type DriverDimension, getSidebarMode, SmartSidebar } from "../components/SmartSidebar.tsx";
11
+ import { SortOverlay } from "../components/SortOverlay.tsx";
10
12
  import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
11
13
  import { useConfig } from "../contexts/ConfigContext.tsx";
12
14
  import { useDashboardRuntime } from "../contexts/DashboardRuntimeContext.tsx";
@@ -15,6 +17,8 @@ import { usePlugins } from "../contexts/PluginContext.tsx";
15
17
  import { useColors } from "../contexts/ThemeContext.tsx";
16
18
  import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
17
19
  import { useDashboardKeyboard } from "../hooks/useDashboardKeyboard.ts";
20
+ import type { SortDirection, SortField } from "../types/sort.ts";
21
+ import { SORT_FIELDS } from "../types/sort.ts";
18
22
  import { getProviderColor } from "../utils/providerColor.ts";
19
23
 
20
24
  export function RealTimeDashboard() {
@@ -30,6 +34,23 @@ export function RealTimeDashboard() {
30
34
  const showLargeHeader = terminalHeight >= 35;
31
35
  const showProviderLimitsPanel = terminalHeight >= 24;
32
36
 
37
+ // SessionsTable hides REQ, NAME, DUR, TIME columns in narrow mode (<140).
38
+ // Filter sort fields to match visible columns.
39
+ const SESSIONS_WIDE_THRESHOLD = 140;
40
+ const NARROW_HIDDEN_FIELDS: ReadonlySet<SortField> = new Set([
41
+ "requests",
42
+ "name",
43
+ "duration",
44
+ "time",
45
+ ]);
46
+ const visibleSortFields = useMemo(
47
+ () =>
48
+ terminalWidth >= SESSIONS_WIDE_THRESHOLD
49
+ ? SORT_FIELDS
50
+ : SORT_FIELDS.filter((f) => !NARROW_HIDDEN_FIELDS.has(f.id)),
51
+ [terminalWidth],
52
+ );
53
+
33
54
  // Visible-row count for scroll tracking — every non-session-data line must be counted.
34
55
  const visibleRows = (() => {
35
56
  const appChrome = (showLargeHeader ? 7 : 1) + 1; // App.tsx: Header + StatusBar
@@ -61,7 +82,9 @@ export function RealTimeDashboard() {
61
82
  const [sidebarCollapsed, setSidebarCollapsed] = useState(config.display.sidebarCollapsed);
62
83
  const [filterQuery, setFilterQuery] = useState("");
63
84
  const [isFiltering, setIsFiltering] = useState(false);
64
- const [sortField, setSortField] = useState<"cost" | "tokens" | "time">("cost");
85
+ const [sortField, setSortField] = useState<SortField>("time");
86
+ const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
87
+ const [showSortOverlay, setShowSortOverlay] = useState(false);
65
88
  const [pendingG, setPendingG] = useState(false);
66
89
  const scrollOffsetRef = useRef(0);
67
90
  const selectedSessionIdRef = useRef<string | null>(null);
@@ -184,14 +207,53 @@ export function RealTimeDashboard() {
184
207
  const bActive = b.status === "active" ? 1 : 0;
185
208
  if (bActive !== aActive) return bActive - aActive;
186
209
 
187
- if (sortField === "cost") return (b.totalCostUsd ?? 0) - (a.totalCostUsd ?? 0);
188
- if (sortField === "tokens")
189
- return b.totals.input + b.totals.output - (a.totals.input + a.totals.output);
190
- return b.lastActivityAt - a.lastActivityAt;
210
+ let cmp = 0;
211
+ switch (sortField) {
212
+ case "cost":
213
+ cmp = (a.totalCostUsd ?? 0) - (b.totalCostUsd ?? 0);
214
+ break;
215
+ case "tokens":
216
+ cmp = a.totals.input + a.totals.output - (b.totals.input + b.totals.output);
217
+ break;
218
+ case "time":
219
+ cmp = a.lastActivityAt - b.lastActivityAt;
220
+ break;
221
+ case "requests":
222
+ cmp = a.requestCount - b.requestCount;
223
+ break;
224
+ case "duration": {
225
+ const aDur = (a.endedAt ?? Date.now()) - a.startedAt;
226
+ const bDur = (b.endedAt ?? Date.now()) - b.startedAt;
227
+ cmp = aDur - bDur;
228
+ break;
229
+ }
230
+ case "project": {
231
+ const aProj = a.projectPath?.split("/").pop() ?? "";
232
+ const bProj = b.projectPath?.split("/").pop() ?? "";
233
+ cmp = aProj.localeCompare(bProj);
234
+ break;
235
+ }
236
+ case "agent":
237
+ cmp = a.agentName.localeCompare(b.agentName);
238
+ break;
239
+ case "model": {
240
+ const aModel = a.streams[0]?.modelId ?? "";
241
+ const bModel = b.streams[0]?.modelId ?? "";
242
+ cmp = aModel.localeCompare(bModel);
243
+ break;
244
+ }
245
+ case "name": {
246
+ const aName = a.sessionName ?? "";
247
+ const bName = b.sessionName ?? "";
248
+ cmp = aName.localeCompare(bName);
249
+ break;
250
+ }
251
+ }
252
+ return sortDirection === "desc" ? -cmp : cmp;
191
253
  });
192
254
 
193
255
  return result;
194
- }, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField]);
256
+ }, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField, sortDirection]);
195
257
 
196
258
  // Stabilize selection across re-sorts: track selected session by ID
197
259
  // so the highlight follows the same session when live data causes reordering.
@@ -261,6 +323,7 @@ export function RealTimeDashboard() {
261
323
  filterQuery,
262
324
  isFiltering,
263
325
  sortField,
326
+ sortDirection,
264
327
  pendingG,
265
328
  scrollOffset: scrollOffsetRef.current,
266
329
  limitSelectedIndex,
@@ -269,6 +332,7 @@ export function RealTimeDashboard() {
269
332
  selectedDriverIndex,
270
333
  activeDriverFilter,
271
334
  sidebarMode,
335
+ showSortOverlay,
272
336
  },
273
337
  actions: {
274
338
  setShowHelp,
@@ -280,12 +344,14 @@ export function RealTimeDashboard() {
280
344
  setFilterQuery,
281
345
  setIsFiltering,
282
346
  setSortField,
347
+ setSortDirection,
283
348
  setPendingG,
284
349
  setScrollOffset,
285
350
  setLimitSelectedIndex,
286
351
  setDriverDimension,
287
352
  setSelectedDriverIndex,
288
353
  setActiveDriverFilter,
354
+ setShowSortOverlay,
289
355
  },
290
356
  processedSessions,
291
357
  });
@@ -348,6 +414,19 @@ export function RealTimeDashboard() {
348
414
  return (
349
415
  <box flexDirection="column" flexGrow={1} padding={1} gap={1} overflow="hidden">
350
416
  {showHelp && <HelpOverlay />}
417
+ {showSortOverlay && (
418
+ <SortOverlay
419
+ fields={visibleSortFields}
420
+ currentField={sortField}
421
+ currentDirection={sortDirection}
422
+ onSelect={(field, direction) => {
423
+ setSortField(() => field);
424
+ setSortDirection(() => direction);
425
+ setShowSortOverlay(false);
426
+ }}
427
+ onClose={() => setShowSortOverlay(false)}
428
+ />
429
+ )}
351
430
 
352
431
  <KpiStrip
353
432
  totalCost={windowedKpis.cost}
@@ -382,7 +461,9 @@ export function RealTimeDashboard() {
382
461
  name: p.plugin.name,
383
462
  usedPercent: getMaxUsedPercent(p),
384
463
  color: providerColorOf(p.plugin.id),
385
- ...(p.usage?.error ? { error: p.usage.error } : {}),
464
+ ...(p.usage?.error
465
+ ? { error: p.usage.error, errorCategory: classifyProviderError(p.usage.error) }
466
+ : {}),
386
467
  }))}
387
468
  focused={focusedPanel === "limits"}
388
469
  selectedIndex={limitSelectedIndex}
@@ -416,6 +497,8 @@ export function RealTimeDashboard() {
416
497
  focusedPanel={focusedPanel}
417
498
  windowLabel={windowLabel}
418
499
  getProviderColor={providerColorOf}
500
+ sortField={sortField}
501
+ sortDirection={sortDirection}
419
502
  />
420
503
 
421
504
  {!effectiveSidebarCollapsed && (
@@ -440,7 +523,7 @@ export function RealTimeDashboard() {
440
523
  : activeDriverFilter
441
524
  ? `Filter: ${activeDriverFilter} Esc clear`
442
525
  : focusedPanel === "sessions"
443
- ? "/ filter ↑↓ navigate Enter details s sort l limits Tab next ⇧Tab prev"
526
+ ? "/ filter ↑↓ navigate Enter details s sort l limits Tab next"
444
527
  : focusedPanel === "limits"
445
528
  ? "←→ select provider Tab next ⇧Tab prev Esc back"
446
529
  : focusedPanel === "sidebar"
@@ -2,6 +2,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
2
2
  import type { ConfigField } from "@tokentop/plugin-sdk";
3
3
  import { useCallback, useMemo, useState } from "react";
4
4
  import type { AppConfig } from "@/config/schema.ts";
5
+ import { notificationBus } from "@/plugins/notification-bus.ts";
5
6
  import { useConfig } from "../contexts/ConfigContext.tsx";
6
7
  import { useDemoMode } from "../contexts/DemoModeContext.tsx";
7
8
  import { usePlugins } from "../contexts/PluginContext.tsx";
@@ -14,10 +15,11 @@ interface SettingItem {
14
15
  key: string;
15
16
  label: string;
16
17
  category: SettingCategory;
17
- type: "toggle" | "select" | "number";
18
+ type: "toggle" | "select" | "number" | "action";
18
19
  options?: string[];
19
20
  getValue: (config: AppConfig) => string | number | boolean;
20
21
  setValue: (config: AppConfig, value: string | number | boolean) => AppConfig;
22
+ onAction?: () => void;
21
23
  }
22
24
 
23
25
  const SETTINGS: SettingItem[] = [
@@ -192,6 +194,15 @@ const SETTINGS: SettingItem[] = [
192
194
  notifications: { ...c.notifications, soundEnabled: v as boolean },
193
195
  }),
194
196
  },
197
+ {
198
+ key: "testNotification",
199
+ label: "Test Notifications",
200
+ category: "notifications",
201
+ type: "action",
202
+ onAction: () => void notificationBus.testNotification(),
203
+ getValue: () => "▸ Run",
204
+ setValue: (c) => c,
205
+ },
195
206
  ];
196
207
 
197
208
  const CATEGORIES: { id: SettingCategory; label: string }[] = [
@@ -384,6 +395,11 @@ export function SettingsView() {
384
395
  } else if (key.name === "up" || key.name === "k") {
385
396
  setSelectedIndex((i) => Math.max(i - 1, 0));
386
397
  } else if (key.name === "return" || key.name === "space") {
398
+ const setting = categorySettings[selectedIndex];
399
+ if (setting?.type === "action" && setting.onAction) {
400
+ setting.onAction();
401
+ return;
402
+ }
387
403
  toggleCurrentSetting();
388
404
  } else if (key.name === "left" || key.name === "h") {
389
405
  const setting = categorySettings[selectedIndex];
@@ -479,13 +495,15 @@ export function SettingsView() {
479
495
  >
480
496
  <text fg={isSelected ? colors.background : colors.text}>{setting.label}</text>
481
497
  <text fg={isSelected ? colors.background : colors.textMuted}>
482
- {setting.type === "toggle"
483
- ? value
484
- ? "● ON"
485
- : "○ OFF"
486
- : setting.type === "select"
487
- ? `◂ ${value} ▸`
488
- : String(value)}
498
+ {setting.type === "action"
499
+ ? "▸ Run"
500
+ : setting.type === "toggle"
501
+ ? value
502
+ ? "● ON"
503
+ : "○ OFF"
504
+ : setting.type === "select"
505
+ ? `◂ ${value} ▸`
506
+ : String(value)}
489
507
  </text>
490
508
  </box>
491
509
  );
@@ -0,0 +1,147 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { classifyProviderError } from "../error-category.ts";
3
+
4
+ describe("classifyProviderError", () => {
5
+ describe("auth errors", () => {
6
+ test("classifies expired token errors", () => {
7
+ expect(classifyProviderError("Token expired. Run any command in OpenCode to refresh.")).toBe(
8
+ "auth",
9
+ );
10
+ expect(
11
+ classifyProviderError(
12
+ "Token expired or invalid. Re-authenticate in OpenCode or Claude Code.",
13
+ ),
14
+ ).toBe("auth");
15
+ expect(classifyProviderError("OAuth token expired. Re-authenticate in OpenCode.")).toBe(
16
+ "auth",
17
+ );
18
+ expect(
19
+ classifyProviderError("OAuth token expired or invalid. Re-authenticate in OpenCode."),
20
+ ).toBe("auth");
21
+ expect(
22
+ classifyProviderError("OAuth token expired or invalid. Re-authenticate via Gemini CLI."),
23
+ ).toBe("auth");
24
+ expect(classifyProviderError("Claude Code token expired")).toBe("auth");
25
+ });
26
+
27
+ test("classifies scope errors", () => {
28
+ expect(
29
+ classifyProviderError(
30
+ "Token lacks required scope. Re-authenticate in OpenCode or Claude Code.",
31
+ ),
32
+ ).toBe("auth");
33
+ });
34
+
35
+ test("classifies missing credential errors", () => {
36
+ expect(
37
+ classifyProviderError(
38
+ "OAuth token or API key required. Authenticate via OpenCode or Claude Code.",
39
+ ),
40
+ ).toBe("auth");
41
+ expect(
42
+ classifyProviderError(
43
+ "OAuth token required. Sign in via OpenCode with ChatGPT Pro account.",
44
+ ),
45
+ ).toBe("auth");
46
+ expect(
47
+ classifyProviderError("OAuth token required. Sign in via OpenCode with Google account."),
48
+ ).toBe("auth");
49
+ expect(
50
+ classifyProviderError("OAuth token required. Login via Gemini CLI with Google account."),
51
+ ).toBe("auth");
52
+ expect(
53
+ classifyProviderError("API key required. Set OPENAI_API_KEY environment variable."),
54
+ ).toBe("auth");
55
+ expect(
56
+ classifyProviderError("API key required. Set PERPLEXITY_API_KEY environment variable."),
57
+ ).toBe("auth");
58
+ expect(classifyProviderError("API key required. Configure MiniMax in OpenCode.")).toBe(
59
+ "auth",
60
+ );
61
+ expect(classifyProviderError("API key required. Configure Chutes in OpenCode.")).toBe("auth");
62
+ expect(classifyProviderError("API key required. Configure Z.ai in OpenCode.")).toBe("auth");
63
+ expect(
64
+ classifyProviderError("API key required. Run /connect in OpenCode to set up Zen."),
65
+ ).toBe("auth");
66
+ });
67
+
68
+ test("classifies authorization failure errors", () => {
69
+ expect(classifyProviderError("Authorization failed. Check your MiniMax API key.")).toBe(
70
+ "auth",
71
+ );
72
+ expect(classifyProviderError("Authorization failed. Check your Chutes API key.")).toBe(
73
+ "auth",
74
+ );
75
+ expect(classifyProviderError("Authorization failed. Check your Z.ai API key.")).toBe("auth");
76
+ });
77
+
78
+ test("classifies invalid key errors", () => {
79
+ expect(classifyProviderError("Invalid API key")).toBe("auth");
80
+ expect(classifyProviderError("Unauthorized – check API key")).toBe("auth");
81
+ });
82
+
83
+ test("classifies HTTP status code auth errors", () => {
84
+ expect(classifyProviderError("API error: 401 Unauthorized")).toBe("auth");
85
+ expect(classifyProviderError("API error: 403 Forbidden")).toBe("auth");
86
+ expect(classifyProviderError("Authentication failed (401). Check API key.")).toBe("auth");
87
+ });
88
+
89
+ test("classifies not-configured errors", () => {
90
+ expect(classifyProviderError("Not configured: missing GitHub token")).toBe("auth");
91
+ expect(classifyProviderError("Copilot not enabled for this account")).toBe("auth");
92
+ });
93
+
94
+ test("classifies refresh failure errors", () => {
95
+ expect(classifyProviderError("No valid access token and refresh failed.")).toBe("auth");
96
+ expect(
97
+ classifyProviderError("ChatGPT account ID required. Re-authenticate in OpenCode."),
98
+ ).toBe("auth");
99
+ expect(
100
+ classifyProviderError(
101
+ "MiniMax groupId required. Set MINIMAX_GROUP_ID or configure in OpenCode.",
102
+ ),
103
+ ).toBe("auth");
104
+ });
105
+ });
106
+
107
+ describe("rate limit errors", () => {
108
+ test("classifies rate limit errors", () => {
109
+ expect(classifyProviderError("Rate limited")).toBe("rate_limit");
110
+ expect(classifyProviderError("API error: 429 Too Many Requests")).toBe("rate_limit");
111
+ });
112
+ });
113
+
114
+ describe("insufficient credits errors", () => {
115
+ test("classifies insufficient credits errors", () => {
116
+ expect(classifyProviderError("Insufficient credits")).toBe("insufficient_credits");
117
+ });
118
+ });
119
+
120
+ describe("timeout errors", () => {
121
+ test("classifies timeout errors", () => {
122
+ expect(classifyProviderError("The operation timed out")).toBe("timeout");
123
+ expect(classifyProviderError("Request timeout")).toBe("timeout");
124
+ expect(classifyProviderError("ETIMEDOUT")).toBe("timeout");
125
+ expect(classifyProviderError("Request aborted")).toBe("timeout");
126
+ });
127
+ });
128
+
129
+ describe("network errors", () => {
130
+ test("classifies network errors", () => {
131
+ expect(classifyProviderError("fetch failed")).toBe("network");
132
+ expect(classifyProviderError("ECONNREFUSED")).toBe("network");
133
+ expect(classifyProviderError("ENOTFOUND")).toBe("network");
134
+ expect(classifyProviderError("ECONNRESET")).toBe("network");
135
+ expect(classifyProviderError("DNS resolution failed")).toBe("network");
136
+ expect(classifyProviderError("Network error")).toBe("network");
137
+ });
138
+ });
139
+
140
+ describe("unknown errors", () => {
141
+ test("classifies unrecognized errors as unknown", () => {
142
+ expect(classifyProviderError("Unknown error")).toBe("unknown");
143
+ expect(classifyProviderError("API error: 500 Internal Server Error")).toBe("unknown");
144
+ expect(classifyProviderError("Something went wrong")).toBe("unknown");
145
+ });
146
+ });
147
+ });
@@ -26,7 +26,9 @@ export function parseCurrencyInput(input: string): number | null {
26
26
  } else if (hasComma) {
27
27
  const parts = normalized.split(",");
28
28
  // 3 digits after comma = thousands separator (1,234), otherwise decimal (10,50)
29
- if (parts.length === 2 && parts[1]!.length === 3 && parts[0]!.length > 0) {
29
+ const afterComma = parts[1]?.length ?? 0;
30
+ const beforeComma = parts[0]?.length ?? 0;
31
+ if (parts.length === 2 && afterComma === 3 && beforeComma > 0) {
30
32
  normalized = normalized.replace(",", "");
31
33
  } else {
32
34
  normalized = normalized.replace(",", ".");
@@ -35,7 +37,7 @@ export function parseCurrencyInput(input: string): number | null {
35
37
 
36
38
  const parsed = parseFloat(normalized);
37
39
 
38
- if (isNaN(parsed) || !isFinite(parsed)) {
40
+ if (Number.isNaN(parsed) || !Number.isFinite(parsed)) {
39
41
  return null;
40
42
  }
41
43
 
@@ -0,0 +1,91 @@
1
+ export type ProviderErrorCategory =
2
+ | "auth"
3
+ | "rate_limit"
4
+ | "timeout"
5
+ | "network"
6
+ | "insufficient_credits"
7
+ | "unknown";
8
+
9
+ export function classifyProviderError(error: string): ProviderErrorCategory {
10
+ const lower = error.toLowerCase();
11
+
12
+ // Auth errors: expired tokens, invalid credentials, scope issues
13
+ if (
14
+ lower.includes("expired") ||
15
+ lower.includes("re-authenticate") ||
16
+ lower.includes("invalid") ||
17
+ lower.includes("unauthorized") ||
18
+ lower.includes("scope") ||
19
+ lower.includes("authentication failed") ||
20
+ lower.includes("authorization failed") ||
21
+ lower.includes("check api key") ||
22
+ lower.includes("api key required") ||
23
+ lower.includes("token required") ||
24
+ lower.includes("not configured") ||
25
+ lower.includes("sign in") ||
26
+ lower.includes("refresh failed") ||
27
+ lower.includes("not enabled") ||
28
+ lower.includes("configure") ||
29
+ /\b401\b/.test(lower) ||
30
+ /\b403\b/.test(lower)
31
+ ) {
32
+ return "auth";
33
+ }
34
+
35
+ // Rate limits
36
+ if (
37
+ lower.includes("rate limit") ||
38
+ lower.includes("too many requests") ||
39
+ /\b429\b/.test(lower)
40
+ ) {
41
+ return "rate_limit";
42
+ }
43
+
44
+ // Insufficient credits
45
+ if (lower.includes("insufficient") || lower.includes("credits") || /\b402\b/.test(lower)) {
46
+ return "insufficient_credits";
47
+ }
48
+
49
+ // Timeouts
50
+ if (
51
+ lower.includes("timeout") ||
52
+ lower.includes("timed out") ||
53
+ lower.includes("etimedout") ||
54
+ lower.includes("aborted")
55
+ ) {
56
+ return "timeout";
57
+ }
58
+
59
+ // Network errors
60
+ if (
61
+ lower.includes("econnrefused") ||
62
+ lower.includes("enotfound") ||
63
+ lower.includes("network") ||
64
+ lower.includes("fetch failed") ||
65
+ lower.includes("dns") ||
66
+ lower.includes("econnreset")
67
+ ) {
68
+ return "network";
69
+ }
70
+
71
+ return "unknown";
72
+ }
73
+
74
+ interface ErrorCategoryDisplay {
75
+ label: string;
76
+ icon: string;
77
+ isWarning: boolean;
78
+ }
79
+
80
+ const CATEGORY_DISPLAY: Record<ProviderErrorCategory, ErrorCategoryDisplay> = {
81
+ auth: { label: "AUTH", icon: "\u26B7", isWarning: true },
82
+ rate_limit: { label: "RATE", icon: "\u29D7", isWarning: true },
83
+ timeout: { label: "TIME", icon: "\u29D7", isWarning: true },
84
+ insufficient_credits: { label: "CRED", icon: "\u2717", isWarning: true },
85
+ network: { label: "NET", icon: "\u2717", isWarning: false },
86
+ unknown: { label: "ERR", icon: "\u2717", isWarning: false },
87
+ };
88
+
89
+ export function getErrorCategoryDisplay(category: ProviderErrorCategory): ErrorCategoryDisplay {
90
+ return CATEGORY_DISPLAY[category];
91
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.5.0"; // x-release-please-version
1
+ export const VERSION = "0.6.1"; // x-release-please-version