Integrate pricing with canonical model (#6130)

This commit is contained in:
David Katz
2025-12-18 11:44:36 -05:00
committed by GitHub
parent 473f269daa
commit 6a0b8c25d5
17 changed files with 263 additions and 1034 deletions
+98
View File
@@ -854,6 +854,36 @@
}
}
},
"/config/pricing": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_pricing",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PricingQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Model pricing data retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PricingResponse"
}
}
}
}
}
}
},
"/config/providers": {
"get": {
"tags": [
@@ -4223,6 +4253,74 @@
"never_allow"
]
},
"PricingData": {
"type": "object",
"required": [
"provider",
"model",
"input_token_cost",
"output_token_cost",
"currency"
],
"properties": {
"context_length": {
"type": "integer",
"format": "int32",
"nullable": true,
"minimum": 0
},
"currency": {
"type": "string"
},
"input_token_cost": {
"type": "number",
"format": "double"
},
"model": {
"type": "string"
},
"output_token_cost": {
"type": "number",
"format": "double"
},
"provider": {
"type": "string"
}
}
},
"PricingQuery": {
"type": "object",
"required": [
"provider",
"model"
],
"properties": {
"model": {
"type": "string"
},
"provider": {
"type": "string"
}
}
},
"PricingResponse": {
"type": "object",
"required": [
"pricing",
"source"
],
"properties": {
"pricing": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PricingData"
}
},
"source": {
"type": "string"
}
}
},
"PrincipalType": {
"type": "string",
"enum": [
+10 -1
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -178,6 +178,15 @@ export const upsertPermissions = <ThrowOnError extends boolean = false>(options:
}
});
export const getPricing = <ThrowOnError extends boolean = false>(options: Options<GetPricingData, ThrowOnError>) => (options.client ?? client).post<GetPricingResponses, unknown, ThrowOnError>({
url: '/config/pricing',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
export const providers = <ThrowOnError extends boolean = false>(options?: Options<ProvidersData, ThrowOnError>) => (options?.client ?? client).get<ProvidersResponses, unknown, ThrowOnError>({ url: '/config/providers', ...options });
export const getProviderModels = <ThrowOnError extends boolean = false>(options: Options<GetProviderModelsData, ThrowOnError>) => (options.client ?? client).get<GetProviderModelsResponses, GetProviderModelsErrors, ThrowOnError>({ url: '/config/providers/{name}/models', ...options });
+35
View File
@@ -527,6 +527,25 @@ export type ParseRecipeResponse = {
*/
export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow';
export type PricingData = {
context_length?: number | null;
currency: string;
input_token_cost: number;
model: string;
output_token_cost: number;
provider: string;
};
export type PricingQuery = {
model: string;
provider: string;
};
export type PricingResponse = {
pricing: Array<PricingData>;
source: string;
};
export type PrincipalType = 'Extension' | 'Tool';
export type ProviderDetails = {
@@ -1718,6 +1737,22 @@ export type UpsertPermissionsResponses = {
export type UpsertPermissionsResponse = UpsertPermissionsResponses[keyof UpsertPermissionsResponses];
export type GetPricingData = {
body: PricingQuery;
path?: never;
query?: never;
url: '/config/pricing';
};
export type GetPricingResponses = {
/**
* Model pricing data retrieved successfully
*/
200: PricingResponse;
};
export type GetPricingResponse = GetPricingResponses[keyof GetPricingResponses];
export type ProvidersData = {
body?: never;
path?: never;
@@ -1,14 +1,9 @@
import { useState, useEffect } from 'react';
import { useModelAndProvider } from '../ModelAndProviderContext';
import { useConfig } from '../ConfigContext';
import { CoinIcon } from '../icons';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import {
getCostForModel,
initializeCostDatabase,
updateAllModelCosts,
fetchAndCachePricing,
} from '../../utils/costDatabase';
import { fetchModelPricing } from '../../utils/pricing';
import { PricingData } from '../../api';
interface CostTrackerProps {
inputTokens?: number;
@@ -24,18 +19,10 @@ interface CostTrackerProps {
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 [costInfo, setCostInfo] = useState<PricingData | 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(() => {
@@ -44,33 +31,11 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
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(() => {});
}, [getProviders]);
useEffect(() => {
const loadCostInfo = async () => {
if (!currentModel || !currentProvider) {
@@ -78,49 +43,20 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
return;
}
setIsLoading(true);
try {
// First check sync cache
let costData = getCostForModel(currentProvider, currentModel);
const costData = await fetchModelPricing(currentProvider, currentModel);
if (costData) {
// We have cached data
setCostInfo(costData);
setPricingFailed(false);
setModelNotFound(false);
setIsLoading(false);
setHasAttemptedFetch(true);
} else {
// Need to fetch from backend
setIsLoading(true);
const result = await fetchAndCachePricing(currentProvider, currentModel);
setHasAttemptedFetch(true);
if (result && result.costInfo) {
setCostInfo(result.costInfo);
setPricingFailed(false);
setModelNotFound(false);
} else if (result && result.error === 'model_not_found') {
// Model not found in pricing database, but API call succeeded
setModelNotFound(true);
setPricingFailed(false);
} else {
// API call failed or other error
const freeProviders = ['ollama', 'local', 'localhost'];
if (!freeProviders.includes(currentProvider.toLowerCase())) {
setPricingFailed(true);
setModelNotFound(false);
}
}
setIsLoading(false);
setPricingFailed(true);
setCostInfo(null);
}
} catch {
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);
}
setPricingFailed(true);
setCostInfo(null);
} finally {
setIsLoading(false);
}
};
@@ -221,10 +157,9 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
// Otherwise show as unavailable
const getUnavailableTooltip = () => {
if (pricingFailed && hasAttemptedFetch && initialLoadComplete) {
return `Pricing data unavailable - OpenRouter connection failed. Click refresh in settings to retry.`;
if (pricingFailed) {
return `Pricing data unavailable for ${currentModel}`;
}
// 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)`;
};
@@ -249,12 +184,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
// 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.`;
if (pricingFailed) {
return `Pricing data unavailable for ${currentProvider}/${currentModel}`;
}
// Handle session costs
@@ -1,13 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { Switch } from '../../ui/switch';
import { Button } from '../../ui/button';
import { Settings, RefreshCw, ExternalLink } from 'lucide-react';
import { Settings } from 'lucide-react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
import UpdateSection from './UpdateSection';
import TunnelSection from '../tunnel/TunnelSection';
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
import { getApiUrl } from '../../../config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import ThemeSelector from '../../GooseSidebar/ThemeSelector';
import BlockLogoBlack from './icons/block-lockup_black.png';
@@ -26,9 +25,6 @@ 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 [isDarkMode, setIsDarkMode] = useState(false);
const updateSectionRef = useRef<HTMLDivElement>(null);
@@ -66,71 +62,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
setShowPricing(stored !== 'false');
}, []);
// Check pricing status on mount
useEffect(() => {
checkPricingStatus();
}, []);
const checkPricingStatus = async () => {
try {
const apiUrl = getApiUrl('/config/pricing');
const secretKey = await window.electron.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();
setPricingStatus('success');
setLastFetchTime(new Date());
} else {
setPricingStatus('error');
}
} catch {
setPricingStatus('error');
}
};
const handleRefreshPricing = async () => {
setIsRefreshing(true);
try {
const apiUrl = getApiUrl('/config/pricing');
const secretKey = await window.electron.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 {
setPricingStatus('error');
} finally {
setIsRefreshing(false);
}
};
// Handle scrolling to update section
useEffect(() => {
if (scrollToSection === 'update' && updateSectionRef.current) {
@@ -326,69 +257,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
</div>
)}
{/* Pricing Status - only show if cost tracking is enabled */}
{COST_TRACKING_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>
)}
</>
)}
</CardContent>
</Card>
-10
View File
@@ -2,7 +2,6 @@ import { useCallback, useRef, useState } from 'react';
import { useConfig } from '../components/ConfigContext';
import { ChatType } from '../types/chat';
import { initializeSystem } from '../utils/providerUtils';
import { initializeCostDatabase } from '../utils/costDatabase';
import {
backupConfig,
initConfig,
@@ -13,7 +12,6 @@ import {
startAgent,
validateConfig,
} from '../api';
import { COST_TRACKING_ENABLED } from '../updates';
export enum AgentState {
UNINITIALIZED = 'uninitialized',
@@ -235,14 +233,6 @@ export function useAgent(): UseAgentReturn {
recipe: recipeForInit,
});
if (COST_TRACKING_ENABLED) {
try {
await initializeCostDatabase();
} catch (error) {
console.error('Failed to initialize cost database:', error);
}
}
const recipe = initContext.recipe || agentSession.recipe;
const conversation = agentSession.conversation || [];
// If we're loading a recipe from initContext (new recipe load), start with empty messages
+42 -35
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useModelAndProvider } from '../components/ModelAndProviderContext';
import { getCostForModel } from '../utils/costDatabase';
import { fetchModelPricing } from '../utils/pricing';
import { Session } from '../api';
interface UseCostTrackingProps {
@@ -32,46 +32,53 @@ export const useCostTracking = ({
// 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}`;
const handleModelChange = async () => {
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);
// Get pricing info for the previous model
const prevCostInfo = await fetchModelPricing(
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;
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,
},
}));
// Save the accumulated costs for this model
setSessionCosts((prev) => ({
...prev,
[prevKey]: {
inputTokens: sessionInputTokens || localInputTokens,
outputTokens: sessionOutputTokens || localOutputTokens,
totalCost: prevTotalCost,
},
}));
}
console.log(
'Model changed from',
`${prevProviderRef.current}/${prevModelRef.current}`,
'to',
`${currentProvider}/${currentModel}`,
'- saved costs and restored session token counters'
);
}
console.log(
'Model changed from',
`${prevProviderRef.current}/${prevModelRef.current}`,
'to',
`${currentProvider}/${currentModel}`,
'- saved costs and restored session token counters'
);
}
prevModelRef.current = currentModel || undefined;
prevProviderRef.current = currentProvider || undefined;
};
prevModelRef.current = currentModel || undefined;
prevProviderRef.current = currentProvider || undefined;
handleModelChange();
}, [
currentModel,
currentProvider,
-207
View File
@@ -1,207 +0,0 @@
import { getApiUrl } from '../config';
import { safeJsonParse } from './conversionUtils';
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 session only
const sessionPricingCache = new Map<string, ModelCostInfo | null>();
/**
* Fetch pricing data from backend for specific provider/model
*/
async function fetchPricingForModel(
provider: string,
model: string
): Promise<ModelCostInfo | null> {
// For OpenRouter models, we need to use the parsed provider and model for the API lookup
let lookupProvider = provider;
let lookupModel = model;
if (provider.toLowerCase() === 'openrouter') {
const parsed = parseOpenRouterModel(model);
if (parsed) {
lookupProvider = parsed[0];
lookupModel = parsed[1];
}
}
const apiUrl = getApiUrl('/config/pricing');
const secretKey = await window.electron.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) {
throw new Error(`API request failed with status ${response.status}`);
}
const data = await safeJsonParse<{
pricing: Array<{
provider: string;
model: string;
input_token_cost: number;
output_token_cost: number;
currency: string;
}>;
}>(response, 'Failed to parse pricing data');
// Find the specific model pricing using the lookup provider/model
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() === lookupProvider.toLowerCase();
// More flexible model matching - handle versioned models
let modelMatch = p.model === lookupModel;
// If exact match fails, try matching without version suffix
if (!modelMatch && lookupModel.includes('-20')) {
// Remove date suffix like -20241022
const modelWithoutDate = lookupModel.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) {
return {
input_token_cost: pricing.input_token_cost,
output_token_cost: pricing.output_token_cost,
currency: pricing.currency || '$',
};
}
// API call succeeded but model not found in pricing data
return null;
}
/**
* Initialize the cost database - no-op since we fetch on demand now
*/
export async function initializeCostDatabase(): Promise<void> {
// Clear session cache on init
sessionPricingCache.clear();
}
/**
* Update model costs from providers - no-op since we fetch on demand
*/
export async function updateAllModelCosts(): Promise<void> {
// No-op - we fetch on demand now
}
/**
* Parse OpenRouter model ID to extract provider and model
* e.g., "anthropic/claude-sonnet-4" -> ["anthropic", "claude-sonnet-4"]
*/
function parseOpenRouterModel(modelId: string): [string, string] | null {
const parts = modelId.split('/');
if (parts.length === 2) {
return [parts[0], parts[1]];
}
return null;
}
/**
* Get cost information for a specific model with session caching
*/
export function getCostForModel(provider: string, model: string): ModelCostInfo | null {
const cacheKey = `${provider}/${model}`;
// Check session cache first
if (sessionPricingCache.has(cacheKey)) {
return sessionPricingCache.get(cacheKey) || null;
}
// For OpenRouter models, also check if we have cached data under the parsed provider/model
if (provider.toLowerCase() === 'openrouter') {
const parsed = parseOpenRouterModel(model);
if (parsed) {
const [parsedProvider, parsedModel] = parsed;
const parsedCacheKey = `${parsedProvider}/${parsedModel}`;
if (sessionPricingCache.has(parsedCacheKey)) {
const cachedData = sessionPricingCache.get(parsedCacheKey) || null;
// Also cache it under the original OpenRouter key for future lookups
sessionPricingCache.set(cacheKey, cachedData);
return cachedData;
}
}
}
// 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: '$',
};
sessionPricingCache.set(cacheKey, zeroCost);
return zeroCost;
}
// Need to fetch - return null and let component handle 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 cacheKey = `${provider}/${model}`;
const costInfo = await fetchPricingForModel(provider, model);
// Cache the result in session cache under the original key
sessionPricingCache.set(cacheKey, costInfo);
// For OpenRouter models, also cache under the parsed provider/model key
// This helps with cross-referencing between frontend requests and backend responses
if (provider.toLowerCase() === 'openrouter') {
const parsed = parseOpenRouterModel(model);
if (parsed) {
const [parsedProvider, parsedModel] = parsed;
const parsedCacheKey = `${parsedProvider}/${parsedModel}`;
sessionPricingCache.set(parsedCacheKey, costInfo);
}
}
if (costInfo) {
return { costInfo };
} else {
// Model not found in pricing data
return { costInfo: null, error: 'model_not_found' };
}
} catch {
// This is a real API/network error
return null;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { getPricing, PricingData } from '../api';
/**
* Fetch pricing for a specific provider/model from the backend
*/
export async function fetchModelPricing(
provider: string,
model: string
): Promise<PricingData | null> {
try {
const response = await getPricing({
body: { provider, model },
throwOnError: false,
});
if (!response.data) {
return null;
}
return response.data.pricing?.[0] ?? null;
} catch {
return null;
}
}