mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-15 15:08:52 +01:00
🌅 fix: Agent Avatar S3 URL Refresh Pagination and Persistence (#11323)
* Refresh all S3 avatars for this user's accessible agent set, not the first page * Cleaner debug messages * Log errors as errors * refactor: avatar refresh logic to process agents in batches and improve error handling. Introduced new utility functions for refreshing S3 avatars and updating agent records. Updated tests to cover various scenarios including cache hits, user ownership checks, and error handling. Added constants for maximum refresh limits. * refactor: update avatar refresh logic to allow users with VIEW access to refresh avatars for all accessible agents. Removed checks for agent ownership and author presence, and updated related tests to reflect new behavior. * chore: Remove YouTube toolkit due to #11331 --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
10f591ab1c
commit
a95fea19bb
6 changed files with 743 additions and 51 deletions
228
packages/api/src/agents/avatars.spec.ts
Normal file
228
packages/api/src/agents/avatars.spec.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { FileSources } from 'librechat-data-provider';
|
||||
import type { Agent, AgentAvatar, AgentModelParameters } from 'librechat-data-provider';
|
||||
import type { RefreshS3UrlFn, UpdateAgentFn } from './avatars';
|
||||
import {
|
||||
MAX_AVATAR_REFRESH_AGENTS,
|
||||
AVATAR_REFRESH_BATCH_SIZE,
|
||||
refreshListAvatars,
|
||||
} from './avatars';
|
||||
|
||||
describe('refreshListAvatars', () => {
|
||||
let mockRefreshS3Url: jest.MockedFunction<RefreshS3UrlFn>;
|
||||
let mockUpdateAgent: jest.MockedFunction<UpdateAgentFn>;
|
||||
const userId = 'user123';
|
||||
|
||||
beforeEach(() => {
|
||||
mockRefreshS3Url = jest.fn();
|
||||
mockUpdateAgent = jest.fn();
|
||||
});
|
||||
|
||||
const createAgent = (overrides: Partial<Agent> = {}): Agent => ({
|
||||
_id: 'obj1',
|
||||
id: 'agent1',
|
||||
name: 'Test Agent',
|
||||
author: userId,
|
||||
description: 'Test',
|
||||
created_at: Date.now(),
|
||||
avatar: {
|
||||
source: FileSources.s3,
|
||||
filepath: 'old-path.jpg',
|
||||
},
|
||||
instructions: null,
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {} as AgentModelParameters,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should return empty stats for empty agents array', async () => {
|
||||
const stats = await refreshListAvatars({
|
||||
agents: [],
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.updated).toBe(0);
|
||||
expect(mockRefreshS3Url).not.toHaveBeenCalled();
|
||||
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip non-S3 avatars', async () => {
|
||||
const agent = createAgent({
|
||||
avatar: { source: 'local', filepath: 'local-path.jpg' } as AgentAvatar,
|
||||
});
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents: [agent],
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.not_s3).toBe(1);
|
||||
expect(stats.updated).toBe(0);
|
||||
expect(mockRefreshS3Url).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip agents without id', async () => {
|
||||
const agent = createAgent({ id: '' });
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents: [agent],
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.no_id).toBe(1);
|
||||
expect(mockRefreshS3Url).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh avatars for agents owned by other users (VIEW access)', async () => {
|
||||
const agent = createAgent({ author: 'otherUser' });
|
||||
mockRefreshS3Url.mockResolvedValue('new-path.jpg');
|
||||
mockUpdateAgent.mockResolvedValue({});
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents: [agent],
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.updated).toBe(1);
|
||||
expect(mockRefreshS3Url).toHaveBeenCalled();
|
||||
expect(mockUpdateAgent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh and persist S3 avatars', async () => {
|
||||
const agent = createAgent();
|
||||
mockRefreshS3Url.mockResolvedValue('new-path.jpg');
|
||||
mockUpdateAgent.mockResolvedValue({});
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents: [agent],
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.updated).toBe(1);
|
||||
expect(mockRefreshS3Url).toHaveBeenCalledWith(agent.avatar);
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
{ id: 'agent1' },
|
||||
{ avatar: { filepath: 'new-path.jpg', source: FileSources.s3 } },
|
||||
{ updatingUserId: userId, skipVersioning: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update if S3 URL unchanged', async () => {
|
||||
const agent = createAgent();
|
||||
mockRefreshS3Url.mockResolvedValue('old-path.jpg');
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents: [agent],
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.no_change).toBe(1);
|
||||
expect(stats.updated).toBe(0);
|
||||
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle S3 refresh errors gracefully', async () => {
|
||||
const agent = createAgent();
|
||||
mockRefreshS3Url.mockRejectedValue(new Error('S3 error'));
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents: [agent],
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.s3_error).toBe(1);
|
||||
expect(stats.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle database persist errors gracefully', async () => {
|
||||
const agent = createAgent();
|
||||
mockRefreshS3Url.mockResolvedValue('new-path.jpg');
|
||||
mockUpdateAgent.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents: [agent],
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.persist_error).toBe(1);
|
||||
expect(stats.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should process agents in batches', async () => {
|
||||
const agents = Array.from({ length: 25 }, (_, i) =>
|
||||
createAgent({
|
||||
_id: `obj${i}`,
|
||||
id: `agent${i}`,
|
||||
avatar: { source: FileSources.s3, filepath: `path${i}.jpg` },
|
||||
}),
|
||||
);
|
||||
|
||||
mockRefreshS3Url.mockImplementation((avatar) =>
|
||||
Promise.resolve(avatar.filepath.replace('.jpg', '-new.jpg')),
|
||||
);
|
||||
mockUpdateAgent.mockResolvedValue({});
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents,
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.updated).toBe(25);
|
||||
expect(mockRefreshS3Url).toHaveBeenCalledTimes(25);
|
||||
expect(mockUpdateAgent).toHaveBeenCalledTimes(25);
|
||||
});
|
||||
|
||||
it('should track mixed statistics correctly', async () => {
|
||||
const agents = [
|
||||
createAgent({ id: 'agent1' }),
|
||||
createAgent({ id: 'agent2', author: 'otherUser' }),
|
||||
createAgent({
|
||||
id: 'agent3',
|
||||
avatar: { source: 'local', filepath: 'local.jpg' } as AgentAvatar,
|
||||
}),
|
||||
createAgent({ id: '' }), // no id
|
||||
];
|
||||
|
||||
mockRefreshS3Url.mockResolvedValue('new-path.jpg');
|
||||
mockUpdateAgent.mockResolvedValue({});
|
||||
|
||||
const stats = await refreshListAvatars({
|
||||
agents,
|
||||
userId,
|
||||
refreshS3Url: mockRefreshS3Url,
|
||||
updateAgent: mockUpdateAgent,
|
||||
});
|
||||
|
||||
expect(stats.updated).toBe(2); // agent1 and agent2 (other user's agent now refreshed)
|
||||
expect(stats.not_s3).toBe(1); // agent3
|
||||
expect(stats.no_id).toBe(1); // agent with empty id
|
||||
});
|
||||
});
|
||||
|
||||
describe('Constants', () => {
|
||||
it('should export MAX_AVATAR_REFRESH_AGENTS as 1000', () => {
|
||||
expect(MAX_AVATAR_REFRESH_AGENTS).toBe(1000);
|
||||
});
|
||||
|
||||
it('should export AVATAR_REFRESH_BATCH_SIZE as 20', () => {
|
||||
expect(AVATAR_REFRESH_BATCH_SIZE).toBe(20);
|
||||
});
|
||||
});
|
||||
122
packages/api/src/agents/avatars.ts
Normal file
122
packages/api/src/agents/avatars.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import type { Agent, AgentAvatar } from 'librechat-data-provider';
|
||||
|
||||
const MAX_AVATAR_REFRESH_AGENTS = 1000;
|
||||
const AVATAR_REFRESH_BATCH_SIZE = 20;
|
||||
|
||||
export { MAX_AVATAR_REFRESH_AGENTS, AVATAR_REFRESH_BATCH_SIZE };
|
||||
|
||||
export type RefreshS3UrlFn = (avatar: AgentAvatar) => Promise<string | undefined>;
|
||||
|
||||
export type UpdateAgentFn = (
|
||||
searchParams: { id: string },
|
||||
updateData: { avatar: AgentAvatar },
|
||||
options: { updatingUserId: string; skipVersioning: boolean },
|
||||
) => Promise<unknown>;
|
||||
|
||||
export type RefreshListAvatarsParams = {
|
||||
agents: Agent[];
|
||||
userId: string;
|
||||
refreshS3Url: RefreshS3UrlFn;
|
||||
updateAgent: UpdateAgentFn;
|
||||
};
|
||||
|
||||
export type RefreshStats = {
|
||||
updated: number;
|
||||
not_s3: number;
|
||||
no_id: number;
|
||||
no_change: number;
|
||||
s3_error: number;
|
||||
persist_error: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opportunistically refreshes S3-backed avatars for agent list responses.
|
||||
* Processes agents in batches to prevent database connection pool exhaustion.
|
||||
* Only list responses are refreshed because they're the highest-traffic surface and
|
||||
* the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes
|
||||
* so we refresh once per interval at most.
|
||||
*
|
||||
* Any user with VIEW access to an agent can refresh its avatar URL. This ensures
|
||||
* avatars remain accessible even when the owner hasn't logged in recently.
|
||||
* The agents array should already be filtered to only include agents the user can access.
|
||||
*/
|
||||
export const refreshListAvatars = async ({
|
||||
agents,
|
||||
userId,
|
||||
refreshS3Url,
|
||||
updateAgent,
|
||||
}: RefreshListAvatarsParams): Promise<RefreshStats> => {
|
||||
const stats: RefreshStats = {
|
||||
updated: 0,
|
||||
not_s3: 0,
|
||||
no_id: 0,
|
||||
no_change: 0,
|
||||
s3_error: 0,
|
||||
persist_error: 0,
|
||||
};
|
||||
|
||||
if (!agents?.length) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
logger.debug('[refreshListAvatars] Refreshing S3 avatars for agents: %d', agents.length);
|
||||
|
||||
for (let i = 0; i < agents.length; i += AVATAR_REFRESH_BATCH_SIZE) {
|
||||
const batch = agents.slice(i, i + AVATAR_REFRESH_BATCH_SIZE);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (agent) => {
|
||||
if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) {
|
||||
stats.not_s3++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agent?.id) {
|
||||
logger.debug(
|
||||
'[refreshListAvatars] Skipping S3 avatar refresh for agent: %s, ID is not set',
|
||||
agent._id,
|
||||
);
|
||||
stats.no_id++;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('[refreshListAvatars] Refreshing S3 avatar for agent: %s', agent._id);
|
||||
const newPath = await refreshS3Url(agent.avatar);
|
||||
|
||||
if (newPath && newPath !== agent.avatar.filepath) {
|
||||
try {
|
||||
await updateAgent(
|
||||
{ id: agent.id },
|
||||
{
|
||||
avatar: {
|
||||
filepath: newPath,
|
||||
source: agent.avatar.source,
|
||||
},
|
||||
},
|
||||
{
|
||||
updatingUserId: userId,
|
||||
skipVersioning: true,
|
||||
},
|
||||
);
|
||||
stats.updated++;
|
||||
} catch (persistErr) {
|
||||
logger.error('[refreshListAvatars] Avatar refresh persist error: %o', persistErr);
|
||||
stats.persist_error++;
|
||||
}
|
||||
} else {
|
||||
stats.no_change++;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[refreshListAvatars] S3 avatar refresh error: %o', err);
|
||||
stats.s3_error++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('[refreshListAvatars] Avatar refresh summary: %o', stats);
|
||||
return stats;
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './avatars';
|
||||
export * from './chain';
|
||||
export * from './edges';
|
||||
export * from './initialize';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue