LLM Clones: correctly group services when updating, inserting custom models

This commit is contained in:
Enrico Ros
2026-01-29 10:52:04 -08:00
parent ecf9703570
commit 5caf614bf7
4 changed files with 109 additions and 70 deletions
+10 -10
View File
@@ -21,12 +21,12 @@ export type DLLMId = string;
export interface DLLM {
id: DLLMId;
// editable properties (kept on update, if isEdited)
// factory properties (overwritten on update)
label: string;
created: number | 0;
updated?: number | 0;
description: string;
hidden: boolean; // default hidden state (can change underlying between refreshes)
hidden: boolean;
// hard properties (overwritten on update)
contextTokens: DLLMContextTokens; // null: must assume it's unknown
@@ -36,22 +36,22 @@ export interface DLLM {
benchmark?: { cbaElo?: number, cbaMmlu?: number }; // benchmark values
pricing?: DModelPricing;
// parameters system
// parameters system (overwritten on update)
parameterSpecs: DModelParameterSpecAny[];
initialParameters: DModelParameterValues;
// references
sId: DModelsServiceId;
vId: ModelVendorId;
// references (const, never change)
sId: DModelsServiceId; // could be weak, but they're removed at the same time
vId: ModelVendorId; // known hardcoded value
// user edited properties - if not undefined/missing, they override the others
userLabel?: string;
userHidden?: boolean;
userStarred?: boolean;
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
userContextTokens?: DLLMContextTokens;
userMaxOutputTokens?: DLLMMaxOutputTokens;
userPricing?: DModelPricing;
userParameters?: DModelParameterValues;
// clone metadata - user-created duplicates of models with independent settings
isUserClone?: boolean; // true if this is a user-created clone
+68 -51
View File
@@ -37,7 +37,7 @@ export interface LlmsRootState {
interface LlmsRootActions {
setServiceLLMs: (serviceId: DModelsServiceId, serviceLLMs: ReadonlyArray<DLLM>, keepUserEdits: boolean, keepMissingLLMs: boolean) => void;
setServiceLLMs: (serviceId: DModelsServiceId, serviceLLMs: ReadonlyArray<DLLM>, keepUserEdits: true, keepMissingLLMs: false) => void;
removeLLM: (id: DLLMId) => void;
rerankLLMsByServices: (serviceIdOrder: DModelsServiceId[]) => void;
updateLLM: (id: DLLMId, partial: Partial<DLLM>) => void;
@@ -78,62 +78,75 @@ export const useModelsStore = create<LlmsStore>()(persist(
// actions
setServiceLLMs: (serviceId: DModelsServiceId, serviceLLMs: ReadonlyArray<DLLM>, keepUserEdits: boolean, keepMissingLLMs: boolean) =>
set(({ llms: existingLLMs, modelAssignments }) => {
setServiceLLMs: (serviceId: DModelsServiceId, updatedServiceLLMs: ReadonlyArray<DLLM>, keepUserEdits: true, keepMissingLLMs: false) =>
set(({ llms, modelAssignments }) => {
// keep existing model customizations
if (keepUserEdits) {
serviceLLMs = serviceLLMs.map((llm: DLLM): DLLM => {
const existing = existingLLMs.find(m => m.id === llm.id);
if (!existing) return llm;
// separate existing models
const otherServiceLLMs = llms.filter(llm => llm.sId !== serviceId);
const previousServiceLLMs = llms.filter(llm => llm.sId === serviceId);
const consumedPreviousIds = new Set<DLLMId>();
const result = {
...llm,
...(existing.userLabel !== undefined ? { userLabel: existing.userLabel } : {}),
...(existing.userHidden !== undefined ? { userHidden: existing.userHidden } : {}),
...(existing.userStarred !== undefined ? { userStarred: existing.userStarred } : {}),
...(existing.userParameters !== undefined ? { userParameters: { ...existing.userParameters } } : {}),
...(existing.userContextTokens !== undefined ? { userContextTokens: existing.userContextTokens } : {}),
...(existing.userMaxOutputTokens !== undefined ? { userMaxOutputTokens: existing.userMaxOutputTokens } : {}),
...(existing.userPricing !== undefined ? { userPricing: existing.userPricing } : {}),
};
// process updated models, re-applying user customizations where applicable
const mergedServiceLLMs: DLLM[] = updatedServiceLLMs.map((llm: DLLM): DLLM => {
// new model: as-is
const e = previousServiceLLMs.find(m => m.id === llm.id);
if (!e) return llm;
// clean up stale parameters from userParameters - e.g. was in the model spec but removed in the new version
if (result.userParameters) {
for (const key of Object.keys(result.userParameters)) {
const paramId = key as DModelParameterId;
// mark this previous model as matched (consumed)
consumedPreviousIds.add(e.id);
// Skip implicit common parameters (always supported, not in parameterSpecs)
if (LLMS_ImplicitParamIds.includes(paramId))
continue;
// re-apply user edits from existing model to the new model data
if (!keepUserEdits) return llm;
const result: DLLM = {
...llm,
...(e.userLabel !== undefined ? { userLabel: e.userLabel } : {}),
...(e.userHidden !== undefined ? { userHidden: e.userHidden } : {}),
...(e.userStarred !== undefined ? { userStarred: e.userStarred } : {}),
...(e.userContextTokens !== undefined ? { userContextTokens: e.userContextTokens } : {}),
...(e.userMaxOutputTokens !== undefined ? { userMaxOutputTokens: e.userMaxOutputTokens } : {}),
...(e.userPricing !== undefined ? { userPricing: e.userPricing } : {}),
...(e.userParameters !== undefined ? { userParameters: { ...e.userParameters } } : {}),
};
// Remove if param no longer in spec
const paramSpec = llm.parameterSpecs.find(spec => spec.paramId === paramId);
if (!paramSpec) {
delete result.userParameters[paramId];
continue;
}
// clean up stale parameters from userParameters -
// - e.g. was in the model spec but removed in the new version
// - or the value of an enum got removed, and so we remove ours
if (result.userParameters) {
for (const key of Object.keys(result.userParameters)) {
const paramId = key as DModelParameterId;
// For enum types, validate the value is still in the allowed values (e.g., 'medium' was removed from thinkingLevel)
const regDef = DModelParameterRegistry[paramId];
if (regDef && regDef.type === 'enum' && 'values' in regDef) {
const currentValue = result.userParameters[paramId];
if (currentValue !== undefined && !(regDef.values as readonly unknown[]).includes(currentValue))
delete result.userParameters[paramId]; // Reset to default (undefined)
}
// keep implicit common parameters (always supported, not in parameterSpecs)
if (LLMS_ImplicitParamIds.includes(paramId))
continue;
// remove parameters no longer in spec
const paramSpec = llm.parameterSpecs.find(spec => spec.paramId === paramId);
if (!paramSpec) {
delete result.userParameters[paramId];
continue;
}
// for enum types, validate the value is still in the allowed values (e.g., 'medium' was removed from thinkingLevel)
const regDef = DModelParameterRegistry[paramId];
if (regDef && regDef.type === 'enum' && 'values' in regDef) {
const currentValue = result.userParameters[paramId];
if (currentValue && typeof currentValue === 'string' && !(regDef.values as readonly string[]).includes(currentValue))
delete result.userParameters[paramId]; // reset to default (undefined)
}
}
}
return result;
});
}
return result;
});
// remove models that are not in the new list, but preserve user clones
if (!keepMissingLLMs)
existingLLMs = existingLLMs.filter(llm => llm.sId !== serviceId || llm.isUserClone === true);
// replace existing llms with the same id
const newLlms = [...serviceLLMs, ...existingLLMs.filter(existingLlm => !serviceLLMs.some(newLlm => newLlm.id === existingLlm.id))];
// Always preserve custom models
// - NOTE: shall we check for the undelying ref to still be in the service, to auto-clean-up older models?
const customModels = previousServiceLLMs.filter(llm => llm.isUserClone === true && !consumedPreviousIds.has(llm.id));
const missingModels = !keepMissingLLMs ? [] : previousServiceLLMs.filter(llm => !llm.isUserClone && !consumedPreviousIds.has(llm.id));
// Build the final list in priority order
const newLlms = [...customModels, ...missingModels, ...mergedServiceLLMs, ...otherServiceLLMs];
return {
llms: newLlms,
modelAssignments: llmsHeuristicUpdateAssignments(newLlms, modelAssignments),
@@ -228,11 +241,15 @@ export const useModelsStore = create<LlmsStore>()(persist(
const cloneId = getDLLMCloneId(sourceId, cloneVariant);
if (llms.some(llm => llm.id === cloneId)) return null;
// create and add the clone
const userCloneLLM = createDLLMUserClone(sourceLlm, cloneLabel, cloneVariant);
set({
llms: [userCloneLLM, ...llms],
});
// create clone
const cloneLlm = createDLLMUserClone(sourceLlm, cloneLabel, cloneVariant);
// IMPORTANT: we have to have this LLM be part of the same group (or the UI will break on multiple-grouping)
const serviceStartIndex = llms.findIndex(llm => llm.sId === sourceLlm.sId);
const newLlms = [...llms];
newLlms.splice(serviceStartIndex, 0, cloneLlm);
set({ llms: newLlms });
return cloneId;
},
+14 -9
View File
@@ -27,7 +27,7 @@ function _clientIdWithVariant(id: string, idVariant?: string): string {
// LLM Model Updates Client Functions
export async function llmsUpdateModelsForServiceOrThrow(serviceId: DModelsServiceId, keepUserEdits: boolean): Promise<{ models: ModelDescriptionSchema[] }> {
export async function llmsUpdateModelsForServiceOrThrow(serviceId: DModelsServiceId, keepUserEdits: true): Promise<{ models: ModelDescriptionSchema[] }> {
// get the access, assuming there's no client config and the server will do all
const { service, vendor, transportAccess } = findServiceAccessOrThrow(serviceId);
@@ -96,7 +96,7 @@ function _createDLLMFromModelDescription(d: ModelDescriptionSchema, service: DMo
// this id is Big-AGI specific, not the vendor's
id: `${service.id}-${_clientIdWithVariant(d.id, d.idVariant)}`,
// editable properties
// factory properties
label: d.label,
created: d.created || 0,
updated: d.updated || 0,
@@ -116,11 +116,12 @@ function _createDLLMFromModelDescription(d: ModelDescriptionSchema, service: DMo
? d.parameterSpecs as DModelParameterSpecAny[] // NOTE: our force cast, assume the server (simple zod type) sent valid specs to the client (TS discriminated type)
: [],
initialParameters: {
llmRef: d.id, // this is the vendor model id
llmTemperature:
d.interfaces.includes(LLM_IF_HOTFIX_NoTemperature) ? null
: d.initialTemperature ?? FALLBACK_LLM_PARAM_TEMPERATURE,
llmRef: d.id, // CONST - this is the vendor model id
llmResponseTokens: llmResponseTokens, // number | null
llmTemperature: // number | null
d.interfaces.includes(LLM_IF_HOTFIX_NoTemperature) ? null
: d.initialTemperature !== undefined ? d.initialTemperature
: FALLBACK_LLM_PARAM_TEMPERATURE,
},
// references
@@ -131,10 +132,14 @@ function _createDLLMFromModelDescription(d: ModelDescriptionSchema, service: DMo
// userLabel: undefined,
// userHidden: undefined
// userStarred: undefined,
// userParameters: undefined,
// userContextTokens: undefined,
// userMaxOutputTokens: undefined,
// userPricing: undefined,
// userParameters: undefined,
// clone metadata
// isUserClone: false,
// cloneSourceId: undefined,
};
// set the pricing
@@ -175,7 +180,7 @@ export function createDLLMUserClone(sourceLlm: DLLM, cloneLabel: string, cloneVa
id: cloneId,
label: cloneLabel,
// -- Inherited Editable
// -- Inherited Factory Properties
// created
// updated
// description
@@ -201,10 +206,10 @@ export function createDLLMUserClone(sourceLlm: DLLM, cloneLabel: string, cloneVa
userLabel: undefined, // use the cloneLabel as label directly
userHidden: sourceLlm.userHidden,
userStarred: false, // don't auto-star clones
userParameters: sourceLlm.userParameters ? { ...sourceLlm.userParameters } : undefined,
userContextTokens: sourceLlm.userContextTokens,
userMaxOutputTokens: sourceLlm.userMaxOutputTokens,
userPricing: sourceLlm.userPricing ? { ...sourceLlm.userPricing } : undefined,
userParameters: sourceLlm.userParameters ? { ...sourceLlm.userParameters } : undefined,
// clone metadata
isUserClone: true,
@@ -106,6 +106,7 @@ export function LLMOptionsModal(props: { id: DLLMId, context?: ModelOptionsConte
// external state
const isMobile = useIsMobile();
const llm = useLLM(props.id);
const cloneSourceLlm = useLLM(llm?.cloneSourceId ?? null);
const { modelsServices, setConfServiceId } = useModelsServices();
const modelService = llm ? modelsServices.find(s => s.id === llm.sId) : null;
@@ -220,6 +221,11 @@ export function LLMOptionsModal(props: { id: DLLMId, context?: ModelOptionsConte
optimaActions().openModelOptions(cloneId, props.context);
};
const handleGoToCloneSource = () => {
if (llm.cloneSourceId)
optimaActions().openModelOptions(llm.cloneSourceId, props.context);
};
const visible = isLLMVisible(llm);
const hasUserParameters = llm.userParameters && Object.keys(llm.userParameters).length > 0;
@@ -331,6 +337,17 @@ export function LLMOptionsModal(props: { id: DLLMId, context?: ModelOptionsConte
{isMobile && resetButton}
</Box>
{/* Clone Source Info */}
{llm.isUserClone && (
<Typography level='body-sm' sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Cloned from:{' '}
{cloneSourceLlm
? <Link component='button' onClick={handleGoToCloneSource}>{cloneSourceLlm.label}</Link>
: <Typography component='span' sx={{ color: 'text.tertiary' }}>{llm.cloneSourceId} (not found)</Typography>
}
</Typography>
)}
{/* General Settings */}