Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions api/server/controllers/agents/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const {
getAgentListAccess,
getAgentListAvatarRefreshKey,
mergeAgentOcrConversion,
sanitizeModelParameters,
applyAgentInferenceInput,
collectToolResourceFileIds,
convertOcrToContextInPlace,
normalizeToolResourceFiles,
Expand Down Expand Up @@ -755,12 +755,7 @@ const createAgentHandler = async (req, res) => {
return;
}

if (agentData.model_parameters && typeof agentData.model_parameters === 'object') {
agentData.model_parameters = removeNullishValues(
sanitizeModelParameters(agentData.model_parameters),
true,
);
}
applyAgentInferenceInput(agentData, undefined, req.config);
const { id: userId, role: userRole } = req.user;
agentData.id = `agent_${nanoid()}`;
agentData.edges = replaceEdgeSourceId(agentData.edges, '', agentData.id);
Expand Down Expand Up @@ -1179,12 +1174,6 @@ const updateAgentHandler = async (req, res) => {
}
}

if (updateData.model_parameters && typeof updateData.model_parameters === 'object') {
updateData.model_parameters = removeNullishValues(
sanitizeModelParameters(updateData.model_parameters),
true,
);
}
if (avatarField === null) {
updateData.avatar = avatarField;
}
Expand Down Expand Up @@ -1237,6 +1226,8 @@ const updateAgentHandler = async (req, res) => {
return res.status(404).json({ error: 'Agent not found' });
}

applyAgentInferenceInput(updateData, existingAgent, req.config);

const instructionsPromptError = await checkInstructionsPromptWrite({
access: instructionsPromptAccess,
operation: 'update',
Expand Down
87 changes: 63 additions & 24 deletions api/server/controllers/agents/v1.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,57 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
};
});

describe('inference input boundary', () => {
test.each(['modelKwargs', 'configuration', 'fallbacks', 'apiKey', 'callbacks'])(
'rejects %s on creation without persisting or disclosing its value',
async (key) => {
mockReq.body = {
name: 'Rejected settings',
provider: 'openAI',
model: 'gpt-4o-mini',
model_parameters: { temperature: 0.2, [key]: { value: 'submitted-secret' } },
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(await Agent.countDocuments()).toBe(0);
expect(JSON.stringify(mockRes.json.mock.calls)).not.toContain('submitted-secret');
},
);

test('preserves configured Bedrock region and reasoning controls on creation', async () => {
mockReq.config = { endpoints: { bedrock: { availableRegions: ['us-west-2'] } } };
mockReq.body = {
name: 'Bedrock inference',
provider: 'bedrock',
model: 'moonshot.kimi',
model_parameters: { region: 'us-west-2', reasoning_effort: 'low' },
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const stored = await Agent.findOne({ name: 'Bedrock inference' }).lean();
expect(stored.model_parameters).toEqual({ region: 'us-west-2', reasoning_effort: 'low' });
});

test('rejects an unsafe partial update using the existing provider and retains stored settings', async () => {
const existing = await Agent.create({
id: 'agent_inference_boundary',
name: 'Existing',
author: mockReq.user.id,
provider: 'openAI',
model: 'gpt-4o-mini',
model_parameters: { temperature: 0.2 },
});
mockReq.params.id = existing.id;
mockReq.body = {
model_parameters: { configuration: { baseURL: 'https://unchecked.invalid' } },
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
const after = await Agent.findOne({ id: existing.id }).lean();
expect(after.model_parameters).toEqual({ temperature: 0.2 });
});
});

describe('agent machine allowlist authorization', () => {
test.each(['create', 'update', 'duplicate', 'restore'])(
'%s refuses machine references outside the principal-scoped configuration',
Expand Down Expand Up @@ -1294,7 +1345,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(createdAgent.model_parameters).toBeDefined();
// Valid numbers should be preserved
expect(createdAgent.model_parameters.temperature).toBe(0.7);
expect(createdAgent.model_parameters.topP).toBe(0);
expect(createdAgent.model_parameters.top_p).toBe(0);
// Empty strings should be removed
expect(createdAgent.model_parameters.max_tokens).toBeUndefined();
expect(createdAgent.model_parameters.maxContextTokens).toBeUndefined();
Expand All @@ -1303,12 +1354,12 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
// Verify in database
const agentInDb = await Agent.findOne({ id: createdAgent.id });
expect(agentInDb.model_parameters.temperature).toBe(0.7);
expect(agentInDb.model_parameters.topP).toBe(0);
expect(agentInDb.model_parameters.top_p).toBe(0);
expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
expect(agentInDb.model_parameters.maxContextTokens).toBeUndefined();
});

test('should drop non-numeric strings and coerce numeric strings in model_parameters', async () => {
test('should reject corrupt numeric model_parameters without persisting them', async () => {
// Regression test for #12920: a stray placeholder string ("System") persisted
// into max_tokens was forwarded to the provider, causing a 400
const dataWithCorruptModelParams = {
Expand All @@ -1327,17 +1378,11 @@ describe('Agent Controllers - Mass Assignment Protection', () => {

await createAgentHandler(mockReq, mockRes);

expect(mockRes.status).toHaveBeenCalledWith(201);

const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.model_parameters.max_tokens).toBeUndefined();
expect(createdAgent.model_parameters.maxContextTokens).toBe(256000);
expect(createdAgent.model_parameters.fileTokenLimit).toBe(256000);
expect(createdAgent.model_parameters.useResponsesApi).toBe(true);

const agentInDb = await Agent.findOne({ id: createdAgent.id });
expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
expect(agentInDb.model_parameters.maxContextTokens).toBe(256000);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(await Agent.countDocuments()).toBe(0);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'Invalid request data' }),
);
});

test('should handle invalid avatar format', async () => {
Expand Down Expand Up @@ -1990,7 +2035,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
);
});

test('should sanitize corrupt numeric model_parameters on update', async () => {
test('should reject corrupt numeric updates and preserve the existing agent', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
Expand All @@ -2004,16 +2049,10 @@ describe('Agent Controllers - Mass Assignment Protection', () => {

await updateAgentHandler(mockReq, mockRes);

expect(mockRes.json).toHaveBeenCalled();

const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.model_parameters.max_tokens).toBeUndefined();
expect(updatedAgent.model_parameters.maxContextTokens).toBe(256000);
expect(updatedAgent.model_parameters.temperature).toBe(0.7);

expect(mockRes.status).toHaveBeenCalledWith(400);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.model_parameters.max_tokens).toBeUndefined();
expect(agentInDb.model_parameters.maxContextTokens).toBe(256000);
expect(agentInDb.name).not.toBe('Healed Agent');
expect(agentInDb.model_parameters?.maxContextTokens).not.toBe(256000);
});

test('should reject update with unauthorized fields (mass assignment protection)', async () => {
Expand Down
147 changes: 146 additions & 1 deletion client/src/components/SidePanel/Agents/__tests__/parameters.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { agentParamSettings, EModelEndpoint } from 'librechat-data-provider';
import {
agentParamSettings,
EModelEndpoint,
SettingTypes,
ComponentTypes,
ReasoningParameterFormat,
} from 'librechat-data-provider';
import type { AgentModelParameters, TModelReasoning } from 'librechat-data-provider';
import { pruneAgentModelParameters, resolveAgentParameterSettings } from '../parameters';

Expand Down Expand Up @@ -188,3 +194,142 @@ describe('resolveAgentParameterSettings: OpenRouter reasoning efforts', () => {
expect(effort?.options).toEqual(['', 'low', 'high']);
});
});

describe('administrator-declared inference controls', () => {
const endpointsConfig = {
Gateway: {
order: 0,
type: EModelEndpoint.custom,
customParams: {
defaultParamsEndpoint: 'custom',
paramDefinitions: [
{
key: 'min_p',
type: SettingTypes.Number,
component: ComponentTypes.Input,
label: 'Minimum Probability',
default: 0.1,
},
{
key: 'foo_generation_mode',
type: SettingTypes.Enum,
component: ComponentTypes.Dropdown,
label: 'Generation Mode',
options: ['fast', 'accurate'],
default: 'accurate',
},
{ key: 'modelLabel', type: SettingTypes.String, component: ComponentTypes.Input },
],
},
},
};
const stored = {
temperature: 0.3,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: null,
frequency_penalty: null,
presence_penalty: null,
min_p: 0.7,
foo_generation_mode: 'fast',
};
const resolve = (drops: string[] = []) =>
resolveAgentParameterSettings({
provider: 'Gateway',
model: 'permitted',
webSearchAllowed: true,
endpointsConfig,
startupConfig: { endpointsDropParamsMap: { Gateway: drops } },
});

it('offers typed controls and retains their saved values on unrelated edits', () => {
const settings = resolve();
expect(settings.visibleParameters).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'min_p', label: 'Minimum Probability' }),
expect.objectContaining({ key: 'foo_generation_mode', options: ['fast', 'accurate'] }),
]),
);
expect(settings.parameters.map(({ key }) => key)).not.toContain('modelLabel');
expect(pruneAgentModelParameters(stored, settings)).toBe(stored);
});

it('honors administrator drops for both rendering and persistence', () => {
const settings = resolve(['min_p']);
expect(settings.visibleParameters.map(({ key }) => key)).not.toContain('min_p');
expect(pruneAgentModelParameters(stored, settings)).not.toHaveProperty('min_p');
expect(pruneAgentModelParameters(stored, settings)).toHaveProperty(
'foo_generation_mode',
'fast',
);
});
});

describe('OpenRouter cache preferences do not depend on reasoning', () => {
it.each(['reasoning', 'reasoning_effort'])(
'keeps cache controls when %s is dropped',
(dropped) => {
const settings = resolveAgentParameterSettings({
provider: 'Gateway',
model: 'permitted',
webSearchAllowed: true,
endpointsConfig: {
Gateway: {
order: 0,
type: EModelEndpoint.custom,
customParams: {
defaultParamsEndpoint: 'openrouter',
reasoningFormat: ReasoningParameterFormat.disabled,
},
},
},
startupConfig: { endpointsDropParamsMap: { Gateway: [dropped] } },
});
expect(settings.visibleParameters.map(({ key }) => key)).toEqual(
expect.arrayContaining(['promptCache', 'promptCacheTtl']),
);
expect(settings.visibleParameters.map(({ key }) => key)).not.toContain('reasoning_effort');
const saved = { ...defaults, promptCache: false, promptCacheTtl: '1h' as const };
expect(pruneAgentModelParameters(saved, settings)).toBe(saved);
},
);
});

describe('legacy reasoning control visibility', () => {
it.each([false, true])(
'preserves stored values but hides an explicitly disabled legacy control (%s)',
(disabled) => {
const settings = resolveAgentParameterSettings({
provider: 'Gateway',
model: 'permitted',
webSearchAllowed: true,
endpointsConfig: {
Gateway: {
order: 0,
type: EModelEndpoint.custom,
customParams: {
defaultParamsEndpoint: 'openrouter',
reasoningFormat: disabled
? ReasoningParameterFormat.disabled
: ReasoningParameterFormat.reasoningEffort,
paramDefinitions: [
{
key: 'include_reasoning',
type: SettingTypes.Boolean,
component: ComponentTypes.Checkbox,
},
],
},
},
},
});
expect(settings.visibleParameters.some(({ key }) => key === 'include_reasoning')).toBe(
!disabled,
);
expect(settings.parameters.some(({ key }) => key === 'include_reasoning')).toBe(true);
const saved = { ...defaults, include_reasoning: true, promptCache: false };
expect(pruneAgentModelParameters(saved, settings)).toBe(saved);
},
);
});
25 changes: 23 additions & 2 deletions client/src/components/SidePanel/Agents/parameters.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import keyBy from 'lodash/keyBy';
import {
agentParamSettings,
tConversationSchema,
applyModelAwareDefaults,
getEndpointField,
getSettingsKeys,
normalizeEndpointName,
resolveDropParamsUIKeys,
ReasoningParameterFormat,
} from 'librechat-data-provider';
import type {
AgentModelParameters,
Expand Down Expand Up @@ -34,6 +36,13 @@ export type ResolvedAgentParameterSettings = {
};

const NON_CONTROL_PARAMETER_KEYS = new Set<keyof AgentModelParameters>(['model']);
const REASONING_PARAMETER_KEYS = new Set([
'include_reasoning',
'reasoning_effort',
'reasoning_summary',
'reasoning_mode',
'reasoning_context',
]);

export function resolveAgentParameterSettings({
endpointsConfig,
Expand Down Expand Up @@ -73,7 +82,14 @@ export function resolveAgentParameterSettings({
const overriddenParamsMap = keyBy(overriddenParams ?? [], 'key');
/** Keep endpoint-recognized keys independent of model visibility. Only explicit
* administrator drops remove keys from the schema used by both save paths. */
const endpointParams = (defaultParams ?? []).filter(
const declaredExtras = (overriddenParams ?? []).filter(
(param): param is SettingDefinition =>
param.key != null &&
param.type != null &&
param.component != null &&
!Object.prototype.hasOwnProperty.call(tConversationSchema.shape, param.key),
);
const endpointParams = [...(defaultParams ?? []), ...declaredExtras].filter(
(param) => param != null && !dropParamsSet.has(param.key),
);
const applyOverride = (param: SettingDefinition) =>
Expand All @@ -87,7 +103,12 @@ export function resolveAgentParameterSettings({
modelReasoning,
)
.map(applyOverride)
.filter((param) => param.key !== 'web_search' || webSearchAllowed);
.filter((param) => param.key !== 'web_search' || webSearchAllowed)
.filter(
(param) =>
customParams?.reasoningFormat !== ReasoningParameterFormat.disabled ||
!REASONING_PARAMETER_KEYS.has(param.key),
);

return {
schemaResolved: defaultParams != null || overriddenParams != null,
Expand Down
Loading
Loading