mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
feat: Add user pricing override for hypothetical cost tracking
Add userPricing field to DLLM interface following the established pattern for user overrides (similar to userContextTokens and userMaxOutputTokens). This enables users to set custom pricing for local models (Ollama, LM Studio, etc.) to track "what if" costs and compare with cloud models. Changes: - Added userPricing field to DLLM interface (llms.types.ts) - Added getLLMPricing() getter function with override precedence - Updated store to preserve userPricing during model updates - Updated all llm.pricing access points to use getLLMPricing() - Added pricing override UI in LLMOptionsModal (Details section) - Input price ($/M tokens) - Output price ($/M tokens) - Reset buttons for each field - Cost calculations automatically use user pricing when set - Existing cost display in tooltips works with user pricing Resolves #860 Co-authored-by: Enrico Ros <enricoros@users.noreply.github.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import { findModelVendor } from '~/modules/llms/vendors/vendors.registry';
|
||||
import { llmsGetVendorIcon, LLMVendorIcon } from '~/modules/llms/components/LLMVendorIcon';
|
||||
|
||||
import type { DModelDomainId } from '~/common/stores/llms/model.domains.types';
|
||||
import { DLLM, DLLMId, LLM_IF_OAI_Reasoning, LLM_IF_Outputs_Audio, LLM_IF_Outputs_Image, LLM_IF_Tools_WebSearch } from '~/common/stores/llms/llms.types';
|
||||
import { DLLM, DLLMId, getLLMPricing, LLM_IF_OAI_Reasoning, LLM_IF_Outputs_Audio, LLM_IF_Outputs_Image, LLM_IF_Tools_WebSearch } from '~/common/stores/llms/llms.types';
|
||||
import { PhGearSixIcon } from '~/common/components/icons/phosphor/PhGearSixIcon';
|
||||
import { StarredNoXL2 } from '~/common/components/StarIcons';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
@@ -182,7 +182,7 @@ export function useLLMSelect(
|
||||
|
||||
let features = '';
|
||||
const isNotSymlink = !llm.label.startsWith('🔗');
|
||||
const seemsFree = !!llm.pricing?.chat?._isFree;
|
||||
const seemsFree = !!getLLMPricing(llm)?.chat?._isFree;
|
||||
if (isNotSymlink) {
|
||||
// check features
|
||||
if (seemsFree) features += 'free ';
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface DLLM {
|
||||
userParameters?: DModelParameterValues; // user has set these parameters
|
||||
userContextTokens?: DLLMContextTokens; // user override for context window
|
||||
userMaxOutputTokens?: DLLMMaxOutputTokens; // user override for max output tokens
|
||||
userPricing?: DModelPricing; // user override for model pricing
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +122,18 @@ export function getLLMMaxOutputTokens(llm: DLLM | null): DLLMMaxOutputTokens | u
|
||||
return llm.userMaxOutputTokens ?? llm.maxOutputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective pricing for a model.
|
||||
* Checks user override first, then falls back to model default.
|
||||
*/
|
||||
export function getLLMPricing(llm: DLLM | null): DModelPricing | undefined {
|
||||
if (!llm)
|
||||
return undefined; // undefined if no model
|
||||
|
||||
// Check user override first, then fall back to model default
|
||||
return llm.userPricing ?? llm.pricing;
|
||||
}
|
||||
|
||||
|
||||
/// Interfaces ///
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { StateCreator } from 'zustand/vanilla';
|
||||
import type { ModelVendorId } from '~/modules/llms/vendors/vendors.registry';
|
||||
|
||||
import type { DModelDomainId } from './model.domains.types';
|
||||
import { DLLM, DLLMId, isLLMHidden, isLLMVisible } from './llms.types';
|
||||
import { DLLM, DLLMId, getLLMPricing, isLLMHidden, isLLMVisible } from './llms.types';
|
||||
import { LlmsRootState, useModelsStore } from './store-llms';
|
||||
import { ModelDomainsList, ModelDomainsRegistry } from './model.domains.registry';
|
||||
import { createDModelConfiguration, DModelConfiguration } from './modelconfiguration.types';
|
||||
@@ -275,7 +275,7 @@ function _groupLlmsByVendorRankedByElo(llms: ReadonlyArray<DLLM>): PreferredRank
|
||||
const eloCostItem = {
|
||||
id: llm.id,
|
||||
cbaElo: llm.benchmark?.cbaElo,
|
||||
costRank: !llm.pricing ? undefined : _getLlmCostBenchmark(llm),
|
||||
costRank: !getLLMPricing(llm) ? undefined : _getLlmCostBenchmark(llm),
|
||||
};
|
||||
if (!group)
|
||||
acc.push({ vendorId: llm.vId, llmsByElo: [eloCostItem] });
|
||||
@@ -295,8 +295,9 @@ function _groupLlmsByVendorRankedByElo(llms: ReadonlyArray<DLLM>): PreferredRank
|
||||
|
||||
// Hypothetical cost benchmark for a model, based on total cost of 100k input tokens and 10k output tokens.
|
||||
function _getLlmCostBenchmark(llm: DLLM): number | undefined {
|
||||
if (!llm.pricing?.chat) return undefined;
|
||||
const costIn = getLlmCostForTokens(100000, 100000, llm.pricing.chat.input);
|
||||
const costOut = getLlmCostForTokens(100000, 10000, llm.pricing.chat.output);
|
||||
const pricing = getLLMPricing(llm);
|
||||
if (!pricing?.chat) return undefined;
|
||||
const costIn = getLlmCostForTokens(100000, 100000, pricing.chat.input);
|
||||
const costOut = getLlmCostForTokens(100000, 10000, pricing.chat.output);
|
||||
return (costIn !== undefined && costOut !== undefined) ? costIn + costOut : undefined;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export const useModelsStore = create<LlmsStore>()(persist(
|
||||
...(existing.userParameters !== undefined ? { userParameters: { ...existing.userParameters } } : {}),
|
||||
...(existing.userContextTokens !== undefined ? { userContextTokens: existing.userContextTokens } : {}),
|
||||
...(existing.userMaxOutputTokens !== undefined ? { userMaxOutputTokens: existing.userMaxOutputTokens } : {}),
|
||||
...(existing.userPricing !== undefined ? { userPricing: existing.userPricing } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { findServiceAccessOrThrow } from '~/modules/llms/vendors/vendor.helpers'
|
||||
|
||||
import type { DMessage, DMessageGenerator } from '~/common/stores/chat/chat.message';
|
||||
import type { MaybePromise } from '~/common/types/useful.types';
|
||||
import { DLLM, DLLMId, LLM_IF_HOTFIX_NoTemperature, LLM_IF_OAI_Responses, LLM_IF_Outputs_Audio, LLM_IF_Outputs_Image, LLM_IF_Outputs_NoText } from '~/common/stores/llms/llms.types';
|
||||
import { DLLM, DLLMId, getLLMPricing, LLM_IF_HOTFIX_NoTemperature, LLM_IF_OAI_Responses, LLM_IF_Outputs_Audio, LLM_IF_Outputs_Image, LLM_IF_Outputs_NoText } from '~/common/stores/llms/llms.types';
|
||||
import { DMetricsChatGenerate_Lg, metricsChatGenerateLgToMd, metricsComputeChatGenerateCostsMd } from '~/common/stores/metrics/metrics.chatgenerate';
|
||||
import { DModelParameterValues, getAllModelParameterValues } from '~/common/stores/llms/llms.parameters';
|
||||
import { apiStream } from '~/common/util/trpc.client';
|
||||
@@ -535,7 +535,7 @@ function _llToDMessage(src: AixChatGenerateContent_LL, dest: AixChatGenerateCont
|
||||
function _updateGeneratorCostsInPlace(generator: DMessageGenerator, llm: DLLM, debugCostSource: string) {
|
||||
// Compute costs
|
||||
const logLlmRefId = getAllModelParameterValues(llm.initialParameters, llm.userParameters).llmRef || llm.id;
|
||||
const costs = metricsComputeChatGenerateCostsMd(generator.metrics, llm.pricing?.chat, logLlmRefId);
|
||||
const costs = metricsComputeChatGenerateCostsMd(generator.metrics, getLLMPricing(llm)?.chat, logLlmRefId);
|
||||
if (!costs) {
|
||||
// FIXME: we shall warn that the costs are missing, as the only way to get pricing is through surfacing missing prices
|
||||
return;
|
||||
|
||||
@@ -9,7 +9,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
import type { DPricingChatGenerate } from '~/common/stores/llms/llms.pricing';
|
||||
import { DLLMId, getLLMContextTokens, getLLMMaxOutputTokens, isLLMVisible } from '~/common/stores/llms/llms.types';
|
||||
import { DLLMId, getLLMContextTokens, getLLMMaxOutputTokens, getLLMPricing, isLLMVisible } from '~/common/stores/llms/llms.types';
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { GoodModal } from '~/common/components/modals/GoodModal';
|
||||
import { ModelDomainsList, ModelDomainsRegistry } from '~/common/stores/llms/model.domains.registry';
|
||||
@@ -107,6 +107,60 @@ export function LLMOptionsModal(props: { id: DLLMId, onClose: () => void }) {
|
||||
|
||||
const handleMaxOutputTokensReset = () => updateLLM(llm.id, { userMaxOutputTokens: undefined });
|
||||
|
||||
const handleInputPriceChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
const numValue = value ? parseFloat(value) : undefined;
|
||||
updateLLM(llm.id, {
|
||||
userPricing: {
|
||||
chat: {
|
||||
...llm.userPricing?.chat,
|
||||
input: numValue,
|
||||
output: llm.userPricing?.chat?.output,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputPriceReset = () => {
|
||||
const newPricing = { ...llm.userPricing };
|
||||
if (newPricing.chat) {
|
||||
delete newPricing.chat.input;
|
||||
// If no other pricing fields are set, clear userPricing entirely
|
||||
if (!newPricing.chat.output && !newPricing.chat.cache) {
|
||||
updateLLM(llm.id, { userPricing: undefined });
|
||||
} else {
|
||||
updateLLM(llm.id, { userPricing: newPricing });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOutputPriceChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
const numValue = value ? parseFloat(value) : undefined;
|
||||
updateLLM(llm.id, {
|
||||
userPricing: {
|
||||
chat: {
|
||||
...llm.userPricing?.chat,
|
||||
input: llm.userPricing?.chat?.input,
|
||||
output: numValue,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOutputPriceReset = () => {
|
||||
const newPricing = { ...llm.userPricing };
|
||||
if (newPricing.chat) {
|
||||
delete newPricing.chat.output;
|
||||
// If no other pricing fields are set, clear userPricing entirely
|
||||
if (!newPricing.chat.input && !newPricing.chat.cache) {
|
||||
updateLLM(llm.id, { userPricing: undefined });
|
||||
} else {
|
||||
updateLLM(llm.id, { userPricing: newPricing });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLlmDelete = () => {
|
||||
removeLLM(llm.id);
|
||||
props.onClose();
|
||||
@@ -235,7 +289,7 @@ export function LLMOptionsModal(props: { id: DLLMId, onClose: () => void }) {
|
||||
{llm.description}
|
||||
</Typography>}
|
||||
|
||||
{!!llm.pricing?.chat?._isFree && <Typography level='body-xs'>
|
||||
{!!getLLMPricing(llm)?.chat?._isFree && <Typography level='body-xs'>
|
||||
🎁 Free model - note: refresh models to check for updates in pricing
|
||||
</Typography>}
|
||||
|
||||
@@ -245,7 +299,7 @@ export function LLMOptionsModal(props: { id: DLLMId, onClose: () => void }) {
|
||||
max output tokens: <b>{getLLMMaxOutputTokens(llm)?.toLocaleString() ?? 'not provided'}</b><br />
|
||||
{!!llm.created && <>created: <TimeAgo date={new Date(llm.created * 1000)} /><br /></>}
|
||||
{/*· tags: {llm.tags.join(', ')}*/}
|
||||
{!!llm.pricing?.chat && prettyPricingComponent(llm.pricing.chat)}
|
||||
{!!getLLMPricing(llm)?.chat && prettyPricingComponent(getLLMPricing(llm)!.chat!)}
|
||||
{/*{!!llm.benchmark && <>benchmark: <b>{llm.benchmark.cbaElo?.toLocaleString() || '(unk) '}</b> CBA Elo<br /></>}*/}
|
||||
{llm.parameterSpecs?.length > 0 && <>options: {llm.parameterSpecs.map(ps => ps.paramId).join(', ')}<br /></>}
|
||||
{Object.keys(llm.initialParameters || {}).length > 0 && <>initial parameters: {JSON.stringify(llm.initialParameters, null, 2)}<br /></>}
|
||||
@@ -297,6 +351,57 @@ export function LLMOptionsModal(props: { id: DLLMId, onClose: () => void }) {
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Advanced: Pricing Overrides */}
|
||||
<Grid container spacing={2} alignItems='center' sx={{ mt: 1 }}>
|
||||
<Grid xs={12}>
|
||||
<Typography level='title-sm'>
|
||||
Pricing Override (for hypothetical cost tracking)
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} md={6}>
|
||||
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormLabelStart title='Input Price' description='$/Million Tokens' sx={{ minWidth: 120 }} />
|
||||
<Input
|
||||
type='number'
|
||||
variant='outlined'
|
||||
placeholder={
|
||||
// NOTE: direct access to the underlying, instead of via getLLMPricing
|
||||
typeof llm.pricing?.chat?.input === 'number' ? llm.pricing.chat.input.toString() : 'not set'
|
||||
}
|
||||
value={llm.userPricing?.chat?.input ?? ''}
|
||||
onChange={handleInputPriceChange}
|
||||
endDecorator={llm.userPricing?.chat?.input !== undefined && (
|
||||
<Button size='sm' variant='plain' onClick={handleInputPriceReset}>Reset</Button>
|
||||
)}
|
||||
slotProps={{ input: { min: 0, step: 0.01 } }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} md={6}>
|
||||
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormLabelStart title='Output Price' description='$/Million Tokens' sx={{ minWidth: 120 }} />
|
||||
<Input
|
||||
type='number'
|
||||
variant='outlined'
|
||||
placeholder={
|
||||
// NOTE: direct access to the underlying, instead of via getLLMPricing
|
||||
typeof llm.pricing?.chat?.output === 'number' ? llm.pricing.chat.output.toString() : 'not set'
|
||||
}
|
||||
value={llm.userPricing?.chat?.output ?? ''}
|
||||
onChange={handleOutputPriceChange}
|
||||
slotProps={{ input: { min: 0, step: 0.01 } }}
|
||||
endDecorator={llm.userPricing?.chat?.output !== undefined && (
|
||||
<Button size='sm' variant='plain' onClick={handleOutputPriceReset}>Reset</Button>
|
||||
)}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</Box>}
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
|
||||
import type { DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
import { DLLM, DLLMId, getLLMContextTokens, getLLMMaxOutputTokens, isLLMHidden, LLM_IF_ANT_PromptCaching, LLM_IF_GEM_CodeExecution, LLM_IF_OAI_Complete, LLM_IF_OAI_Fn, LLM_IF_OAI_Json, LLM_IF_OAI_PromptCaching, LLM_IF_OAI_Realtime, LLM_IF_OAI_Reasoning, LLM_IF_OAI_Vision, LLM_IF_Outputs_Audio, LLM_IF_Outputs_Image, LLM_IF_Tools_WebSearch } from '~/common/stores/llms/llms.types';
|
||||
import { DLLM, DLLMId, getLLMContextTokens, getLLMMaxOutputTokens, getLLMPricing, isLLMHidden, LLM_IF_ANT_PromptCaching, LLM_IF_GEM_CodeExecution, LLM_IF_OAI_Complete, LLM_IF_OAI_Fn, LLM_IF_OAI_Json, LLM_IF_OAI_PromptCaching, LLM_IF_OAI_Realtime, LLM_IF_OAI_Reasoning, LLM_IF_OAI_Vision, LLM_IF_Outputs_Audio, LLM_IF_Outputs_Image, LLM_IF_Tools_WebSearch } from '~/common/stores/llms/llms.types';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { PhGearSixIcon } from '~/common/components/icons/phosphor/PhGearSixIcon';
|
||||
import { STAR_EMOJI, StarredToggle, starredToggleStyle } from '~/common/components/StarIcons';
|
||||
@@ -81,7 +81,7 @@ function ModelItem(props: {
|
||||
// derived
|
||||
const { llm, onModelClicked, onModelSetHidden /*, onModelSetStarred*/ } = props;
|
||||
|
||||
const seemsFree = !!llm.pricing?.chat?._isFree;
|
||||
const seemsFree = !!getLLMPricing(llm)?.chat?._isFree;
|
||||
const isNotSymlink = !llm.label.startsWith('🔗');
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import type { DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
import { AlreadySet } from '~/common/components/AlreadySet';
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { getLLMPricing } from '~/common/stores/llms/llms.types';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
@@ -53,7 +54,7 @@ export function OpenRouterServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
const { updateLLM } = llmsStoreActions();
|
||||
llms
|
||||
.filter(llm => llm.sId === props.serviceId)
|
||||
.filter(llm => llm.pricing?.chat?._isFree === false)
|
||||
.filter(llm => getLLMPricing(llm)?.chat?._isFree === false)
|
||||
.forEach(llm => updateLLM(llm.id, { userHidden: true }));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IModelVendor } from '../IModelVendor';
|
||||
import type { OpenAIAccessSchema } from '../../server/openai/openai.router';
|
||||
|
||||
import { getLLMPricing } from '~/common/stores/llms/llms.types';
|
||||
|
||||
import { ModelVendorOpenAI } from '../openai/openai.vendor';
|
||||
|
||||
|
||||
@@ -51,7 +53,7 @@ export const ModelVendorOpenRouter: IModelVendor<DOpenRouterServiceSettings, Ope
|
||||
rateLimitChatGenerate: async (llm) => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - nextGenerationTs;
|
||||
const wait = llm.pricing?.chat?._isFree
|
||||
const wait = getLLMPricing(llm)?.chat?._isFree
|
||||
? 5000 + 100 /* 5 seconds for free call, plus some safety margin */
|
||||
: 100;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user