diff --git a/client/src/hooks/Input/useQueryParams.ts b/client/src/hooks/Input/useQueryParams.ts index b29f408a3a..85b5d8838b 100644 --- a/client/src/hooks/Input/useQueryParams.ts +++ b/client/src/hooks/Input/useQueryParams.ts @@ -12,6 +12,7 @@ import type { import { clearModelForNonEphemeralAgent, removeUnavailableTools, + specDisplayFieldReset, processValidSettings, getModelSpecIconURL, getConvoSwitchLogic, @@ -128,13 +129,10 @@ export default function useQueryParams({ endpointsConfig, }); - let resetParams = {}; + const resetFields = newPreset.spec == null ? specDisplayFieldReset : {}; if (newPreset.spec == null) { - template.spec = null; - template.iconURL = null; - template.modelLabel = null; - resetParams = { spec: null, iconURL: null, modelLabel: null }; - newPreset = { ...newPreset, ...resetParams }; + Object.assign(template, specDisplayFieldReset); + newPreset = { ...newPreset, ...specDisplayFieldReset }; } // Sync agent_id from newPreset to template, then clear model if non-ephemeral agent @@ -152,7 +150,7 @@ export default function useQueryParams({ conversation: { ...(conversation ?? {}), endpointType: template.endpointType, - ...resetParams, + ...resetFields, }, preset: template, cleanOutput: newPreset.spec != null && newPreset.spec !== '', diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index dcb58c3f49..a17d349037 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -6,20 +6,21 @@ import { Constants, EModelEndpoint } from 'librechat-data-provider'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; import type { TPreset } from 'librechat-data-provider'; import { - useNewConvo, - useAppStartup, + mergeQuerySettingsWithSpec, + processValidSettings, + getDefaultModelSpec, + getModelSpecPreset, + isNotFoundError, + logger, +} from '~/utils'; +import { useAssistantListMap, useIdChangeEffect, + useAppStartup, + useNewConvo, useLocalize, } from '~/hooks'; import { useGetConvoIdQuery, useGetStartupConfig, useGetEndpointsQuery } from '~/data-provider'; -import { - getDefaultModelSpec, - getModelSpecPreset, - processValidSettings, - logger, - isNotFoundError, -} from '~/utils'; import { ToolCallsMapProvider } from '~/Providers'; import ChatView from '~/components/Chat/ChatView'; import { NotificationSeverity } from '~/common'; @@ -102,9 +103,10 @@ export default function ChatRoute() { }); const querySettings = processValidSettings(queryParams); - return Object.keys(querySettings).length > 0 - ? { ...specPreset, ...querySettings } - : specPreset; + if (Object.keys(querySettings).length > 0) { + return mergeQuerySettingsWithSpec(specPreset, querySettings); + } + return specPreset; }; if (isNewConvo && endpointsQuery.data && modelsQuery.data) { diff --git a/client/src/utils/__tests__/mergeQuerySettingsWithSpec.test.ts b/client/src/utils/__tests__/mergeQuerySettingsWithSpec.test.ts new file mode 100644 index 0000000000..76e104f62f --- /dev/null +++ b/client/src/utils/__tests__/mergeQuerySettingsWithSpec.test.ts @@ -0,0 +1,152 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TPreset } from 'librechat-data-provider'; +import { mergeQuerySettingsWithSpec, specDisplayFieldReset } from '../endpoints'; + +describe('mergeQuerySettingsWithSpec', () => { + const specPreset: TPreset = { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + spec: 'my-spec', + iconURL: 'https://example.com/icon.png', + modelLabel: 'My Custom GPT', + greeting: 'Hello from the spec!', + temperature: 0.7, + }; + + describe('when specPreset is active and query has no spec', () => { + it('clears all spec display fields for agent share links', () => { + const querySettings: TPreset = { + agent_id: 'agent_123', + endpoint: EModelEndpoint.agents, + }; + + const result = mergeQuerySettingsWithSpec(specPreset, querySettings); + + expect(result.agent_id).toBe('agent_123'); + expect(result.endpoint).toBe(EModelEndpoint.agents); + expect(result.spec).toBeNull(); + expect(result.iconURL).toBeNull(); + expect(result.modelLabel).toBeNull(); + expect(result.greeting).toBeUndefined(); + }); + + it('preserves non-display settings from the spec base', () => { + const querySettings: TPreset = { + agent_id: 'agent_123', + endpoint: EModelEndpoint.agents, + }; + + const result = mergeQuerySettingsWithSpec(specPreset, querySettings); + + expect(result.temperature).toBe(0.7); + }); + + it('clears spec display fields for assistant share links', () => { + const querySettings: TPreset = { + assistant_id: 'asst_abc', + endpoint: EModelEndpoint.assistants, + }; + + const result = mergeQuerySettingsWithSpec(specPreset, querySettings); + + expect(result.assistant_id).toBe('asst_abc'); + expect(result.endpoint).toBe(EModelEndpoint.assistants); + expect(result.spec).toBeNull(); + expect(result.iconURL).toBeNull(); + expect(result.modelLabel).toBeNull(); + expect(result.greeting).toBeUndefined(); + }); + + it('clears spec display fields for model override links', () => { + const querySettings: TPreset = { + model: 'claude-sonnet-4-20250514', + endpoint: EModelEndpoint.anthropic, + }; + + const result = mergeQuerySettingsWithSpec(specPreset, querySettings); + + expect(result.model).toBe('claude-sonnet-4-20250514'); + expect(result.endpoint).toBe(EModelEndpoint.anthropic); + expect(result.spec).toBeNull(); + expect(result.iconURL).toBeNull(); + expect(result.modelLabel).toBeNull(); + expect(result.greeting).toBeUndefined(); + }); + }); + + describe('when query explicitly sets a spec', () => { + it('preserves spec display fields from the base', () => { + const querySettings = { spec: 'other-spec' } as TPreset; + + const result = mergeQuerySettingsWithSpec(specPreset, querySettings); + + expect(result.spec).toBe('other-spec'); + expect(result.iconURL).toBe('https://example.com/icon.png'); + expect(result.modelLabel).toBe('My Custom GPT'); + expect(result.greeting).toBe('Hello from the spec!'); + }); + }); + + describe('when specPreset is undefined (no spec configured)', () => { + it('returns querySettings without injecting null display fields', () => { + const querySettings: TPreset = { + agent_id: 'agent_123', + endpoint: EModelEndpoint.agents, + }; + + const result = mergeQuerySettingsWithSpec(undefined, querySettings); + + expect(result.agent_id).toBe('agent_123'); + expect(result.endpoint).toBe(EModelEndpoint.agents); + expect(result).not.toHaveProperty('spec'); + expect(result).not.toHaveProperty('iconURL'); + expect(result).not.toHaveProperty('modelLabel'); + expect(result).not.toHaveProperty('greeting'); + }); + }); + + describe('when querySettings is empty', () => { + it('still clears spec display fields (no query params is not an explicit spec)', () => { + const result = mergeQuerySettingsWithSpec(specPreset, {} as TPreset); + + expect(result.spec).toBeNull(); + expect(result.iconURL).toBeNull(); + expect(result.modelLabel).toBeNull(); + expect(result.greeting).toBeUndefined(); + expect(result.endpoint).toBe(EModelEndpoint.openAI); + expect(result.model).toBe('gpt-4'); + expect(result.temperature).toBe(0.7); + }); + }); + + describe('query settings override spec values', () => { + it('overrides endpoint and model from spec', () => { + const querySettings: TPreset = { + endpoint: EModelEndpoint.anthropic, + model: 'claude-sonnet-4-20250514', + }; + + const result = mergeQuerySettingsWithSpec(specPreset, querySettings); + + expect(result.endpoint).toBe(EModelEndpoint.anthropic); + expect(result.model).toBe('claude-sonnet-4-20250514'); + expect(result.temperature).toBe(0.7); + expect(result.spec).toBeNull(); + }); + }); +}); + +describe('specDisplayFieldReset', () => { + it('contains all spec display fields that need clearing', () => { + expect(specDisplayFieldReset).toEqual({ + spec: null, + iconURL: null, + modelLabel: null, + greeting: undefined, + }); + }); + + it('has exactly 4 fields', () => { + expect(Object.keys(specDisplayFieldReset)).toHaveLength(4); + }); +}); diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index 33aa7a8525..a27f71b8e9 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -311,6 +311,30 @@ export function getModelSpecPreset(modelSpec?: t.TModelSpec) { }; } +/** Fields set by a model spec that should be cleared when switching to a non-spec conversation. */ +export const specDisplayFieldReset = { + spec: null as string | null, + iconURL: null as string | null, + modelLabel: null as string | null, + greeting: undefined as string | undefined, +}; + +/** + * Merges a spec preset base with URL query settings, clearing spec display fields + * when the query doesn't explicitly set a spec. Prevents spec contamination on + * agent/assistant share links. + */ +export function mergeQuerySettingsWithSpec( + specPreset: t.TPreset | undefined, + querySettings: t.TPreset, +): t.TPreset { + return { + ...specPreset, + ...querySettings, + ...(specPreset != null && querySettings.spec == null ? specDisplayFieldReset : {}), + }; +} + /** Gets the default spec iconURL by order or definition. * * First, the admin defined default, then last selected spec, followed by first spec