Skip to content

Commit 30167c3

Browse files
tombiiclaude
andcommitted
Merge pull request #212 from d4rken/feat/recent-errors-detail-upstream
feat: add Recent Errors detail panel with provider-aware suggestions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 8b45ec9 + 31ad700 commit 30167c3

12 files changed

Lines changed: 234 additions & 17 deletions

File tree

packages/dashboard-web/src/api.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ class API extends HttpClient {
167167
}
168168
}
169169

170-
async getStats(opts?: { errorsSinceHours?: number }): Promise<Stats> {
170+
async getStats(opts?: {
171+
errorsSinceHours?: number;
172+
}): Promise<StatsWithAccounts> {
171173
const startTime = Date.now();
172174
const hours = opts?.errorsSinceHours;
173175
const url =
@@ -178,7 +180,7 @@ class API extends HttpClient {
178180
this.logger.debug(`→ GET ${url}`);
179181

180182
try {
181-
const response = await this.get<Stats>(url);
183+
const response = await this.get<StatsWithAccounts>(url);
182184
const duration = Date.now() - startTime;
183185
this.logger.debug(`← GET ${url} - 200 (${duration}ms)`);
184186
return response;

packages/dashboard-web/src/components/OverviewTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const OverviewTab = React.memo(() => {
3131
// Fetch all data using React Query hooks
3232
const { data: stats, isLoading: statsLoading } = useStats(
3333
REFRESH_INTERVALS.default,
34+
24,
3435
);
3536
const [timeRange, setTimeRange] = useState("24h");
3637
const { data: analytics, isLoading: analyticsLoading } = useAnalytics(

packages/dashboard-web/src/components/StatsTab.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import type { RecentErrorGroup } from "@better-ccflare/types";
12
import { formatPercentage } from "@better-ccflare/ui-common";
2-
import { RefreshCw } from "lucide-react";
3+
import { formatDistanceToNow } from "date-fns";
4+
import { RefreshCw, XCircle } from "lucide-react";
35
import { useResetStats, useStats } from "../hooks/queries";
46
import { useApiError } from "../hooks/useApiError";
57
import { RecentErrorsCard } from "./overview/system-status/RecentErrorsCard";

packages/dashboard-web/src/components/overview/system-status/ErrorDetailsModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { getErrorMeta } from "./errorCodeMeta";
1717

1818
interface ErrorDetailsModalProps {
1919
error: RecentErrorGroup | null;
20+
otherAccountsAvailable: boolean;
2021
onClose: () => void;
2122
onDismiss: (group: RecentErrorGroup) => void;
2223
}
@@ -54,12 +55,18 @@ function DetailRow({ label, children }: DetailRowProps) {
5455

5556
export function ErrorDetailsModal({
5657
error,
58+
otherAccountsAvailable,
5759
onClose,
5860
onDismiss,
5961
}: ErrorDetailsModalProps) {
6062
const open = error !== null;
6163

62-
const meta = error ? getErrorMeta(error.errorCode) : null;
64+
const meta = error
65+
? getErrorMeta(error.errorCode, {
66+
provider: error.provider,
67+
otherAccountsAvailable,
68+
})
69+
: null;
6370
const isWarning = meta?.severity === "warning";
6471
const Icon = isWarning ? AlertTriangle : XCircle;
6572
const iconColor = isWarning ? "text-warning" : "text-destructive";

packages/dashboard-web/src/components/overview/system-status/RecentErrorRow.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getErrorMeta } from "./errorCodeMeta";
88

99
interface RecentErrorRowProps {
1010
error: RecentErrorGroup;
11+
otherAccountsAvailable: boolean;
1112
onClick: () => void;
1213
onDismiss: () => void;
1314
}
@@ -20,10 +21,14 @@ function accountLabel(error: RecentErrorGroup): string {
2021

2122
export function RecentErrorRow({
2223
error,
24+
otherAccountsAvailable,
2325
onClick,
2426
onDismiss,
2527
}: RecentErrorRowProps) {
26-
const meta = getErrorMeta(error.errorCode);
28+
const meta = getErrorMeta(error.errorCode, {
29+
provider: error.provider,
30+
otherAccountsAvailable,
31+
});
2732
const isWarning = meta.severity === "warning";
2833
const Icon = isWarning ? AlertTriangle : XCircle;
2934
const bgClass = isWarning ? "bg-warning/10" : "bg-destructive/10";

packages/dashboard-web/src/components/overview/system-status/RecentErrorsCard.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NO_ACCOUNT_ID, type RecentErrorGroup } from "@better-ccflare/types";
22
import { useState } from "react";
3-
import { useStats } from "../../../hooks/queries";
3+
import { useAccounts, useStats } from "../../../hooks/queries";
44
import {
55
Select,
66
SelectContent,
@@ -9,6 +9,7 @@ import {
99
SelectValue,
1010
} from "../../ui/select";
1111
import { ErrorDetailsModal } from "./ErrorDetailsModal";
12+
import { otherAccountsAvailable } from "./otherAccountsAvailable";
1213
import { RecentErrorRow } from "./RecentErrorRow";
1314
import { useDismissedErrors } from "./useDismissedErrors";
1415
import { type ErrorWindowKey, useErrorWindow } from "./useErrorWindow";
@@ -23,6 +24,7 @@ const WINDOW_OPTIONS: Array<{ value: ErrorWindowKey; label: string }> = [
2324
export function RecentErrorsCard() {
2425
const { windowKey, setWindowKey, windowHours } = useErrorWindow();
2526
const { data, isLoading } = useStats(undefined, windowHours);
27+
const { data: accounts } = useAccounts();
2628
const { dismiss, isDismissed } = useDismissedErrors();
2729
const [selectedError, setSelectedError] = useState<RecentErrorGroup | null>(
2830
null,
@@ -31,6 +33,9 @@ export function RecentErrorsCard() {
3133
const recentErrors = data?.recentErrors;
3234
const visibleErrors = recentErrors?.filter((err) => !isDismissed(err)) ?? [];
3335

36+
const hasOtherAvailableAccounts = (errorAccountId: string | null) =>
37+
otherAccountsAvailable(accounts, errorAccountId);
38+
3439
if (isLoading && !data) return null;
3540
if (visibleErrors.length === 0) return null;
3641

@@ -62,6 +67,7 @@ export function RecentErrorsCard() {
6267
<RecentErrorRow
6368
key={`${error.accountId ?? NO_ACCOUNT_ID}:${error.errorCode}:${error.latestRequestId}`}
6469
error={error}
70+
otherAccountsAvailable={hasOtherAvailableAccounts(error.accountId)}
6571
onClick={() => setSelectedError(error)}
6672
onDismiss={() => dismiss(error)}
6773
/>
@@ -70,6 +76,11 @@ export function RecentErrorsCard() {
7076

7177
<ErrorDetailsModal
7278
error={selectedError}
79+
otherAccountsAvailable={
80+
selectedError
81+
? hasOtherAvailableAccounts(selectedError.accountId)
82+
: false
83+
}
7384
onClose={() => setSelectedError(null)}
7485
onDismiss={(group) => dismiss(group)}
7586
/>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { getErrorMeta } from "../errorCodeMeta";
3+
4+
describe("getErrorMeta", () => {
5+
test("unknown code returns fallback meta with the code as title", () => {
6+
const meta = getErrorMeta("totally_unknown_code");
7+
expect(meta.title).toBe("totally_unknown_code");
8+
expect(meta.severity).toBe("error");
9+
});
10+
11+
test("model_fallback_429 with no context defaults to a warning that points at Model Mappings", () => {
12+
const meta = getErrorMeta("model_fallback_429");
13+
expect(meta.severity).toBe("warning");
14+
expect(meta.suggestion).toContain("Model Mappings");
15+
});
16+
17+
test("model_fallback_429 with anthropic provider replaces the suggestion with the OAuth-friendly copy", () => {
18+
const meta = getErrorMeta("model_fallback_429", { provider: "anthropic" });
19+
expect(meta.suggestion).toContain("No action needed");
20+
});
21+
22+
test("model_fallback_429 with anthropic provider and no other accounts upgrades severity and prefixes description", () => {
23+
const meta = getErrorMeta("model_fallback_429", {
24+
provider: "anthropic",
25+
otherAccountsAvailable: false,
26+
});
27+
expect(meta.severity).toBe("error");
28+
expect(meta.description.startsWith("No other accounts are available")).toBe(
29+
true,
30+
);
31+
});
32+
33+
test("model_fallback_429 with multi-model provider keeps default suggestion and warning severity", () => {
34+
const meta = getErrorMeta("model_fallback_429", {
35+
provider: "zai",
36+
otherAccountsAvailable: true,
37+
});
38+
expect(meta.severity).toBe("warning");
39+
expect(meta.suggestion).toContain("Model Mappings");
40+
});
41+
42+
test("upstream_429_with_reset entry stays unchanged", () => {
43+
const meta = getErrorMeta("upstream_429_with_reset");
44+
expect(meta.title).toBe("Provider rate limit");
45+
expect(meta.severity).toBe("warning");
46+
});
47+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { otherAccountsAvailable } from "../otherAccountsAvailable";
3+
4+
type Account = Parameters<typeof otherAccountsAvailable>[0][number];
5+
6+
const baseAccount: Account = {
7+
id: "acc-1",
8+
paused: false,
9+
rateLimitedUntil: null,
10+
};
11+
12+
describe("otherAccountsAvailable", () => {
13+
test("returns true when a healthy other account exists", () => {
14+
const accounts: Account[] = [
15+
{ ...baseAccount, id: "acc-self" },
16+
{ ...baseAccount, id: "acc-other" },
17+
];
18+
expect(otherAccountsAvailable(accounts, "acc-self")).toBe(true);
19+
});
20+
21+
test("excludes the error's own account", () => {
22+
const accounts: Account[] = [{ ...baseAccount, id: "acc-self" }];
23+
expect(otherAccountsAvailable(accounts, "acc-self")).toBe(false);
24+
});
25+
26+
test("excludes paused accounts", () => {
27+
const accounts: Account[] = [
28+
{ ...baseAccount, id: "acc-self" },
29+
{ ...baseAccount, id: "acc-paused", paused: true },
30+
];
31+
expect(otherAccountsAvailable(accounts, "acc-self")).toBe(false);
32+
});
33+
34+
test("excludes accounts whose rateLimitedUntil is in the future", () => {
35+
const future = Date.now() + 60_000;
36+
const accounts: Account[] = [
37+
{ ...baseAccount, id: "acc-self" },
38+
{ ...baseAccount, id: "acc-rl", rateLimitedUntil: future },
39+
];
40+
expect(otherAccountsAvailable(accounts, "acc-self")).toBe(false);
41+
});
42+
43+
test("counts accounts whose rateLimitedUntil has already elapsed", () => {
44+
const past = Date.now() - 60_000;
45+
const accounts: Account[] = [
46+
{ ...baseAccount, id: "acc-self" },
47+
{ ...baseAccount, id: "acc-recovered", rateLimitedUntil: past },
48+
];
49+
expect(otherAccountsAvailable(accounts, "acc-self")).toBe(true);
50+
});
51+
52+
test("treats null rateLimitedUntil as available", () => {
53+
const accounts: Account[] = [
54+
{ ...baseAccount, id: "acc-self" },
55+
{ ...baseAccount, id: "acc-other", rateLimitedUntil: null },
56+
];
57+
expect(otherAccountsAvailable(accounts, "acc-self")).toBe(true);
58+
});
59+
60+
test("handles undefined accounts list as no available", () => {
61+
expect(otherAccountsAvailable(undefined, "acc-self")).toBe(false);
62+
});
63+
64+
test("returns false when only paused and rate-limited accounts exist", () => {
65+
const future = Date.now() + 60_000;
66+
const accounts: Account[] = [
67+
{ ...baseAccount, id: "acc-self" },
68+
{ ...baseAccount, id: "acc-paused", paused: true },
69+
{ ...baseAccount, id: "acc-rl", rateLimitedUntil: future },
70+
];
71+
expect(otherAccountsAvailable(accounts, "acc-self")).toBe(false);
72+
});
73+
});

packages/dashboard-web/src/components/overview/system-status/errorCodeMeta.ts

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ export interface ErrorMeta {
99
severity: ErrorSeverity;
1010
}
1111

12-
const KNOWN_ERROR_META: Record<RateLimitReason, ErrorMeta> = {
12+
export interface ErrorContext {
13+
provider?: string | null;
14+
otherAccountsAvailable?: boolean;
15+
}
16+
17+
const KNOWN_ERROR_META: Record<
18+
Exclude<RateLimitReason, "model_fallback_429">,
19+
ErrorMeta
20+
> = {
1321
upstream_429_with_reset: {
1422
title: "Provider rate limit",
1523
description: "The upstream provider returned 429 with a known reset time.",
@@ -31,14 +39,6 @@ const KNOWN_ERROR_META: Record<RateLimitReason, ErrorMeta> = {
3139
suggestion: "Historical record — no action needed.",
3240
severity: "warning",
3341
},
34-
model_fallback_429: {
35-
title: "Rate limited — no fallback models",
36-
description:
37-
"The account was rate-limited and has no fallback models configured.",
38-
suggestion:
39-
"Configure model fallbacks for this account to enable automatic retry.",
40-
severity: "error",
41-
},
4242
all_models_exhausted_429: {
4343
title: "All fallback models rate-limited",
4444
description: "Every fallback model also returned 429.",
@@ -47,9 +47,42 @@ const KNOWN_ERROR_META: Record<RateLimitReason, ErrorMeta> = {
4747
},
4848
};
4949

50-
export function getErrorMeta(code: string): ErrorMeta {
50+
function getModelFallbackMeta(context?: ErrorContext): ErrorMeta {
51+
const provider = context?.provider ?? null;
52+
const otherAccountsAvailable = context?.otherAccountsAvailable;
53+
54+
const isOAuthOnlyProvider = provider === "anthropic" || provider === "codex";
55+
56+
const suggestion = isOAuthOnlyProvider
57+
? "No action needed — Claude/Codex accounts only serve their native models, so the proxy will use the next account until this one recovers."
58+
: 'To retry on the same account before failing over, open this account\'s More actions → Model Mappings and add comma-separated alternates (e.g. "primary, fallback-1").';
59+
60+
const baseDescription =
61+
"This account hit a 429 with only one model configured, so the proxy failed over to the next account in priority order.";
62+
63+
if (otherAccountsAvailable === false) {
64+
return {
65+
title: "Account rate-limited — no in-account fallback",
66+
description: `No other accounts are available — requests will fail until this account recovers. ${baseDescription}`,
67+
suggestion,
68+
severity: "error",
69+
};
70+
}
71+
72+
return {
73+
title: "Account rate-limited — no in-account fallback",
74+
description: baseDescription,
75+
suggestion,
76+
severity: "warning",
77+
};
78+
}
79+
80+
export function getErrorMeta(code: string, context?: ErrorContext): ErrorMeta {
81+
if (code === "model_fallback_429") {
82+
return getModelFallbackMeta(context);
83+
}
5184
if (code in KNOWN_ERROR_META) {
52-
return KNOWN_ERROR_META[code as RateLimitReason];
85+
return KNOWN_ERROR_META[code as keyof typeof KNOWN_ERROR_META];
5386
}
5487
return {
5588
title: code || "Unknown error",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Predicate used by RecentErrorsCard to decide whether failover from the
3+
* account that produced an error has any chance of succeeding.
4+
*
5+
* An "available" account is one that is:
6+
* - not the account that produced the error,
7+
* - not manually paused, and
8+
* - not currently rate-limited (rateLimitedUntil null or in the past).
9+
*
10+
* Rate-limited accounts must be excluded — otherwise model_fallback_429
11+
* displays as "warning" implying failover will succeed when in fact every
12+
* candidate is also exhausted.
13+
*/
14+
export type AccountForFailoverCheck = {
15+
id: string;
16+
paused: boolean;
17+
rateLimitedUntil: number | null;
18+
};
19+
20+
export function otherAccountsAvailable(
21+
accounts: AccountForFailoverCheck[] | undefined | null,
22+
errorAccountId: string | null,
23+
): boolean {
24+
const now = Date.now();
25+
return (accounts ?? []).some(
26+
(a) =>
27+
a.id !== errorAccountId &&
28+
!a.paused &&
29+
(a.rateLimitedUntil == null || a.rateLimitedUntil <= now),
30+
);
31+
}

0 commit comments

Comments
 (0)