Skip to content

Commit 4523170

Browse files
committed
🗣️ fix: Keep Provider Error Text on Unclassified Agent Failures
An upstream failure LangChain does not classify discarded the provider's own message and answered with the generic upstream sentence plus a status, so a gateway or privacy-proxy rejection lost the only account of what happened. The provider text now rides along in the persisted payload as `message`, withheld only where a content policy inspects the traffic — the same condition `getUserFacingProviderError` and `getUserFacingRequestError` already decide by.
1 parent 9e83b10 commit 4523170

14 files changed

Lines changed: 344 additions & 32 deletions

File tree

api/server/controllers/agents/client.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4402,13 +4402,17 @@ class AgentClient extends BaseClient {
44024402
let run;
44034403
/** @type {Promise<(TAttachment | null)[] | undefined>} */
44044404
let memoryPromise;
4405+
const appConfig = this.options.req.config;
44054406
const terminalRunError = createTerminalRunErrorObserver({
44064407
logger,
44074408
responseMessageId: this.responseMessageId,
44084409
source: '[api/server/controllers/agents/client.js #sendCompletion]',
44094410
genericMessage: '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error',
4411+
protectionEnabled: hasModelBoundContentProtection(
4412+
appConfig?.filters,
4413+
appConfig?.messageFilter?.pii,
4414+
),
44104415
});
4411-
const appConfig = this.options.req.config;
44124416
const balanceConfig = getBalanceConfig(appConfig);
44134417
const transactionsConfig = getTransactionsConfig(appConfig);
44144418
try {
@@ -5287,13 +5291,17 @@ class AgentClient extends BaseClient {
52875291
let config;
52885292
/** @type {ReturnType<createRun>} */
52895293
let run;
5294+
const appConfig = this.options.req.config;
52905295
const terminalRunError = createTerminalRunErrorObserver({
52915296
logger,
52925297
responseMessageId: this.responseMessageId,
52935298
source: '[api/server/controllers/agents/client.js #resumeCompletion]',
52945299
genericMessage: '[api/server/controllers/agents/client.js #resumeCompletion] Unhandled error',
5300+
protectionEnabled: hasModelBoundContentProtection(
5301+
appConfig?.filters,
5302+
appConfig?.messageFilter?.pii,
5303+
),
52955304
});
5296-
const appConfig = this.options.req.config;
52975305
const balanceConfig = getBalanceConfig(appConfig);
52985306
const transactionsConfig = getTransactionsConfig(appConfig);
52995307
try {

api/server/controllers/agents/client.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10083,6 +10083,53 @@ describe('AgentClient - resumeCompletion content protection', () => {
1008310083
errorSpy.mockRestore();
1008410084
});
1008510085

10086+
/** A gateway or privacy proxy states its rejection in its own message and nowhere else, so an
10087+
* unclassified upstream failure carries it exactly as every other failure text does. */
10088+
it('keeps the provider explanation on a terminal resumed model failure', async () => {
10089+
const explanation = '400 Request rejected: this prompt cannot be masked safely';
10090+
const trackTerminalProviderError = (providerError) => {
10091+
mockCreateRun.mockImplementation(async (options) => {
10092+
const tracker = options.modelCallbacks.find(
10093+
(callback) => callback.name === 'librechat-upstream-model-error-tracker',
10094+
);
10095+
return {
10096+
resume: jest.fn(async () => {
10097+
tracker.handleLLMError(providerError, 'resumed-model-run');
10098+
throw providerError;
10099+
}),
10100+
getCalibrationRatio: jest.fn(() => 0),
10101+
};
10102+
});
10103+
};
10104+
10105+
trackTerminalProviderError(Object.assign(new Error(explanation), { status: 400 }));
10106+
const context = makeContext(undefined);
10107+
10108+
await AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} });
10109+
10110+
expect(context.contentParts).toContainEqual({
10111+
type: ContentTypes.ERROR,
10112+
[ContentTypes.ERROR]:
10113+
'The model provider could not complete this request.\n' +
10114+
JSON.stringify({ type: 'upstream_model_error', status: 400, message: explanation }),
10115+
});
10116+
10117+
/** With a policy inspecting the traffic, the body may echo submitted content: status only. */
10118+
trackTerminalProviderError(Object.assign(new Error(explanation), { status: 400 }));
10119+
const protectedContext = makeContext({
10120+
messages: { pii: { fields: ['text'], starterPatterns: ['email'] } },
10121+
});
10122+
10123+
await AgentClient.prototype.resumeCompletion.call(protectedContext, { resumeValue: {} });
10124+
10125+
expect(protectedContext.contentParts).toContainEqual({
10126+
type: ContentTypes.ERROR,
10127+
[ContentTypes.ERROR]:
10128+
'The model provider could not complete this request.\n' +
10129+
JSON.stringify({ type: 'upstream_model_error', status: 400 }),
10130+
});
10131+
});
10132+
1008610133
it('preserves provider error detail when content protection is disabled', async () => {
1008710134
const providerMessage = 'Legacy provider detail';
1008810135
mockCreateRun.mockRejectedValue(new Error(providerMessage));

api/server/controllers/agents/openai.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,10 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
383383
logger,
384384
responseMessageId: responseId,
385385
source: '[OpenAI API]',
386+
protectionEnabled: hasModelBoundContentProtection(
387+
appConfig?.filters,
388+
appConfig?.messageFilter?.pii,
389+
),
386390
});
387391
const created = Math.floor(Date.now() / 1000);
388392

api/server/controllers/agents/responses.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,10 @@ const executeResponse = async (envelope, { req, res }) => {
632632
logger,
633633
responseMessageId: responseId,
634634
source: '[Responses API]',
635+
protectionEnabled: hasModelBoundContentProtection(
636+
appConfig?.filters,
637+
appConfig?.messageFilter?.pii,
638+
),
635639
});
636640
const context = createResponseContext(request, responseId);
637641

client/src/components/Messages/Content/Error/ModelError.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import {
44
isCodeWorkspaceSelectionErrorReason,
55
} from 'librechat-data-provider';
66
import type { ErrorRendererProps } from './parts';
7-
import { getProviderName, readNumber, readString, useErrorEndpoint } from './parts';
7+
import {
8+
ErrorWithDetail,
9+
getProviderName,
10+
readNumber,
11+
readString,
12+
useErrorEndpoint,
13+
} from './parts';
814
import { codeWorkspaceErrorKeys } from '~/utils/errors';
915
import { useLocalize } from '~/hooks';
1016

@@ -50,9 +56,21 @@ export default function ModelError({ json, message }: ErrorRendererProps) {
5056
: localize('com_error_code_workspace_unavailable');
5157
}
5258

53-
/** Provider-neutral, matching the sentence the server persists as the failure's own text. */
59+
/**
60+
* Provider-neutral headline, matching the sentence the server persists as the failure's own
61+
* text. The provider's own message rides along in `message` when the deployment lets provider
62+
* text through: a gateway or proxy rejection explains itself there, and nothing generic can.
63+
*/
5464
const status = readNumber(json, 'status');
55-
return status != null
56-
? localize('com_error_upstream_model_status', { 0: status })
57-
: localize('com_error_upstream_model');
65+
const headline =
66+
status != null
67+
? localize('com_error_upstream_model_status', { 0: status })
68+
: localize('com_error_upstream_model');
69+
return (
70+
<ErrorWithDetail
71+
headline={headline}
72+
detail={readString(json, 'message')}
73+
label={localize('com_error_details_provider')}
74+
/>
75+
);
5876
}

client/src/components/Messages/Content/Error/ProviderError.tsx

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { ErrorTypes, stripLangChainTroubleshootingUrl } from 'librechat-data-provider';
22
import type { ErrorPayload, ErrorRendererProps, UnclassifiedErrorProps } from './parts';
3-
import { ErrorBody, ErrorDetails, readObject, readString, useErrorEndpoint } from './parts';
3+
import {
4+
ErrorBody,
5+
ErrorDetails,
6+
ErrorWithDetail,
7+
readObject,
8+
readString,
9+
useErrorEndpoint,
10+
} from './parts';
411
import { extractJson } from '~/utils/json';
512
import { useLocalize } from '~/hooks';
613

@@ -115,21 +122,11 @@ export function UnclassifiedError({ json, text, message }: UnclassifiedErrorProp
115122
: localize('com_error_upstream_model');
116123
const headline = prose == null && json != null ? localize('com_error_unknown') : providerHeadline;
117124

118-
if (prose == null) {
119-
return headline;
120-
}
121-
122-
if (prose.length <= 240 && !/[\r\n]/.test(prose)) {
123-
return (
124-
<ErrorBody>
125-
<div>{headline}</div>
126-
<div className="text-text-secondary">{prose}</div>
127-
</ErrorBody>
128-
);
129-
}
130-
131-
return withHeadline(headline, {
132-
label: localize('com_error_details_provider'),
133-
value: prose,
134-
});
125+
return (
126+
<ErrorWithDetail
127+
headline={headline}
128+
detail={prose}
129+
label={localize('com_error_details_provider')}
130+
/>
131+
);
135132
}

client/src/components/Messages/Content/Error/parts.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,39 @@ export function ErrorDetails({ label, children }: { label: string; children: Rea
299299
);
300300
}
301301

302+
/** Past a sentence's worth of text, or across lines, a detail is a body to open rather than read. */
303+
const INLINE_DETAIL_LENGTH = 240;
304+
305+
/**
306+
* A headline plus the failure's own words, the way every provider-produced error reads: what is
307+
* known first, the reported text second. A single sentence stays in place, where a reader gets it
308+
* without acting; a body of text collapses under `label`.
309+
*/
310+
export function ErrorWithDetail({
311+
headline,
312+
detail,
313+
label,
314+
}: {
315+
headline: string;
316+
detail?: string;
317+
label: string;
318+
}) {
319+
if (detail == null) {
320+
return <>{headline}</>;
321+
}
322+
323+
return (
324+
<ErrorBody>
325+
<div>{headline}</div>
326+
{detail.length <= INLINE_DETAIL_LENGTH && !/[\r\n]/.test(detail) ? (
327+
<div className="text-text-secondary">{detail}</div>
328+
) : (
329+
<ErrorDetails label={label}>{detail}</ErrorDetails>
330+
)}
331+
</ErrorBody>
332+
);
333+
}
334+
302335
export function ErrorActions({ children }: { children: React.ReactNode }) {
303336
return <div className="flex flex-wrap items-center gap-2">{children}</div>;
304337
}

client/src/components/Messages/Content/__tests__/Error.spec.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,37 @@ describe('Error — provider and model identity', () => {
387387
expect(document.body.textContent).not.toContain('OpenAI');
388388
});
389389

390+
/** What a gateway or privacy proxy rejects a request with is only stated in its own message. */
391+
it('reads the provider explanation an upstream failure carries', () => {
392+
const explanation = 'Request rejected: this prompt cannot be masked safely';
393+
const { unmount } = renderError(
394+
{ type: ErrorTypes.UPSTREAM_MODEL_ERROR, status: 400, message: explanation },
395+
providerMessage,
396+
);
397+
398+
expect(
399+
screen.getByText(localized('com_error_upstream_model_status', '400')),
400+
).toBeInTheDocument();
401+
expect(screen.getByText(explanation)).toBeInTheDocument();
402+
expectReadable();
403+
unmount();
404+
405+
const body = `Upstream rejection\n${JSON.stringify({ reason: 'masking_unavailable' })}`.padEnd(
406+
400,
407+
'.',
408+
);
409+
renderError(
410+
{ type: ErrorTypes.UPSTREAM_MODEL_ERROR, status: 400, message: body },
411+
providerMessage,
412+
);
413+
414+
const disclosure = screen.getByRole('button', { name: catalog.com_error_details_provider });
415+
expect(disclosure).toHaveAttribute('aria-expanded', 'false');
416+
fireEvent.click(disclosure);
417+
expect(disclosure).toHaveAttribute('aria-expanded', 'true');
418+
expect(document.body.textContent).toContain(body);
419+
});
420+
390421
it.each([
391422
['required', 'com_error_code_workspace_required'],
392423
['invalid', 'com_error_code_workspace_invalid'],

e2e/specs/mock/completion.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ test.describe('generation finalization invariant', () => {
2424
await expect(
2525
messagesView(page).getByText('The model provider could not complete this request.'),
2626
).toBeVisible();
27-
await expect(messagesView(page).getByText(providerError)).toHaveCount(0);
27+
/** No content policy is configured here, so the provider's own words reach the reader. */
28+
await expect(messagesView(page).getByText(providerError)).toBeVisible();
2829
});
2930
});

e2e/specs/mock/message-tree.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,11 @@ test.describe('message tree stream operations', () => {
11351135
const errorPrompt = `E2E_FORCED_ERROR:${label}`;
11361136
const providerError = `E2E forced stream error ${label}`;
11371137
const errorText = 'The model provider could not complete this request.';
1138-
const errorPayload = `${errorText}\n${JSON.stringify({ type: 'upstream_model_error' })}`;
1138+
/** No content policy is configured here, so the failure keeps the provider's own words. */
1139+
const errorPayload = `${errorText}\n${JSON.stringify({
1140+
type: 'upstream_model_error',
1141+
message: providerError,
1142+
})}`;
11391143
const afterErrorPrompt = replyPrompt(`${label}-after-error`);
11401144
const afterErrorReply = replyText(`${label}-after-error`);
11411145

@@ -1145,7 +1149,7 @@ test.describe('message tree stream operations', () => {
11451149

11461150
await sendAndExpectReply(page, errorPrompt, errorText);
11471151
await expect(messagesView(page).getByText(errorText)).toBeVisible({ timeout: 30000 });
1148-
await expect(messagesView(page).getByText(providerError)).toHaveCount(0);
1152+
await expect(messagesView(page).getByText(providerError)).toBeVisible();
11491153

11501154
await sendAndExpectReply(page, afterErrorPrompt, afterErrorReply);
11511155
const messages = await waitForMessages(

0 commit comments

Comments
 (0)