feat(ui): add plan quota display to analytics dashboard
- Fix analytics loading bug where client.request returns payload directly - Add plan quota section showing usage against plan limits (5h, weekly windows) - Display provider errors when OAuth token lacks required scopes - Add helpful message when no quota data is available - Add screenshot script with dynamic Playwright chromium detection - Update tests for quota state and parallel data fetching Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6abd372a48
commit
93614c4799
BIN
docs/images/control-ui-analytics.png
Normal file
BIN
docs/images/control-ui-analytics.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@ -311,6 +311,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
loading: state.analyticsLoading,
|
loading: state.analyticsLoading,
|
||||||
error: state.analyticsError,
|
error: state.analyticsError,
|
||||||
data: state.analyticsData,
|
data: state.analyticsData,
|
||||||
|
quota: state.analyticsQuota,
|
||||||
days: state.analyticsDays,
|
days: state.analyticsDays,
|
||||||
onDaysChange: (days) => {
|
onDaysChange: (days) => {
|
||||||
state.analyticsDays = days;
|
state.analyticsDays = days;
|
||||||
|
|||||||
@ -115,6 +115,7 @@ export type AppViewState = {
|
|||||||
analyticsLoading: boolean;
|
analyticsLoading: boolean;
|
||||||
analyticsError: string | null;
|
analyticsError: string | null;
|
||||||
analyticsData: unknown | null;
|
analyticsData: unknown | null;
|
||||||
|
analyticsQuota: unknown | null;
|
||||||
analyticsDays: number;
|
analyticsDays: number;
|
||||||
cronLoading: boolean;
|
cronLoading: boolean;
|
||||||
cronJobs: CronJob[];
|
cronJobs: CronJob[];
|
||||||
|
|||||||
@ -205,6 +205,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() analyticsLoading = false;
|
@state() analyticsLoading = false;
|
||||||
@state() analyticsError: string | null = null;
|
@state() analyticsError: string | null = null;
|
||||||
@state() analyticsData: unknown | null = null;
|
@state() analyticsData: unknown | null = null;
|
||||||
|
@state() analyticsQuota: unknown | null = null;
|
||||||
@state() analyticsDays = 30;
|
@state() analyticsDays = 30;
|
||||||
|
|
||||||
@state() cronLoading = false;
|
@state() cronLoading = false;
|
||||||
|
|||||||
@ -1,26 +1,25 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { AnalyticsState } from "./analytics";
|
import type { AnalyticsState } from "./analytics";
|
||||||
|
import { loadAnalytics } from "./analytics";
|
||||||
|
import type { CostUsageSummary } from "../views/analytics";
|
||||||
|
|
||||||
describe("analytics controller", () => {
|
describe("analytics controller", () => {
|
||||||
it("AnalyticsState has expected properties", () => {
|
it("AnalyticsState has expected properties", () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
request: vi.fn().mockResolvedValue({
|
request: vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
updatedAt: Date.now(),
|
||||||
result: {
|
days: 30,
|
||||||
updatedAt: Date.now(),
|
daily: [],
|
||||||
days: 30,
|
totals: {
|
||||||
daily: [],
|
input: 0,
|
||||||
totals: {
|
output: 0,
|
||||||
input: 0,
|
cacheRead: 0,
|
||||||
output: 0,
|
cacheWrite: 0,
|
||||||
cacheRead: 0,
|
totalTokens: 0,
|
||||||
cacheWrite: 0,
|
totalCost: 0,
|
||||||
totalTokens: 0,
|
missingCostEntries: 0,
|
||||||
totalCost: 0,
|
|
||||||
missingCostEntries: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
} as CostUsageSummary),
|
||||||
};
|
};
|
||||||
|
|
||||||
const state: AnalyticsState = {
|
const state: AnalyticsState = {
|
||||||
@ -28,12 +27,14 @@ describe("analytics controller", () => {
|
|||||||
analyticsLoading: false,
|
analyticsLoading: false,
|
||||||
analyticsError: null,
|
analyticsError: null,
|
||||||
analyticsData: null,
|
analyticsData: null,
|
||||||
|
analyticsQuota: null,
|
||||||
analyticsDays: 30,
|
analyticsDays: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(state.analyticsLoading).toBe(false);
|
expect(state.analyticsLoading).toBe(false);
|
||||||
expect(state.analyticsDays).toBe(30);
|
expect(state.analyticsDays).toBe(30);
|
||||||
expect(state.analyticsData).toBeNull();
|
expect(state.analyticsData).toBeNull();
|
||||||
|
expect(state.analyticsQuota).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have valid day options", () => {
|
it("should have valid day options", () => {
|
||||||
@ -43,4 +44,94 @@ describe("analytics controller", () => {
|
|||||||
expect(days).toBeLessThanOrEqual(90);
|
expect(days).toBeLessThanOrEqual(90);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loadAnalytics sets data and quota on success", async () => {
|
||||||
|
const mockCostData: CostUsageSummary = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
days: 30,
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
date: "2026-01-26",
|
||||||
|
input: 1000,
|
||||||
|
output: 500,
|
||||||
|
cacheRead: 200,
|
||||||
|
cacheWrite: 100,
|
||||||
|
totalTokens: 1800,
|
||||||
|
totalCost: 0.05,
|
||||||
|
missingCostEntries: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totals: {
|
||||||
|
input: 1000,
|
||||||
|
output: 500,
|
||||||
|
cacheRead: 200,
|
||||||
|
cacheWrite: 100,
|
||||||
|
totalTokens: 1800,
|
||||||
|
totalCost: 0.05,
|
||||||
|
missingCostEntries: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockQuotaData = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: "anthropic",
|
||||||
|
displayName: "Claude",
|
||||||
|
windows: [
|
||||||
|
{ label: "5h", usedPercent: 42 },
|
||||||
|
{ label: "Week", usedPercent: 25 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
request: vi.fn().mockImplementation((method: string) => {
|
||||||
|
if (method === "usage.cost") return Promise.resolve(mockCostData);
|
||||||
|
if (method === "usage.status") return Promise.resolve(mockQuotaData);
|
||||||
|
return Promise.reject(new Error("Unknown method"));
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: AnalyticsState = {
|
||||||
|
client: mockClient as any,
|
||||||
|
analyticsLoading: false,
|
||||||
|
analyticsError: null,
|
||||||
|
analyticsData: null,
|
||||||
|
analyticsQuota: null,
|
||||||
|
analyticsDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadAnalytics(state);
|
||||||
|
|
||||||
|
expect(mockClient.request).toHaveBeenCalledWith("usage.cost", { days: 30 });
|
||||||
|
expect(mockClient.request).toHaveBeenCalledWith("usage.status", {});
|
||||||
|
expect(state.analyticsData).toEqual(mockCostData);
|
||||||
|
expect(state.analyticsQuota).toHaveLength(1);
|
||||||
|
expect(state.analyticsQuota![0].displayName).toBe("Claude");
|
||||||
|
expect(state.analyticsError).toBeNull();
|
||||||
|
expect(state.analyticsLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loadAnalytics sets error on failure", async () => {
|
||||||
|
const mockClient = {
|
||||||
|
request: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: AnalyticsState = {
|
||||||
|
client: mockClient as any,
|
||||||
|
analyticsLoading: false,
|
||||||
|
analyticsError: null,
|
||||||
|
analyticsData: null,
|
||||||
|
analyticsQuota: null,
|
||||||
|
analyticsDays: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadAnalytics(state);
|
||||||
|
|
||||||
|
expect(state.analyticsData).toBeNull();
|
||||||
|
expect(state.analyticsError).toBe("Network error");
|
||||||
|
expect(state.analyticsLoading).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import type { GatewayBrowserClient } from "../gateway.js";
|
import type { GatewayBrowserClient } from "../gateway.js";
|
||||||
import type { CostUsageSummary } from "../views/analytics.js";
|
import type { CostUsageSummary, ProviderQuota, UsageSummary } from "../views/analytics.js";
|
||||||
|
|
||||||
export type AnalyticsState = {
|
export type AnalyticsState = {
|
||||||
client: GatewayBrowserClient;
|
client: GatewayBrowserClient;
|
||||||
analyticsLoading: boolean;
|
analyticsLoading: boolean;
|
||||||
analyticsError: string | null;
|
analyticsError: string | null;
|
||||||
analyticsData: CostUsageSummary | null;
|
analyticsData: CostUsageSummary | null;
|
||||||
|
analyticsQuota: ProviderQuota[] | null;
|
||||||
analyticsDays: number;
|
analyticsDays: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -14,11 +15,30 @@ export async function loadAnalytics(state: AnalyticsState): Promise<void> {
|
|||||||
state.analyticsError = null;
|
state.analyticsError = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await state.client.request("usage.cost", { days: state.analyticsDays });
|
// Fetch both cost and quota data in parallel
|
||||||
if (res.ok && res.result) {
|
const [costRes, quotaRes] = await Promise.all([
|
||||||
state.analyticsData = res.result as CostUsageSummary;
|
state.client.request("usage.cost", {
|
||||||
} else {
|
days: state.analyticsDays,
|
||||||
state.analyticsError = res.error?.message ?? "Failed to load usage data";
|
}) as Promise<CostUsageSummary | undefined>,
|
||||||
|
state.client.request("usage.status", {}) as Promise<UsageSummary | undefined>,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (costRes) {
|
||||||
|
state.analyticsData = costRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotaRes?.providers) {
|
||||||
|
state.analyticsQuota = quotaRes.providers.map((p) => ({
|
||||||
|
provider: p.provider,
|
||||||
|
displayName: p.displayName,
|
||||||
|
windows: p.windows,
|
||||||
|
plan: p.plan,
|
||||||
|
error: p.error,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!costRes && !quotaRes) {
|
||||||
|
state.analyticsError = "Failed to load usage data";
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.analyticsError = err instanceof Error ? err.message : "Failed to load usage data";
|
state.analyticsError = err instanceof Error ? err.message : "Failed to load usage data";
|
||||||
|
|||||||
@ -88,6 +88,7 @@ describe("analytics data types", () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
data: null,
|
data: null,
|
||||||
|
quota: null,
|
||||||
days: 30,
|
days: 30,
|
||||||
onDaysChange: () => {},
|
onDaysChange: () => {},
|
||||||
onRefresh: () => {},
|
onRefresh: () => {},
|
||||||
@ -95,5 +96,6 @@ describe("analytics data types", () => {
|
|||||||
|
|
||||||
expect(props.loading).toBe(false);
|
expect(props.loading).toBe(false);
|
||||||
expect(props.days).toBe(30);
|
expect(props.days).toBe(30);
|
||||||
|
expect(props.quota).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -21,10 +21,30 @@ export type CostUsageSummary = {
|
|||||||
totals: CostUsageTotals;
|
totals: CostUsageTotals;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UsageWindow = {
|
||||||
|
label: string;
|
||||||
|
usedPercent: number;
|
||||||
|
resetAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderQuota = {
|
||||||
|
provider: string;
|
||||||
|
displayName: string;
|
||||||
|
windows: UsageWindow[];
|
||||||
|
plan?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsageSummary = {
|
||||||
|
updatedAt: number;
|
||||||
|
providers: ProviderQuota[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AnalyticsProps = {
|
export type AnalyticsProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
data: CostUsageSummary | null;
|
data: CostUsageSummary | null;
|
||||||
|
quota: ProviderQuota[] | null;
|
||||||
days: number;
|
days: number;
|
||||||
onDaysChange: (days: number) => void;
|
onDaysChange: (days: number) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@ -42,6 +62,25 @@ function formatCost(n: number): string {
|
|||||||
return `$${n.toFixed(2)}`;
|
return `$${n.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPercent(n: number): string {
|
||||||
|
return `${Math.round(n)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResetTime(timestamp?: number): string {
|
||||||
|
if (!timestamp) return "";
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = timestamp - now;
|
||||||
|
if (diff <= 0) return "now";
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const minutes = Math.floor((diff % 3600000) / 60000);
|
||||||
|
if (hours > 24) {
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "totalCost") {
|
function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "totalCost") {
|
||||||
if (!daily.length) {
|
if (!daily.length) {
|
||||||
return html`<div class="chart-empty">No data for this period</div>`;
|
return html`<div class="chart-empty">No data for this period</div>`;
|
||||||
@ -54,7 +93,7 @@ function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "t
|
|||||||
return html`
|
return html`
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<div class="chart-bars">
|
<div class="chart-bars">
|
||||||
${daily.map((entry, i) => {
|
${daily.map((entry) => {
|
||||||
const value = entry[metric];
|
const value = entry[metric];
|
||||||
const height = Math.max(2, (value / max) * 150);
|
const height = Math.max(2, (value / max) * 150);
|
||||||
const label = metric === "totalCost" ? formatCost(value) : formatNumber(value);
|
const label = metric === "totalCost" ? formatCost(value) : formatNumber(value);
|
||||||
@ -76,9 +115,59 @@ function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "t
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderQuotaBar(percent: number) {
|
||||||
|
const color = percent >= 90 ? "var(--danger)" : percent >= 70 ? "var(--warning)" : "var(--accent)";
|
||||||
|
return html`
|
||||||
|
<div class="quota-bar-container">
|
||||||
|
<div class="quota-bar-fill" style="width: ${Math.min(100, percent)}%; background: ${color}"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProviderQuota(provider: ProviderQuota) {
|
||||||
|
if (provider.error) {
|
||||||
|
return html`
|
||||||
|
<div class="quota-provider">
|
||||||
|
<div class="quota-provider-name">${provider.displayName}</div>
|
||||||
|
<div class="quota-error">${provider.error}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.windows.length) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="quota-provider">
|
||||||
|
<div class="quota-provider-header">
|
||||||
|
<div class="quota-provider-name">${provider.displayName}</div>
|
||||||
|
${provider.plan ? html`<div class="quota-plan">${provider.plan}</div>` : nothing}
|
||||||
|
</div>
|
||||||
|
<div class="quota-windows">
|
||||||
|
${provider.windows.map(
|
||||||
|
(w) => html`
|
||||||
|
<div class="quota-window">
|
||||||
|
<div class="quota-window-header">
|
||||||
|
<span class="quota-window-label">${w.label}</span>
|
||||||
|
<span class="quota-window-value">${formatPercent(w.usedPercent)} used</span>
|
||||||
|
</div>
|
||||||
|
${renderQuotaBar(w.usedPercent)}
|
||||||
|
${w.resetAt ? html`<div class="quota-reset">Resets in ${formatResetTime(w.resetAt)}</div>` : nothing}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderAnalytics(props: AnalyticsProps) {
|
export function renderAnalytics(props: AnalyticsProps) {
|
||||||
const { loading, error, data, days } = props;
|
const { loading, error, data, quota, days } = props;
|
||||||
const totals = data?.totals;
|
const totals = data?.totals;
|
||||||
|
const hasQuota = quota && quota.some((p) => p.windows.length > 0);
|
||||||
|
const hasQuotaError = quota && quota.some((p) => p.error);
|
||||||
|
const quotaEmpty = quota && quota.length === 0;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
@ -164,9 +253,104 @@ export function renderAnalytics(props: AnalyticsProps) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.quota-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.quota-providers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.quota-provider {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.quota-provider-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.quota-provider-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.quota-plan {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.quota-error {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.quota-windows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.quota-window {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.quota-window-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.quota-window-label {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
.quota-window-value {
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.quota-bar-container {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.quota-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.quota-reset {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<section class="card">
|
${hasQuota || hasQuotaError
|
||||||
|
? html`
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-title">Plan Quota</div>
|
||||||
|
<div class="card-sub">Current usage against your plan limits.</div>
|
||||||
|
<div class="quota-providers" style="margin-top: 16px;">
|
||||||
|
${quota!.map((p) => renderProviderQuota(p))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
: quotaEmpty
|
||||||
|
? html`
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-title">Plan Quota</div>
|
||||||
|
<div class="card-sub">Current usage against your plan limits.</div>
|
||||||
|
<div class="callout" style="margin-top: 16px;">
|
||||||
|
No plan quota data available. To see usage limits for Claude Max or other plans,
|
||||||
|
configure OAuth authentication with the required scopes.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: ${hasQuota || hasQuotaError || quotaEmpty ? "18px" : "0"};">
|
||||||
<div class="card-title">Usage Summary</div>
|
<div class="card-title">Usage Summary</div>
|
||||||
<div class="card-sub">Token consumption and estimated costs.</div>
|
<div class="card-sub">Token consumption and estimated costs.</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user