mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 07:46:47 +00:00
fix: restore inherited Langfuse agent state
This commit is contained in:
parent
001c3b5401
commit
dc2fa2b40b
10 changed files with 170 additions and 9 deletions
|
|
@ -988,6 +988,36 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(latestVersion.langfuse).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should remove Langfuse config when update clears only enabled inheritance override', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
langfuse: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
langfuse: {
|
||||
enabled: null,
|
||||
publicKey: '',
|
||||
secretKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.langfuse).toBeUndefined();
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
|
||||
expect(agentInDb.langfuse).toBeUndefined();
|
||||
});
|
||||
|
||||
test('uploadAgentAvatarHandler should redact Langfuse secret in response', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ export type TAgentCapabilities = {
|
|||
[AgentCapabilities.hide_sequential_outputs]?: boolean;
|
||||
};
|
||||
|
||||
type AgentLangfuseFormConfig = Omit<LangfuseConfig, 'enabled'> & {
|
||||
enabled?: boolean | null;
|
||||
};
|
||||
|
||||
export type AgentForm = {
|
||||
agent?: TAgentOption;
|
||||
id: string;
|
||||
|
|
@ -47,7 +51,7 @@ export type AgentForm = {
|
|||
agent_ids?: string[];
|
||||
edges?: GraphEdge[];
|
||||
subagents?: AgentSubagentsConfig;
|
||||
langfuse?: LangfuseConfig;
|
||||
langfuse?: AgentLangfuseFormConfig;
|
||||
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
||||
recursion_limit?: number;
|
||||
support_contact?: SupportContact;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Activity, Eye, EyeOff, X } from 'lucide-react';
|
||||
import { Activity, Eye, EyeOff, RotateCcw, X } from 'lucide-react';
|
||||
import { Input, Label, Switch } from '@librechat/client';
|
||||
import { LANGFUSE_SECRET_CLEAR_VALUE } from 'librechat-data-provider';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
|
|
@ -11,7 +11,7 @@ interface AgentLangfuseProps {
|
|||
}
|
||||
|
||||
const fieldDefaults = {
|
||||
enabled: undefined as boolean | undefined,
|
||||
enabled: undefined as boolean | null | undefined,
|
||||
publicKey: '',
|
||||
secretKey: '',
|
||||
baseUrl: '',
|
||||
|
|
@ -22,15 +22,19 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
|
|||
const [showSecret, setShowSecret] = useState(false);
|
||||
const value = useMemo(() => ({ ...fieldDefaults, ...(field.value ?? {}) }), [field.value]);
|
||||
const enabled = value.enabled === true;
|
||||
const hasEnabledOverride = typeof value.enabled === 'boolean';
|
||||
let statusKey = 'com_ui_agent_langfuse_inherited';
|
||||
if (typeof value.enabled === 'boolean') {
|
||||
if (hasEnabledOverride) {
|
||||
statusKey = enabled ? 'com_ui_agent_langfuse_enabled' : 'com_ui_agent_langfuse_disabled';
|
||||
}
|
||||
const secretMarkedForClear = value.secretKey === LANGFUSE_SECRET_CLEAR_VALUE;
|
||||
const secretInputValue = secretMarkedForClear ? '' : value.secretKey;
|
||||
const hasCredentialValue =
|
||||
value.publicKey !== '' || secretInputValue !== '' || value.baseUrl !== '';
|
||||
const showConfigFields = enabled || hasCredentialValue || secretMarkedForClear;
|
||||
|
||||
const updateField = useCallback(
|
||||
(key: keyof typeof fieldDefaults, next: string | boolean | undefined) => {
|
||||
(key: keyof typeof fieldDefaults, next: string | boolean | null | undefined) => {
|
||||
field.onChange({
|
||||
...value,
|
||||
[key]: next,
|
||||
|
|
@ -43,6 +47,10 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
|
|||
updateField('secretKey', secretMarkedForClear ? '' : LANGFUSE_SECRET_CLEAR_VALUE);
|
||||
}, [secretMarkedForClear, updateField]);
|
||||
|
||||
const inheritEnabled = useCallback(() => {
|
||||
updateField('enabled', null);
|
||||
}, [updateField]);
|
||||
|
||||
const enableId = 'agent-langfuse-enable-toggle';
|
||||
|
||||
return (
|
||||
|
|
@ -65,6 +73,16 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
|
|||
<span className="rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary">
|
||||
{localize(statusKey)}
|
||||
</span>
|
||||
{hasEnabledOverride && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={inheritEnabled}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{localize('com_ui_agent_langfuse_use_inherited')}
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
id={enableId}
|
||||
checked={enabled}
|
||||
|
|
@ -74,7 +92,7 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
{showConfigFields && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-langfuse-public-key" className="text-xs font-medium">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import AgentLangfuse from '../AgentLangfuse';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
function createField(
|
||||
value: AgentForm['langfuse'],
|
||||
onChange = jest.fn(),
|
||||
): ControllerRenderProps<AgentForm, 'langfuse'> {
|
||||
return {
|
||||
name: 'langfuse',
|
||||
value,
|
||||
onChange,
|
||||
onBlur: jest.fn(),
|
||||
ref: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentLangfuse', () => {
|
||||
it('can clear an explicit enabled override back to inherited', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<AgentLangfuse field={createField({ enabled: false }, onChange)} />);
|
||||
|
||||
fireEvent.click(screen.getByText('com_ui_agent_langfuse_use_inherited'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
enabled: null,
|
||||
publicKey: '',
|
||||
secretKey: '',
|
||||
baseUrl: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps credential fields visible while enabled is inherited', () => {
|
||||
render(<AgentLangfuse field={createField({ enabled: null, publicKey: 'pk-agent' })} />);
|
||||
|
||||
expect(screen.getByDisplayValue('pk-agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_agent_langfuse_inherited')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -65,11 +65,13 @@ function composeLangfusePayload(
|
|||
};
|
||||
if (typeof langfuse.enabled === 'boolean') {
|
||||
normalized.enabled = langfuse.enabled;
|
||||
} else if (langfuse.enabled === null) {
|
||||
normalized.enabled = null;
|
||||
}
|
||||
|
||||
const hasCredentialValue =
|
||||
normalized.publicKey !== '' || normalized.secretKey !== '' || normalized.baseUrl !== '';
|
||||
const hasExplicitEnabled = typeof normalized.enabled === 'boolean';
|
||||
const hasExplicitEnabled = typeof normalized.enabled === 'boolean' || normalized.enabled === null;
|
||||
|
||||
if (!hasExplicitEnabled && !hasCredentialValue) {
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -143,6 +143,31 @@ describe('composeAgentUpdatePayload', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('sends null Langfuse enabled to clear an explicit override', () => {
|
||||
const form = createForm();
|
||||
form.agent = {
|
||||
id: 'agent_123',
|
||||
langfuse: {
|
||||
enabled: false,
|
||||
},
|
||||
} as Agent;
|
||||
form.langfuse = {
|
||||
enabled: null,
|
||||
publicKey: '',
|
||||
secretKey: '',
|
||||
baseUrl: '',
|
||||
};
|
||||
|
||||
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
|
||||
|
||||
expect(payload.langfuse).toEqual({
|
||||
enabled: null,
|
||||
publicKey: '',
|
||||
secretKey: '',
|
||||
baseUrl: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the Langfuse secret clear sentinel when requested', () => {
|
||||
const form = createForm();
|
||||
form.agent = {
|
||||
|
|
|
|||
|
|
@ -717,6 +717,7 @@
|
|||
"com_ui_agent_langfuse_public_key_placeholder": "Enter your Public Key",
|
||||
"com_ui_agent_langfuse_secret_key": "Secret Key",
|
||||
"com_ui_agent_langfuse_secret_key_placeholder": "Leave blank to keep current key",
|
||||
"com_ui_agent_langfuse_use_inherited": "Use inherited",
|
||||
"com_ui_agent_subagents": "Subagents",
|
||||
"com_ui_agent_subagents_add": "Add subagent",
|
||||
"com_ui_agent_subagents_agents": "Additional subagents",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,37 @@ describe('normalizeLangfuseConfig', () => {
|
|||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears an explicit enabled override when null is sent', async () => {
|
||||
const result = await normalizeLangfuseConfig(
|
||||
{
|
||||
enabled: null,
|
||||
},
|
||||
{
|
||||
enabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves credentials while clearing an explicit enabled override', async () => {
|
||||
const result = await normalizeLangfuseConfig(
|
||||
{
|
||||
enabled: null,
|
||||
},
|
||||
{
|
||||
enabled: false,
|
||||
publicKey: 'pk-agent',
|
||||
secretKey: '0123456789abcdef0123456789abcdef:736b2d6167656e74',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
publicKey: 'pk-agent',
|
||||
secretKey: '0123456789abcdef0123456789abcdef:736b2d6167656e74',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears explicit blank non-secret fields while preserving absent fields', async () => {
|
||||
const result = await normalizeLangfuseConfig(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -61,9 +61,10 @@ export async function normalizeLangfuseConfig(
|
|||
const existingLangfuse = isRecord(existingConfig) ? existingConfig : {};
|
||||
const normalized: LangfuseConfig = {};
|
||||
|
||||
const shouldClearEnabled = hasOwn(incoming, 'enabled') && incoming.enabled === null;
|
||||
if (hasOwn(incoming, 'enabled') && typeof incoming.enabled === 'boolean') {
|
||||
normalized.enabled = incoming.enabled;
|
||||
} else if (typeof existingLangfuse.enabled === 'boolean') {
|
||||
} else if (!shouldClearEnabled && typeof existingLangfuse.enabled === 'boolean') {
|
||||
normalized.enabled = existingLangfuse.enabled;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ export const agentSubagentsSchema = z
|
|||
})
|
||||
.optional();
|
||||
|
||||
export const agentLangfuseSchema = langfuseConfigSchema.optional();
|
||||
export const agentLangfuseSchema = langfuseConfigSchema
|
||||
.extend({
|
||||
enabled: z.union([z.boolean(), z.null()]).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
/** Base agent schema with all common fields */
|
||||
export const agentBaseSchema = z.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue