diff --git a/credentials/UnstractApi.credentials.ts b/credentials/UnstractApi.credentials.ts index 06aa8a1..0a37b00 100644 --- a/credentials/UnstractApi.credentials.ts +++ b/credentials/UnstractApi.credentials.ts @@ -9,7 +9,7 @@ export class UnstractApi implements ICredentialType { displayName = 'Unstract API'; documentationUrl = 'https://docs.unstract.com/unstract/index.html'; icon = 'file:llmWhisperer.svg' as const; - + properties: INodeProperties[] = [ { displayName: 'API Key', @@ -36,7 +36,7 @@ export class UnstractApi implements ICredentialType { type: 'generic', properties: { headers: { - 'Authorization': '=Bearer apiKey', + 'Authorization': '=Bearer {{$credentials.apiKey}}', }, }, }; diff --git a/credentials/UnstractHITLApi.credentials.ts b/credentials/UnstractHITLApi.credentials.ts index e18de2c..35b819e 100644 --- a/credentials/UnstractHITLApi.credentials.ts +++ b/credentials/UnstractHITLApi.credentials.ts @@ -9,7 +9,7 @@ export class UnstractHITLApi implements ICredentialType { displayName = 'Unstract HITL API'; documentationUrl = 'https://docs.unstract.com/unstract/index.html'; icon = 'file:llmWhisperer.svg' as const; - + properties: INodeProperties[] = [ { displayName: 'HITL Key', @@ -36,7 +36,7 @@ export class UnstractHITLApi implements ICredentialType { type: 'generic', properties: { headers: { - 'Authorization': '=Bearer apiKey', + 'Authorization': '=Bearer {{$credentials.HITLKey}}', }, }, }; diff --git a/nodes/LlmWhisperer/LlmWhisperer.node.ts b/nodes/LlmWhisperer/LlmWhisperer.node.ts index 08352d2..5a417f3 100644 --- a/nodes/LlmWhisperer/LlmWhisperer.node.ts +++ b/nodes/LlmWhisperer/LlmWhisperer.node.ts @@ -195,7 +195,7 @@ export class LlmWhisperer implements INodeType { const returnData: INodeExecutionData[] = []; try { - const { helpers, logger } = this; + const { helpers } = this; for (let i = 0; i < items.length; i++) { const fileContents = this.getNodeParameter('file_contents', i) as string; @@ -250,20 +250,25 @@ export class LlmWhisperer implements INodeType { accept: 'application/json', }; - logger.info('Making API request to LLMWhisperer API...'); + let result: any; try { result = await helpers.httpRequestWithAuthentication.call(this, 'llmWhispererApi', requestOptions); - } catch (requestError) { - logger.error('Error during LLMWhisperer API request:', requestError); + } catch (requestError: any) { throw requestError; } - if (result.status && result.status !== 202) { - throw new NodeOperationError(this.getNode(), result.body); + // httpRequestWithAuthentication returns already-parsed JSON if Content-Type is application/json + const resultContent = typeof result === 'string' ? JSON.parse(result) : result; + + + if (!resultContent.whisper_hash) { + throw new NodeOperationError( + this.getNode(), + `Invalid API response: ${resultContent.message || JSON.stringify(resultContent)}`, + ); } - const resultContent = JSON.parse(result) as any; const whisperHash = resultContent.whisper_hash; let status = 'processing'; @@ -281,7 +286,8 @@ export class LlmWhisperer implements INodeType { }, }); - resultContentX = JSON.parse(statusResult); + // httpRequestWithAuthentication returns already-parsed JSON if Content-Type is application/json + resultContentX = typeof statusResult === 'string' ? JSON.parse(statusResult) : statusResult; status = resultContentX.status; const currentTime = Date.now(); @@ -313,7 +319,9 @@ export class LlmWhisperer implements INodeType { }, }); - const retrieveResultContent = JSON.parse(retrieveResult); + // httpRequestWithAuthentication returns already-parsed JSON if Content-Type is application/json + const retrieveResultContent = typeof retrieveResult === 'string' ? JSON.parse(retrieveResult) : retrieveResult; + delete retrieveResultContent.metadata; delete retrieveResultContent.webhook_metadata; returnData.push({ diff --git a/nodes/Unstract/Unstract.node.ts b/nodes/Unstract/Unstract.node.ts index d74b14c..4b7a422 100644 --- a/nodes/Unstract/Unstract.node.ts +++ b/nodes/Unstract/Unstract.node.ts @@ -112,8 +112,9 @@ export class Unstract implements INodeType { try { const credentials = await this.getCredentials('unstractApi'); + const apiKey = credentials.apiKey as string; const orgId = credentials.orgId as string; - const { helpers, logger } = this; + const { helpers } = this; for (let i = 0; i < items.length; i++) { const binaryPropertyName = this.getNodeParameter('file_contents', i) as string; @@ -136,34 +137,75 @@ export class Unstract implements INodeType { const tags = this.getNodeParameter('tags', i) as string; const useFileHistory = this.getNodeParameter('use_file_history', i) as boolean; - const formData: any = { - files: { - value: fileBuffer, - options: { - filename: binaryData.fileName, - contentType: binaryData.mimeType, - }, - }, - timeout: 1, + // Manual multipart/form-data construction (cloud-compatible, no external dependencies) + // Workaround for n8n issue #18271 where httpRequestWithAuthentication doesn't properly + // handle formData objects. See: https://github.com/n8n-io/n8n/issues/18271 + const boundary = `----n8nFormBoundary${Date.now()}`; + const CRLF = '\r\n'; + + // Build multipart body parts + const parts: Buffer[] = []; + + // File field + parts.push(Buffer.from( + `--${boundary}${CRLF}` + + `Content-Disposition: form-data; name="files"; filename="${binaryData.fileName}"${CRLF}` + + `Content-Type: ${binaryData.mimeType}${CRLF}${CRLF}` + )); + parts.push(fileBuffer); + parts.push(Buffer.from(CRLF)); + + // Other fields + const fields = { + timeout: '1', include_metrics: includeMetrics.toString(), include_metadata: includeMetadata.toString(), use_file_history: useFileHistory.toString(), + ...(tags && { tags }), }; - if (tags) { - formData.tags = tags; + for (const [name, value] of Object.entries(fields)) { + if (value) { + parts.push(Buffer.from( + `--${boundary}${CRLF}` + + `Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}` + + `${value}${CRLF}` + )); + } } - const requestOptions = { + // Closing boundary + parts.push(Buffer.from(`--${boundary}--${CRLF}`)); + + // Combine all parts + const body = Buffer.concat(parts); + + const requestOptions: any = { method: 'POST' as IHttpRequestMethods, url: `${host}/deployment/api/${orgId}/${deploymentName}/`, - formData, + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': body.length.toString(), + }, + body, timeout: 5 * 60 * 1000, }; - logger.info('Making API request to Unstract API...'); + const result = await helpers.httpRequestWithAuthentication.call(this, 'unstractApi', requestOptions); - let resultContent = JSON.parse(result).message; + // httpRequestWithAuthentication returns already-parsed JSON if Content-Type is application/json + const resultData = typeof result === 'string' ? JSON.parse(result) : result; + + + if (!resultData || !resultData.message) { + throw new NodeOperationError( + this.getNode(), + `Unexpected API response structure: ${JSON.stringify(resultData)}`, + ); + } + + let resultContent = resultData.message; let executionStatus = resultContent.execution_status; if (executionStatus === 'PENDING' || executionStatus === 'EXECUTING') { @@ -173,24 +215,34 @@ export class Unstract implements INodeType { while (executionStatus !== 'COMPLETED') { await sleep(2000); - const statusRequestOptions = { + const statusRequestOptions: any = { method: 'GET' as IHttpRequestMethods, url: `${host}${statusApi}`, + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, timeout: 5 * 60 * 1000, }; + try { - const statusResult = await helpers.httpRequestWithAuthentication.call(this, 'unstractApi', statusRequestOptions); - resultContent = JSON.parse(statusResult); + const statusResult = await helpers.httpRequest(statusRequestOptions); + resultContent = typeof statusResult === 'string' ? JSON.parse(statusResult) : statusResult; executionStatus = resultContent.status; } catch (error: any) { - if (error.response && error.response.statusCode === 400) { - throw new NodeOperationError(this.getNode(), `Error: ${error}`); + // HTTP 422 indicates execution still in progress - this is expected + if (error.response?.status === 422 && error.response?.data) { + resultContent = typeof error.response.data === 'string' ? JSON.parse(error.response.data) : error.response.data; + executionStatus = resultContent.status; + } else { + // Actual error - log and rethrow + if (error.response?.status) { + } + throw new NodeOperationError( + this.getNode(), + `Failed to check execution status: ${error.message}`, + ); } - const jsonResponse = error.message.split(' - ')[1]; - const cleanJson = jsonResponse.replace(/\\"/g, '"').slice(1, -1); - resultContent = JSON.parse(cleanJson); - executionStatus = resultContent.status; } const t2 = new Date(); @@ -216,6 +268,12 @@ export class Unstract implements INodeType { return [returnData]; } catch (error: any) { + if (error.response?.data) { + } + if (error.response?.status) { + } + if (error.context?.data) { + } if (error.message) { throw new NodeOperationError(this.getNode(), error.message); } diff --git a/nodes/UnstractHitlPush/UnstractHitlPush.node.ts b/nodes/UnstractHitlPush/UnstractHitlPush.node.ts index 9b769e2..3778baf 100644 --- a/nodes/UnstractHitlPush/UnstractHitlPush.node.ts +++ b/nodes/UnstractHitlPush/UnstractHitlPush.node.ts @@ -114,12 +114,13 @@ export class UnstractHitlPush implements INodeType { try { const credentials = await this.getCredentials('unstractApi'); + const apiKey = credentials.apiKey as string; const orgId = credentials.orgId as string; - const { helpers, logger } = this; + const { helpers } = this; for (let i = 0; i < items.length; i++) { const binaryPropertyName = this.getNodeParameter('file_contents', i) as string; - + if (!items[i].binary?.[binaryPropertyName]) { throw new NodeOperationError(this.getNode(), `No binary data property "${binaryPropertyName}" exists on input`); } @@ -136,62 +137,112 @@ export class UnstractHitlPush implements INodeType { const useFileHistory = this.getNodeParameter('use_file_history', i) as boolean; const hitlQueueName = this.getNodeParameter('hitl_queue_name', i) as string; - const formData: any = { - files: { - value: fileBuffer, - options: { - filename: binaryData.fileName, - contentType: binaryData.mimeType, - }, - }, - timeout: 1, + // Manual multipart/form-data construction (cloud-compatible, no external dependencies) + // Workaround for n8n issue #18271 where httpRequestWithAuthentication doesn't properly + // handle formData objects. See: https://github.com/n8n-io/n8n/issues/18271 + const boundary = `----n8nFormBoundary${Date.now()}`; + const CRLF = '\r\n'; + + // Build multipart body parts + const parts: Buffer[] = []; + + // File field + parts.push(Buffer.from( + `--${boundary}${CRLF}` + + `Content-Disposition: form-data; name="files"; filename="${binaryData.fileName}"${CRLF}` + + `Content-Type: ${binaryData.mimeType}${CRLF}${CRLF}` + )); + parts.push(fileBuffer); + parts.push(Buffer.from(CRLF)); + + // Other fields + const fields = { + timeout: '1', include_metrics: includeMetrics.toString(), include_metadata: includeMetadata.toString(), use_file_history: useFileHistory.toString(), hitl_queue_name: hitlQueueName, + ...(tags && { tags }), }; - if (tags) { - formData.tags = tags; + for (const [name, value] of Object.entries(fields)) { + if (value) { + parts.push(Buffer.from( + `--${boundary}${CRLF}` + + `Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}` + + `${value}${CRLF}` + )); + } } - const requestOptions = { + // Closing boundary + parts.push(Buffer.from(`--${boundary}--${CRLF}`)); + + // Combine all parts + const body = Buffer.concat(parts); + + const requestOptions: any = { method: 'POST' as IHttpRequestMethods, url: `${host}/deployment/api/${orgId}/${deploymentName}/`, - formData, + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': body.length.toString(), + }, + body, timeout: 5 * 60 * 1000, }; - logger.info('[HITL] Sending file to Unstract HITL API...'); + const result = await helpers.httpRequestWithAuthentication.call(this, 'unstractApi', requestOptions); - let resultContent = JSON.parse(result).message; + // httpRequestWithAuthentication returns already-parsed JSON if Content-Type is application/json + const resultData = typeof result === 'string' ? JSON.parse(result) : result; + + + if (!resultData || !resultData.message) { + throw new NodeOperationError( + this.getNode(), + `Unexpected API response structure: ${JSON.stringify(resultData)}`, + ); + } + + let resultContent = resultData.message; let executionStatus = resultContent.execution_status; if (executionStatus === 'PENDING' || executionStatus === 'EXECUTING') { - const statusApi = resultContent.status_api; const t1 = new Date(); + const statusApi = resultContent.status_api; while (executionStatus !== 'COMPLETED') { await sleep(2000); - const pollRequest = { + const statusRequestOptions: any = { method: 'GET' as IHttpRequestMethods, url: `${host}${statusApi}`, + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, timeout: 5 * 60 * 1000, }; try { - const pollResult = await helpers.httpRequestWithAuthentication.call(this, 'unstractApi', pollRequest); - resultContent = JSON.parse(pollResult); + const statusResult = await helpers.httpRequest(statusRequestOptions); + resultContent = typeof statusResult === 'string' ? JSON.parse(statusResult) : statusResult; executionStatus = resultContent.status; } catch (error: any) { - if (error.response && error.response.statusCode === 400) { - throw new NodeOperationError(this.getNode(), `Polling error: ${error}`); + // HTTP 422 indicates execution still in progress - this is expected + if (error.response?.status === 422 && error.response?.data) { + resultContent = typeof error.response.data === 'string' ? JSON.parse(error.response.data) : error.response.data; + executionStatus = resultContent.status; + } else { + // Actual error - log and rethrow + if (error.response?.status) { + } + throw new NodeOperationError( + this.getNode(), + `Failed to check execution status: ${error.message}`, + ); } - const json = error.message.split(' - ')[1]; - const cleanJson = json.replace(/\\"/g, '"').slice(1, -1); - resultContent = JSON.parse(cleanJson); - executionStatus = resultContent.status; } const t2 = new Date();