-
-
Notifications
You must be signed in to change notification settings - Fork 8
Implement AI Cost & Usage Tracking System #701
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { NextResponse } from 'next/server'; | ||
| import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; | ||
| import { getUserUsageSummary } from '@/lib/actions/usage'; | ||
|
|
||
| export async function GET() { | ||
| const userId = await getCurrentUserIdOnServer(); | ||
| if (!userId) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | ||
| } | ||
|
|
||
| try { | ||
| const summary = await getUserUsageSummary(userId); | ||
| return NextResponse.json(summary); | ||
| } catch (error) { | ||
| console.error('API Error in /api/usage:', error); | ||
| return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ import { Zap, ChevronDown, ChevronUp } from 'lucide-react'; | |
| import { useHistoryToggle } from '../history-toggle-context'; | ||
| import HistoryItem from '@/components/history-item'; // Adjust path if HistoryItem is moved or renamed | ||
| import type { Chat as DrizzleChat } from '@/lib/actions/chat-db'; // Use the Drizzle-based Chat type | ||
| import { UsageSummary } from '@/lib/types'; | ||
|
|
||
| interface ChatHistoryClientProps { | ||
| // userId is no longer passed as prop; API route will use authenticated user | ||
|
|
@@ -34,47 +35,45 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) { | |
| const [isClearPending, startClearTransition] = useTransition(); | ||
| const [isAlertDialogOpen, setIsAlertDialogOpen] = useState(false); | ||
| const [isCreditsVisible, setIsCreditsVisible] = useState(false); | ||
| const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null); | ||
| const { isHistoryOpen } = useHistoryToggle(); | ||
| const router = useRouter(); | ||
|
|
||
| useEffect(() => { | ||
| async function fetchChats() { | ||
| async function fetchData() { | ||
| setIsLoading(true); | ||
| setError(null); | ||
| try { | ||
| // API route /api/chats uses getCurrentUserId internally | ||
| const response = await fetch('/api/chats?limit=50&offset=0'); // Example limit/offset | ||
| if (!response.ok) { | ||
| const errorData = await response.json(); | ||
| throw new Error(errorData.error || `Failed to fetch chats: ${response.statusText}`); | ||
| const [chatsRes, usageRes] = await Promise.all([ | ||
| fetch('/api/chats?limit=50&offset=0'), | ||
| fetch('/api/usage') | ||
| ]); | ||
|
|
||
| if (chatsRes.ok) { | ||
| const chatsData = await chatsRes.json(); | ||
| setChats(chatsData.chats); | ||
| } | ||
| const data: { chats: DrizzleChat[], nextOffset: number | null } = await response.json(); | ||
| setChats(data.chats); | ||
| } catch (err) { | ||
| if (err instanceof Error) { | ||
| setError(err.message); | ||
| toast.error(`Error fetching chats: ${err.message}`); | ||
| } else { | ||
| setError('An unknown error occurred.'); | ||
| toast.error('Error fetching chats: An unknown error occurred.'); | ||
|
|
||
| if (usageRes.ok) { | ||
| const usageData = await usageRes.json(); | ||
| setUsageSummary(usageData); | ||
| } | ||
| } catch (err) { | ||
| console.error('Error fetching data:', err); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| } | ||
|
|
||
| if (isHistoryOpen) { | ||
| fetchChats(); | ||
| fetchData(); | ||
| } | ||
| }, [isHistoryOpen]); | ||
|
Comment on lines
42
to
71
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Handle failed fetch responses instead of rendering an empty-success state.
🧰 Tools🪛 React Doctor (0.5.8)[warning] 42-42: fetch() inside useEffect can race, double-fire, or leak. Use a data-fetching layer or Server Component instead. Use a data-fetching layer or Server Component so fetches do not race, double-fire, or leak from (no-fetch-in-effect) 🤖 Prompt for AI Agents |
||
|
|
||
| const handleClearHistory = async () => { | ||
| startClearTransition(async () => { | ||
| try { | ||
| // We need a new API endpoint for clearing history | ||
| // Example: DELETE /api/chats (or POST /api/clear-history) | ||
| // This endpoint will call clearHistory(userId) from chat-db.ts | ||
| const response = await fetch('/api/chats/all', { // Placeholder for the actual clear endpoint | ||
| const response = await fetch('/api/chats/all', { | ||
| method: 'DELETE', | ||
| }); | ||
|
|
||
|
|
@@ -86,9 +85,7 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) { | |
| toast.success('History cleared'); | ||
| setChats([]); // Clear chats from UI | ||
| setIsAlertDialogOpen(false); | ||
| router.refresh(); // Refresh to reflect changes, potentially redirect if on a chat page | ||
| // Consider redirecting to '/' if current page is a chat that got deleted. | ||
| // The old clearChats action did redirect('/'); | ||
| router.refresh(); | ||
| } catch (err) { | ||
| if (err instanceof Error) { | ||
| toast.error(err.message); | ||
|
|
@@ -100,6 +97,11 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) { | |
| }); | ||
| }; | ||
|
|
||
| const totalCredits = 500; | ||
| const usedCredits = usageSummary ? Math.ceil(usageSummary.totalCost * 100) : 0; | ||
| const availableCredits = Math.max(0, totalCredits - usedCredits); | ||
| const percentage = Math.min(100, (availableCredits / totalCredits) * 100); | ||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <div className="flex flex-col flex-1 space-y-3 h-full items-center justify-center"> | ||
|
|
@@ -110,7 +112,6 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) { | |
| } | ||
|
|
||
| if (error) { | ||
| // Optionally provide a retry button | ||
| return ( | ||
| <div className="flex flex-col flex-1 space-y-3 h-full items-center justify-center text-destructive"> | ||
| <p>Error loading chat history: {error}</p> | ||
|
|
@@ -138,12 +139,17 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) { | |
| <div className="mt-2 p-3 rounded-lg bg-muted/50 border border-border/50 space-y-2"> | ||
| <div className="flex justify-between items-center text-xs"> | ||
| <span>Available Credits</span> | ||
| <span className="font-bold">0</span> | ||
| <span className="font-bold">{availableCredits}</span> | ||
| </div> | ||
| <div className="w-full bg-secondary h-1.5 rounded-full overflow-hidden"> | ||
| <div className="bg-yellow-500 h-full w-[0%]" /> | ||
| <div | ||
| className="bg-yellow-500 h-full transition-all duration-500" | ||
| style={{ width: `${percentage}%` }} | ||
| /> | ||
| </div> | ||
| <p className="text-[10px] text-muted-foreground">Upgrade to get more credits</p> | ||
| <p className="text-[10px] text-muted-foreground"> | ||
| {usedCredits} credits used (${usageSummary?.totalCost.toFixed(4)} USD) | ||
| </p> | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
@@ -155,9 +161,7 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) { | |
| </div> | ||
| ) : ( | ||
| chats.map((chat) => ( | ||
| // Assuming HistoryItem is adapted for DrizzleChat and expects chat.id and chat.title | ||
| // Also, chat.path will need to be constructed, e.g., `/search/${chat.id}` | ||
| <HistoryItem key={chat.id} chat={{...chat, path: `/search/${chat.id}`}} /> | ||
| <HistoryItem key={chat.id} chat={{...chat, path: `/search/${chat.id}` }} /> | ||
| )) | ||
| )} | ||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,16 +5,35 @@ import { Button } from '@/components/ui/button' | |
| import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' | ||
| import { Zap, RefreshCw, LayoutPanelLeft, Minus } from 'lucide-react' | ||
| import { useUsageToggle } from './usage-toggle-context' | ||
| import { UsageSummary } from '@/lib/types' | ||
| import { Spinner } from '@/components/ui/spinner' | ||
|
|
||
| export function UsageView() { | ||
| const [usage] = useState([ | ||
| { details: 'QCX-TERRA Crop yield Analysis', date: 'upcoming', change: 7 }, | ||
| { details: 'QCX-TERRA Flood predictions', date: 'upcoming', change: 5 }, | ||
| { details: 'Planet computer weather synchronization', date: 'upcoming', change: 3 }, | ||
| ]) | ||
| const [credits] = useState(0) | ||
| const [summary, setSummary] = useState<UsageSummary | null>(null) | ||
| const [loading, setLoading] = useState(true) | ||
| const { toggleUsage } = useUsageToggle() | ||
|
|
||
| useEffect(() => { | ||
| async function fetchUsage() { | ||
| try { | ||
| const response = await fetch('/api/usage') | ||
| if (response.ok) { | ||
| const data = await response.json() | ||
| setSummary(data) | ||
| } | ||
| } catch (error) { | ||
| console.error('Failed to fetch usage:', error) | ||
| } finally { | ||
| setLoading(false) | ||
| } | ||
| } | ||
| fetchUsage() | ||
| }, []) | ||
|
Comment on lines
+16
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Treat non-OK This effect only sets 🧰 Tools🪛 React Doctor (0.5.8)[warning] 16-16: fetch() inside useEffect can race, double-fire, or leak. Use a data-fetching layer or Server Component instead. Use a data-fetching layer or Server Component so fetches do not race, double-fire, or leak from (no-fetch-in-effect) 🤖 Prompt for AI Agents |
||
|
|
||
| const totalCredits = 500 | ||
| const usedCredits = summary ? Math.ceil(summary.totalCost * 100) : 0 // Simplified credit model | ||
| const availableCredits = Math.max(0, totalCredits - usedCredits) | ||
|
|
||
|
Comment on lines
+33
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift This credit balance is using lifetime spend, not the advertised yearly allowance.
Also applies to: 79-81 🤖 Prompt for AI Agents |
||
| return ( | ||
| <div className="container py-8 h-full overflow-y-auto"> | ||
| <div className="flex justify-between items-center mb-8"> | ||
|
|
@@ -43,11 +62,11 @@ export function UsageView() { | |
| <Zap size={16} className="text-muted-foreground" /> | ||
| <span>Credits</span> | ||
| </div> | ||
| <span className="font-bold">{credits}</span> | ||
| <span className="font-bold">{loading ? '...' : availableCredits}</span> | ||
| </div> | ||
| <div className="flex items-center justify-between text-xs text-muted-foreground pl-6"> | ||
| <span>Free credits</span> | ||
| <span>0</span> | ||
| <span>Used credits</span> | ||
| <span>{loading ? '...' : usedCredits}</span> | ||
| </div> | ||
| </div> | ||
|
|
||
|
|
@@ -57,9 +76,9 @@ export function UsageView() { | |
| <RefreshCw size={16} className="text-muted-foreground" /> | ||
| <span>Yearly refresh credits</span> | ||
| </div> | ||
| <span className="font-bold">500</span> | ||
| <span className="font-bold">{totalCredits}</span> | ||
| </div> | ||
| <p className="text-[10px] text-muted-foreground pl-6">Refresh to 500 every year.</p> | ||
| <p className="text-[10px] text-muted-foreground pl-6">Refresh to {totalCredits} every year.</p> | ||
| </div> | ||
| </div> | ||
|
|
||
|
|
@@ -71,24 +90,46 @@ export function UsageView() { | |
| </div> | ||
| </div> | ||
|
|
||
| <Table> | ||
| <TableHeader> | ||
| <TableRow> | ||
| <TableHead className="text-xs">Details</TableHead> | ||
| <TableHead className="text-xs">Date</TableHead> | ||
| <TableHead className="text-xs text-right">Credits change</TableHead> | ||
| </TableRow> | ||
| </TableHeader> | ||
| <TableBody> | ||
| {usage.map((item, i) => ( | ||
| <TableRow key={i}> | ||
| <TableCell className="text-xs font-medium">{item.details}</TableCell> | ||
| <TableCell className="text-[10px] text-muted-foreground">{item.date}</TableCell> | ||
| <TableCell className="text-xs text-right font-medium">{item.change}</TableCell> | ||
| {loading ? ( | ||
| <div className="flex justify-center py-8"> | ||
| <Spinner /> | ||
| </div> | ||
| ) : ( | ||
| <Table> | ||
| <TableHeader> | ||
| <TableRow> | ||
| <TableHead className="text-xs">Source</TableHead> | ||
| <TableHead className="text-xs">Date</TableHead> | ||
| <TableHead className="text-xs text-right">Cost (USD)</TableHead> | ||
| </TableRow> | ||
| ))} | ||
| </TableBody> | ||
| </Table> | ||
| </TableHeader> | ||
| <TableBody> | ||
| {summary?.recentEvents.map((item) => ( | ||
| <TableRow key={item.id}> | ||
| <TableCell className="text-xs font-medium"> | ||
| <div className="flex flex-col"> | ||
| <span>{item.source}</span> | ||
| <span className="text-[10px] text-muted-foreground uppercase">{item.kind}</span> | ||
| </div> | ||
| </TableCell> | ||
| <TableCell className="text-[10px] text-muted-foreground"> | ||
| {new Date(item.createdAt).toLocaleDateString()} | ||
| </TableCell> | ||
| <TableCell className="text-xs text-right font-medium"> | ||
| ${parseFloat(item.cost).toFixed(4)} | ||
| </TableCell> | ||
| </TableRow> | ||
| ))} | ||
| {(!summary || summary.recentEvents.length === 0) && ( | ||
| <TableRow> | ||
| <TableCell colSpan={3} className="text-center py-4 text-muted-foreground italic"> | ||
| No usage events recorded yet. | ||
| </TableCell> | ||
| </TableRow> | ||
| )} | ||
| </TableBody> | ||
| </Table> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -81,7 +81,7 @@ export async function getCrossSessionContext(userId?: string): Promise<string> { | |||||||||||||
| return combinedText.trim() | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export async function generateReportContext(messages: AIMessage[]) { | ||||||||||||||
| export async function generateReportContext(userId: string, chatId: string, messages: AIMessage[]) { | ||||||||||||||
| try { | ||||||||||||||
| const crossSessionContext = await getCrossSessionContext() | ||||||||||||||
|
|
||||||||||||||
|
Comment on lines
+84
to
87
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Use the threaded
Proposed fix- const crossSessionContext = await getCrossSessionContext()
+ const crossSessionContext = await getCrossSessionContext(userId)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
@@ -107,8 +107,8 @@ export async function generateReportContext(messages: AIMessage[]) { | |||||||||||||
| const strategicContent = activeMessages.filter(msg => msg.role === 'assistant') | ||||||||||||||
|
|
||||||||||||||
| const [execSummary, strategicSynthesis] = await Promise.all([ | ||||||||||||||
| executiveSummaryAgent(crossSessionContext, activeMessages), | ||||||||||||||
| strategicSynthesisAgent(sensorFusionFindings, strategicContent) | ||||||||||||||
| executiveSummaryAgent(userId, chatId, crossSessionContext, activeMessages), | ||||||||||||||
| strategicSynthesisAgent(userId, chatId, sensorFusionFindings, strategicContent) | ||||||||||||||
| ]) | ||||||||||||||
|
|
||||||||||||||
| return { | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win
Use a UUID chat id and persist it into AI state before passing it downstream.
chats.idandusage_events.chat_idare UUID columns, but this fallback usesnanoid(). New sessions can therefore emit a chat id that does not round-trip through the database, and because it stays local hereonSetAIStatestill readsstate.chatIdinstead of this generated value.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents