Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/pr-assets/usage/chat-usage-widget.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-assets/usage/settings-usage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 62 additions & 16 deletions apps/server/src/provider/usage/modules/claudeUsageModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,38 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

function normalizeClaudePlanName(value: string | null | undefined): string | null {
const raw = value?.trim();
if (!raw) {
return null;
}

const normalized = raw.toLowerCase().replace(/[\s_-]+/g, "");
switch (normalized) {
case "pro":
case "claudepro":
case "defaultclaudeai":
return "pro";
case "max":
case "max5":
case "max20":
case "maxplan":
case "claudemax":
return "max";
case "team":
case "claudeteam":
return "team";
case "enterprise":
case "claudeenterprise":
return "enterprise";
case "free":
case "claudefree":
return "free";
default:
return raw;
}
}

function toUsageState(status: "ready" | "warning" | "error"): ProviderUsageState {
switch (status) {
case "ready":
Expand All @@ -58,20 +90,23 @@ function parseAuthStatusJson(stdout: string): {
readonly planName: string | null;
readonly loginMethod: string | null;
readonly email: string | null;
readonly org: string | null;
} {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return { planName: null, loginMethod: null, email: null };
return { planName: null, loginMethod: null, email: null, org: null };
}

const planName =
findStringByKeys(parsed, ["plan", "planType", "subscriptionType", "subscription", "tier"]) ??
null;
const planName = normalizeClaudePlanName(
findStringByKeys(parsed, ["plan", "planType", "subscriptionType", "subscription", "tier"]),
);
const loginMethod = findStringByKeys(parsed, ["authMethod", "auth_method"]) ?? null;
const email = findStringByKeys(parsed, ["email"]) ?? null;
return { planName, loginMethod, email };
const org =
findStringByKeys(parsed, ["orgName", "organization", "organizationName", "org"]) ?? null;
return { planName, loginMethod, email, org };
}

function parseClaudeOAuthCredentials(stdout: string): {
Expand All @@ -89,10 +124,11 @@ function parseClaudeOAuthCredentials(stdout: string): {
const oauth = asRecord(root?.claudeAiOauth) ?? root;
return {
accessToken: asString(oauth?.accessToken) ?? null,
planName:
planName: normalizeClaudePlanName(
asString(oauth?.subscriptionType) ??
findStringByKeys(parsed, ["subscriptionType", "subscription_type"]) ??
null,
asString(oauth?.rateLimitTier) ??
findStringByKeys(parsed, ["subscriptionType", "subscription_type", "rateLimitTier"]),
),
};
}

Expand Down Expand Up @@ -266,7 +302,7 @@ export const makeClaudeUsageModule = Effect.gen(function* () {
planName: parsedJson.planName,
loginMethod: parsedJson.loginMethod,
email: parsedJson.email,
org: null,
org: parsedJson.org,
},
windows: [],
raw: result.stdout || result.stderr || null,
Expand Down Expand Up @@ -316,15 +352,24 @@ export const makeClaudeUsageModule = Effect.gen(function* () {
}

if (event.type === "account.updated") {
const planName = findStringByKeys(event.payload.account, [
"subscriptionType",
"planType",
"plan",
"tier",
]);
const planName = normalizeClaudePlanName(
findStringByKeys(event.payload.account, [
"subscriptionType",
"planType",
"plan",
"tier",
"rateLimitTier",
]),
);
const loginMethod = findStringByKeys(event.payload.account, ["authMethod", "auth_method"]);
const email = findStringByKeys(event.payload.account, ["email"]);
if (!planName && !loginMethod && !email) {
const org = findStringByKeys(event.payload.account, [
"orgName",
"organization",
"organizationName",
"org",
]);
if (!planName && !loginMethod && !email && !org) {
return undefined;
}

Expand All @@ -337,6 +382,7 @@ export const makeClaudeUsageModule = Effect.gen(function* () {
...(planName ? { planName } : {}),
...(loginMethod ? { loginMethod } : {}),
...(email ? { email } : {}),
...(org ? { org } : {}),
},
};
}
Expand Down
17 changes: 10 additions & 7 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5034,12 +5034,6 @@ export default function ChatView({ threadId }: ChatViewProps) {

{/* Error banner */}
<ProviderStatusBanner status={activeProviderStatus} />
<ProviderUsageNotice
provider={selectedProvider}
usage={activeProviderUsage}
visible={providerUsagePanelOpen}
onDismiss={() => setProviderUsagePanelOpen(false)}
/>
<ThreadErrorBanner
error={activeThread.error}
onDismiss={() => setThreadError(activeThread.id, null)}
Expand Down Expand Up @@ -5123,9 +5117,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
className="mx-auto w-full min-w-0 max-w-208"
data-chat-composer-form="true"
>
<div className="pointer-events-none relative z-0 mb-[-8px] flex justify-center px-4">
<ProviderUsageNotice
provider={selectedProvider}
usage={activeProviderUsage}
visible={providerUsagePanelOpen}
onDismiss={() => setProviderUsagePanelOpen(false)}
className="pointer-events-auto max-w-[32rem]"
/>
</div>
<div
className={cn(
"group rounded-[22px] p-px transition-colors duration-200",
"group relative z-10 rounded-[22px] p-px transition-colors duration-200",
composerProviderState.composerFrameClassName,
)}
style={
Expand Down
128 changes: 28 additions & 100 deletions apps/web/src/components/chat/ProviderUsageNotice.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
PROVIDER_DISPLAY_NAMES,
type ProviderKind,
type ServerProviderUsage,
} from "@t3tools/contracts";
import { type ProviderKind, type ServerProviderUsage } from "@t3tools/contracts";
import { PROVIDER_USAGE_METADATA } from "@t3tools/shared/provider-usage";
import { memo } from "react";
import { XIcon } from "lucide-react";
Expand All @@ -15,37 +11,6 @@ import {
} from "../../providerUsageDisplay";
import { cn } from "~/lib/utils";

function statusLabel(status: ServerProviderUsage["status"]): string {
switch (status) {
case "ready":
return "Healthy";
case "limited":
return "Limited";
case "exhausted":
return "Exhausted";
case "error":
return "Error";
case "unknown":
default:
return "Unknown";
}
}

function statusBadgeClass(status: ServerProviderUsage["status"]): string {
switch (status) {
case "ready":
return "bg-success/18 text-success";
case "limited":
return "bg-warning/18 text-warning";
case "exhausted":
case "error":
return "bg-destructive/18 text-destructive";
case "unknown":
default:
return "bg-muted text-muted-foreground";
}
}

function usageBarClass(percentRemaining: number | null): string {
if (percentRemaining === null) {
return "bg-muted-foreground/35";
Expand All @@ -71,19 +36,15 @@ export const ProviderUsageNotice = memo(function ProviderUsageNotice({
usage,
visible,
onDismiss,
className,
}: {
provider: ProviderKind;
usage: ServerProviderUsage | null;
visible: boolean;
onDismiss?: () => void;
className?: string;
}) {
if (!visible) {
return null;
}

const metadata = PROVIDER_USAGE_METADATA[provider];
const providerLabel = PROVIDER_DISPLAY_NAMES[provider] ?? provider;
const usageMessage = usage?.detail ?? usage?.summary ?? null;
const usageWindows = usage?.windows ?? [];
const { sessionWindow, weeklyWindow } = selectPrimaryUsageWindows({
windows: usageWindows,
Expand All @@ -105,50 +66,41 @@ export const ProviderUsageNotice = memo(function ProviderUsageNotice({
];

return (
<div className="pointer-events-none fixed right-4 top-[calc(var(--desktop-window-safe-inset)+52px+8.75rem)] z-40 w-[min(22.5rem,calc(100vw-2rem))] sm:right-8 sm:w-[min(22.5rem,calc(100vw-4rem))]">
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2 text-[11px] text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground/90">{providerLabel} usage</span>
{usage ? (
<span
className={cn("rounded px-1.5 py-0.5 font-medium", statusBadgeClass(usage.status))}
>
{statusLabel(usage.status)}
</span>
) : null}
{usage?.stale ? (
<span className="rounded bg-warning/20 px-1.5 py-0.5 text-warning">Stale</span>
) : null}
{usage?.identity.planName ? <span>Plan: {usage.identity.planName}</span> : null}
{onDismiss ? (
<button
type="button"
aria-label="Hide usage panel"
className="pointer-events-auto ml-auto inline-flex size-5 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={onDismiss}
>
<XIcon className="size-3.5" />
</button>
) : null}
</div>
{usageMessage ? (
<p className="mt-1 line-clamp-2 text-muted-foreground" title={usageMessage}>
{usageMessage}
</p>
) : !usage ? (
<p className="mt-1 text-muted-foreground">
<div
className={cn(
"w-full origin-bottom overflow-hidden transition-[max-height,opacity,transform] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[max-height,opacity,transform] motion-reduce:transition-none",
visible
? "max-h-64 translate-y-0 opacity-100"
: "pointer-events-none max-h-0 translate-y-1 opacity-0",
className,
)}
aria-hidden={!visible}
>
<div className="relative rounded-[18px] border border-border/60 bg-card/95 px-3 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.16)] backdrop-blur-sm text-[11px] text-muted-foreground">
{onDismiss ? (
<button
type="button"
aria-label="Hide usage panel"
className="absolute top-2 right-2 inline-flex size-5 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={onDismiss}
>
<XIcon className="size-3.5" />
</button>
) : null}
{!usage ? (
<p className="px-7 text-muted-foreground">
No usage snapshot available yet for this provider.
</p>
) : null}
{usage ? (
<div className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
<div className="flex flex-wrap gap-2 px-7">
{rows.map((row) => {
const percentUsed = clampUsagePercentUsed(row.window?.percentUsed ?? null);
const percentRemaining = toRemainingUsagePercent(percentUsed);
return (
<div
key={row.key}
className="rounded border border-border/60 bg-background/20 px-2 py-1.5"
className="min-w-0 flex-1 rounded-xl border border-border/50 bg-background/35 px-2.5 py-1.5"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground/90">{row.label}</span>
Expand All @@ -173,30 +125,6 @@ export const ProviderUsageNotice = memo(function ProviderUsageNotice({
})}
</div>
) : null}
{metadata.usageUrl || metadata.dashboardUrl ? (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2">
{metadata.usageUrl ? (
<a
href={metadata.usageUrl}
target="_blank"
rel="noreferrer"
className="pointer-events-auto text-primary hover:underline"
>
Usage
</a>
) : null}
{metadata.dashboardUrl ? (
<a
href={metadata.dashboardUrl}
target="_blank"
rel="noreferrer"
className="pointer-events-auto text-primary hover:underline"
>
{metadata.dashboardLabel ?? "Dashboard"}
</a>
) : null}
</div>
) : null}
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { SettingsModelsPanel } from "./SettingsModelsPanel";
export { SettingsGitPanel } from "./SettingsGitPanel";
export { SettingsEnhancePanel } from "./SettingsEnhancePanel";
export { SettingsProvidersPanel } from "./SettingsProvidersPanel";
export { SettingsUsagePanel } from "./SettingsUsagePanel";
export { SettingsAdvancedPanel } from "./SettingsAdvancedPanel";
export { SettingsAboutPanel } from "./SettingsAboutPanel";
export { ArchivedThreadsPanel } from "./SettingsArchivedPanel";
Loading
Loading