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:
claude[bot]
2025-10-31 08:37:58 +00:00
parent 233a0d4b35
commit 9e3819b9c7
9 changed files with 139 additions and 16 deletions
+2 -2
View File
@@ -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 ';
+13
View File
@@ -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;
}
+1
View File
@@ -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 -2
View File
@@ -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>
+2 -2
View File
@@ -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 }));
};
+3 -1
View File
@@ -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;