From 7ee9e4e3638f4e36118c0b5e0d0700eb76c98ecb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 17 Aug 2026 18:02:16 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=8F=20fix:=20Preserve=20Endpoint=20Rou?= =?UTF-8?q?ting=20on=20HITL=20Resume=20Replay=20(#14948)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/routes/agents/chat.js | 12 ++-- packages/api/src/agents/hitl/policy.spec.ts | 61 +++++++++++++++++++++ packages/api/src/agents/hitl/policy.ts | 33 +++++++++++ 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js index 9463406652..1cd52c6d8c 100644 --- a/api/server/routes/agents/chat.js +++ b/api/server/routes/agents/chat.js @@ -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); diff --git a/packages/api/src/agents/hitl/policy.spec.ts b/packages/api/src/agents/hitl/policy.spec.ts index 46a6e3175e..1a43846d54 100644 --- a/packages/api/src/agents/hitl/policy.spec.ts +++ b/packages/api/src/agents/hitl/policy.spec.ts @@ -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 = { + 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 = { 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', () => { diff --git a/packages/api/src/agents/hitl/policy.ts b/packages/api/src/agents/hitl/policy.ts index 5550873322..535ce37d72 100644 --- a/packages/api/src/agents/hitl/policy.ts +++ b/packages/api/src/agents/hitl/policy.ts @@ -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([ + ...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 | 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)) { + if (!RESUME_REQUEST_CONTROL_KEYS.has(key)) { + body[key] = value; + } + } +} + export function computeAgentRequestFingerprint(fields: AgentRequestFingerprintFields): string { const canonical = JSON.stringify({ endpoint: fields.endpoint ?? null,