chore: remove tokenlens and token usage tracking (#1357)

This commit is contained in:
josh 2025-12-15 16:56:10 +00:00 committed by GitHub
parent 5f9e231788
commit 7942e97095
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 60 additions and 773 deletions

View file

@ -7,15 +7,11 @@ import {
stepCountIs, stepCountIs,
streamText, streamText,
} from "ai"; } from "ai";
import { unstable_cache as cache } from "next/cache";
import { after } from "next/server"; import { after } from "next/server";
import { import {
createResumableStreamContext, createResumableStreamContext,
type ResumableStreamContext, type ResumableStreamContext,
} from "resumable-stream"; } from "resumable-stream";
import type { ModelCatalog } from "tokenlens/core";
import { fetchModels } from "tokenlens/fetch";
import { getUsage } from "tokenlens/helpers";
import { auth, type UserType } from "@/app/(auth)/auth"; import { auth, type UserType } from "@/app/(auth)/auth";
import type { VisibilityType } from "@/components/visibility-selector"; import type { VisibilityType } from "@/components/visibility-selector";
import { entitlementsByUserType } from "@/lib/ai/entitlements"; import { entitlementsByUserType } from "@/lib/ai/entitlements";
@ -35,12 +31,11 @@ import {
getMessagesByChatId, getMessagesByChatId,
saveChat, saveChat,
saveMessages, saveMessages,
updateChatLastContextById, updateChatTitleById,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema"; import type { DBMessage } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { convertToUIMessages, generateUUID } from "@/lib/utils"; import { convertToUIMessages, generateUUID } from "@/lib/utils";
import { generateTitleFromUserMessage } from "../../actions"; import { generateTitleFromUserMessage } from "../../actions";
import { type PostRequestBody, postRequestBodySchema } from "./schema"; import { type PostRequestBody, postRequestBodySchema } from "./schema";
@ -49,22 +44,6 @@ export const maxDuration = 60;
let globalStreamContext: ResumableStreamContext | null = null; let globalStreamContext: ResumableStreamContext | null = null;
const getTokenlensCatalog = cache(
async (): Promise<ModelCatalog | undefined> => {
try {
return await fetchModels();
} catch (err) {
console.warn(
"TokenLens: catalog fetch failed, using default catalog",
err
);
return; // tokenlens helpers will fall back to defaultCatalog
}
},
["tokenlens-catalog"],
{ revalidate: 24 * 60 * 60 } // 24 hours
);
export function getStreamContext() { export function getStreamContext() {
if (!globalStreamContext) { if (!globalStreamContext) {
try { try {
@ -127,6 +106,7 @@ export async function POST(request: Request) {
const chat = await getChatById({ id }); const chat = await getChatById({ id });
let messagesFromDb: DBMessage[] = []; let messagesFromDb: DBMessage[] = [];
let titlePromise: Promise<string> | null = null;
if (chat) { if (chat) {
if (chat.userId !== session.user.id) { if (chat.userId !== session.user.id) {
@ -135,17 +115,16 @@ export async function POST(request: Request) {
// Only fetch messages if chat already exists // Only fetch messages if chat already exists
messagesFromDb = await getMessagesByChatId({ id }); messagesFromDb = await getMessagesByChatId({ id });
} else { } else {
const title = await generateTitleFromUserMessage({ // Save chat immediately with placeholder title
message,
});
await saveChat({ await saveChat({
id, id,
userId: session.user.id, userId: session.user.id,
title, title: "New chat",
visibility: selectedVisibilityType, visibility: selectedVisibilityType,
}); });
// New chat - no need to fetch messages, it's empty
// Start title generation in parallel (don't await)
titlePromise = generateTitleFromUserMessage({ message });
} }
const uiMessages = [...convertToUIMessages(messagesFromDb), message]; const uiMessages = [...convertToUIMessages(messagesFromDb), message];
@ -175,10 +154,16 @@ export async function POST(request: Request) {
const streamId = generateUUID(); const streamId = generateUUID();
await createStreamId({ streamId, chatId: id }); await createStreamId({ streamId, chatId: id });
let finalMergedUsage: AppUsage | undefined;
const stream = createUIMessageStream({ const stream = createUIMessageStream({
execute: ({ writer: dataStream }) => { execute: ({ writer: dataStream }) => {
// Handle title generation in parallel
if (titlePromise) {
titlePromise.then((title) => {
updateChatTitleById({ chatId: id, title });
dataStream.write({ type: "data-chat-title", data: title });
});
}
const isReasoningModel = const isReasoningModel =
selectedChatModel.includes("reasoning") || selectedChatModel.includes("reasoning") ||
selectedChatModel.includes("thinking"); selectedChatModel.includes("thinking");
@ -219,37 +204,6 @@ export async function POST(request: Request) {
isEnabled: isProductionEnvironment, isEnabled: isProductionEnvironment,
functionId: "stream-text", functionId: "stream-text",
}, },
onFinish: async ({ usage }) => {
try {
const providers = await getTokenlensCatalog();
const modelId = getLanguageModel(selectedChatModel).modelId;
if (!modelId) {
finalMergedUsage = usage;
dataStream.write({
type: "data-usage",
data: finalMergedUsage,
});
return;
}
if (!providers) {
finalMergedUsage = usage;
dataStream.write({
type: "data-usage",
data: finalMergedUsage,
});
return;
}
const summary = getUsage({ modelId, usage, providers });
finalMergedUsage = { ...usage, ...summary, modelId } as AppUsage;
dataStream.write({ type: "data-usage", data: finalMergedUsage });
} catch (err) {
console.warn("TokenLens enrichment failed", err);
finalMergedUsage = usage;
dataStream.write({ type: "data-usage", data: finalMergedUsage });
}
},
}); });
result.consumeStream(); result.consumeStream();
@ -272,17 +226,6 @@ export async function POST(request: Request) {
chatId: id, chatId: id,
})), })),
}); });
if (finalMergedUsage) {
try {
await updateChatLastContextById({
chatId: id,
context: finalMergedUsage,
});
} catch (err) {
console.warn("Unable to persist last usage for chat", id, err);
}
}
}, },
onError: () => { onError: () => {
return "Oops, an error occurred!"; return "Oops, an error occurred!";

View file

@ -22,7 +22,7 @@ async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
const chat = await getChatById({ id }); const chat = await getChatById({ id });
if (!chat) { if (!chat) {
notFound(); redirect("/");
} }
const session = await auth(); const session = await auth();
@ -57,7 +57,6 @@ async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
autoResume={true} autoResume={true}
id={chat.id} id={chat.id}
initialChatModel={DEFAULT_CHAT_MODEL} initialChatModel={DEFAULT_CHAT_MODEL}
initialLastContext={chat.lastContext ?? undefined}
initialMessages={uiMessages} initialMessages={uiMessages}
initialVisibilityType={chat.visibility} initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId} isReadonly={session?.user?.id !== chat.userId}
@ -73,7 +72,6 @@ async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
autoResume={true} autoResume={true}
id={chat.id} id={chat.id}
initialChatModel={chatModelFromCookie.value} initialChatModel={chatModelFromCookie.value}
initialLastContext={chat.lastContext ?? undefined}
initialMessages={uiMessages} initialMessages={uiMessages}
initialVisibilityType={chat.visibility} initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId} isReadonly={session?.user?.id !== chat.userId}

View file

@ -1,408 +0,0 @@
"use client";
import type { LanguageModelUsage } from "ai";
import { type ComponentProps, createContext, useContext } from "react";
import { getUsage } from "tokenlens";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
const PERCENT_MAX = 100;
const ICON_RADIUS = 10;
const ICON_VIEWBOX = 24;
const ICON_CENTER = 12;
const ICON_STROKE_WIDTH = 2;
type ModelId = string;
type ContextSchema = {
usedTokens: number;
maxTokens: number;
usage?: LanguageModelUsage;
modelId?: ModelId;
};
const ContextContext = createContext<ContextSchema | null>(null);
const useContextValue = () => {
const context = useContext(ContextContext);
if (!context) {
throw new Error("Context components must be used within Context");
}
return context;
};
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
export const Context = ({
usedTokens,
maxTokens,
usage,
modelId,
...props
}: ContextProps) => (
<ContextContext.Provider
value={{
usedTokens,
maxTokens,
usage,
modelId,
}}
>
<HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider>
);
const ContextIcon = () => {
const { usedTokens, maxTokens } = useContextValue();
const circumference = 2 * Math.PI * ICON_RADIUS;
const usedPercent = usedTokens / maxTokens;
const dashOffset = circumference * (1 - usedPercent);
return (
<svg
aria-label="Model context usage"
height="20"
role="img"
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="20"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={ICON_RADIUS}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={ICON_RADIUS}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
/>
</svg>
);
};
export type ContextTriggerProps = ComponentProps<typeof Button>;
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const renderedPercent = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(usedPercent);
return (
<HoverCardTrigger asChild>
{children ?? (
<Button type="button" variant="ghost" {...props}>
<span className="font-medium text-muted-foreground">
{renderedPercent}
</span>
<ContextIcon />
</Button>
)}
</HoverCardTrigger>
);
};
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
export const ContextContent = ({
className,
...props
}: ContextContentProps) => (
<HoverCardContent
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
{...props}
/>
);
export type ContextContentHeaderProps = ComponentProps<"div">;
export const ContextContentHeader = ({
children,
className,
...props
}: ContextContentHeaderProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const displayPct = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(usedPercent);
const used = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(usedTokens);
const total = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(maxTokens);
return (
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
{children ?? (
<>
<div className="flex items-center justify-between gap-3 text-xs">
<p>{displayPct}</p>
<p className="font-mono text-muted-foreground">
{used} / {total}
</p>
</div>
<div className="space-y-2">
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
</div>
</>
)}
</div>
);
};
export type ContextContentBodyProps = ComponentProps<"div">;
export const ContextContentBody = ({
children,
className,
...props
}: ContextContentBodyProps) => (
<div className={cn("w-full p-3", className)} {...props}>
{children}
</div>
);
export type ContextContentFooterProps = ComponentProps<"div">;
export const ContextContentFooter = ({
children,
className,
...props
}: ContextContentFooterProps) => {
const { modelId, usage } = useContextValue();
const costUSD = modelId
? getUsage({
modelId,
usage: {
input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0,
},
}).costUSD?.totalUSD
: undefined;
const totalCost = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(costUSD ?? 0);
return (
<div
className={cn(
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
className
)}
{...props}
>
{children ?? (
<>
<span className="text-muted-foreground">Total cost</span>
<span>{totalCost}</span>
</>
)}
</div>
);
};
export type ContextInputUsageProps = ComponentProps<"div">;
export const ContextInputUsage = ({
className,
children,
...props
}: ContextInputUsageProps) => {
const { usage, modelId } = useContextValue();
const inputTokens = usage?.inputTokens ?? 0;
if (children) {
return children;
}
if (!inputTokens) {
return null;
}
const inputCost = modelId
? getUsage({
modelId,
usage: { input: inputTokens, output: 0 },
}).costUSD?.totalUSD
: undefined;
const inputCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(inputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Input</span>
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
</div>
);
};
export type ContextOutputUsageProps = ComponentProps<"div">;
export const ContextOutputUsage = ({
className,
children,
...props
}: ContextOutputUsageProps) => {
const { usage, modelId } = useContextValue();
const outputTokens = usage?.outputTokens ?? 0;
if (children) {
return children;
}
if (!outputTokens) {
return null;
}
const outputCost = modelId
? getUsage({
modelId,
usage: { input: 0, output: outputTokens },
}).costUSD?.totalUSD
: undefined;
const outputCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(outputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Output</span>
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
</div>
);
};
export type ContextReasoningUsageProps = ComponentProps<"div">;
export const ContextReasoningUsage = ({
className,
children,
...props
}: ContextReasoningUsageProps) => {
const { usage, modelId } = useContextValue();
const reasoningTokens = usage?.reasoningTokens ?? 0;
if (children) {
return children;
}
if (!reasoningTokens) {
return null;
}
const reasoningCost = modelId
? getUsage({
modelId,
usage: { reasoningTokens },
}).costUSD?.totalUSD
: undefined;
const reasoningCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(reasoningCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Reasoning</span>
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
</div>
);
};
export type ContextCacheUsageProps = ComponentProps<"div">;
export const ContextCacheUsage = ({
className,
children,
...props
}: ContextCacheUsageProps) => {
const { usage, modelId } = useContextValue();
const cacheTokens = usage?.cachedInputTokens ?? 0;
if (children) {
return children;
}
if (!cacheTokens) {
return null;
}
const cacheCost = modelId
? getUsage({
modelId,
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
}).costUSD?.totalUSD
: undefined;
const cacheCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cacheCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Cache</span>
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
</div>
);
};
const TokensWithCost = ({
tokens,
costText,
}: {
tokens?: number;
costText?: string;
}) => (
<span>
{tokens === undefined
? "—"
: new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(tokens)}
{costText ? (
<span className="ml-2 text-muted-foreground"> {costText}</span>
) : null}
</span>
);

View file

@ -49,8 +49,9 @@ export function AppSidebar({ user }: { user: User | undefined }) {
loading: "Deleting all chats...", loading: "Deleting all chats...",
success: () => { success: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey)); mutate(unstable_serialize(getChatHistoryPaginationKey));
router.push("/");
setShowDeleteAllDialog(false); setShowDeleteAllDialog(false);
router.replace("/");
router.refresh();
return "All chats deleted successfully"; return "All chats deleted successfully";
}, },
error: "Failed to delete all chats", error: "Failed to delete all chats",

View file

@ -23,7 +23,6 @@ import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Vote } from "@/lib/db/schema"; import type { Vote } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import type { Attachment, ChatMessage } from "@/lib/types"; import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "./artifact"; import { Artifact } from "./artifact";
import { useDataStream } from "./data-stream-provider"; import { useDataStream } from "./data-stream-provider";
@ -40,7 +39,6 @@ export function Chat({
initialVisibilityType, initialVisibilityType,
isReadonly, isReadonly,
autoResume, autoResume,
initialLastContext,
}: { }: {
id: string; id: string;
initialMessages: ChatMessage[]; initialMessages: ChatMessage[];
@ -48,7 +46,6 @@ export function Chat({
initialVisibilityType: VisibilityType; initialVisibilityType: VisibilityType;
isReadonly: boolean; isReadonly: boolean;
autoResume: boolean; autoResume: boolean;
initialLastContext?: AppUsage;
}) { }) {
const router = useRouter(); const router = useRouter();
@ -72,7 +69,6 @@ export function Chat({
const { setDataStream } = useDataStream(); const { setDataStream } = useDataStream();
const [input, setInput] = useState<string>(""); const [input, setInput] = useState<string>("");
const [usage, setUsage] = useState<AppUsage | undefined>(initialLastContext);
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false); const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
const [currentModelId, setCurrentModelId] = useState(initialChatModel); const [currentModelId, setCurrentModelId] = useState(initialChatModel);
const currentModelIdRef = useRef(currentModelId); const currentModelIdRef = useRef(currentModelId);
@ -111,9 +107,6 @@ export function Chat({
}), }),
onData: (dataPart) => { onData: (dataPart) => {
setDataStream((ds) => (ds ? [...ds, dataPart] : [])); setDataStream((ds) => (ds ? [...ds, dataPart] : []));
if (dataPart.type === "data-usage") {
setUsage(dataPart.data);
}
}, },
onFinish: () => { onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey)); mutate(unstable_serialize(getChatHistoryPaginationKey));
@ -204,7 +197,6 @@ export function Chat({
setMessages={setMessages} setMessages={setMessages}
status={status} status={status}
stop={stop} stop={stop}
usage={usage}
/> />
)} )}
</div> </div>

View file

@ -1,12 +1,16 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact"; import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact"; import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider"; import { useDataStream } from "./data-stream-provider";
import { getChatHistoryPaginationKey } from "./sidebar-history";
export function DataStreamHandler() { export function DataStreamHandler() {
const { dataStream, setDataStream } = useDataStream(); const { dataStream, setDataStream } = useDataStream();
const { mutate } = useSWRConfig();
const { artifact, setArtifact, setMetadata } = useArtifact(); const { artifact, setArtifact, setMetadata } = useArtifact();
@ -19,6 +23,11 @@ export function DataStreamHandler() {
setDataStream([]); setDataStream([]);
for (const delta of newDeltas) { for (const delta of newDeltas) {
// Handle chat title updates
if (delta.type === "data-chat-title") {
mutate(unstable_serialize(getChatHistoryPaginationKey));
continue;
}
const artifactDefinition = artifactDefinitions.find( const artifactDefinition = artifactDefinitions.find(
(currentArtifactDefinition) => (currentArtifactDefinition) =>
currentArtifactDefinition.kind === artifact.kind currentArtifactDefinition.kind === artifact.kind
@ -77,7 +86,7 @@ export function DataStreamHandler() {
} }
}); });
} }
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream]); }, [dataStream, setArtifact, setMetadata, artifact, setDataStream, mutate]);
return null; return null;
} }

View file

@ -1,190 +0,0 @@
"use client";
import type { ComponentProps } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils";
export type ContextProps = ComponentProps<"button"> & {
/** Optional full usage payload to enable breakdown view */
usage?: AppUsage;
};
const _THOUSAND = 1000;
const _MILLION = 1_000_000;
const _BILLION = 1_000_000_000;
const PERCENT_MAX = 100;
// Lucide CircleIcon geometry
const ICON_VIEWBOX = 24;
const ICON_CENTER = 12;
const ICON_RADIUS = 10;
const ICON_STROKE_WIDTH = 2;
type ContextIconProps = {
percent: number; // 0 - 100
};
export const ContextIcon = ({ percent }: ContextIconProps) => {
const radius = ICON_RADIUS;
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - percent / PERCENT_MAX);
return (
<svg
aria-label={`${percent.toFixed(2)}% of model context used`}
height="28"
role="img"
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="28"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={radius}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={radius}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
transform={`rotate(-90 ${ICON_CENTER} ${ICON_CENTER})`}
/>
</svg>
);
};
function InfoRow({
label,
tokens,
costText,
}: {
label: string;
tokens?: number;
costText?: string;
}) {
return (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2 font-mono">
<span className="min-w-[4ch] text-right">
{tokens === undefined ? "—" : tokens.toLocaleString()}
</span>
{costText !== undefined &&
costText !== null &&
!Number.isNaN(Number.parseFloat(costText)) && (
<span className="text-muted-foreground">
${Number.parseFloat(costText).toFixed(6)}
</span>
)}
</div>
</div>
);
}
export const Context = ({ className, usage, ...props }: ContextProps) => {
const used = usage?.totalTokens ?? 0;
const max =
usage?.context?.totalMax ??
usage?.context?.combinedMax ??
usage?.context?.inputMax;
const hasMax = typeof max === "number" && Number.isFinite(max) && max > 0;
const usedPercent = hasMax ? Math.min(100, (used / max) * 100) : 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"inline-flex select-none items-center gap-1 rounded-md text-sm",
"cursor-pointer bg-background text-foreground",
"outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
type="button"
{...props}
>
<span className="hidden font-medium text-muted-foreground">
{usedPercent.toFixed(1)}%
</span>
<ContextIcon percent={usedPercent} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit p-3" side="top">
<div className="min-w-[240px] space-y-2">
<div className="flex items-start justify-between text-sm">
<span>{usedPercent.toFixed(1)}%</span>
<span className="text-muted-foreground">
{hasMax ? `${used} / ${max} tokens` : `${used} tokens`}
</span>
</div>
<div className="space-y-2">
<Progress className="h-2 bg-muted" value={usedPercent} />
</div>
<div className="mt-1 space-y-1">
{usage?.cachedInputTokens && usage.cachedInputTokens > 0 && (
<InfoRow
costText={usage?.costUSD?.cacheReadUSD?.toString()}
label="Cache Hits"
tokens={usage?.cachedInputTokens}
/>
)}
<InfoRow
costText={usage?.costUSD?.inputUSD?.toString()}
label="Input"
tokens={usage?.inputTokens}
/>
<InfoRow
costText={usage?.costUSD?.outputUSD?.toString()}
label="Output"
tokens={usage?.outputTokens}
/>
<InfoRow
costText={usage?.costUSD?.reasoningUSD?.toString()}
label="Reasoning"
tokens={
usage?.reasoningTokens && usage.reasoningTokens > 0
? usage.reasoningTokens
: undefined
}
/>
{usage?.costUSD?.totalUSD !== undefined && (
<>
<Separator className="mt-1" />
<div className="flex items-center justify-between pt-1 text-xs">
<span className="text-muted-foreground">Total cost</span>
<div className="flex items-center gap-2 font-mono">
<span className="min-w-[4ch] text-right" />
<span>
{Number.isNaN(
Number.parseFloat(usage.costUSD.totalUSD.toString())
)
? "—"
: `$${Number.parseFloat(usage.costUSD.totalUSD.toString()).toFixed(6)}`}
</span>
</div>
</div>
</>
)}
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};

View file

@ -34,9 +34,7 @@ import {
modelsByProvider, modelsByProvider,
} from "@/lib/ai/models"; } from "@/lib/ai/models";
import type { Attachment, ChatMessage } from "@/lib/types"; import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Context } from "./elements/context";
import { import {
PromptInput, PromptInput,
PromptInputSubmit, PromptInputSubmit,
@ -71,7 +69,6 @@ function PureMultimodalInput({
selectedVisibilityType, selectedVisibilityType,
selectedModelId, selectedModelId,
onModelChange, onModelChange,
usage,
}: { }: {
chatId: string; chatId: string;
input: string; input: string;
@ -87,7 +84,6 @@ function PureMultimodalInput({
selectedVisibilityType: VisibilityType; selectedVisibilityType: VisibilityType;
selectedModelId: string; selectedModelId: string;
onModelChange?: (modelId: string) => void; onModelChange?: (modelId: string) => void;
usage?: AppUsage;
}) { }) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width } = useWindowSize(); const { width } = useWindowSize();
@ -204,13 +200,6 @@ function PureMultimodalInput({
} }
}, []); }, []);
const contextProps = useMemo(
() => ({
usage,
}),
[usage]
);
const handleFileChange = useCallback( const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => { async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
@ -374,8 +363,7 @@ function PureMultimodalInput({
ref={textareaRef} ref={textareaRef}
rows={1} rows={1}
value={input} value={input}
/>{" "} />
<Context {...contextProps} />
</div> </div>
<PromptInputToolbar className="border-top-0! border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!"> <PromptInputToolbar className="border-top-0! border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
<PromptInputTools className="gap-0 sm:gap-0.5"> <PromptInputTools className="gap-0 sm:gap-0.5">

View file

@ -2,7 +2,7 @@
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns"; import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useParams, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import type { User } from "next-auth"; import type { User } from "next-auth";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -99,7 +99,8 @@ export function getChatHistoryPaginationKey(
export function SidebarHistory({ user }: { user: User | undefined }) { export function SidebarHistory({ user }: { user: User | undefined }) {
const { setOpenMobile } = useSidebar(); const { setOpenMobile } = useSidebar();
const { id } = useParams(); const pathname = usePathname();
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
const { const {
data: paginatedChatHistories, data: paginatedChatHistories,
@ -124,7 +125,12 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
: false; : false;
const handleDelete = () => { const handleDelete = () => {
const deletePromise = fetch(`/api/chat?id=${deleteId}`, { const chatToDelete = deleteId;
const isCurrentChat = pathname === `/chat/${chatToDelete}`;
setShowDeleteDialog(false);
const deletePromise = fetch(`/api/chat?id=${chatToDelete}`, {
method: "DELETE", method: "DELETE",
}); });
@ -135,21 +141,22 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
if (chatHistories) { if (chatHistories) {
return chatHistories.map((chatHistory) => ({ return chatHistories.map((chatHistory) => ({
...chatHistory, ...chatHistory,
chats: chatHistory.chats.filter((chat) => chat.id !== deleteId), chats: chatHistory.chats.filter(
(chat) => chat.id !== chatToDelete
),
})); }));
} }
}); });
if (isCurrentChat) {
router.replace("/");
router.refresh();
}
return "Chat deleted successfully"; return "Chat deleted successfully";
}, },
error: "Failed to delete chat", error: "Failed to delete chat",
}); });
setShowDeleteDialog(false);
if (deleteId === id) {
router.push("/");
}
}; };
if (!user) { if (!user) {

View file

@ -117,8 +117,10 @@ export const updateDocumentPrompt = (
${currentContent}`; ${currentContent}`;
}; };
export const titlePrompt = `\n export const titlePrompt = `Generate a very short chat title (2-5 words max) based on the user's message.
- you will generate a short title based on the first message a user begins a conversation with Rules:
- ensure it is not more than 80 characters long - Maximum 30 characters
- the title should be a summary of the user's message - No quotes, colons, hashtags, or markdown
- do not use quotes or colons`; - Just the topic/intent, not a full sentence
- If the message is a greeting like "hi" or "hello", respond with just "New conversation"
- Be concise: "Weather in NYC" not "User asking about the weather in New York City"`;

View file

@ -17,7 +17,6 @@ import postgres from "postgres";
import type { ArtifactKind } from "@/components/artifact"; import type { ArtifactKind } from "@/components/artifact";
import type { VisibilityType } from "@/components/visibility-selector"; import type { VisibilityType } from "@/components/visibility-selector";
import { ChatSDKError } from "../errors"; import { ChatSDKError } from "../errors";
import type { AppUsage } from "../usage";
import { generateUUID } from "../utils"; import { generateUUID } from "../utils";
import { import {
type Chat, type Chat,
@ -502,21 +501,17 @@ export async function updateChatVisibilityById({
} }
} }
export async function updateChatLastContextById({ export async function updateChatTitleById({
chatId, chatId,
context, title,
}: { }: {
chatId: string; chatId: string;
// Store merged server-enriched usage object title: string;
context: AppUsage;
}) { }) {
try { try {
return await db return await db.update(chat).set({ title }).where(eq(chat.id, chatId));
.update(chat)
.set({ lastContext: context })
.where(eq(chat.id, chatId));
} catch (error) { } catch (error) {
console.warn("Failed to update lastContext for chat", chatId, error); console.warn("Failed to update title for chat", chatId, error);
return; return;
} }
} }

View file

@ -3,7 +3,6 @@ import {
boolean, boolean,
foreignKey, foreignKey,
json, json,
jsonb,
pgTable, pgTable,
primaryKey, primaryKey,
text, text,
@ -11,7 +10,6 @@ import {
uuid, uuid,
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import type { AppUsage } from "../usage";
export const user = pgTable("User", { export const user = pgTable("User", {
id: uuid("id").primaryKey().notNull().defaultRandom(), id: uuid("id").primaryKey().notNull().defaultRandom(),
@ -31,7 +29,6 @@ export const chat = pgTable("Chat", {
visibility: varchar("visibility", { enum: ["public", "private"] }) visibility: varchar("visibility", { enum: ["public", "private"] })
.notNull() .notNull()
.default("private"), .default("private"),
lastContext: jsonb("lastContext").$type<AppUsage | null>(),
}); });
export type Chat = InferSelectModel<typeof chat>; export type Chat = InferSelectModel<typeof chat>;

View file

@ -6,7 +6,6 @@ import type { getWeather } from "./ai/tools/get-weather";
import type { requestSuggestions } from "./ai/tools/request-suggestions"; import type { requestSuggestions } from "./ai/tools/request-suggestions";
import type { updateDocument } from "./ai/tools/update-document"; import type { updateDocument } from "./ai/tools/update-document";
import type { Suggestion } from "./db/schema"; import type { Suggestion } from "./db/schema";
import type { AppUsage } from "./usage";
export type DataPart = { type: "append-message"; message: string }; export type DataPart = { type: "append-message"; message: string };
@ -42,7 +41,7 @@ export type CustomUIDataTypes = {
kind: ArtifactKind; kind: ArtifactKind;
clear: null; clear: null;
finish: null; finish: null;
usage: AppUsage; "chat-title": string;
}; };
export type ChatMessage = UIMessage< export type ChatMessage = UIMessage<

View file

@ -1,5 +0,0 @@
import type { LanguageModelUsage } from "ai";
import type { UsageData } from "tokenlens/helpers";
// Server-merged usage: base usage + TokenLens summary + optional modelId
export type AppUsage = LanguageModelUsage & UsageData & { modelId?: string };

View file

@ -95,7 +95,6 @@
"swr": "^2.2.5", "swr": "^2.2.5",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tokenlens": "1.3.0",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
"zod": "^3.25.76" "zod": "^3.25.76"

40
pnpm-lock.yaml generated
View file

@ -239,9 +239,6 @@ importers:
tailwindcss-animate: tailwindcss-animate:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.16) version: 1.0.7(tailwindcss@4.1.16)
tokenlens:
specifier: 1.3.0
version: 1.3.0
use-stick-to-bottom: use-stick-to-bottom:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1(react@19.0.1) version: 1.1.1(react@19.0.1)
@ -2281,18 +2278,6 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tokenlens/core@1.3.0':
resolution: {integrity: sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ==}
'@tokenlens/fetch@1.3.0':
resolution: {integrity: sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ==}
'@tokenlens/helpers@1.3.0':
resolution: {integrity: sha512-GWe4p9OHYppr7moqyM3Y4NH+kVGCOFpN+voaXgKgvvMBo6Z97Pu17fgCHet10Ls8r4X/tPkb/yHCWP7Ktssu/w==}
'@tokenlens/models@1.3.0':
resolution: {integrity: sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw==}
'@trpc/server@11.7.1': '@trpc/server@11.7.1':
resolution: {integrity: sha512-N3U8LNLIP4g9C7LJ/sLkjuPHwqlvE3bnspzC4DEFVdvx2+usbn70P80E3wj5cjOTLhmhRiwJCSXhlB+MHfGeCw==} resolution: {integrity: sha512-N3U8LNLIP4g9C7LJ/sLkjuPHwqlvE3bnspzC4DEFVdvx2+usbn70P80E3wj5cjOTLhmhRiwJCSXhlB+MHfGeCw==}
peerDependencies: peerDependencies:
@ -4259,9 +4244,6 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
tokenlens@1.3.0:
resolution: {integrity: sha512-qrwHFO7CI8HEd+UvKjlL+veTzCVr3N4AYp3cquGL6z62Q/OtoEHbTv5uNqiiejP54y7eR+h8VVRRpZbm3t/99Q==}
trim-lines@3.0.1: trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@ -6267,21 +6249,6 @@ snapshots:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 4.1.16 tailwindcss: 4.1.16
'@tokenlens/core@1.3.0': {}
'@tokenlens/fetch@1.3.0':
dependencies:
'@tokenlens/core': 1.3.0
'@tokenlens/helpers@1.3.0':
dependencies:
'@tokenlens/core': 1.3.0
'@tokenlens/fetch': 1.3.0
'@tokenlens/models@1.3.0':
dependencies:
'@tokenlens/core': 1.3.0
'@trpc/server@11.7.1(typescript@5.8.2)': '@trpc/server@11.7.1(typescript@5.8.2)':
dependencies: dependencies:
typescript: 5.8.2 typescript: 5.8.2
@ -8690,13 +8657,6 @@ snapshots:
tinyspy@4.0.4: {} tinyspy@4.0.4: {}
tokenlens@1.3.0:
dependencies:
'@tokenlens/core': 1.3.0
'@tokenlens/fetch': 1.3.0
'@tokenlens/helpers': 1.3.0
'@tokenlens/models': 1.3.0
trim-lines@3.0.1: {} trim-lines@3.0.1: {}
trough@2.2.0: {} trough@2.2.0: {}

File diff suppressed because one or more lines are too long