mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Merge runtime token recovery and quota review fixes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
60f7e0981d
10 files changed
+195
-56
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDateTime } from "../lib/utils";
|
||||
import { AccountQuotaPanels } from "./AccountQuotaPanels";
|
||||
|
||||
describe("account quota panels", () => {
|
||||
@@ -18,7 +19,7 @@ describe("account quota panels", () => {
|
||||
{ provider: "openai", accountKey: "a", ok: true, windows: [{ label: "Credits", usedPercent: null, resetsAt: "2026-10-05T00:00:00Z", valueLabel: "$5 remaining" }] },
|
||||
]} />);
|
||||
expect(html).toContain("$5 remaining");
|
||||
expect(html).toContain(`Resets ${new Date("2026-10-05T00:00:00Z").toLocaleString()}`);
|
||||
expect(html).toContain(`Resets ${formatDateTime("2026-10-05T00:00:00Z")}`);
|
||||
expect(html).not.toContain("0%");
|
||||
expect(html).not.toContain('role="progressbar"');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProviderQuotaResult } from "@paperclipai/shared";
|
||||
import { formatDateTime } from "../lib/utils";
|
||||
import { QuotaBar } from "./QuotaBar";
|
||||
import { quotaUnavailableMessage } from "../lib/quota-refresh";
|
||||
|
||||
@@ -32,11 +33,12 @@ export function AccountQuotaPanels({
|
||||
<span>{window.label}</span>
|
||||
<span className="text-muted-foreground">{window.valueLabel ?? "Usage not reported"}</span>
|
||||
</div>
|
||||
{window.resetsAt && <p className="text-xs text-muted-foreground">Resets {new Date(window.resetsAt).toLocaleString()}</p>}
|
||||
{window.resetsAt && <p className="text-xs font-mono text-muted-foreground">Resets {formatDateTime(window.resetsAt)}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<QuotaBar
|
||||
key={`${window.label}:${index}`}
|
||||
className="font-mono"
|
||||
label={window.label}
|
||||
percentUsed={window.usedPercent}
|
||||
leftLabel={
|
||||
@@ -47,14 +49,14 @@ export function AccountQuotaPanels({
|
||||
}
|
||||
rightLabel={
|
||||
window.resetsAt
|
||||
? `Resets ${new Date(window.resetsAt).toLocaleString()}`
|
||||
? `Resets ${formatDateTime(window.resetsAt)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{account.capturedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last checked {new Date(account.capturedAt).toLocaleString()}
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
Last checked {formatDateTime(account.capturedAt)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ProviderQuotaCardProps {
|
||||
quotaSource?: string | null;
|
||||
quotaLoading?: boolean;
|
||||
quotaAccounts?: ProviderQuotaResult[];
|
||||
quotaRequestFailed?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderQuotaCard({
|
||||
@@ -50,6 +51,7 @@ export function ProviderQuotaCard({
|
||||
quotaSource = null,
|
||||
quotaLoading = false,
|
||||
quotaAccounts,
|
||||
quotaRequestFailed,
|
||||
}: ProviderQuotaCardProps) {
|
||||
// single-pass aggregation over rows — memoized so the 8 derived values are not
|
||||
// recomputed on every parent render tick (providers tab polls every 30s, and each
|
||||
@@ -325,7 +327,7 @@ export function ProviderQuotaCard({
|
||||
{quotaLoading ? (
|
||||
<QuotaPanelSkeleton />
|
||||
) : quotaAccounts ? (
|
||||
<AccountQuotaPanels accounts={quotaAccounts} />
|
||||
<AccountQuotaPanels accounts={quotaAccounts} failed={quotaRequestFailed} />
|
||||
) : isClaudeQuotaPanel ? (
|
||||
<ClaudeSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
|
||||
) : isCodexQuotaPanel ? (
|
||||
|
||||
@@ -332,7 +332,7 @@ export function Costs() {
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: quotaData, isLoading: quotaLoading } = useQuery({
|
||||
const { data: quotaData, isLoading: quotaLoading, error: quotaFetchError } = useQuery({
|
||||
queryKey: queryKeys.usageQuotaWindows(companyId),
|
||||
queryFn: () => costsApi.quotaWindows(companyId),
|
||||
structuralSharing: (previous, incoming) => retainQuotaWindows(
|
||||
@@ -405,8 +405,12 @@ export function Costs() {
|
||||
for (const result of quotaData ?? []) {
|
||||
if (!result.ok && result.error) map.set(result.provider, result.error);
|
||||
}
|
||||
if (quotaFetchError) {
|
||||
map.set("anthropic", "unavailable");
|
||||
map.set("openai", "unavailable");
|
||||
}
|
||||
return map;
|
||||
}, [quotaData]);
|
||||
}, [quotaData, quotaFetchError]);
|
||||
|
||||
const quotaSourcesByProvider = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -980,6 +984,7 @@ export function Costs() {
|
||||
windowRows={windowSpendByProvider.get(provider) ?? []}
|
||||
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
|
||||
quotaAccounts={quotaData?.filter(account => account.provider === provider)}
|
||||
quotaRequestFailed={Boolean(quotaFetchError)}
|
||||
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
|
||||
quotaError={quotaErrorsByProvider.get(provider) ?? null}
|
||||
quotaSource={quotaSourcesByProvider.get(provider) ?? null}
|
||||
@@ -1001,6 +1006,7 @@ export function Costs() {
|
||||
windowRows={windowSpendByProvider.get(provider) ?? []}
|
||||
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
|
||||
quotaAccounts={quotaData?.filter(account => account.provider === provider)}
|
||||
quotaRequestFailed={Boolean(quotaFetchError)}
|
||||
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
|
||||
quotaError={quotaErrorsByProvider.get(provider) ?? null}
|
||||
quotaSource={quotaSourcesByProvider.get(provider) ?? null}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Costs } from "./Costs";
|
||||
import { Costs as ProductionCosts } from "./Costs.production";
|
||||
import { SidebarProvider } from "../context/SidebarContext";
|
||||
|
||||
const api = vi.hoisted(() => Object.fromEntries([
|
||||
@@ -34,6 +35,20 @@ describe("Costs account quota polling", () => {
|
||||
});
|
||||
afterEach(() => { act(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
|
||||
const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); };
|
||||
it.each([Costs, ProductionCosts])("keeps quota readings with a safe notice when the endpoint rejects (%#)", async (Page) => {
|
||||
await act(async () => root.render(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Page /></QueryClientProvider></SidebarProvider></MemoryRouter>));
|
||||
await flush();
|
||||
const tab = [...container.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(button => button.textContent === "Providers")!;
|
||||
await act(async () => { tab.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); tab.click(); });
|
||||
await flush();
|
||||
expect(container.textContent).toContain("37% used");
|
||||
api.quotaWindows.mockRejectedValue(new Error("Raw provider diagnostic: secret command"));
|
||||
await act(async () => { await client.invalidateQueries({ queryKey }); }); await flush();
|
||||
expect(container.textContent).toContain("37% used");
|
||||
expect(container.textContent).toContain("Showing the last available quota");
|
||||
expect(container.textContent).not.toContain("Raw provider diagnostic");
|
||||
expect(client.getQueryData(queryKey)).toMatchObject([{ capturedAt: observed.capturedAt }]);
|
||||
});
|
||||
it.each(["provider_unavailable", "permission_denied"])("retains the observation on %s but clears rejected or rotated credentials", async (errorFamily) => {
|
||||
await act(async () => root.render(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs initialTab="providers" /></QueryClientProvider></SidebarProvider></MemoryRouter>));
|
||||
await flush();
|
||||
|
||||
@@ -353,7 +353,7 @@ export function Costs({
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: quotaData, isLoading: quotaLoading } = useQuery({
|
||||
const { data: quotaData, isLoading: quotaLoading, error: quotaFetchError } = useQuery({
|
||||
queryKey: queryKeys.usageQuotaWindows(companyId),
|
||||
queryFn: () => costsApi.quotaWindows(companyId),
|
||||
structuralSharing: (previous, incoming) => retainQuotaWindows(
|
||||
@@ -426,8 +426,12 @@ export function Costs({
|
||||
for (const result of quotaData ?? []) {
|
||||
if (!result.ok && result.error) map.set(result.provider, result.error);
|
||||
}
|
||||
if (quotaFetchError) {
|
||||
map.set("anthropic", "unavailable");
|
||||
map.set("openai", "unavailable");
|
||||
}
|
||||
return map;
|
||||
}, [quotaData]);
|
||||
}, [quotaData, quotaFetchError]);
|
||||
|
||||
const quotaSourcesByProvider = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -1008,6 +1012,7 @@ export function Costs({
|
||||
windowRows={windowSpendByProvider.get(provider) ?? []}
|
||||
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
|
||||
quotaAccounts={quotaData?.filter(account => account.provider === provider)}
|
||||
quotaRequestFailed={Boolean(quotaFetchError)}
|
||||
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
|
||||
quotaError={quotaErrorsByProvider.get(provider) ?? null}
|
||||
quotaSource={quotaSourcesByProvider.get(provider) ?? null}
|
||||
@@ -1029,6 +1034,7 @@ export function Costs({
|
||||
windowRows={windowSpendByProvider.get(provider) ?? []}
|
||||
showDeficitNotch={deficitNotchByProvider.get(provider) ?? false}
|
||||
quotaAccounts={quotaData?.filter(account => account.provider === provider)}
|
||||
quotaRequestFailed={Boolean(quotaFetchError)}
|
||||
quotaWindows={quotaWindowsByProvider.get(provider) ?? []}
|
||||
quotaError={quotaErrorsByProvider.get(provider) ?? null}
|
||||
quotaSource={quotaSourcesByProvider.get(provider) ?? null}
|
||||
|
||||
Reference in new issue
Block a user