mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🚏 fix: Preserve Endpoint Routing on HITL Resume Replay (#14948)
This commit is contained in:
parent
68ea03ffb2
commit
7ee9e4e363
3 changed files with 98 additions and 8 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue