diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js
index 51d9bcf6eb..a75683a4b8 100644
--- a/api/server/services/Endpoints/agents/initialize.js
+++ b/api/server/services/Endpoints/agents/initialize.js
@@ -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
diff --git a/client/src/hooks/Conversations/__tests__/useGetSender.spec.tsx b/client/src/hooks/Conversations/__tests__/useGetSender.spec.tsx
new file mode 100644
index 0000000000..efba85a76b
--- /dev/null
+++ b/client/src/hooks/Conversations/__tests__/useGetSender.spec.tsx
@@ -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 (
+
+ {children}
+
+ );
+ },
+ });
+ return result.current;
+};
+
+const asOption = (option: Record) => 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)));
+ });
+});
diff --git a/client/src/hooks/Conversations/useGetSender.ts b/client/src/hooks/Conversations/useGetSender.ts
index cced4ca4fe..1f978049fa 100644
--- a/client/src/hooks/Conversations/useGetSender.ts
+++ b/client/src/hooks/Conversations/useGetSender.ts
@@ -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],
);
}
diff --git a/client/src/utils/__tests__/messages.test.ts b/client/src/utils/__tests__/messages.test.ts
index 0a403c32ed..22e00ff678 100644
--- a/client/src/utils/__tests__/messages.test.ts
+++ b/client/src/utils/__tests__/messages.test.ts
@@ -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 = {
@@ -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> & { endpoint: string }) =>
+ convo as unknown as TConversation;
+ const agentIds = (parts: ReturnType) =>
+ 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']);
+ });
+});
diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts
index 7faa49d6be..8625021af9 100644
--- a/client/src/utils/messages.ts
+++ b/client/src/utils/messages.ts
@@ -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,
diff --git a/e2e/specs/mock/soft-default.spec.ts b/e2e/specs/mock/soft-default.spec.ts
index 3bac47e1c6..20b550abf7 100644
--- a/e2e/specs/mock/soft-default.spec.ts
+++ b/e2e/specs/mock/soft-default.spec.ts
@@ -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);
diff --git a/packages/api/src/agents/added.ts b/packages/api/src/agents/added.ts
index 0d23204500..192b48d18c 100644
--- a/packages/api/src/agents/added.ts
+++ b/packages/api/src/agents/added.ts
@@ -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 = {
@@ -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 = {
diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts
index 8e01ad4f58..fb78376e8a 100644
--- a/packages/api/src/agents/index.ts
+++ b/packages/api/src/agents/index.ts
@@ -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';
diff --git a/packages/api/src/agents/load.spec.ts b/packages/api/src/agents/load.spec.ts
index 75abb6a3fd..48620851e2 100644
--- a/packages/api/src/agents/load.spec.ts
+++ b/packages/api/src/agents/load.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/api/src/agents/load.ts b/packages/api/src/agents/load.ts
index a160319f62..af981b8187 100644
--- a/packages/api/src/agents/load.ts
+++ b/packages/api/src/agents/load.ts
@@ -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 = {
diff --git a/packages/api/src/agents/sender.spec.ts b/packages/api/src/agents/sender.spec.ts
new file mode 100644
index 0000000000..454f4334c0
--- /dev/null
+++ b/packages/api/src/agents/sender.spec.ts
@@ -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> = {},
+): Partial => ({
+ 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),
+ );
+ });
+});
diff --git a/packages/api/src/agents/sender.ts b/packages/api/src/agents/sender.ts
new file mode 100644
index 0000000000..49067b66b5
--- /dev/null
+++ b/packages/api/src/agents/sender.ts
@@ -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>;
+ specLabel?: string | null;
+ endpointOption: Partial;
+}): 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);
+}
diff --git a/packages/data-provider/specs/parsers.spec.ts b/packages/data-provider/specs/parsers.spec.ts
index e88eab64ab..8d86dedb0c 100644
--- a/packages/data-provider/specs/parsers.spec.ts
+++ b/packages/data-provider/specs/parsers.spec.ts
@@ -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('');
+ });
+});
diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts
index 261ad9031d..26f478c63c 100644
--- a/packages/data-provider/src/parsers.ts
+++ b/packages/data-provider/src/parsers.ts
@@ -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.