🖍️ fix: Honor Agent Image Detail Settings (#15407)

* 🖼️ fix: Apply an Agent's Configured Image Detail

The Agent Builder exposes an Image Detail slider and saves it to
`model_parameters.imageDetail`, but nothing ever read it back: `encodeAndFormat`
resolves the level from `req.body.imageDetail`, which the agents route never
populates, so every image was encoded as `detail: "auto"`. Providers that reject
`auto` — MiniMax's OpenAI-compatible API among them — fail every image upload
through an agent, and the setting that would fix it has no effect.

The root cause is a classification error rather than a missing argument.
`extractLibreChatParams` splits LibreChat's own settings out of an agent's model
parameters, and its key set is derived from the `librechat` group in
`parameterSettings`. `imageDetail` sat in `baseDefinitions` instead, so it was
neither extracted for LibreChat's use nor stripped from the options handed to the
provider.

It is now grouped with `resendFiles` and the other LibreChat-level parameters,
which gives it the same path those already take: extracted during agent
initialization, carried on the initialized agent, passed to the client, and read
by `encodeAndFormat`. `AgentClient.getSaveOptions` already expected
`options.imageDetail` — nothing had ever populated it.

The request body still wins where it is set, so the classic endpoints keep
resolving detail from conversation settings exactly as before.

Fixes #15359

* 🔤 fix: Type Image Detail as Its Enum, Not a Bare String

`TConversation.imageDetail` is the `ImageDetail` enum, so the extractor's return
type and the initialized agent's field are now declared as that enum rather than
widened to `string`, and the tests pass enum members instead of raw literals.

Caught by CI's type check, which I had run before adding the tests and not after —
jest compiles the specs through babel, so the suite stayed green over a type error.
This commit is contained in:
Danny Avila 2026-08-31 08:34:05 -04:00 • committed by GitHub
parent e9e77a4b33
commit 413906abc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 164 additions and 24 deletions

View file

@ -0,0 +1,55 @@
const mockEncodeAndFormat = jest.fn().mockResolvedValue({ files: [], image_urls: [] });
jest.mock('~/server/services/Files/images/encode', () => ({
encodeAndFormat: (...args) => mockEncodeAndFormat(...args),
}));
const AgentClient = require('./client');
describe('AgentClient.addImageURLs - image detail', () => {
const buildClient = (options) => {
const client = Object.create(AgentClient.prototype);
client.options = {
req: { body: {} },
endpoint: 'agents',
agent: { id: 'agent_1', provider: 'openai' },
...options,
};
return client;
};
beforeEach(() => {
jest.clearAllMocks();
});
it("forwards the agent's configured detail to the encoder", async () => {
const client = buildClient({ imageDetail: 'high' });
await client.addImageURLs({}, [{ file_id: 'f_1' }]);
expect(mockEncodeAndFormat).toHaveBeenCalledTimes(1);
expect(mockEncodeAndFormat.mock.calls[0][2]).toEqual(
expect.objectContaining({ imageDetail: 'high' }),
);
});
it('leaves the detail undefined when the agent configures none, so the encoder falls back', async () => {
const client = buildClient({});
await client.addImageURLs({}, [{ file_id: 'f_1' }]);
expect(mockEncodeAndFormat.mock.calls[0][2].imageDetail).toBeUndefined();
});
it('still passes the provider and endpoint alongside the detail', async () => {
const client = buildClient({ imageDetail: 'low' });
await client.addImageURLs({}, [{ file_id: 'f_1' }]);
expect(mockEncodeAndFormat.mock.calls[0][2]).toEqual({
provider: 'openai',
endpoint: 'agents',
imageDetail: 'low',
});
});
});

View file

@ -1675,6 +1675,7 @@ class AgentClient extends BaseClient {
{
provider: this.options.agent.provider,
endpoint: this.options.endpoint,
imageDetail: this.options.imageDetail,
},
VisionModes.agents,
);

View file

@ -1567,6 +1567,7 @@ const initializeClient = async ({
agentContextAttachmentsByAgentId,
endpointType: endpointOption.endpointType,
resendFiles: primaryConfig.resendFiles ?? true,
imageDetail: primaryConfig.imageDetail,
maxContextTokens: primaryConfig.maxContextTokens,
endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents,
subagentAggregatorsByToolCallId,

View file

@ -94,6 +94,8 @@ const blobStorageSources = new Set([
* @param {object} params - Object containing provider/endpoint information
* @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image
* @param {string} [params.endpoint] - Optional: The endpoint for the image
* @param {string} [params.imageDetail] - Optional: Detail level resolved by the caller, used
* where the request body carries no conversation-level setting (the agents route).
* @param {string} [mode] - Optional: The endpoint mode for the image.
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
*/
@ -159,7 +161,7 @@ async function encodeAndFormat(req, files, params, mode) {
promises.push(preparePayload(req, file));
}
const detail = req.body.imageDetail ?? ImageDetail.auto;
const detail = params.imageDetail ?? req.body.imageDetail ?? ImageDetail.auto;
/** @type {Array<[MongoFile, string]>} */
const formattedImages = await Promise.all(promises);

View file

@ -109,3 +109,56 @@ describe('encodeAndFormat - request memory guard', () => {
expect(result.image_urls[0].image_url.url).toBe(`data:image/png;base64,${localBase64}`);
});
});
describe('encodeAndFormat - image detail', () => {
const imageFile = () => ({
source: FileSources.s3,
height: 10,
width: 10,
type: 'image/png',
file_id: 'f-detail',
filepath: 'bucket/a.png',
filename: 'a.png',
bytes: 128,
});
beforeEach(() => {
mockGetDownloadStream.mockResolvedValue(Readable.from([Buffer.from('image-bytes')]));
});
it('falls back to auto when no detail is configured anywhere', async () => {
const result = await encodeAndFormat(makeReq(), [imageFile()], { endpoint: 'openai' });
expect(result.image_urls[0].image_url.detail).toBe('auto');
});
it('uses the conversation-level detail from the request body', async () => {
const req = makeReq();
req.body.imageDetail = 'low';
const result = await encodeAndFormat(req, [imageFile()], { endpoint: 'openai' });
expect(result.image_urls[0].image_url.detail).toBe('low');
});
it('uses the detail resolved by the caller on routes with no body setting', async () => {
const result = await encodeAndFormat(makeReq(), [imageFile()], {
endpoint: 'agents',
imageDetail: 'high',
});
expect(result.image_urls[0].image_url.detail).toBe('high');
});
it('prefers the caller-resolved detail over the request body', async () => {
const req = makeReq();
req.body.imageDetail = 'low';
const result = await encodeAndFormat(req, [imageFile()], {
endpoint: 'agents',
imageDetail: 'high',
});
expect(result.image_urls[0].image_url.detail).toBe('high');
});
});