Skip to content
Closed
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
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- **Agent execution host**: the protocol-neutral module that owns run admission, disconnect cancellation, provider-start fencing, and terminal settlement. Protocol implementations execute behind its callback interface; HTTP adapters retain validation and final stream rendering.
- **Agent execution enrollment**: the durable, protocol-neutral lifecycle authority for an admitted Agent run. It is created under the authenticated user and tenant before user-owned initialization, rechecks the shared owner-deletion admission fence after registration, exposes the only provider abort signal, fences exact provider start, terminalizes the run, waits for every trailing usage, artifact, and stored-response write, and acknowledges provider drain last. A transient terminalization failure is reconciled after trailing writes; provider drain is never acknowledged while the exact job remains nonterminal. Delete-all holds the owner fence, drains every owner run before selecting its first persistence snapshot, and repeats both the drain and an idempotent owner-persistence sweep after any recovered fence lapse before releasing admission. Exact-conversation deletion additionally performs an unconditional idempotent cleanup over its immutable deleted-ID set because a fully drained run may leave the active index after racing the first delete; only the explicit empty result is benign, while storage failures remain fatal. Chat Completions, Responses, Channels, and future ingress adapters share this authority without moving LibreChat persistence policy into the Agents SDK.
- **Agent turn execution plan**: the immutable, request-local decision compiled once after authentication, agent resolution, and tool initialization. It records the trusted turn origin, conversation lineage, pause capability, binding/action context, and the preferred checkpoint, history, or fresh state-loading strategy without executing the model or owning persistence. Checkpoint failure falls back to durable history within the same Agents lifecycle.
- **Turn delivery routing**: the per-agent, request-local value that decides how each attachment reaches the model on one turn (`provider`, `text`, or `none`). Initialization settles it once, after the provider swap and the Responses API decision, under the endpoint's own name and the media dialect its config declares. Every reader of a turn route consumes that one value rather than deriving it from the agent. A stored route is an upload-time inference that this value resolves again for the turn; a destination the user chose stands.
- **Effective agent selection**: the resolved endpoint and agent identity after an enforced model spec is applied. Authorization and agent loading must consume this same identity before the Agent run envelope is initialized.
- **MCP runtime request body**: trusted chat identifiers supplied only while an MCP server handles an agent request. It enables request-scoped header placeholders without retaining user-specific request data on a shared server definition.
- **MCP direct OpenID bearer**: an operator-trusted remote MCP credential mode that resolves the logged-in user's live OpenID access token into an Authorization header. It may replace one rejected connection after a forced session refresh, but it never replays the rejected tool invocation automatically.
Expand Down
52 changes: 9 additions & 43 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,15 @@ const {
isCompactedLeaf,
excludedKeys,
EModelEndpoint,
mergeFileConfig,
isParamEndpoint,
isAgentsEndpoint,
isEphemeralAgentId,
supportsBalanceCheck,
isBedrockDocumentType,
HITL_MESSAGE_FILTER_FIELDS,
getEndpointFileConfig,
stripReasoningLabelMetadata,
resolveUploadLLMDeliveryPath,
isSpeechProviderConfigured,
resolveTurnLLMDeliveryPath,
resolveUseResponsesApi,
getCustomEndpointProvider,
} = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { logViolation } = require('~/cache');
Expand Down Expand Up @@ -242,10 +238,6 @@ class BaseClient {
this.currentMessages = [];
/** @type {import('librechat-data-provider').VisionModes | undefined} */
this.visionMode;
/** @type {import('librechat-data-provider').FileConfig | undefined} */
this._mergedFileConfig;
/** @type {import('librechat-data-provider').EndpointFileConfig | undefined} */
this._endpointFileConfig;
}

setOptions() {
Expand Down Expand Up @@ -1804,38 +1796,9 @@ class BaseClient {
});
}

/** Re-resolves an inferred upload route against the provider handling this turn. */
/** The route a stored attachment takes on this turn, from the routing settled for the agent. */
getAttachmentDeliveryPath(file) {
if (!this._mergedFileConfig) {
this._mergedFileConfig = mergeFileConfig(this.options.req?.config?.fileConfig);
/* Agent file policy is configured under the endpoint it names, not the client
* family initialization may rewrite it to. */
const agentEndpoint = this.options.agent?.endpoint ?? this.options.agent?.provider;
this._deliveryEndpoint = agentEndpoint ?? this.options.endpoint;
this._endpointFileConfig = getEndpointFileConfig({
fileConfig: this._mergedFileConfig,
endpoint: this._deliveryEndpoint,
endpointType: agentEndpoint != null ? undefined : this.options.endpointType,
});
}

return file.llmDeliveryPath == null || file.metadata?.destinationChosen === true
? file.llmDeliveryPath
: resolveUploadLLMDeliveryPath({
/* Conversion changes the stored type, so use the type routing originally saw. */
mimeType: file.metadata?.routingMimeType ?? file.type,
endpointConfig: this._endpointFileConfig,
fileConfig: this._mergedFileConfig,
endpoint: this._deliveryEndpoint,
endpointProvider:
this.options.agent?.provider ??
getCustomEndpointProvider(
this.options.req?.config?.endpoints?.custom,
this._deliveryEndpoint,
),
useResponsesApi: this.usesResponsesApi(),
sttConfigured: isSpeechProviderConfigured(this.options.req?.config?.speech?.stt),
});
return resolveTurnLLMDeliveryPath(this.options.agent?.deliveryRouting, file);
Comment thread
danny-avila marked this conversation as resolved.
}

async processAttachments(message, attachments) {
Expand All @@ -1849,6 +1812,7 @@ class BaseClient {
const allFiles = [];
const provider = this.options.agent?.provider ?? this.options.endpoint;
const isBedrock = provider === EModelEndpoint.bedrock;
const deliveryRouting = this.options.agent?.deliveryRouting;

/* The stored path records what upload time inferred from the endpoint it saw, and this
* turn may be running somewhere else: audio stored as `provider` under Google reaches
Expand Down Expand Up @@ -1897,9 +1861,11 @@ class BaseClient {
allFiles.push(file);
} else if (
file.type &&
this._mergedFileConfig &&
this._endpointFileConfig?.supportedMimeTypes &&
this._mergedFileConfig.checkType(file.type, this._endpointFileConfig.supportedMimeTypes)
deliveryRouting?.endpointConfig.supportedMimeTypes &&
deliveryRouting.fileConfig.checkType(
file.type,
deliveryRouting.endpointConfig.supportedMimeTypes,
)
) {
categorizedAttachments.documents.push(file);
allFiles.push(file);
Expand Down
50 changes: 29 additions & 21 deletions api/app/clients/specs/BaseClient.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { Constants, ContentTypes, EModelEndpoint } = require('librechat-data-provider');
const BaseClientClass = require('../BaseClient');
const { ContentFilterError } = require('@librechat/api');
const { ContentFilterError, resolveTurnDeliveryRouting } = require('@librechat/api');
const { FakeClient, initializeFakeClient } = require('./FakeClient');

function deferred() {
Expand Down Expand Up @@ -3192,8 +3192,6 @@ describe('BaseClient', () => {
TestClient.options = {
endpoint: EModelEndpoint.openAI,
};
TestClient._mergedFileConfig = undefined;
TestClient._endpointFileConfig = undefined;
TestClient.addImageURLs = jest.fn(async (message, files) => {
message.image_urls = ['encoded-image'];
return files;
Expand All @@ -3207,9 +3205,18 @@ describe('BaseClient', () => {
TestClient.addAudios = jest.fn(async (_message, files) => files);
});

/* The stored path is an upload-time inference, so delivery re-resolves it for the
* endpoint running the turn. A test asserting a route has to configure that route
* rather than rely on the stored value alone. */
/** The routing initialization settles for an agent, from the request config it reads. */
const routedAgent = (agent) => ({
...agent,
deliveryRouting: resolveTurnDeliveryRouting({
agent,
config: TestClient.options.req?.config,
}),
});

/* The stored path is an upload-time inference, so delivery resolves it again by the
* routing settled for the agent running the turn. A test asserting a route has to
* configure that route rather than rely on the stored value alone. */
const routeTo = (path, ...mimeTypes) => {
TestClient.options.req = {
config: {
Expand All @@ -3224,8 +3231,10 @@ describe('BaseClient', () => {
},
},
};
TestClient._mergedFileConfig = undefined;
TestClient._endpointFileConfig = undefined;
TestClient.options.agent = routedAgent({
provider: EModelEndpoint.openAI,
endpoint: EModelEndpoint.openAI,
});
};

test('keeps a none image in returned files without adding image URLs', async () => {
Expand Down Expand Up @@ -3327,17 +3336,19 @@ describe('BaseClient', () => {
expect(TestClient.addImageURLs).not.toHaveBeenCalled();
});

test('reads the Responses setting from a plain conversation too', async () => {
/* A non-agent Azure chat carries it in model options, and reading only the agent
* parameters re-resolves a natively supported PDF to text, which the record has
* none of, so the model receives nothing. */
test('reads the Responses setting the turn runs on from the settled routing', async () => {
/* Azure sends a PDF natively only under the Responses API. The routing carries the
* decision initialization made, so a record stored as `provider` is not resolved
* again to text it has none of, which would leave the model with nothing. */
TestClient.options = {
endpoint: EModelEndpoint.azureOpenAI,
endpoint: EModelEndpoint.agents,
req: { config: { fileConfig: undefined } },
};
TestClient.modelOptions = { useResponsesApi: true };
TestClient._mergedFileConfig = undefined;
TestClient._endpointFileConfig = undefined;
TestClient.options.agent = routedAgent({
provider: EModelEndpoint.azureOpenAI,
endpoint: EModelEndpoint.azureOpenAI,
model_parameters: { useResponsesApi: true },
});
const message = {};
const file = {
user: 'user1',
Expand All @@ -3353,7 +3364,6 @@ describe('BaseClient', () => {
await TestClient.processAttachments(message, [file]);

expect(TestClient.addDocuments).toHaveBeenCalled();
TestClient.modelOptions = undefined;
});

test('resolves a custom endpoint policy by the name the admin configured', async () => {
Expand All @@ -3377,8 +3387,7 @@ describe('BaseClient', () => {
},
},
};
TestClient._mergedFileConfig = undefined;
TestClient._endpointFileConfig = undefined;
TestClient.options.agent = routedAgent(TestClient.options.agent);
const message = {};
const file = {
user: 'user1',
Expand Down Expand Up @@ -3418,8 +3427,7 @@ describe('BaseClient', () => {
},
},
};
TestClient._mergedFileConfig = undefined;
TestClient._endpointFileConfig = undefined;
TestClient.options.agent = routedAgent(TestClient.options.agent);
const message = {};
const file = {
user: 'user1',
Expand Down
3 changes: 3 additions & 0 deletions api/server/controllers/agents/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5471,6 +5471,9 @@ describe('AgentClient - titleConvo', () => {
},
};
mockRes = {};
mockAgent.deliveryRouting = jest
.requireActual('@librechat/api')
.resolveTurnDeliveryRouting({ agent: mockAgent, config: mockReq.config });

client = new AgentClient({
req: mockReq,
Expand Down
2 changes: 1 addition & 1 deletion api/server/services/Endpoints/agents/skillDeps.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,10 +291,10 @@ function buildAgentToolContext({ agent, config }) {
agent,
fileEncodingAgent: {
provider: config.provider,
endpoint: config.endpoint,
model_parameters: config.model_parameters,
imageDetail: config.imageDetail,
agentContextAttachments: config.agentContextAttachments,
deliveryRouting: config.deliveryRouting,
},
/** Per-agent resolved endpoint token/pricing config. Retained here because
* `agentToolContexts` is the one map that holds every agent — including
Expand Down
84 changes: 83 additions & 1 deletion packages/api/src/agents/__tests__/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1655,7 +1655,6 @@ describe('initializeAgent — attachment scoping', () => {
expect(db.updateFilesUsage).not.toHaveBeenCalled();
expect(primeResources).not.toHaveBeenCalled();
expect(loadTools).not.toHaveBeenCalled();
expect(mockGetProviderConfig).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -4499,3 +4498,86 @@ describe('initializeAgent — provider-native web search role gate', () => {
expect(result.tools).toContainEqual(OPENAI_SEARCH);
});
});

describe('initializeAgent turn delivery routing', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('settles the routing once, after the provider swap and the Responses API decision', async () => {
const { agent, req, res, loadTools, db } = createMocks({ provider: 'MyClaude' });
req.config = {
fileConfig: { endpoints: { MyClaude: { supportedMimeTypes: ['video/mp4'] } } },
endpoints: { custom: [{ name: 'MyClaude', provider: 'anthropic' }] },
} as unknown as ServerRequest['config'];
mockGetProviderConfig.mockReturnValue({
overrideProvider: Providers.ANTHROPIC,
getOptions: jest
.fn()
.mockResolvedValue({ llmConfig: { model: 'test-model', useResponsesApi: true } }),
});

const result = await initializeAgent(
{
req,
res,
agent,
loadTools,
endpointOption: { endpoint: EModelEndpoint.agents },
allowedProviders: new Set(['MyClaude']),
isInitialAgent: true,
},
db,
);

expect(result.provider).toBe(Providers.ANTHROPIC);
expect(result.deliveryRouting).toMatchObject({
endpoint: 'MyClaude',
endpointProvider: 'anthropic',
useResponsesApi: true,
sttConfigured: false,
});
expect(result.deliveryRouting.endpointConfig.supportedMimeTypes).toEqual([/video\/mp4/]);
});

it('resolves the provider and its options before any attachment is loaded', async () => {
const { filterFilesByEndpointRuntimeConfig } = jest.requireMock('~/files') as {
filterFilesByEndpointRuntimeConfig: jest.Mock;
};
const { agent, req, res, loadTools, db } = createMocks();
const getOptions = jest.fn().mockResolvedValue({ llmConfig: { model: 'test-model' } });
mockGetProviderConfig.mockReturnValue({ overrideProvider: Providers.OPENAI, getOptions });
const file = {
file_id: 'file-1',
filename: 'notes.txt',
type: 'text/plain',
bytes: 10,
text: 'notes',
llmDeliveryPath: 'text',
} as IMongoFile;
(db.getFiles as jest.Mock).mockResolvedValueOnce([file]);
filterFilesByEndpointRuntimeConfig.mockImplementationOnce(
(_config: ServerRequest['config'], { files }: { files: IMongoFile[] }) => files,
);

await initializeAgent(
{
req,
res,
agent,
loadTools,
requestFiles: [file],
endpointOption: { endpoint: EModelEndpoint.agents },
allowedProviders: new Set([Providers.OPENAI]),
isInitialAgent: true,
},
db,
);

const [optionsOrder] = getOptions.mock.invocationCallOrder;
const [filesOrder] = (db.getFiles as jest.Mock).mock.invocationCallOrder;
const [toolsOrder] = loadTools.mock.invocationCallOrder;
expect(optionsOrder).toBeLessThan(filesOrder);
expect(filesOrder).toBeLessThan(toolsOrder);
});
});
69 changes: 69 additions & 0 deletions packages/api/src/agents/files/delivery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { TurnDeliveryConfig } from './delivery';
import { resolveTurnDeliveryRouting } from './delivery';

const config = {
fileConfig: {
endpoints: {
MyClaude: { supportedMimeTypes: ['video/mp4'] },
[EModelEndpoint.openAI]: { fileLimit: 3 },
},
},
endpoints: { custom: [{ name: 'MyClaude', provider: 'anthropic' }] },
speech: { stt: { openai: { apiKey: 'key', model: 'whisper-1' } } },
} as unknown as TurnDeliveryConfig;

describe('resolveTurnDeliveryRouting', () => {
it('reads the file policy under the endpoint name, before and after the provider swap', () => {
/* Initialization first stores the endpoint name in both fields and only later replaces
* the provider with the backing client, so neither the policy nor the dialect may move. */
const before = resolveTurnDeliveryRouting({
agent: { provider: 'MyClaude', endpoint: 'MyClaude' },
config,
});
const after = resolveTurnDeliveryRouting({
agent: { provider: 'anthropic', endpoint: 'MyClaude' },
config,
});

expect(after).toEqual(before);
expect(before.endpoint).toBe('MyClaude');
expect(before.endpointConfig.supportedMimeTypes).toEqual([/video\/mp4/]);
expect(before.endpointProvider).toBe('anthropic');
});

it('leaves the dialect undefined for a built-in or OpenAI-compatible endpoint', () => {
expect(
resolveTurnDeliveryRouting({
agent: { provider: EModelEndpoint.openAI, endpoint: EModelEndpoint.openAI },
config,
}).endpointProvider,
).toBeUndefined();
expect(
resolveTurnDeliveryRouting({ agent: { provider: 'MyGateway' }, config }).endpointProvider,
).toBeUndefined();
});

it('routes an agent loaded without an endpoint name by its provider', () => {
const routing = resolveTurnDeliveryRouting({
agent: { provider: EModelEndpoint.openAI },
config,
});

expect(routing.endpoint).toBe(EModelEndpoint.openAI);
expect(routing.endpointConfig.fileLimit).toBe(3);
});

it('carries the Responses API decision and the transcription setting', () => {
const routing = resolveTurnDeliveryRouting({
agent: { provider: EModelEndpoint.azureOpenAI, model_parameters: { useResponsesApi: true } },
config,
});

expect(routing.useResponsesApi).toBe(true);
expect(routing.sttConfigured).toBe(true);
expect(
resolveTurnDeliveryRouting({ agent: { provider: EModelEndpoint.openAI }, config: {} }),
).toMatchObject({ useResponsesApi: undefined, sttConfigured: false });
});
});
Loading
Loading