mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 09:32:24 +00:00
🏷️ fix: Persist the Ephemeral Agent's Display Label as Sender (#14899)
* 🏷️ feat: Add getEphemeralSender and Cover the Ephemeral-Id Format * ♻️ refactor: Consolidate the Ephemeral Sender Chains * 🏷️ fix: Decode the Ephemeral Sender for Persisted Messages * 🏷️ fix: Mirror the Persisted Sender Chain in useGetSender * ✅ test: Widen the Custom-Endpoint Fixture Type * ✅ test: Expect the Spec Label in the Composer Placeholder * 🏷️ fix: Resolve the Sender from Exact Labels, Not the Lossy Id
This commit is contained in:
parent
c939a6fb17
commit
27ed491a2a
14 changed files with 561 additions and 44 deletions
|
|
@ -2,6 +2,7 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const { createContentAggregator, GraphNodeKeys } = require('@librechat/agents');
|
||||
const {
|
||||
checkAccess,
|
||||
resolveSender,
|
||||
loadSkillStates,
|
||||
initializeAgent,
|
||||
isMemoryEnabled,
|
||||
|
|
@ -28,7 +29,6 @@ const {
|
|||
PermissionTypes,
|
||||
MAX_SUBAGENT_DEPTH,
|
||||
isAgentsEndpoint,
|
||||
getResponseSender,
|
||||
AgentCapabilities,
|
||||
Tools,
|
||||
MAX_SUBAGENT_GRAPH_NODES,
|
||||
|
|
@ -1070,20 +1070,22 @@ const initializeClient = async ({
|
|||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config',
|
||||
'[/api/server/services/Endpoints/agents/initialize.js] Error getting custom endpoint config',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sender =
|
||||
primaryAgent.name ??
|
||||
getResponseSender({
|
||||
const sender = resolveSender({
|
||||
agent: primaryConfig,
|
||||
specLabel: selectedModelSpec?.label,
|
||||
endpointOption: {
|
||||
...endpointOption,
|
||||
model: endpointOption.model_parameters.model,
|
||||
modelDisplayLabel: endpointConfig?.modelDisplayLabel,
|
||||
modelLabel: endpointOption.model_parameters.modelLabel,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** History priming uses the user's full ACL-accessible skill set (not
|
||||
* per-agent scoped) because prior turns may reference skills no longer
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { RecoilRoot } from 'recoil';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { QueryKeys, EModelEndpoint, getResponseSender } from 'librechat-data-provider';
|
||||
import type { TStartupConfig, TEndpointsConfig, TEndpointOption } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import { startupConfigKey } from '~/data-provider';
|
||||
import useGetSender from '../useGetSender';
|
||||
|
||||
const startupConfig = {
|
||||
modelSpecs: {
|
||||
list: [{ name: 'fast-qwen', label: 'Fast Qwen' }],
|
||||
},
|
||||
} as unknown as TStartupConfig;
|
||||
|
||||
const endpointsConfig = {
|
||||
'Together AI': { modelDisplayLabel: 'Together' },
|
||||
openAI: {},
|
||||
} as unknown as TEndpointsConfig;
|
||||
|
||||
const renderGetSender = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, enabled: false } },
|
||||
});
|
||||
queryClient.setQueryData([QueryKeys.endpoints], endpointsConfig);
|
||||
queryClient.setQueryData(startupConfigKey(false), startupConfig);
|
||||
const { result } = renderHook(() => useGetSender(), {
|
||||
wrapper: function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
},
|
||||
});
|
||||
return result.current;
|
||||
};
|
||||
|
||||
const asOption = (option: Record<string, unknown>) => option as TEndpointOption;
|
||||
|
||||
describe('useGetSender', () => {
|
||||
it('prefers modelLabel over the spec and endpoint labels', () => {
|
||||
const getSender = renderGetSender();
|
||||
expect(
|
||||
getSender(
|
||||
asOption({
|
||||
endpoint: 'Together AI',
|
||||
endpointType: EModelEndpoint.custom,
|
||||
model: 'Qwen/Qwen2.5-72B-Instruct',
|
||||
modelLabel: 'My Qwen',
|
||||
spec: 'fast-qwen',
|
||||
}),
|
||||
),
|
||||
).toBe('My Qwen');
|
||||
});
|
||||
|
||||
it('surfaces the model spec label when no modelLabel is set', () => {
|
||||
const getSender = renderGetSender();
|
||||
expect(
|
||||
getSender(
|
||||
asOption({
|
||||
endpoint: 'Together AI',
|
||||
endpointType: EModelEndpoint.custom,
|
||||
model: 'Qwen/Qwen2.5-72B-Instruct',
|
||||
spec: 'fast-qwen',
|
||||
}),
|
||||
),
|
||||
).toBe('Fast Qwen');
|
||||
});
|
||||
|
||||
it('lets the explicit modelDisplayLabel beat the family heuristic', () => {
|
||||
const getSender = renderGetSender();
|
||||
expect(
|
||||
getSender(
|
||||
asOption({
|
||||
endpoint: 'Together AI',
|
||||
endpointType: EModelEndpoint.custom,
|
||||
model: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
||||
}),
|
||||
),
|
||||
).toBe('Together');
|
||||
});
|
||||
|
||||
it('falls through to getResponseSender when no label resolves', () => {
|
||||
const getSender = renderGetSender();
|
||||
expect(getSender(asOption({ endpoint: EModelEndpoint.anthropic }))).toBe('Claude');
|
||||
const unknownCustom = {
|
||||
endpoint: 'LocalAI',
|
||||
endpointType: EModelEndpoint.custom,
|
||||
model: 'some-unknown-model',
|
||||
};
|
||||
expect(getSender(asOption(unknownCustom))).toBe(getResponseSender(asOption(unknownCustom)));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,28 @@
|
|||
import { useCallback } from 'react';
|
||||
import { getResponseSender } from 'librechat-data-provider';
|
||||
import { getResponseSender, getEphemeralSender } from 'librechat-data-provider';
|
||||
import type { TEndpointOption, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import { getModelSpec } from '~/utils';
|
||||
|
||||
/**
|
||||
* Mirrors the server's sender resolution (`modelLabel` → spec label → endpoint
|
||||
* `modelDisplayLabel` → `getResponseSender`) so the optimistic streaming label
|
||||
* and composer placeholder match the persisted `message.sender`.
|
||||
*/
|
||||
export default function useGetSender() {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
return useCallback(
|
||||
(endpointOption: TEndpointOption) => {
|
||||
const { modelDisplayLabel } = endpointsConfig?.[endpointOption.endpoint ?? ''] ?? {};
|
||||
return getResponseSender({ ...endpointOption, modelDisplayLabel });
|
||||
const modelSpec = getModelSpec({ specName: endpointOption.spec, startupConfig });
|
||||
const sender = getEphemeralSender({
|
||||
modelLabel: endpointOption.modelLabel,
|
||||
specLabel: modelSpec?.label,
|
||||
modelDisplayLabel,
|
||||
});
|
||||
return sender || getResponseSender({ ...endpointOption, modelDisplayLabel });
|
||||
},
|
||||
[endpointsConfig],
|
||||
[endpointsConfig, startupConfig],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
import type { TMessage, TConversation } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction, TMessageProps } from '~/common';
|
||||
import {
|
||||
clearMessagesCache,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
areMessageFieldsEqual,
|
||||
areMessageRowPropsEqual,
|
||||
isSubmittableMessage,
|
||||
createDualMessageContent,
|
||||
} from '../messages';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
|
|
@ -369,3 +371,59 @@ describe('isSubmittableMessage', () => {
|
|||
expect(isSubmittableMessage('Translate this', 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDualMessageContent', () => {
|
||||
/** Custom endpoints carry their configured name (e.g. "Together AI") in
|
||||
* `endpoint` at runtime, which `TConversation` types as `EModelEndpoint`. */
|
||||
const asConvo = (convo: Partial<Omit<TConversation, 'endpoint'>> & { endpoint: string }) =>
|
||||
convo as unknown as TConversation;
|
||||
const agentIds = (parts: ReturnType<typeof createDualMessageContent>) =>
|
||||
parts.map((part) => (part as unknown as { agentId: string }).agentId);
|
||||
|
||||
it('encodes the model spec label into ephemeral agent ids', () => {
|
||||
const parts = createDualMessageContent(
|
||||
asConvo({ endpoint: 'Together AI', model: 'Qwen/Qwen2.5-72B-Instruct', spec: 'fast-qwen' }),
|
||||
asConvo({ endpoint: 'openAI', model: 'gpt-4o', modelLabel: 'My GPT' }),
|
||||
undefined,
|
||||
[{ name: 'fast-qwen', label: 'Fast Qwen' }],
|
||||
);
|
||||
expect(agentIds(parts)).toEqual([
|
||||
'Together AI__Qwen/Qwen2.5-72B-Instruct___Fast Qwen',
|
||||
'openAI__gpt-4o___My GPT____1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the endpoint modelDisplayLabel when no labels are set', () => {
|
||||
const endpointsConfig = {
|
||||
'Together AI': { modelDisplayLabel: 'Together' },
|
||||
} as unknown as TEndpointsConfig;
|
||||
const parts = createDualMessageContent(
|
||||
asConvo({ endpoint: 'Together AI', model: 'mixtral-8x7b' }),
|
||||
asConvo({ endpoint: 'Together AI', model: 'mixtral-8x7b' }),
|
||||
endpointsConfig,
|
||||
);
|
||||
expect(agentIds(parts)).toEqual([
|
||||
'Together AI__mixtral-8x7b___Together',
|
||||
'Together AI__mixtral-8x7b___Together____1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the sender segment entirely when no label resolves', () => {
|
||||
const parts = createDualMessageContent(
|
||||
asConvo({ endpoint: 'Together AI', model: 'mixtral-8x7b' }),
|
||||
asConvo({ endpoint: 'Together AI', model: 'mixtral-8x7b' }),
|
||||
);
|
||||
expect(agentIds(parts)).toEqual([
|
||||
'Together AI__mixtral-8x7b',
|
||||
'Together AI__mixtral-8x7b____1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes real agent ids through, suffixing only the added agent', () => {
|
||||
const parts = createDualMessageContent(
|
||||
asConvo({ endpoint: 'agents', agent_id: 'agent_abc123' }),
|
||||
asConvo({ endpoint: 'agents', agent_id: 'agent_abc123' }),
|
||||
);
|
||||
expect(agentIds(parts)).toEqual(['agent_abc123', 'agent_abc123____1']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
buildTree,
|
||||
ContentTypes,
|
||||
isEphemeralAgentId,
|
||||
getEphemeralSender,
|
||||
appendAgentIdSuffix,
|
||||
encodeEphemeralAgentId,
|
||||
} from 'librechat-data-provider';
|
||||
|
|
@ -494,13 +495,13 @@ export const createDualMessageContent = (
|
|||
primaryConvo.spec != null && primaryConvo.spec !== ''
|
||||
? modelSpecs?.find((s) => s.name === primaryConvo.spec)
|
||||
: undefined;
|
||||
// For ephemeral agents, use modelLabel if provided, then model spec's label,
|
||||
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
|
||||
const primarySender =
|
||||
primaryConvo.modelLabel ??
|
||||
primarySpec?.label ??
|
||||
(primaryEndpoint ? endpointsConfig?.[primaryEndpoint]?.modelDisplayLabel : undefined) ??
|
||||
'';
|
||||
const primarySender = getEphemeralSender({
|
||||
modelLabel: primaryConvo.modelLabel,
|
||||
specLabel: primarySpec?.label,
|
||||
modelDisplayLabel: primaryEndpoint
|
||||
? endpointsConfig?.[primaryEndpoint]?.modelDisplayLabel
|
||||
: undefined,
|
||||
});
|
||||
primaryAgentId = encodeEphemeralAgentId({
|
||||
endpoint: primaryEndpoint ?? '',
|
||||
model: primaryModel,
|
||||
|
|
@ -534,13 +535,13 @@ export const createDualMessageContent = (
|
|||
addedConvo.spec != null && addedConvo.spec !== ''
|
||||
? modelSpecs?.find((s) => s.name === addedConvo.spec)
|
||||
: undefined;
|
||||
// For ephemeral agents, use modelLabel if provided, then model spec's label,
|
||||
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
|
||||
const addedSender =
|
||||
addedConvo.modelLabel ??
|
||||
addedSpec?.label ??
|
||||
(addedEndpoint ? endpointsConfig?.[addedEndpoint]?.modelDisplayLabel : undefined) ??
|
||||
'';
|
||||
const addedSender = getEphemeralSender({
|
||||
modelLabel: addedConvo.modelLabel,
|
||||
specLabel: addedSpec?.label,
|
||||
modelDisplayLabel: addedEndpoint
|
||||
? endpointsConfig?.[addedEndpoint]?.modelDisplayLabel
|
||||
: undefined,
|
||||
});
|
||||
addedAgentId = encodeEphemeralAgentId({
|
||||
endpoint: addedEndpoint ?? '',
|
||||
model: addedModel,
|
||||
|
|
|
|||
|
|
@ -262,7 +262,11 @@ test.describe('soft default model spec', () => {
|
|||
const main = page.getByRole('main');
|
||||
const composer = page.getByRole('textbox', { name: 'Message input' });
|
||||
const user = getPrimaryE2EUser();
|
||||
await expect(composer).toHaveAttribute('placeholder', /Mock Provider A/, { timeout: 15000 });
|
||||
// The placeholder mirrors the sender chain, so a spec-launched chat shows the
|
||||
// spec label (matching the model selector), not the endpoint's display label.
|
||||
await expect(composer).toHaveAttribute('placeholder', new RegExp(SOFT_DEFAULT_LABEL), {
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(main).toContainText(user.name, { timeout: 15000 });
|
||||
await expect(main).not.toContainText(agentName);
|
||||
await expect(main).not.toContainText(agentDescription);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Constants,
|
||||
isAgentsEndpoint,
|
||||
isEphemeralAgentId,
|
||||
getEphemeralSender,
|
||||
appendAgentIdSuffix,
|
||||
encodeEphemeralAgentId,
|
||||
} from 'librechat-data-provider';
|
||||
|
|
@ -146,11 +147,11 @@ export async function loadAddedAgent(
|
|||
|
||||
const modelSpecs = (appConfig?.modelSpecs as { list?: TModelSpec[] })?.list;
|
||||
const modelSpec = spec != null && spec !== '' ? modelSpecs?.find((s) => s.name === spec) : null;
|
||||
const sender =
|
||||
rest.modelLabel ??
|
||||
modelSpec?.label ??
|
||||
(endpointConfig?.modelDisplayLabel as string | undefined) ??
|
||||
'';
|
||||
const sender = getEphemeralSender({
|
||||
modelLabel: rest.modelLabel,
|
||||
specLabel: modelSpec?.label,
|
||||
modelDisplayLabel: endpointConfig?.modelDisplayLabel as string | undefined,
|
||||
});
|
||||
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 });
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
|
|
@ -268,11 +269,11 @@ export async function loadAddedAgent(
|
|||
}
|
||||
}
|
||||
|
||||
const sender =
|
||||
rest.modelLabel ??
|
||||
modelSpec?.label ??
|
||||
(endpointConfig?.modelDisplayLabel as string | undefined) ??
|
||||
'';
|
||||
const sender = getEphemeralSender({
|
||||
modelLabel: rest.modelLabel,
|
||||
specLabel: modelSpec?.label,
|
||||
modelDisplayLabel: endpointConfig?.modelDisplayLabel as string | undefined,
|
||||
});
|
||||
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 });
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export * from './tools';
|
|||
export * from './validation';
|
||||
export * from './added';
|
||||
export * from './load';
|
||||
export * from './sender';
|
||||
export * from './hitl';
|
||||
export * from './hooks';
|
||||
export * from './steering';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { EModelEndpoint, parseEphemeralAgentId } from 'librechat-data-provider';
|
||||
import type { LoadAgentDeps } from './load';
|
||||
import { loadEphemeralAgent } from './load';
|
||||
import { resolveSender } from './sender';
|
||||
|
||||
const deps: LoadAgentDeps = {
|
||||
getAgent: async () => null,
|
||||
|
|
@ -50,3 +52,51 @@ describe('loadEphemeralAgent ephemeral id stability (#14253 Bug 2)', () => {
|
|||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
/** Custom endpoints carry their configured name in `endpoint` at runtime,
|
||||
* which `TEndpointOption` types as `EModelEndpoint`. */
|
||||
const customEndpointOption = {
|
||||
endpoint: 'my-custom-endpoint' as EModelEndpoint,
|
||||
model: 'claude-opus-4',
|
||||
};
|
||||
|
||||
describe('loadEphemeralAgent → resolveSender parity', () => {
|
||||
test('the persisted sender matches the spec label encoded into the agent id', async () => {
|
||||
const agent = await loadEphemeralAgent(
|
||||
{
|
||||
req: baseReq,
|
||||
spec: 'my-opus-spec',
|
||||
endpoint: 'my-custom-endpoint',
|
||||
model_parameters: { model: 'claude-opus-4' } as never,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
expect(agent?.id).toBeTruthy();
|
||||
const sender = resolveSender({
|
||||
agent: { id: agent?.id },
|
||||
specLabel: 'Spec Label',
|
||||
endpointOption: customEndpointOption,
|
||||
});
|
||||
expect(sender).toBe('Spec Label');
|
||||
expect(sender).toBe(parseEphemeralAgentId(agent?.id ?? '')?.sender);
|
||||
});
|
||||
|
||||
test('a user modelLabel wins over the spec label in the persisted sender', async () => {
|
||||
const agent = await loadEphemeralAgent(
|
||||
{
|
||||
req: baseReq,
|
||||
spec: 'my-opus-spec',
|
||||
endpoint: 'my-custom-endpoint',
|
||||
model_parameters: { model: 'claude-opus-4', modelLabel: 'My Opus' } as never,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
const sender = resolveSender({
|
||||
agent: { id: agent?.id },
|
||||
specLabel: 'Spec Label',
|
||||
endpointOption: { ...customEndpointOption, modelLabel: 'My Opus' },
|
||||
});
|
||||
expect(sender).toBe('My Opus');
|
||||
expect(sender).toBe(parseEphemeralAgentId(agent?.id ?? '')?.sender);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Constants,
|
||||
isAgentsEndpoint,
|
||||
isEphemeralAgentId,
|
||||
getEphemeralSender,
|
||||
encodeEphemeralAgentId,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
|
|
@ -134,19 +135,18 @@ export async function loadEphemeralAgent(
|
|||
}
|
||||
}
|
||||
|
||||
// For ephemeral agents, use modelLabel if provided, then model spec's label,
|
||||
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
|
||||
const sender =
|
||||
(model_parameters as AgentModelParameters & { modelLabel?: string })?.modelLabel ??
|
||||
modelSpec?.label ??
|
||||
(endpointConfig as { modelDisplayLabel?: string } | undefined)?.modelDisplayLabel ??
|
||||
'';
|
||||
const sender = getEphemeralSender({
|
||||
modelLabel: (model_parameters as AgentModelParameters & { modelLabel?: string })?.modelLabel,
|
||||
specLabel: modelSpec?.label,
|
||||
modelDisplayLabel: (endpointConfig as { modelDisplayLabel?: string } | undefined)
|
||||
?.modelDisplayLabel,
|
||||
});
|
||||
|
||||
// Encode ephemeral agent ID with endpoint, model, and computed sender for display
|
||||
const ephemeralId = encodeEphemeralAgentId({
|
||||
endpoint,
|
||||
model: model as string,
|
||||
sender: sender as string,
|
||||
sender,
|
||||
});
|
||||
|
||||
const result: Partial<Agent> = {
|
||||
|
|
|
|||
119
packages/api/src/agents/sender.spec.ts
Normal file
119
packages/api/src/agents/sender.spec.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { EModelEndpoint, getResponseSender, encodeEphemeralAgentId } from 'librechat-data-provider';
|
||||
import type { TEndpointOption } from 'librechat-data-provider';
|
||||
import { resolveSender } from './sender';
|
||||
|
||||
/** Custom endpoints carry their configured name (e.g. "Together AI") in
|
||||
* `endpoint` at runtime, which `TEndpointOption` types as `EModelEndpoint`. */
|
||||
const customOption = (
|
||||
model: string,
|
||||
labels: Partial<Pick<TEndpointOption, 'modelLabel' | 'modelDisplayLabel'>> = {},
|
||||
): Partial<TEndpointOption> => ({
|
||||
endpoint: 'Together AI' as EModelEndpoint,
|
||||
endpointType: EModelEndpoint.custom,
|
||||
model,
|
||||
...labels,
|
||||
});
|
||||
|
||||
const ephemeralAgent = (model: string, sender?: string) => ({
|
||||
id: encodeEphemeralAgentId({ endpoint: 'Together AI', model, sender }),
|
||||
});
|
||||
|
||||
describe('resolveSender', () => {
|
||||
test('prefers a real agent name over everything else', () => {
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: { id: 'agent_abc123', name: 'My Agent' },
|
||||
endpointOption: { endpoint: EModelEndpoint.anthropic },
|
||||
}),
|
||||
).toBe('My Agent');
|
||||
});
|
||||
|
||||
test('returns an empty-string name as-is, preserving legacy `??` semantics', () => {
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: { id: 'agent_abc123', name: '' },
|
||||
endpointOption: { endpoint: EModelEndpoint.anthropic },
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('skips the label chain for a nameless real agent', () => {
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: { id: 'agent_abc123', name: null },
|
||||
specLabel: 'Spec Label',
|
||||
endpointOption: { endpoint: EModelEndpoint.agents, modelLabel: 'A Label' },
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('resolves the modelLabel for an ephemeral agent', () => {
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: ephemeralAgent('Qwen/Qwen2.5-72B-Instruct', 'My Qwen'),
|
||||
endpointOption: customOption('Qwen/Qwen2.5-72B-Instruct', { modelLabel: 'My Qwen' }),
|
||||
}),
|
||||
).toBe('My Qwen');
|
||||
});
|
||||
|
||||
test('persists delimiter-heavy labels verbatim instead of decoding the lossy id', () => {
|
||||
for (const modelLabel of ['A___B', 'My__Bot', 'v2____3']) {
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: ephemeralAgent('Qwen/Qwen2.5-72B-Instruct', modelLabel),
|
||||
endpointOption: customOption('Qwen/Qwen2.5-72B-Instruct', { modelLabel }),
|
||||
}),
|
||||
).toBe(modelLabel);
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to the spec label, then the endpoint display label', () => {
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: ephemeralAgent('Qwen/Qwen2.5-72B-Instruct', 'Spec Label'),
|
||||
specLabel: 'Spec Label',
|
||||
endpointOption: customOption('Qwen/Qwen2.5-72B-Instruct', {
|
||||
modelDisplayLabel: 'Together',
|
||||
}),
|
||||
}),
|
||||
).toBe('Spec Label');
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: ephemeralAgent('mistralai/Mixtral-8x7B-Instruct-v0.1', 'Together'),
|
||||
endpointOption: customOption('mistralai/Mixtral-8x7B-Instruct-v0.1', {
|
||||
modelDisplayLabel: 'Together',
|
||||
}),
|
||||
}),
|
||||
).toBe('Together');
|
||||
});
|
||||
|
||||
test('keeps the family heuristic for a label-less custom endpoint', () => {
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: ephemeralAgent('mistralai/Mixtral-8x7B-Instruct-v0.1'),
|
||||
endpointOption: customOption('mistralai/Mixtral-8x7B-Instruct-v0.1'),
|
||||
}),
|
||||
).toBe('Mistral');
|
||||
});
|
||||
|
||||
test('keeps the model-derived name for a label-less anthropic agent', () => {
|
||||
expect(
|
||||
resolveSender({
|
||||
agent: {
|
||||
id: encodeEphemeralAgentId({
|
||||
endpoint: EModelEndpoint.anthropic,
|
||||
model: 'claude-sonnet-5',
|
||||
}),
|
||||
},
|
||||
endpointOption: { endpoint: EModelEndpoint.anthropic, model: 'claude-sonnet-5' },
|
||||
}),
|
||||
).toBe('Claude');
|
||||
});
|
||||
|
||||
test('matches getResponseSender exactly for an unknown label-less custom model', () => {
|
||||
const endpointOption = customOption('some-unknown-model');
|
||||
expect(resolveSender({ agent: ephemeralAgent('some-unknown-model'), endpointOption })).toBe(
|
||||
getResponseSender(endpointOption),
|
||||
);
|
||||
});
|
||||
});
|
||||
39
packages/api/src/agents/sender.ts
Normal file
39
packages/api/src/agents/sender.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { getResponseSender, getEphemeralSender, isEphemeralAgentId } from 'librechat-data-provider';
|
||||
import type { Agent, TEndpointOption } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Resolves the display name persisted as `message.sender` for a response.
|
||||
*
|
||||
* Real agents use their own name. Ephemeral agents run the same label chain
|
||||
* `loadEphemeralAgent` encodes into their id (`modelLabel` → spec label →
|
||||
* endpoint `modelDisplayLabel`), recomputed here from the exact source values
|
||||
* rather than decoded from the id — the id encoding is lossy for labels
|
||||
* containing `__`/`___` sequences, and the persisted sender must match the
|
||||
* configured label verbatim. `getResponseSender` remains the fallback for
|
||||
* label-less agents (model-derived names such as `Claude` or `GPT-5`, family
|
||||
* heuristics, `'AI'`).
|
||||
*/
|
||||
export function resolveSender({
|
||||
agent,
|
||||
specLabel,
|
||||
endpointOption,
|
||||
}: {
|
||||
agent: Partial<Pick<Agent, 'id' | 'name'>>;
|
||||
specLabel?: string | null;
|
||||
endpointOption: Partial<TEndpointOption>;
|
||||
}): string {
|
||||
if (agent.name != null) {
|
||||
return agent.name;
|
||||
}
|
||||
if (agent.id != null && isEphemeralAgentId(agent.id)) {
|
||||
const sender = getEphemeralSender({
|
||||
modelLabel: endpointOption.modelLabel,
|
||||
specLabel,
|
||||
modelDisplayLabel: endpointOption.modelDisplayLabel,
|
||||
});
|
||||
if (sender) {
|
||||
return sender;
|
||||
}
|
||||
}
|
||||
return getResponseSender(endpointOption);
|
||||
}
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
import { replaceSpecialVars, parseConvo, parseCompactConvo, parseTextParts } from '../src/parsers';
|
||||
import {
|
||||
parseConvo,
|
||||
parseTextParts,
|
||||
parseCompactConvo,
|
||||
replaceSpecialVars,
|
||||
getEphemeralSender,
|
||||
encodeEphemeralAgentId,
|
||||
parseEphemeralAgentId,
|
||||
} from '../src/parsers';
|
||||
import { specialVariables } from '../src/config';
|
||||
import { EModelEndpoint, Providers } from '../src/schemas';
|
||||
import { ContentTypes } from '../src/types/runs';
|
||||
|
|
@ -701,3 +709,113 @@ describe('parseTextParts', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeEphemeralAgentId / parseEphemeralAgentId', () => {
|
||||
test('round-trips endpoint and model without a sender', () => {
|
||||
const id = encodeEphemeralAgentId({ endpoint: 'openAI', model: 'gpt-4o' });
|
||||
expect(id).toBe('openAI__gpt-4o');
|
||||
expect(parseEphemeralAgentId(id)).toEqual({
|
||||
endpoint: 'openAI',
|
||||
model: 'gpt-4o',
|
||||
sender: undefined,
|
||||
index: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('round-trips a sender', () => {
|
||||
const id = encodeEphemeralAgentId({
|
||||
endpoint: 'Together AI',
|
||||
model: 'Qwen/Qwen2.5-72B-Instruct',
|
||||
sender: 'Fast Qwen',
|
||||
});
|
||||
expect(id).toBe('Together AI__Qwen/Qwen2.5-72B-Instruct___Fast Qwen');
|
||||
expect(parseEphemeralAgentId(id)?.sender).toBe('Fast Qwen');
|
||||
expect(parseEphemeralAgentId(id)?.model).toBe('Qwen/Qwen2.5-72B-Instruct');
|
||||
});
|
||||
|
||||
test('round-trips a sender alongside an index suffix', () => {
|
||||
const id = encodeEphemeralAgentId({
|
||||
endpoint: 'openAI',
|
||||
model: 'gpt-4o',
|
||||
sender: 'GPT-4o',
|
||||
index: 1,
|
||||
});
|
||||
expect(id).toBe('openAI__gpt-4o___GPT-4o____1');
|
||||
expect(parseEphemeralAgentId(id)).toEqual({
|
||||
endpoint: 'openAI',
|
||||
model: 'gpt-4o',
|
||||
sender: 'GPT-4o',
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('omits the sender segment for an empty sender, parsing back to undefined', () => {
|
||||
const id = encodeEphemeralAgentId({ endpoint: 'openAI', model: 'gpt-4o', sender: '' });
|
||||
expect(id).toBe('openAI__gpt-4o');
|
||||
expect(parseEphemeralAgentId(id)?.sender).toBeUndefined();
|
||||
});
|
||||
|
||||
test('restores colons in the endpoint, model, and sender', () => {
|
||||
const id = encodeEphemeralAgentId({
|
||||
endpoint: 'custom',
|
||||
model: 'claude-3:opus',
|
||||
sender: 'Label:With:Colons',
|
||||
});
|
||||
expect(parseEphemeralAgentId(id)).toEqual({
|
||||
endpoint: 'custom',
|
||||
model: 'claude-3:opus',
|
||||
sender: 'Label:With:Colons',
|
||||
index: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns undefined for ids without the ephemeral format', () => {
|
||||
expect(parseEphemeralAgentId('agent_abc123')).toBeUndefined();
|
||||
});
|
||||
|
||||
/** Characterization of known format quirks (SiblingHeader and the persisted
|
||||
* sender both decode this format, so lock the behavior rather than change it):
|
||||
* the parser splits on the first `___` and keeps only the next segment, and
|
||||
* restores every `__` in the sender to `:`. */
|
||||
test('truncates a sender containing a triple underscore (known quirk)', () => {
|
||||
const id = encodeEphemeralAgentId({ endpoint: 'openAI', model: 'gpt-4o', sender: 'A___B' });
|
||||
expect(parseEphemeralAgentId(id)?.sender).toBe('A');
|
||||
});
|
||||
|
||||
test('decodes a literal double underscore in a sender to a colon (known quirk)', () => {
|
||||
const id = encodeEphemeralAgentId({ endpoint: 'openAI', model: 'gpt-4o', sender: 'My__Bot' });
|
||||
expect(parseEphemeralAgentId(id)?.sender).toBe('My:Bot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEphemeralSender', () => {
|
||||
test('prefers modelLabel over the spec and endpoint labels', () => {
|
||||
expect(
|
||||
getEphemeralSender({
|
||||
modelLabel: 'My Label',
|
||||
specLabel: 'Spec Label',
|
||||
modelDisplayLabel: 'Endpoint Label',
|
||||
}),
|
||||
).toBe('My Label');
|
||||
});
|
||||
|
||||
test('falls back to the spec label, then the endpoint display label', () => {
|
||||
expect(
|
||||
getEphemeralSender({ specLabel: 'Spec Label', modelDisplayLabel: 'Endpoint Label' }),
|
||||
).toBe('Spec Label');
|
||||
expect(getEphemeralSender({ modelDisplayLabel: 'Endpoint Label' })).toBe('Endpoint Label');
|
||||
});
|
||||
|
||||
test('returns an empty string when no label is set', () => {
|
||||
expect(getEphemeralSender({})).toBe('');
|
||||
expect(getEphemeralSender({ modelLabel: null, specLabel: null, modelDisplayLabel: null })).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
/** `??` chain: an empty-string label short-circuits, preserving the exact
|
||||
* pre-consolidation behavior of every call site. */
|
||||
test('an empty-string modelLabel short-circuits the chain', () => {
|
||||
expect(getEphemeralSender({ modelLabel: '', specLabel: 'Spec Label' })).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -489,6 +489,23 @@ export type ParsedEphemeralAgentId = {
|
|||
index?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the display label ("sender") for an ephemeral agent:
|
||||
* `modelLabel` (user/preset) → model spec's `label` → endpoint config's
|
||||
* `modelDisplayLabel` → `''` (lets consumers fall back to the model name).
|
||||
*/
|
||||
export function getEphemeralSender({
|
||||
modelLabel,
|
||||
specLabel,
|
||||
modelDisplayLabel,
|
||||
}: {
|
||||
modelLabel?: string | null;
|
||||
specLabel?: string | null;
|
||||
modelDisplayLabel?: string | null;
|
||||
}): string {
|
||||
return modelLabel ?? specLabel ?? modelDisplayLabel ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an ephemeral agent ID from endpoint, model, optional sender, and optional index.
|
||||
* Uses __ to replace : (reserved in graph node names) and ___ to separate sender.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue