feat: Add comprehensive cost tracking display for LLM usage (#2992)
Co-authored-by: jack <jack@deck.local> Co-authored-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
@@ -29,9 +29,18 @@ interface ChatInputProps {
|
||||
droppedFiles?: string[];
|
||||
setView: (view: View) => void;
|
||||
numTokens?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
hasMessages?: boolean;
|
||||
messages?: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
sessionCosts?: {
|
||||
[key: string]: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCost: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
@@ -42,9 +51,12 @@ export default function ChatInput({
|
||||
initialValue = '',
|
||||
setView,
|
||||
numTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
droppedFiles = [],
|
||||
messages = [],
|
||||
setMessages,
|
||||
sessionCosts,
|
||||
}: ChatInputProps) {
|
||||
const [_value, setValue] = useState(initialValue);
|
||||
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
||||
@@ -557,9 +569,12 @@ export default function ChatInput({
|
||||
<BottomMenu
|
||||
setView={setView}
|
||||
numTokens={numTokens}
|
||||
inputTokens={inputTokens}
|
||||
outputTokens={outputTokens}
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
setMessages={setMessages}
|
||||
sessionCosts={sessionCosts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
} from './context_management/ChatContextManager';
|
||||
import { ContextHandler } from './context_management/ContextHandler';
|
||||
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
||||
import { useModelAndProvider } from './ModelAndProviderContext';
|
||||
import { getCostForModel } from '../utils/costDatabase';
|
||||
import {
|
||||
Message,
|
||||
createUserMessage,
|
||||
@@ -106,11 +108,25 @@ function ChatContent({
|
||||
const [showGame, setShowGame] = useState(false);
|
||||
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
|
||||
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
|
||||
const [sessionInputTokens, setSessionInputTokens] = useState<number>(0);
|
||||
const [sessionOutputTokens, setSessionOutputTokens] = useState<number>(0);
|
||||
const [localInputTokens, setLocalInputTokens] = useState<number>(0);
|
||||
const [localOutputTokens, setLocalOutputTokens] = useState<number>(0);
|
||||
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
|
||||
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
|
||||
const [sessionCosts, setSessionCosts] = useState<{
|
||||
[key: string]: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCost: number;
|
||||
};
|
||||
}>({});
|
||||
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
|
||||
|
||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||
const { currentModel, currentProvider } = useModelAndProvider();
|
||||
const prevModelRef = useRef<string | undefined>();
|
||||
const prevProviderRef = useRef<string | undefined>();
|
||||
|
||||
const {
|
||||
summaryContent,
|
||||
@@ -160,6 +176,7 @@ function ChatContent({
|
||||
updateMessageStreamBody,
|
||||
notifications,
|
||||
currentModelInfo,
|
||||
sessionMetadata,
|
||||
} = useMessageStream({
|
||||
api: getApiUrl('/reply'),
|
||||
initialMessages: chat.messages,
|
||||
@@ -518,12 +535,40 @@ function ChatContent({
|
||||
.reverse();
|
||||
}, [filteredMessages]);
|
||||
|
||||
// Simple token estimation function (roughly 4 characters per token)
|
||||
const estimateTokens = (text: string): number => {
|
||||
return Math.ceil(text.length / 4);
|
||||
};
|
||||
|
||||
// Calculate token counts from messages
|
||||
useEffect(() => {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
messages.forEach((message) => {
|
||||
const textContent = getTextContent(message);
|
||||
if (textContent) {
|
||||
const tokens = estimateTokens(textContent);
|
||||
if (message.role === 'user') {
|
||||
inputTokens += tokens;
|
||||
} else if (message.role === 'assistant') {
|
||||
outputTokens += tokens;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setLocalInputTokens(inputTokens);
|
||||
setLocalOutputTokens(outputTokens);
|
||||
}, [messages]);
|
||||
|
||||
// Fetch session metadata to get token count
|
||||
useEffect(() => {
|
||||
const fetchSessionTokens = async () => {
|
||||
try {
|
||||
const sessionDetails = await fetchSessionDetails(chat.id);
|
||||
setSessionTokenCount(sessionDetails.metadata.total_tokens || 0);
|
||||
setSessionInputTokens(sessionDetails.metadata.accumulated_input_tokens || 0);
|
||||
setSessionOutputTokens(sessionDetails.metadata.accumulated_output_tokens || 0);
|
||||
} catch (err) {
|
||||
console.error('Error fetching session token count:', err);
|
||||
}
|
||||
@@ -533,6 +578,74 @@ function ChatContent({
|
||||
}
|
||||
}, [chat.id, messages]);
|
||||
|
||||
// Update token counts when sessionMetadata changes from the message stream
|
||||
useEffect(() => {
|
||||
console.log('Session metadata received:', sessionMetadata);
|
||||
if (sessionMetadata) {
|
||||
setSessionTokenCount(sessionMetadata.totalTokens || 0);
|
||||
setSessionInputTokens(sessionMetadata.accumulatedInputTokens || 0);
|
||||
setSessionOutputTokens(sessionMetadata.accumulatedOutputTokens || 0);
|
||||
}
|
||||
}, [sessionMetadata]);
|
||||
|
||||
// Handle model changes and accumulate costs
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevModelRef.current !== undefined &&
|
||||
prevProviderRef.current !== undefined &&
|
||||
(prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider)
|
||||
) {
|
||||
// Model/provider has changed, save the costs for the previous model
|
||||
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
|
||||
|
||||
// Get pricing info for the previous model
|
||||
const prevCostInfo = getCostForModel(prevProviderRef.current, prevModelRef.current);
|
||||
|
||||
if (prevCostInfo) {
|
||||
const prevInputCost =
|
||||
(sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0);
|
||||
const prevOutputCost =
|
||||
(sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0);
|
||||
const prevTotalCost = prevInputCost + prevOutputCost;
|
||||
|
||||
// Save the accumulated costs for this model
|
||||
setSessionCosts((prev) => ({
|
||||
...prev,
|
||||
[prevKey]: {
|
||||
inputTokens: sessionInputTokens || localInputTokens,
|
||||
outputTokens: sessionOutputTokens || localOutputTokens,
|
||||
totalCost: prevTotalCost,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Reset token counters for the new model
|
||||
setSessionTokenCount(0);
|
||||
setSessionInputTokens(0);
|
||||
setSessionOutputTokens(0);
|
||||
setLocalInputTokens(0);
|
||||
setLocalOutputTokens(0);
|
||||
|
||||
console.log(
|
||||
'Model changed from',
|
||||
`${prevProviderRef.current}/${prevModelRef.current}`,
|
||||
'to',
|
||||
`${currentProvider}/${currentModel}`,
|
||||
'- saved costs and reset token counters'
|
||||
);
|
||||
}
|
||||
|
||||
prevModelRef.current = currentModel || undefined;
|
||||
prevProviderRef.current = currentProvider || undefined;
|
||||
}, [
|
||||
currentModel,
|
||||
currentProvider,
|
||||
sessionInputTokens,
|
||||
sessionOutputTokens,
|
||||
localInputTokens,
|
||||
localOutputTokens,
|
||||
]);
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
@@ -684,9 +797,12 @@ function ChatContent({
|
||||
setView={setView}
|
||||
hasMessages={hasMessages}
|
||||
numTokens={sessionTokenCount}
|
||||
inputTokens={sessionInputTokens || localInputTokens}
|
||||
outputTokens={sessionOutputTokens || localOutputTokens}
|
||||
droppedFiles={droppedFiles}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
sessionCosts={sessionCosts}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useConfig } from '../ConfigContext';
|
||||
import { useModelAndProvider } from '../ModelAndProviderContext';
|
||||
import { Message } from '../../types/message';
|
||||
import { ManualSummarizeButton } from '../context_management/ManualSummaryButton';
|
||||
import { CostTracker } from './CostTracker';
|
||||
|
||||
const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about
|
||||
const TOKEN_WARNING_THRESHOLD = 0.8; // warning shows at 80% of the token limit
|
||||
@@ -22,15 +23,27 @@ interface ModelLimit {
|
||||
export default function BottomMenu({
|
||||
setView,
|
||||
numTokens = 0,
|
||||
inputTokens = 0,
|
||||
outputTokens = 0,
|
||||
messages = [],
|
||||
isLoading = false,
|
||||
setMessages,
|
||||
sessionCosts,
|
||||
}: {
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
numTokens?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
messages?: Message[];
|
||||
isLoading?: boolean;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
sessionCosts?: {
|
||||
[key: string]: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCost: number;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||
const { alerts, addAlert, clearAlerts } = useAlerts();
|
||||
@@ -202,29 +215,45 @@ export default function BottomMenu({
|
||||
}, [isModelMenuOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center transition-colors text-textSubtle relative text-xs align-middle">
|
||||
<div className="flex items-center pl-2">
|
||||
<div className="flex justify-between items-center transition-colors text-textSubtle relative text-xs h-6">
|
||||
<div className="flex items-center h-full">
|
||||
{/* Tool and Token count */}
|
||||
{<BottomMenuAlertPopover alerts={alerts} />}
|
||||
<div className="flex items-center h-full pl-2">
|
||||
{<BottomMenuAlertPopover alerts={alerts} />}
|
||||
</div>
|
||||
|
||||
{/* Cost Tracker - no separator before it */}
|
||||
<div className="flex items-center h-full ml-1">
|
||||
<CostTracker inputTokens={inputTokens} outputTokens={outputTokens} sessionCosts={sessionCosts} />
|
||||
</div>
|
||||
|
||||
{/* Separator between cost and model */}
|
||||
<div className="w-[1px] h-4 bg-borderSubtle mx-1.5" />
|
||||
|
||||
{/* Model Selector Dropdown */}
|
||||
<ModelsBottomBar dropdownRef={dropdownRef} setView={setView} />
|
||||
<div className="flex items-center h-full">
|
||||
<ModelsBottomBar dropdownRef={dropdownRef} setView={setView} />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-[1px] h-4 bg-borderSubtle mx-2" />
|
||||
<div className="w-[1px] h-4 bg-borderSubtle mx-1.5" />
|
||||
|
||||
{/* Goose Mode Selector Dropdown */}
|
||||
<BottomMenuModeSelection setView={setView} />
|
||||
<div className="flex items-center h-full">
|
||||
<BottomMenuModeSelection setView={setView} />
|
||||
</div>
|
||||
|
||||
{/* Summarize Context Button - ADD THIS */}
|
||||
{/* Summarize Context Button */}
|
||||
{messages.length > 0 && (
|
||||
<>
|
||||
<div className="w-[1px] h-4 bg-borderSubtle mx-2" />
|
||||
<ManualSummarizeButton
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
<div className="w-[1px] h-4 bg-borderSubtle mx-1.5" />
|
||||
<div className="flex items-center h-full">
|
||||
<ManualSummarizeButton
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useModelAndProvider } from '../ModelAndProviderContext';
|
||||
import { useConfig } from '../ConfigContext';
|
||||
import {
|
||||
getCostForModel,
|
||||
initializeCostDatabase,
|
||||
updateAllModelCosts,
|
||||
fetchAndCachePricing,
|
||||
} from '../../utils/costDatabase';
|
||||
|
||||
interface CostTrackerProps {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
sessionCosts?: {
|
||||
[key: string]: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCost: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: CostTrackerProps) {
|
||||
const { currentModel, currentProvider } = useModelAndProvider();
|
||||
const { getProviders } = useConfig();
|
||||
const [costInfo, setCostInfo] = useState<{
|
||||
input_token_cost?: number;
|
||||
output_token_cost?: number;
|
||||
currency?: string;
|
||||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showPricing, setShowPricing] = useState(true);
|
||||
const [pricingFailed, setPricingFailed] = useState(false);
|
||||
const [modelNotFound, setModelNotFound] = useState(false);
|
||||
const [hasAttemptedFetch, setHasAttemptedFetch] = useState(false);
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
|
||||
// Check if pricing is enabled
|
||||
useEffect(() => {
|
||||
const checkPricingSetting = () => {
|
||||
const stored = localStorage.getItem('show_pricing');
|
||||
setShowPricing(stored !== 'false');
|
||||
};
|
||||
|
||||
// Check on mount
|
||||
checkPricingSetting();
|
||||
|
||||
// Listen for storage changes
|
||||
window.addEventListener('storage', checkPricingSetting);
|
||||
return () => window.removeEventListener('storage', checkPricingSetting);
|
||||
}, []);
|
||||
|
||||
// Set initial load complete after a short delay
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setInitialLoadComplete(true);
|
||||
}, 3000); // Give 3 seconds for initial load
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Debug log props removed
|
||||
|
||||
// Initialize cost database on mount
|
||||
useEffect(() => {
|
||||
initializeCostDatabase();
|
||||
|
||||
// Update costs for all models in background
|
||||
updateAllModelCosts().catch((error) => {
|
||||
console.error('Failed to update model costs:', error);
|
||||
});
|
||||
}, [getProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCostInfo = async () => {
|
||||
if (!currentModel || !currentProvider) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`CostTracker: Loading cost info for ${currentProvider}/${currentModel}`);
|
||||
|
||||
try {
|
||||
// First check sync cache
|
||||
let costData = getCostForModel(currentProvider, currentModel);
|
||||
|
||||
if (costData) {
|
||||
// We have cached data
|
||||
console.log(
|
||||
`CostTracker: Found cached data for ${currentProvider}/${currentModel}:`,
|
||||
costData
|
||||
);
|
||||
setCostInfo(costData);
|
||||
setPricingFailed(false);
|
||||
setModelNotFound(false);
|
||||
setIsLoading(false);
|
||||
setHasAttemptedFetch(true);
|
||||
} else {
|
||||
// Need to fetch from backend
|
||||
console.log(
|
||||
`CostTracker: No cached data, fetching from backend for ${currentProvider}/${currentModel}`
|
||||
);
|
||||
setIsLoading(true);
|
||||
const result = await fetchAndCachePricing(currentProvider, currentModel);
|
||||
setHasAttemptedFetch(true);
|
||||
|
||||
if (result && result.costInfo) {
|
||||
console.log(
|
||||
`CostTracker: Fetched data for ${currentProvider}/${currentModel}:`,
|
||||
result.costInfo
|
||||
);
|
||||
setCostInfo(result.costInfo);
|
||||
setPricingFailed(false);
|
||||
setModelNotFound(false);
|
||||
} else if (result && result.error === 'model_not_found') {
|
||||
console.log(
|
||||
`CostTracker: Model not found in pricing data for ${currentProvider}/${currentModel}`
|
||||
);
|
||||
// Model not found in pricing database, but API call succeeded
|
||||
setModelNotFound(true);
|
||||
setPricingFailed(false);
|
||||
} else {
|
||||
console.log(`CostTracker: API failed for ${currentProvider}/${currentModel}`);
|
||||
// API call failed or other error
|
||||
const freeProviders = ['ollama', 'local', 'localhost'];
|
||||
if (!freeProviders.includes(currentProvider.toLowerCase())) {
|
||||
setPricingFailed(true);
|
||||
setModelNotFound(false);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading cost info:', error);
|
||||
setHasAttemptedFetch(true);
|
||||
// Only set pricing failed if we're not dealing with a known free provider
|
||||
const freeProviders = ['ollama', 'local', 'localhost'];
|
||||
if (!freeProviders.includes(currentProvider.toLowerCase())) {
|
||||
setPricingFailed(true);
|
||||
setModelNotFound(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCostInfo();
|
||||
}, [currentModel, currentProvider]);
|
||||
|
||||
// Return null early if pricing is disabled
|
||||
if (!showPricing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const calculateCost = (): number => {
|
||||
// If we have session costs, calculate the total across all models
|
||||
if (sessionCosts) {
|
||||
let totalCost = 0;
|
||||
|
||||
// Add up all historical costs from different models
|
||||
Object.values(sessionCosts).forEach((modelCost) => {
|
||||
totalCost += modelCost.totalCost;
|
||||
});
|
||||
|
||||
// Add current model cost if we have pricing info
|
||||
if (
|
||||
costInfo &&
|
||||
(costInfo.input_token_cost !== undefined || costInfo.output_token_cost !== undefined)
|
||||
) {
|
||||
const currentInputCost = inputTokens * (costInfo.input_token_cost || 0);
|
||||
const currentOutputCost = outputTokens * (costInfo.output_token_cost || 0);
|
||||
totalCost += currentInputCost + currentOutputCost;
|
||||
}
|
||||
|
||||
return totalCost;
|
||||
}
|
||||
|
||||
// Fallback to simple calculation for current model only
|
||||
if (
|
||||
!costInfo ||
|
||||
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const inputCost = inputTokens * (costInfo.input_token_cost || 0);
|
||||
const outputCost = outputTokens * (costInfo.output_token_cost || 0);
|
||||
const total = inputCost + outputCost;
|
||||
|
||||
return total;
|
||||
};
|
||||
|
||||
const formatCost = (cost: number): string => {
|
||||
// Always show 6 decimal places for consistency
|
||||
return cost.toFixed(6);
|
||||
};
|
||||
|
||||
// Debug logging removed
|
||||
|
||||
// Show loading state or when we don't have model/provider info
|
||||
if (!currentModel || !currentProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If still loading, show a placeholder
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px]">
|
||||
<span className="text-xs font-mono">...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If no cost info found, try to return a default
|
||||
if (
|
||||
!costInfo ||
|
||||
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined)
|
||||
) {
|
||||
// If it's a known free/local provider, show $0.000000 without "not available" message
|
||||
const freeProviders = ['ollama', 'local', 'localhost'];
|
||||
if (freeProviders.includes(currentProvider.toLowerCase())) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center h-full text-textSubtle hover:text-textStandard transition-colors cursor-default translate-y-[1px]"
|
||||
title={`Local model (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`}
|
||||
>
|
||||
<span className="text-xs font-mono">$0.000000</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise show as unavailable
|
||||
const getUnavailableTooltip = () => {
|
||||
if (pricingFailed && hasAttemptedFetch && initialLoadComplete) {
|
||||
return `Pricing data unavailable - OpenRouter connection failed. Click refresh in settings to retry.`;
|
||||
}
|
||||
// If we reach here, it must be modelNotFound (since we only get here after attempting fetch)
|
||||
return `Cost data not available for ${currentModel} (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] ${
|
||||
(pricingFailed || modelNotFound) && hasAttemptedFetch && initialLoadComplete
|
||||
? 'text-red-500 hover:text-red-400'
|
||||
: 'text-textSubtle hover:text-textStandard'
|
||||
}`}
|
||||
title={getUnavailableTooltip()}
|
||||
>
|
||||
<span className="text-xs font-mono">$0.000000</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCost = calculateCost();
|
||||
|
||||
// Build tooltip content
|
||||
const getTooltipContent = (): string => {
|
||||
// Handle error states first
|
||||
if (pricingFailed && hasAttemptedFetch && initialLoadComplete) {
|
||||
return `Pricing data unavailable - OpenRouter connection failed. Click refresh in settings to retry.`;
|
||||
}
|
||||
|
||||
if (modelNotFound && hasAttemptedFetch && initialLoadComplete) {
|
||||
return `Pricing not available for ${currentProvider}/${currentModel}. This model may not be supported by the pricing service.`;
|
||||
}
|
||||
|
||||
// Handle session costs
|
||||
if (sessionCosts && Object.keys(sessionCosts).length > 0) {
|
||||
// Show session breakdown
|
||||
let tooltip = 'Session cost breakdown:\n';
|
||||
|
||||
Object.entries(sessionCosts).forEach(([modelKey, cost]) => {
|
||||
const costStr = `${costInfo?.currency || '$'}${cost.totalCost.toFixed(6)}`;
|
||||
tooltip += `${modelKey}: ${costStr} (${cost.inputTokens.toLocaleString()} in, ${cost.outputTokens.toLocaleString()} out)\n`;
|
||||
});
|
||||
|
||||
// Add current model if it has costs
|
||||
if (costInfo && (inputTokens > 0 || outputTokens > 0)) {
|
||||
const currentCost =
|
||||
inputTokens * (costInfo.input_token_cost || 0) +
|
||||
outputTokens * (costInfo.output_token_cost || 0);
|
||||
if (currentCost > 0) {
|
||||
tooltip += `${currentProvider}/${currentModel} (current): ${costInfo.currency || '$'}${currentCost.toFixed(6)} (${inputTokens.toLocaleString()} in, ${outputTokens.toLocaleString()} out)\n`;
|
||||
}
|
||||
}
|
||||
|
||||
tooltip += `\nTotal session cost: ${costInfo?.currency || '$'}${totalCost.toFixed(6)}`;
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
// Default tooltip for single model
|
||||
return `Input: ${inputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${(inputTokens * (costInfo?.input_token_cost || 0)).toFixed(6)}) | Output: ${outputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${(outputTokens * (costInfo?.output_token_cost || 0)).toFixed(6)})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] ${
|
||||
(pricingFailed || modelNotFound) && hasAttemptedFetch && initialLoadComplete
|
||||
? 'text-red-500 hover:text-red-400'
|
||||
: 'text-textSubtle hover:text-textStandard'
|
||||
}`}
|
||||
title={getTooltipContent()}
|
||||
>
|
||||
<span className="text-xs font-mono">
|
||||
{costInfo.currency || '$'}
|
||||
{formatCost(totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Switch } from '../../ui/switch';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { Settings, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
import Modal from '../../Modal';
|
||||
import UpdateSection from './UpdateSection';
|
||||
import { UPDATES_ENABLED } from '../../../updates';
|
||||
import { getApiUrl, getSecretKey } from '../../../config';
|
||||
|
||||
interface AppSettingsSectionProps {
|
||||
scrollToSection?: string;
|
||||
@@ -17,6 +18,10 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
|
||||
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
||||
const [pricingStatus, setPricingStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [lastFetchTime, setLastFetchTime] = useState<Date | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showPricing, setShowPricing] = useState(true);
|
||||
const updateSectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check if running on macOS
|
||||
@@ -24,6 +29,77 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
||||
setIsMacOS(window.electron.platform === 'darwin');
|
||||
}, []);
|
||||
|
||||
// Load show pricing setting
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('show_pricing');
|
||||
setShowPricing(stored !== 'false');
|
||||
}, []);
|
||||
|
||||
// Check pricing status on mount
|
||||
useEffect(() => {
|
||||
checkPricingStatus();
|
||||
}, []);
|
||||
|
||||
const checkPricingStatus = async () => {
|
||||
try {
|
||||
const apiUrl = getApiUrl('/config/pricing');
|
||||
const secretKey = getSecretKey();
|
||||
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
if (secretKey) {
|
||||
headers['X-Secret-Key'] = secretKey;
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ configured_only: true }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await response.json(); // Consume the response
|
||||
setPricingStatus('success');
|
||||
setLastFetchTime(new Date());
|
||||
} else {
|
||||
setPricingStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
setPricingStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshPricing = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const apiUrl = getApiUrl('/config/pricing');
|
||||
const secretKey = getSecretKey();
|
||||
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
if (secretKey) {
|
||||
headers['X-Secret-Key'] = secretKey;
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ configured_only: false }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setPricingStatus('success');
|
||||
setLastFetchTime(new Date());
|
||||
// Trigger a reload of the cost database
|
||||
window.dispatchEvent(new CustomEvent('pricing-updated'));
|
||||
} else {
|
||||
setPricingStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
setPricingStatus('error');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle scrolling to update section
|
||||
useEffect(() => {
|
||||
if (scrollToSection === 'update' && updateSectionRef.current) {
|
||||
@@ -99,6 +175,13 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowPricingToggle = (checked: boolean) => {
|
||||
setShowPricing(checked);
|
||||
localStorage.setItem('show_pricing', String(checked));
|
||||
// Trigger storage event for other components
|
||||
window.dispatchEvent(new CustomEvent('storage'));
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="appSettings" className="px-8">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
@@ -173,6 +256,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quit Confirmation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-textStandard">Quit Confirmation</h3>
|
||||
@@ -188,6 +272,87 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Tracking */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-textStandard">Cost Tracking</h3>
|
||||
<p className="text-xs text-textSubtle max-w-md mt-[2px]">
|
||||
Show model pricing and usage costs
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={showPricing}
|
||||
onCheckedChange={handleShowPricingToggle}
|
||||
variant="mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Status - only show if cost tracking is enabled */}
|
||||
{showPricing && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs mb-2 px-4">
|
||||
<span className="text-textSubtle">Pricing Source:</span>
|
||||
<a
|
||||
href="https://openrouter.ai/docs#models"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
OpenRouter Docs
|
||||
<ExternalLink size={10} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs mb-2 px-4">
|
||||
<span className="text-textSubtle">Status:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-medium ${
|
||||
pricingStatus === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: pricingStatus === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-textSubtle'
|
||||
}`}
|
||||
>
|
||||
{pricingStatus === 'success'
|
||||
? '✓ Connected'
|
||||
: pricingStatus === 'error'
|
||||
? '✗ Failed'
|
||||
: '... Checking'}
|
||||
</span>
|
||||
<button
|
||||
className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors disabled:opacity-50"
|
||||
onClick={handleRefreshPricing}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh pricing data"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw
|
||||
size={8}
|
||||
className={`text-textSubtle hover:text-textStandard ${isRefreshing ? 'animate-spin-fast' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastFetchTime && (
|
||||
<div className="flex items-center justify-between text-xs mb-2 px-4">
|
||||
<span className="text-textSubtle">Last updated:</span>
|
||||
<span className="text-textSubtle">{lastFetchTime.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pricingStatus === 'error' && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 px-4">
|
||||
Unable to fetch pricing data. Costs will not be displayed.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help & Feedback Section */}
|
||||
|
||||
@@ -43,7 +43,10 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
|
||||
}, [read]);
|
||||
|
||||
// Determine which model to display - activeModel takes priority when lead/worker is active
|
||||
const displayModel = (isLeadWorkerActive && currentModelInfo?.model) ? currentModelInfo.model : (currentModel || 'Select Model');
|
||||
const displayModel =
|
||||
isLeadWorkerActive && currentModelInfo?.model
|
||||
? currentModelInfo.model
|
||||
: currentModel || 'Select Model';
|
||||
const modelMode = currentModelInfo?.mode;
|
||||
|
||||
// Update display provider when current provider changes
|
||||
@@ -106,9 +109,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
|
||||
>
|
||||
{displayModel}
|
||||
{isLeadWorkerActive && modelMode && (
|
||||
<span className="ml-1 text-[10px] opacity-60">
|
||||
({modelMode})
|
||||
</span>
|
||||
<span className="ml-1 text-[10px] opacity-60">({modelMode})</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -116,9 +117,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
|
||||
<TooltipContent className="max-w-96 overflow-auto scrollbar-thin" side="top">
|
||||
{displayModel}
|
||||
{isLeadWorkerActive && modelMode && (
|
||||
<span className="ml-1 text-[10px] opacity-60">
|
||||
({modelMode})
|
||||
</span>
|
||||
<span className="ml-1 text-[10px] opacity-60">({modelMode})</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
)}
|
||||
@@ -164,7 +163,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
|
||||
{isAddModelModalOpen ? (
|
||||
<AddModelModal setView={setView} onClose={() => setIsAddModelModalOpen(false)} />
|
||||
) : null}
|
||||
|
||||
|
||||
{isLeadWorkerModalOpen ? (
|
||||
<Modal onClose={() => setIsLeadWorkerModalOpen(false)}>
|
||||
<LeadWorkerSettings onClose={() => setIsLeadWorkerModalOpen(false)} />
|
||||
|
||||
@@ -21,7 +21,9 @@ export function LeadWorkerSettings({ onClose }: LeadWorkerSettingsProps) {
|
||||
const [failureThreshold, setFailureThreshold] = useState<number>(2);
|
||||
const [fallbackTurns, setFallbackTurns] = useState<number>(2);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<{ value: string; label: string; provider: string }[]>([]);
|
||||
const [modelOptions, setModelOptions] = useState<
|
||||
{ value: string; label: string; provider: string }[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load current configuration
|
||||
@@ -51,7 +53,7 @@ export function LeadWorkerSettings({ onClose }: LeadWorkerSettingsProps) {
|
||||
if (leadTurnsConfig) setLeadTurns(Number(leadTurnsConfig));
|
||||
if (failureThresholdConfig) setFailureThreshold(Number(failureThresholdConfig));
|
||||
if (fallbackTurnsConfig) setFallbackTurns(Number(fallbackTurnsConfig));
|
||||
|
||||
|
||||
// Set worker model to current model or from config
|
||||
const workerModelConfig = await read('GOOSE_MODEL', false);
|
||||
if (workerModelConfig) {
|
||||
@@ -59,7 +61,7 @@ export function LeadWorkerSettings({ onClose }: LeadWorkerSettingsProps) {
|
||||
} else if (currentModel) {
|
||||
setWorkerModel(currentModel as string);
|
||||
}
|
||||
|
||||
|
||||
const workerProviderConfig = await read('GOOSE_PROVIDER', false);
|
||||
if (workerProviderConfig) {
|
||||
setWorkerProvider(workerProviderConfig as string);
|
||||
@@ -69,7 +71,7 @@ export function LeadWorkerSettings({ onClose }: LeadWorkerSettingsProps) {
|
||||
const providers = await getProviders(false);
|
||||
const activeProviders = providers.filter((p) => p.is_configured);
|
||||
const options: { value: string; label: string; provider: string }[] = [];
|
||||
|
||||
|
||||
activeProviders.forEach(({ metadata, name }) => {
|
||||
if (metadata.known_models) {
|
||||
metadata.known_models.forEach((model) => {
|
||||
@@ -81,7 +83,7 @@ export function LeadWorkerSettings({ onClose }: LeadWorkerSettingsProps) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setModelOptions(options);
|
||||
} catch (error) {
|
||||
console.error('Error loading configuration:', error);
|
||||
@@ -184,9 +186,7 @@ export function LeadWorkerSettings({ onClose }: LeadWorkerSettingsProps) {
|
||||
placeholder="Select worker model..."
|
||||
isDisabled={!isEnabled}
|
||||
/>
|
||||
<p className="text-xs text-textSubtle">
|
||||
Fast model for routine execution tasks
|
||||
</p>
|
||||
<p className="text-xs text-textSubtle">Fast model for routine execution tasks</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-borderSubtle">
|
||||
@@ -242,9 +242,7 @@ export function LeadWorkerSettings({ onClose }: LeadWorkerSettingsProps) {
|
||||
className="w-20"
|
||||
disabled={!isEnabled}
|
||||
/>
|
||||
<p className="text-xs text-textSubtle">
|
||||
Turns to use lead model during fallback
|
||||
</p>
|
||||
<p className="text-xs text-textSubtle">Turns to use lead model during fallback</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,4 +257,4 @@ export function LeadWorkerSettings({ onClose }: LeadWorkerSettingsProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user