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:
jack
2025-06-26 10:46:14 +01:00
committed by GitHub
parent 9d48ed980f
commit d2ff4f3746
24 changed files with 1985 additions and 47 deletions

View File

@@ -1700,9 +1700,26 @@
"description": "The maximum context length this model supports",
"minimum": 0
},
"currency": {
"type": "string",
"description": "Currency for the costs (default: \"$\")",
"nullable": true
},
"input_token_cost": {
"type": "number",
"format": "double",
"description": "Cost per token for input (optional)",
"nullable": true
},
"name": {
"type": "string",
"description": "The name of the model"
},
"output_token_cost": {
"type": "number",
"format": "double",
"description": "Cost per token for output (optional)",
"nullable": true
}
}
},

View File

@@ -3,6 +3,7 @@ import { IpcRendererEvent } from 'electron';
import { openSharedSessionFromDeepLink, type SessionLinksViewOptions } from './sessionLinks';
import { type SharedSessionDetails } from './sharedSessions';
import { initializeSystem } from './utils/providerUtils';
import { initializeCostDatabase } from './utils/costDatabase';
import { ErrorUI } from './components/ErrorBoundary';
import { ConfirmationModal } from './components/ui/ConfirmationModal';
import { ToastContainer } from 'react-toastify';
@@ -158,6 +159,11 @@ export default function App() {
const initializeApp = async () => {
try {
// Initialize cost database early to pre-load pricing data
initializeCostDatabase().catch((error) => {
console.error('Failed to initialize cost database:', error);
});
await initConfig();
try {
await readAllConfig({ throwOnError: true });

View File

@@ -229,10 +229,22 @@ export type ModelInfo = {
* The maximum context length this model supports
*/
context_limit: number;
/**
* Currency for the costs (default: "$")
*/
currency?: string | null;
/**
* Cost per token for input (optional)
*/
input_token_cost?: number | null;
/**
* The name of the model
*/
name: string;
/**
* Cost per token for output (optional)
*/
output_token_cost?: number | null;
};
export type PermissionConfirmationRequest = {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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 */}

View File

@@ -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)} />

View File

@@ -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>
);
}
}

View File

@@ -2,9 +2,9 @@ import { useCurrentModelInfo } from '../components/ChatView';
export function useCurrentModel() {
const modelInfo = useCurrentModelInfo();
return {
return {
currentModel: modelInfo?.model || null,
isLoading: false
isLoading: false,
};
}
}

View File

@@ -2,12 +2,26 @@ import { useState, useCallback, useEffect, useRef, useId } from 'react';
import useSWR from 'swr';
import { getSecretKey } from '../config';
import { Message, createUserMessage, hasCompletedToolCalls } from '../types/message';
import { getSessionHistory } from '../api';
// Ensure TextDecoder is available in the global scope
const TextDecoder = globalThis.TextDecoder;
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export interface SessionMetadata {
workingDir: string;
description: string;
scheduleId: string | null;
messageCount: number;
totalTokens: number | null;
inputTokens: number | null;
outputTokens: number | null;
accumulatedTotalTokens: number | null;
accumulatedInputTokens: number | null;
accumulatedOutputTokens: number | null;
}
export interface NotificationEvent {
type: 'Notification';
request_id: string;
@@ -141,9 +155,12 @@ export interface UseMessageStreamHelpers {
updateMessageStreamBody?: (newBody: object) => void;
notifications: NotificationEvent[];
/** Current model info from the backend */
currentModelInfo: { model: string; mode: string } | null;
/** Session metadata including token counts */
sessionMetadata: SessionMetadata | null;
}
/**
@@ -172,7 +189,10 @@ export function useMessageStream({
});
const [notifications, setNotifications] = useState<NotificationEvent[]>([]);
const [currentModelInfo, setCurrentModelInfo] = useState<{ model: string; mode: string } | null>(null);
const [currentModelInfo, setCurrentModelInfo] = useState<{ model: string; mode: string } | null>(
null
);
const [sessionMetadata, setSessionMetadata] = useState<SessionMetadata | null>(null);
// expose a way to update the body so we can update the session id when CLE occurs
const updateMessageStreamBody = useCallback((newBody: object) => {
@@ -291,13 +311,41 @@ export function useMessageStream({
case 'Error':
throw new Error(parsedEvent.error);
case 'Finish':
case 'Finish': {
// Call onFinish with the last message if available
if (onFinish && currentMessages.length > 0) {
const lastMessage = currentMessages[currentMessages.length - 1];
onFinish(lastMessage, parsedEvent.reason);
}
// Fetch updated session metadata with token counts
const sessionId = (extraMetadataRef.current.body as Record<string, unknown>)?.session_id as string;
if (sessionId) {
try {
const sessionResponse = await getSessionHistory({
path: { session_id: sessionId },
});
if (sessionResponse.data?.metadata) {
setSessionMetadata({
workingDir: sessionResponse.data.metadata.working_dir,
description: sessionResponse.data.metadata.description,
scheduleId: sessionResponse.data.metadata.schedule_id || null,
messageCount: sessionResponse.data.metadata.message_count,
totalTokens: sessionResponse.data.metadata.total_tokens || null,
inputTokens: sessionResponse.data.metadata.input_tokens || null,
outputTokens: sessionResponse.data.metadata.output_tokens || null,
accumulatedTotalTokens: sessionResponse.data.metadata.accumulated_total_tokens || null,
accumulatedInputTokens: sessionResponse.data.metadata.accumulated_input_tokens || null,
accumulatedOutputTokens: sessionResponse.data.metadata.accumulated_output_tokens || null,
});
}
} catch (error) {
console.error('Failed to fetch session metadata:', error);
}
}
break;
}
}
} catch (e) {
console.error('Error parsing SSE event:', e);
@@ -559,5 +607,6 @@ export function useMessageStream({
updateMessageStreamBody,
notifications,
currentModelInfo,
sessionMetadata,
};
}

View File

@@ -7,6 +7,10 @@ export interface SessionMetadata {
message_count: number;
total_tokens: number | null;
working_dir: string; // Required in type, but may be missing in old sessions
// Add the accumulated token fields from the API
accumulated_input_tokens?: number | null;
accumulated_output_tokens?: number | null;
accumulated_total_tokens?: number | null;
}
// Helper function to ensure working directory is set
@@ -16,6 +20,9 @@ export function ensureWorkingDir(metadata: Partial<SessionMetadata>): SessionMet
message_count: metadata.message_count || 0,
total_tokens: metadata.total_tokens || null,
working_dir: metadata.working_dir || process.env.HOME || '',
accumulated_input_tokens: metadata.accumulated_input_tokens || null,
accumulated_output_tokens: metadata.accumulated_output_tokens || null,
accumulated_total_tokens: metadata.accumulated_total_tokens || null,
};
}

View File

@@ -0,0 +1,593 @@
// Import the proper type from ConfigContext
import { getApiUrl, getSecretKey } from '../config';
export interface ModelCostInfo {
input_token_cost: number; // Cost per token for input (in USD)
output_token_cost: number; // Cost per token for output (in USD)
currency: string; // Currency symbol
}
// In-memory cache for current model pricing only
let currentModelPricing: {
provider: string;
model: string;
costInfo: ModelCostInfo | null;
} | null = null;
// LocalStorage keys
const PRICING_CACHE_KEY = 'goose_pricing_cache';
const PRICING_CACHE_TIMESTAMP_KEY = 'goose_pricing_cache_timestamp';
const RECENTLY_USED_MODELS_KEY = 'goose_recently_used_models';
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
const MAX_RECENTLY_USED_MODELS = 20; // Keep only the last 20 used models in cache
interface PricingItem {
provider: string;
model: string;
input_token_cost: number;
output_token_cost: number;
currency: string;
}
interface PricingCacheData {
pricing: PricingItem[];
timestamp: number;
}
interface RecentlyUsedModel {
provider: string;
model: string;
lastUsed: number;
}
/**
* Get recently used models from localStorage
*/
function getRecentlyUsedModels(): RecentlyUsedModel[] {
try {
const stored = localStorage.getItem(RECENTLY_USED_MODELS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Error loading recently used models:', error);
return [];
}
}
/**
* Add a model to the recently used list
*/
function addToRecentlyUsed(provider: string, model: string): void {
try {
let recentModels = getRecentlyUsedModels();
// Remove existing entry if present
recentModels = recentModels.filter((m) => !(m.provider === provider && m.model === model));
// Add to front
recentModels.unshift({ provider, model, lastUsed: Date.now() });
// Keep only the most recent models
recentModels = recentModels.slice(0, MAX_RECENTLY_USED_MODELS);
localStorage.setItem(RECENTLY_USED_MODELS_KEY, JSON.stringify(recentModels));
} catch (error) {
console.error('Error saving recently used models:', error);
}
}
/**
* Load pricing data from localStorage cache - only for recently used models
*/
function loadPricingFromLocalStorage(): PricingCacheData | null {
try {
const cached = localStorage.getItem(PRICING_CACHE_KEY);
const timestamp = localStorage.getItem(PRICING_CACHE_TIMESTAMP_KEY);
if (cached && timestamp) {
const cacheAge = Date.now() - parseInt(timestamp, 10);
if (cacheAge < CACHE_TTL_MS) {
const fullCache = JSON.parse(cached) as PricingCacheData;
const recentModels = getRecentlyUsedModels();
// Filter to only include recently used models
const filteredPricing = fullCache.pricing.filter((p) =>
recentModels.some((r) => r.provider === p.provider && r.model === p.model)
);
console.log(
`Loading ${filteredPricing.length} recently used models from cache (out of ${fullCache.pricing.length} total)`
);
return {
pricing: filteredPricing,
timestamp: fullCache.timestamp,
};
} else {
console.log('LocalStorage pricing cache expired');
}
}
} catch (error) {
console.error('Error loading pricing from localStorage:', error);
}
return null;
}
/**
* Save pricing data to localStorage - merge with existing data
*/
function savePricingToLocalStorage(data: PricingCacheData, mergeWithExisting = true): void {
try {
if (mergeWithExisting) {
// Load existing full cache
const existingCached = localStorage.getItem(PRICING_CACHE_KEY);
if (existingCached) {
const existingData = JSON.parse(existingCached) as PricingCacheData;
// Create a map of existing pricing for quick lookup
const pricingMap = new Map<string, (typeof data.pricing)[0]>();
existingData.pricing.forEach((p) => {
pricingMap.set(`${p.provider}/${p.model}`, p);
});
// Update with new data
data.pricing.forEach((p) => {
pricingMap.set(`${p.provider}/${p.model}`, p);
});
// Convert back to array
data = {
pricing: Array.from(pricingMap.values()),
timestamp: data.timestamp,
};
}
}
localStorage.setItem(PRICING_CACHE_KEY, JSON.stringify(data));
localStorage.setItem(PRICING_CACHE_TIMESTAMP_KEY, data.timestamp.toString());
console.log(`Saved ${data.pricing.length} models to localStorage cache`);
} catch (error) {
console.error('Error saving pricing to localStorage:', error);
}
}
/**
* Fetch pricing data from backend for specific provider/model
*/
async function fetchPricingForModel(
provider: string,
model: string
): Promise<ModelCostInfo | null> {
try {
const apiUrl = getApiUrl('/config/pricing');
const secretKey = getSecretKey();
console.log(`Fetching pricing for ${provider}/${model} from ${apiUrl}`);
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) {
console.error('Failed to fetch pricing data:', response.status);
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
console.log('Pricing response:', data);
// Find the specific model pricing
const pricing = data.pricing?.find(
(p: {
provider: string;
model: string;
input_token_cost: number;
output_token_cost: number;
currency: string;
}) => {
const providerMatch = p.provider.toLowerCase() === provider.toLowerCase();
// More flexible model matching - handle versioned models
let modelMatch = p.model === model;
// If exact match fails, try matching without version suffix
if (!modelMatch && model.includes('-20')) {
// Remove date suffix like -20241022
const modelWithoutDate = model.replace(/-20\d{6}$/, '');
modelMatch = p.model === modelWithoutDate;
// Also try with dots instead of dashes (claude-3-5-sonnet vs claude-3.5-sonnet)
if (!modelMatch) {
const modelWithDots = modelWithoutDate.replace(/-(\d)-/g, '.$1.');
modelMatch = p.model === modelWithDots;
}
}
console.log(
`Comparing: ${p.provider}/${p.model} with ${provider}/${model} - Provider match: ${providerMatch}, Model match: ${modelMatch}`
);
return providerMatch && modelMatch;
}
);
console.log(`Found pricing for ${provider}/${model}:`, pricing);
if (pricing) {
return {
input_token_cost: pricing.input_token_cost,
output_token_cost: pricing.output_token_cost,
currency: pricing.currency || '$',
};
}
console.log(
`No pricing found for ${provider}/${model} in:`,
data.pricing?.map((p: { provider: string; model: string }) => `${p.provider}/${p.model}`)
);
// API call succeeded but model not found in pricing data
return null;
} catch (error) {
console.error('Error fetching pricing data:', error);
// Re-throw the error so the caller can distinguish between API failure and model not found
throw error;
}
}
/**
* Initialize the cost database - only load commonly used models on startup
*/
export async function initializeCostDatabase(): Promise<void> {
try {
// Clean up any existing large caches first
cleanupPricingCache();
// First check if we have valid cached data
const cachedData = loadPricingFromLocalStorage();
if (cachedData && cachedData.pricing.length > 0) {
console.log('Using cached pricing data from localStorage');
return;
}
// List of commonly used models to pre-fetch
const commonModels = [
{ provider: 'openai', model: 'gpt-4o' },
{ provider: 'openai', model: 'gpt-4o-mini' },
{ provider: 'openai', model: 'gpt-4-turbo' },
{ provider: 'openai', model: 'gpt-4' },
{ provider: 'openai', model: 'gpt-3.5-turbo' },
{ provider: 'anthropic', model: 'claude-3-5-sonnet' },
{ provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' },
{ provider: 'anthropic', model: 'claude-3-opus' },
{ provider: 'anthropic', model: 'claude-3-sonnet' },
{ provider: 'anthropic', model: 'claude-3-haiku' },
{ provider: 'google', model: 'gemini-1.5-pro' },
{ provider: 'google', model: 'gemini-1.5-flash' },
{ provider: 'deepseek', model: 'deepseek-chat' },
{ provider: 'deepseek', model: 'deepseek-reasoner' },
{ provider: 'meta-llama', model: 'llama-3.2-90b-text-preview' },
{ provider: 'meta-llama', model: 'llama-3.1-405b-instruct' },
];
// Get recently used models
const recentModels = getRecentlyUsedModels();
// Combine common and recent models (deduplicated)
const modelsToFetch = new Map<string, { provider: string; model: string }>();
// Add common models
commonModels.forEach((m) => {
modelsToFetch.set(`${m.provider}/${m.model}`, m);
});
// Add recent models
recentModels.forEach((m) => {
modelsToFetch.set(`${m.provider}/${m.model}`, { provider: m.provider, model: m.model });
});
console.log(`Initializing cost database with ${modelsToFetch.size} models...`);
// Fetch only the pricing we need
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,
models: Array.from(modelsToFetch.values()), // Send specific models if API supports it
}),
});
if (!response.ok) {
console.error('Failed to fetch initial pricing data:', response.status);
return;
}
const data = await response.json();
console.log(`Fetched pricing for ${data.pricing?.length || 0} models`);
if (data.pricing && data.pricing.length > 0) {
// Filter to only the models we requested (in case API returns all)
const filteredPricing = data.pricing.filter((p: PricingItem) =>
modelsToFetch.has(`${p.provider}/${p.model}`)
);
// Save to localStorage
const cacheData: PricingCacheData = {
pricing: filteredPricing.length > 0 ? filteredPricing : data.pricing.slice(0, 50), // Fallback to first 50 if filtering didn't work
timestamp: Date.now(),
};
savePricingToLocalStorage(cacheData, false); // Don't merge on initial load
}
} catch (error) {
console.error('Error initializing cost database:', error);
}
}
/**
* Update model costs from providers - no longer needed
*/
export async function updateAllModelCosts(): Promise<void> {
// No-op - we fetch on demand now
}
/**
* Get cost information for a specific model with caching
*/
export function getCostForModel(provider: string, model: string): ModelCostInfo | null {
// Track this model as recently used
addToRecentlyUsed(provider, model);
// Check if it's the same model we already have cached in memory
if (
currentModelPricing &&
currentModelPricing.provider === provider &&
currentModelPricing.model === model
) {
return currentModelPricing.costInfo;
}
// For local/free providers, return zero cost immediately
const freeProviders = ['ollama', 'local', 'localhost'];
if (freeProviders.includes(provider.toLowerCase())) {
const zeroCost = {
input_token_cost: 0,
output_token_cost: 0,
currency: '$',
};
currentModelPricing = { provider, model, costInfo: zeroCost };
return zeroCost;
}
// Check localStorage cache (which now only contains recently used models)
const cachedData = loadPricingFromLocalStorage();
if (cachedData) {
const pricing = cachedData.pricing.find((p) => {
const providerMatch = p.provider.toLowerCase() === provider.toLowerCase();
// More flexible model matching - handle versioned models
let modelMatch = p.model === model;
// If exact match fails, try matching without version suffix
if (!modelMatch && model.includes('-20')) {
// Remove date suffix like -20241022
const modelWithoutDate = model.replace(/-20\d{6}$/, '');
modelMatch = p.model === modelWithoutDate;
// Also try with dots instead of dashes (claude-3-5-sonnet vs claude-3.5-sonnet)
if (!modelMatch) {
const modelWithDots = modelWithoutDate.replace(/-(\d)-/g, '.$1.');
modelMatch = p.model === modelWithDots;
}
}
return providerMatch && modelMatch;
});
if (pricing) {
const costInfo = {
input_token_cost: pricing.input_token_cost,
output_token_cost: pricing.output_token_cost,
currency: pricing.currency || '$',
};
currentModelPricing = { provider, model, costInfo };
return costInfo;
}
}
// Need to fetch new pricing - return null for now
// The component will handle the async fetch
return null;
}
/**
* Fetch and cache pricing for a model
*/
export async function fetchAndCachePricing(
provider: string,
model: string
): Promise<{ costInfo: ModelCostInfo | null; error?: string } | null> {
try {
const costInfo = await fetchPricingForModel(provider, model);
if (costInfo) {
// Cache the result in memory
currentModelPricing = { provider, model, costInfo };
// Update localStorage cache with this new data
const cachedData = loadPricingFromLocalStorage();
if (cachedData) {
// Check if this model already exists in cache
const existingIndex = cachedData.pricing.findIndex(
(p) => p.provider.toLowerCase() === provider.toLowerCase() && p.model === model
);
const newPricing = {
provider,
model,
input_token_cost: costInfo.input_token_cost,
output_token_cost: costInfo.output_token_cost,
currency: costInfo.currency,
};
if (existingIndex >= 0) {
// Update existing
cachedData.pricing[existingIndex] = newPricing;
} else {
// Add new
cachedData.pricing.push(newPricing);
}
// Save updated cache
savePricingToLocalStorage(cachedData);
}
return { costInfo };
} else {
// Cache the null result in memory
currentModelPricing = { provider, model, costInfo: null };
// Check if the API call succeeded but model wasn't found
// We can determine this by checking if we got a response but no matching model
return { costInfo: null, error: 'model_not_found' };
}
} catch (error) {
console.error('Error in fetchAndCachePricing:', error);
// This is a real API/network error
return null;
}
}
/**
* Refresh pricing data from backend - only refresh recently used models
*/
export async function refreshPricing(): Promise<boolean> {
try {
const apiUrl = getApiUrl('/config/pricing');
const secretKey = getSecretKey();
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (secretKey) {
headers['X-Secret-Key'] = secretKey;
}
// Get recently used models to refresh
const recentModels = getRecentlyUsedModels();
// Add some common models as well
const commonModels = [
{ provider: 'openai', model: 'gpt-4o' },
{ provider: 'openai', model: 'gpt-4o-mini' },
{ provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' },
{ provider: 'google', model: 'gemini-1.5-pro' },
];
// Combine and deduplicate
const modelsToRefresh = new Map<string, { provider: string; model: string }>();
commonModels.forEach((m) => {
modelsToRefresh.set(`${m.provider}/${m.model}`, m);
});
recentModels.forEach((m) => {
modelsToRefresh.set(`${m.provider}/${m.model}`, { provider: m.provider, model: m.model });
});
console.log(`Refreshing pricing for ${modelsToRefresh.size} models...`);
const response = await fetch(apiUrl, {
method: 'POST',
headers,
body: JSON.stringify({
configured_only: false,
models: Array.from(modelsToRefresh.values()), // Send specific models if API supports it
}),
});
if (response.ok) {
const data = await response.json();
if (data.pricing && data.pricing.length > 0) {
// Filter to only the models we requested (in case API returns all)
const filteredPricing = data.pricing.filter((p: PricingItem) =>
modelsToRefresh.has(`${p.provider}/${p.model}`)
);
// Save fresh data to localStorage (merge with existing)
const cacheData: PricingCacheData = {
pricing: filteredPricing.length > 0 ? filteredPricing : data.pricing.slice(0, 50),
timestamp: Date.now(),
};
savePricingToLocalStorage(cacheData, true); // Merge with existing
}
// Clear current memory cache to force re-fetch
currentModelPricing = null;
return true;
}
return false;
} catch (error) {
console.error('Error refreshing pricing data:', error);
return false;
}
}
/**
* Clean up old/unused models from the cache
*/
export function cleanupPricingCache(): void {
try {
const recentModels = getRecentlyUsedModels();
const cachedData = localStorage.getItem(PRICING_CACHE_KEY);
if (!cachedData) return;
const fullCache = JSON.parse(cachedData) as PricingCacheData;
const recentModelKeys = new Set(recentModels.map((m) => `${m.provider}/${m.model}`));
// Keep only recently used models and common models
const commonModelKeys = new Set([
'openai/gpt-4o',
'openai/gpt-4o-mini',
'openai/gpt-4-turbo',
'anthropic/claude-3-5-sonnet',
'anthropic/claude-3-5-sonnet-20241022',
'google/gemini-1.5-pro',
'google/gemini-1.5-flash',
]);
const filteredPricing = fullCache.pricing.filter((p) => {
const key = `${p.provider}/${p.model}`;
return recentModelKeys.has(key) || commonModelKeys.has(key);
});
if (filteredPricing.length < fullCache.pricing.length) {
console.log(
`Cleaned up pricing cache: reduced from ${fullCache.pricing.length} to ${filteredPricing.length} models`
);
const cleanedCache: PricingCacheData = {
pricing: filteredPricing,
timestamp: fullCache.timestamp,
};
localStorage.setItem(PRICING_CACHE_KEY, JSON.stringify(cleanedCache));
}
} catch (error) {
console.error('Error cleaning up pricing cache:', error);
}
}

View File

@@ -44,6 +44,10 @@ export default {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
'spin-fast': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
indeterminate: {
'0%': { left: '-40%', width: '40%' },
'50%': { left: '20%', width: '60%' },
@@ -54,6 +58,7 @@ export default {
'shimmer-pulse': 'shimmer 4s ease-in-out infinite',
'gradient-loader': 'loader 750ms ease-in-out infinite',
indeterminate: 'indeterminate 1.5s infinite linear',
'spin-fast': 'spin-fast 0.5s linear infinite',
},
colors: {
bgApp: 'var(--background-app)',