🚏 fix: Preserve Endpoint Routing on HITL Resume Replay (#14948)

This commit is contained in:
Danny Avila 2026-08-17 18:02:16 -04:00 committed by GitHub
parent 68ea03ffb2
commit 7ee9e4e363
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 98 additions and 8 deletions

View file

@ -5,6 +5,7 @@ const {
generateCheckAccess,
skipAgentCheck,
applyResumeContext,
applyResumeModelParameters,
GenerationJobManager,
} = require('@librechat/api');
const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider');
@ -56,14 +57,9 @@ const restoreResumeContext = async (req, res, next) => {
// resume payload omits — without this the continuation runs with defaults. They're
// scattered top-level fields (folded into model_parameters by buildOptions' rest
// spread), not part of the RESUME_CONTEXT_KEYS allowlist, so merge them back here.
// Authoritative: overwrites any client-supplied values with the captured set.
// `model` is excluded — it's replayed via RESUME_CONTEXT_KEYS to the exact value the
// resume fingerprint was pinned on, so overwriting it here could trip that check.
const resumedModelParameters = resumeContext?.model_parameters;
if (resumedModelParameters && typeof resumedModelParameters === 'object') {
const { model: _replayedModel, ...replayParams } = resumedModelParameters;
Object.assign(req.body, replayParams);
}
// Generation params are authoritative, but routing, graph identity, and resume-action
// fields remain owned by the restored context/request envelope.
applyResumeModelParameters(req.body, resumeContext?.model_parameters);
}
} catch (err) {
logger.warn('[agents/chat] Failed to restore resume context', err?.message ?? err);

View file

@ -12,6 +12,7 @@ import {
sanitizeResumeModelParameters,
pickResumeContext,
applyResumeContext,
applyResumeModelParameters,
exemptAskUserQuestionFromApproval,
} from './policy';
@ -319,6 +320,7 @@ describe('sanitizeResumeModelParameters', () => {
authOptions: { credentials: { private_key: 'google-secret' } },
credentials: { accessKeyId: 'aws-id', secretAccessKey: 'aws-secret' },
client: { config: { token: { token: 'bedrock-bearer' } } },
endpoint: 'aiplatform.eu.rep.googleapis.com',
endpointHost: 'vpce.internal.example',
baseURL: 'https://internal-gateway.example',
});
@ -474,6 +476,19 @@ describe('captureResumeModelParameters', () => {
});
});
test('does not capture a provider transport endpoint for request replay (#14946)', () => {
expect(
captureResumeModelParameters(
{ temperature: 0.2 },
{
model: 'gemini-3.7-flash',
temperature: 0.2,
endpoint: 'aiplatform.eu.rep.googleapis.com',
},
),
).toEqual({ model: 'gemini-3.7-flash', temperature: 0.2 });
});
test('only replays schema-known generation params; identity fields stay owned elsewhere', () => {
// model/spec/modelLabel/promptPrefix ride RESUME_CONTEXT_KEYS; text/files/etc.
// never reach model_parameters (parseCompactConvo strips them).
@ -694,6 +709,52 @@ describe('pickResumeContext / applyResumeContext', () => {
});
});
describe('applyResumeModelParameters', () => {
it('replays generation params without replacing routing or resume identity fields (#14946)', () => {
const body: Record<string, unknown> = {
conversationId: 'conversation-1',
generationCreatedAt: 123,
actionId: 'action-1',
endpoint: 'agents',
endpointType: 'google',
agent_id: 'agent-1',
model: 'gemini-3.7-flash',
temperature: 1,
};
applyResumeModelParameters(body, {
conversationId: 'provider-conversation',
generationCreatedAt: 999,
actionId: 'provider-action',
endpoint: 'aiplatform.eu.rep.googleapis.com',
endpointType: 'custom',
agent_id: 'provider-agent',
model: 'provider-model',
temperature: 0.2,
maxOutputTokens: 2048,
});
expect(body).toEqual({
conversationId: 'conversation-1',
generationCreatedAt: 123,
actionId: 'action-1',
endpoint: 'agents',
endpointType: 'google',
agent_id: 'agent-1',
model: 'gemini-3.7-flash',
temperature: 0.2,
maxOutputTokens: 2048,
});
});
it('is a no-op for invalid captured parameters', () => {
const body: Record<string, unknown> = { endpoint: 'agents' };
applyResumeModelParameters(body, undefined);
applyResumeModelParameters(body, ['aiplatform.eu.rep.googleapis.com']);
expect(body).toEqual({ endpoint: 'agents' });
});
});
describe('exemptAskUserQuestionFromApproval', () => {
const NAME = 'ask_user_question';
it('adds the tool to allow when the admin did not mention it', () => {

View file

@ -278,6 +278,7 @@ const SENSITIVE_PARAM_KEYS = new Set([
'httpagent',
'httpsagent',
'callbacks',
'endpoint',
'endpointhost',
'endpoint_host',
]);
@ -515,6 +516,38 @@ export function applyResumeContext(
}
}
/** Request-envelope fields that resolved provider params must never replace. */
const RESUME_REQUEST_CONTROL_KEYS = new Set<string>([
...RESUME_CONTEXT_KEYS,
'conversationId',
'generationCreatedAt',
'generationProtocolVersion',
'actionId',
'decisions',
'answer',
'answers',
'isTemporary',
]);
/**
* Replay captured generation parameters without allowing provider configuration to
* replace routing, graph identity, or resume-action fields. This guard also makes
* pending actions captured by older versions safe to resume after an upgrade.
*/
export function applyResumeModelParameters(
body: Record<string, unknown> | undefined | null,
params: unknown,
): void {
if (body == null || params == null || typeof params !== 'object' || Array.isArray(params)) {
return;
}
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
if (!RESUME_REQUEST_CONTROL_KEYS.has(key)) {
body[key] = value;
}
}
}
export function computeAgentRequestFingerprint(fields: AgentRequestFingerprintFields): string {
const canonical = JSON.stringify({
endpoint: fields.endpoint ?? null,