Types/OpenAIClient.ps1

class OpenAIClient {
    [System.Management.Automation.HiddenAttribute()]
    [string]$apikey
    [System.Management.Automation.HiddenAttribute()]
    [string]$baseUri
    [System.Management.Automation.HiddenAttribute()]
    [string]$model
    [System.Management.Automation.HiddenAttribute()]
    [hashtable]$headers
    [System.Management.Automation.HiddenAttribute()]
    [string]$apiVersion

    [Assistant]$assistants
    [Vector_store]$vector_stores
    [File]$files
    [Thread]$threads

    OpenAIClient([string]$apiKey, [string]$baseUri, [string]$model, [string]$apiVersion) {
        $this.apikey = $apiKey
        $this.baseUri = $baseUri
        $this.model = $model
        $this.apiVersion = $apiVersion

        $this.init()
    }
    [System.Management.Automation.HiddenAttribute()]
    [void]init() {
        # check the apikey, endpoint and model, if empty, then return error
        $this.headers = @{
            "OpenAI-Beta" = "assistants=v2"
        }

        if ($this.baseUri -match "azure") {
            $this.headers.Add("api-key", $this.apikey)
            $this.baseUri = $this.baseUri + "openai/"
        }
        else {
            $this.headers.Add("Authorization", "Bearer $($this.apikey)")
            $this.apiVersion = ""
        }

        $this.assistants = [Assistant]::new($this)
        $this.vector_stores = [Vector_store]::new($this)
        $this.files = [File]::new($this)
        $this.threads = [Thread]::new($this)
    }

    [psobject]web(
        [string]$urifragment, 
        [string]$method = "GET", 
        [psobject]$body = $null) {
        $url = "{0}{1}" -f $this.baseUri, $urifragment
        if ($this.apiVersion -ne "") {
            if ($url -match "\?") {
                $url = "{0}&api-version={1}" -f $url, $this.apiVersion
            }
            else {
                $url = "{0}?api-version={1}" -f $url, $this.apiVersion
            }
        }

        if ($method -eq "GET" -or $null -eq $body) {
            $params = @{
                Method  = $method
                Uri     = $url
                Headers = $this.headers
            }
            return $this.unicodeiwr($params)
        }
        else {

            $params = @{
                Method  = $method
                Uri     = $url
                Headers = $this.headers
                Body    = ($body | ConvertTo-Json -Depth 10)
            }
            return $this.unicodeiwr($params)
        }
    }

    [System.Management.Automation.HiddenAttribute()]
    [psobject]unicodeiwr([hashtable]$params) {
        $oldProgressPreference = Get-Variable -Name ProgressPreference -ValueOnly
        Set-Variable -Name ProgressPreference -Value "SilentlyContinue" -Scope Script -Force
        $response = Invoke-WebRequest @params -ContentType "application/json;charset=utf-8"
        Set-Variable -Name ProgressPreference -Value $oldProgressPreference -Scope Script -Force

        $contentType = $response.Headers["Content-Type"]
        $version = Get-Variable -Name PSVersionTable -ValueOnly
        if ($version.PSVersion.Major -gt 5 -or $contentType -match 'charset=utf-8') {
            return $response.Content | ConvertFrom-Json
        }
        else {
            $response = $response.Content
            $charset = if ($contentType -match "charset=([^;]+)") { $matches[1] } else { "ISO-8859-1" } 
            $dstEncoding = [System.Text.Encoding]::GetEncoding($charset)
            $srcEncoding = [System.Text.Encoding]::UTF8
            $result = $srcEncoding.GetString([System.Text.Encoding]::Convert($srcEncoding, $dstEncoding, $srcEncoding.GetBytes($response)))
            return $result | ConvertFrom-Json
        }
    }

    [psobject]web($urifragment) {
        return $this.web($urifragment, "GET", @{})
    }
}

class AssistantResource {
    [System.Management.Automation.HiddenAttribute()]
    [OpenAIClient]$client
    [System.Management.Automation.HiddenAttribute()]
    [string]$urifragment
    [System.Management.Automation.HiddenAttribute()]
    [string]$objTypeName

    AssistantResource([OpenAIClient]$client, [string]$urifragment, [string]$objTypeName) {
        $this.client = $client
        $this.urifragment = $urifragment
        $this.objTypeName = $objTypeName
    }
    [psobject[]]list() {

        if ($this.objTypeName) {
            return $this.client.web($this.urifragment).data | ForEach-Object {
                $temp = "{0}/{1}" -f $this.urifragment, $_.id
                $result = New-Object -TypeName $this.objTypeName -ArgumentList $_
                $result | Add-Member -MemberType NoteProperty -Name client -Value $this.client
                $result | Add-Member -MemberType NoteProperty -Name urifragment -Value $temp
                $result
            }
        }

        return $this.client.web($this.urifragment).data
    }
    
    [psobject]get([string]$id) {
        if ($this.objTypeName) {
            $temp = "{0}/{1}" -f $this.urifragment, $id
            $result = New-Object -TypeName $this.objTypeName -ArgumentList $this.client.web($temp)
            $result | Add-Member -MemberType NoteProperty -Name client -Value $this.client
            $result | Add-Member -MemberType NoteProperty -Name urifragment -Value $temp
            return $result
        }

        return $this.client.web("$($this.urifragment)/$id")
    }

    [psobject]delete([string]$id) {
        return $this.client.web("$($this.urifragment)/$id", "DELETE", @{})
    }

    [psobject]create([hashtable]$body) {
        if ($this.objTypeName) {
            $result = New-Object -TypeName $this.objTypeName -ArgumentList $this.client.web("$($this.urifragment)", "POST", $body)
            $result | Add-Member -MemberType NoteProperty -Name client -Value $this.client
            $result | Add-Member -MemberType NoteProperty -Name urifragment -Value "$($this.urifragment)/$($result.id)"
            return $result
        }
        return $this.client.web("$($this.urifragment)", "POST", $body)
    }

    [psobject]create() {
        return $this.create(@{})
    }

    
    [void]clear() {
        # warn user this is very dangerous action, it will remove all the instance, and ask for confirmation
        $confirm = Read-Host "Are you sure you want to remove all the instances? (yes/no)"
        if ($confirm -ne "yes" -and $confirm -ne "y") {
            return
        }
        # get all the instances and remove it
        $this.list() | ForEach-Object {
            $this.delete($_.id)
            Write-Host "remove the instance: $($_.id)"
        }
    }
}

class AssistantResourceObject {
    AssistantResourceObject([psobject]$data) {
        # check all the properties and assign it to the object
        $data.PSObject.Properties | ForEach-Object {
            $this | Add-Member -MemberType NoteProperty -Name $_.Name -Value $_.Value
        }
    }

    [AssistantResourceObject]update([hashtable]$data) {
        $this.client.web($this.urifragment, "PATCH", $data)
        return $this
    }
}


class FileObject:AssistantResourceObject {
    FileObject([psobject]$data):base($data) {}
    [AssistantResourceObject]update([hashtable]$data) {
        Write-Host "You can't update the file object."
        return $this
    }

}

class File:AssistantResource {
    File([OpenAIClient]$client): base($client, "files", "FileObject") {}

    [psobject]create([hashtable]$body) {
        if ($body.files) {
            $files = $body.files
            return $this.upload($files)
        }

        throw "The body must contain 'files' key."
    }


    [System.Management.Automation.HiddenAttribute()]
    [FileObject[]]upload([string[]]$fullname) {

        $PSVersion = Get-Variable -Name PSVersionTable -ValueOnly
        if ($PSVersion.PSVersion.Major -lt 6) {
            throw "The upload file feature is only supported in PowerShell 6 or later."
        }

        # process the input, if it is a wildcard or a folder, then get all the files based on this pattern
        $fullname = $fullname | Get-ChildItem | Select-Object -ExpandProperty FullName
        # read all the files and check the filename, compute
        $existing_files = $this.list() | Select-Object id, @{l = "hash"; e = { $_.filename.split("-")[0] } }
        $localfiles = $fullname | Select-Object @{l = "fullname"; e = { $_ } }, @{l = "hash"; e = { (Get-FileHash $_).Hash } }
        $result = @(
            $existing_files | Where-Object {
                $_.hash -in $localfiles.hash
            } | ForEach-Object {
                [FileObject]::new($_)
            }
        )

        $fullname = $localfiles | Where-Object {
            $_.hash -notin $existing_files.hash
        } | Select-Object -ExpandProperty fullname

        if ($fullname.Count -gt 0) {
            # confirm if user want to upload those files to openai
            $confirm = Read-Host "Are you sure you want to upload the $($fullname.Count) files? (yes/no)"
            if ($confirm -ne "yes" -and $confirm -ne "y") {
                throw "The user canceled the operation."
            }


            $url = "{0}{1}" -f $this.client.baseUri, $this.urifragment
            if ($this.client.baseUri -match "azure") {
                $url = "{0}?api-version=2024-05-01-preview" -f $url
            }


            foreach ($file in $fullname) {
                Write-Host "process file: $file"
                $name = "{0}-{1}" -f (Get-FileHash $file).Hash, (Split-Path $file -Leaf)
                # rename the file to the new name
                Rename-Item -Path $file -NewName $name
                $temppath = Join-Path -Path (Split-Path $file) -ChildPath $name
                try{
                    $form = @{
                        file    = Get-Item -Path $temppath
                        purpose = "assistants"
                    }

                    $response = Invoke-RestMethod -Uri $url -Method Post -Headers $this.client.headers -Form $form
                    $result += [FileObject]::new($response)
                }
                finally{
                    # rename the file back to the original name
                    Rename-Item -Path $temppath -NewName (Split-Path $file -Leaf)
                }
            }
        }

        return $result
    }

  
}

class Assistant:AssistantResource {
    Assistant([OpenAIClient]$client): base($client, "assistants", "AssistantObject") {}


    <#
        .SYNOPSIS
            Create a new assistant
        .DESCRIPTION
            Create a new assistant with the given name, model, and instructions.
        .PARAMETER body
            The body must contain 'name', 'model', and 'instructions' keys. But it can also contain 'config', 'vector_store_ids', 'functions', and 'files' keys.
    #>

    [AssistantObject]create([hashtable]$body) {
        if ($body.name -and $body.model -and $body.instructions) {
            $vector_store_ids = $body.vector_store_ids
            $functions = $body.functions
            $files = $body.files
            $config = $body.config

            if ($files) {
                # upload the files and create new vector store
                $file_ids = $this.client.files.create(@{ "files" = $files }) | Select-Object -ExpandProperty id 
                $body.Add("tools", @(
                        @{
                            "type" = "file_search"
                        }))
                        
                $body.Add("tool_resources", @{
                        "file_search" = @{
                            "vector_stores" = @(
                                @{
                                    file_ids = @($file_ids)
                                })
                        }
                    })
                
            }

            if ($vector_store_ids -and $vector_store_ids.Count -gt 0) {
                $body.Add("tool_resources", @{
                        "file_search" = @{
                            "vector_store_ids" = @($vector_store_ids)
                        }
                    })
                
                $body.Add("tools", @(
                        @{
                            "type" = "file_search"
                        }))
            }

            if ($functions -and $functions.Count -gt 0) {
        
                if ($null -eq $body.tools) {
                    $body.Add("tools", @())
                }

                $functions | ForEach-Object {
                    $func = Get-FunctionJson -functionName $_
                    $body.tools += $func
                }
            }

            if ($config) {
                Merge-Hashtable -table1 $body -table2 $config
            }

            # remove files, vector_store_ids, functions, and config from the body
            $body.Remove("files")
            $body.Remove("vector_store_ids")
            $body.Remove("functions")
            $body.Remove("config")
            
            $result = [AssistantObject]::new($this.client.web("$($this.urifragment)", "POST", $body)) 
            $result | Add-Member -MemberType NoteProperty -Name client -Value $this.client
            return $result
        }
        
        throw "The body must contain 'name' and 'model', 'instructions' keys."
    }
}



class AssistantObject:AssistantResourceObject {
    [ThreadObject]$thread
        
    AssistantObject([psobject]$data):base($data) {}

    [void]chat([bool]$clean = $false) {
        if (-not $this.thread) {
            # create a thread, and associate the assistant id
            $this.thread = $this.client.threads.create($this.id)
        }

        try {
            while ($true) {
                # ask use to input, until the user type 'q' or 'bye'
                $prompt = Read-Host ">"
                if ($prompt -eq "q" -or $prompt -eq "bye") {
                    break
                }

                # send the message to the thread
                $response = $this.thread.send($prompt).run().get_last_message()

                if ($response) {
                    Write-Host $response -ForegroundColor Green
                }
            }
        }
        finally {
            $this.client.threads.delete($this.thread.id)
            if ($clean) {
                Write-Host "clean up the thread, assistant, and vector_store..." -ForegroundColor Yellow
                # clean up the thread, assistant, and vector_store
                $vc_id = $this.tool_resources.file_search.vector_store_ids[0]
                $this.client.vector_stores.delete($vc_id)
                $this.client.assistants.delete($this.id)
            }
        }

    }
}


class ThreadObject:AssistantResourceObject {
    ThreadObject([psobject]$data):base($data) {}

    [ThreadObject]send([string]$message) {
        # send a message
        [AssistantResource]::new($this.client, ("threads/{0}/messages" -f $this.id), $null ).create(@{
                role    = "user"
                content = $message
            }) | Out-Null
            
        return $this
    }

    [ThreadObject]run([string]$assistantId) {
        $obj = [AssistantResource]::new($this.client, ("threads/{0}/runs" -f $this.id), $null ).create(@{assistant_id = $assistantId })
        if ($null -eq $this.last_run_id) {
            $this | Add-Member -MemberType NoteProperty -Name last_run_id -Value $obj.id
        }
        else {
            $this.last_run_id = $obj.id
        }
        return $this
    }

    [ThreadObject]run() {
        return $this.run($this.assistant_id)
    }

    [string]get_last_message() {
        # check if the last_run is set, if not, then return null
        if ($this.last_run_id) {
            $run = [AssistantResource]::new($this.client, ("threads/{0}/runs" -f $this.id), $null ).get($this.last_run_id)
            while ($run.status -ne "completed") {
                Write-Verbose ("Run status: {0}" -f $run.status)

                if ($run.status -eq "failed") {
                    Write-Host ("Run failed: {0}" -f $run.last_error.message) -ForegroundColor Red
                    break
                }

                # The status of the run, which can be either queued, in_progress, requires_action, cancelling, cancelled, failed, completed, incomplete, or expired.

                if ($run.status -eq "requires_action") {
                    $tool_calls = $run.required_action.submit_tool_outputs.tool_calls
                    $tool_output = @()

                    if ($tool_calls -and $tool_calls.Count -gt 0) {

                        foreach ($tool_call in $tool_calls) {
                            $call_id = $tool_call.id
                            $function = $tool_call.function
                            $function_args = $function.arguments | ConvertFrom-Json
                            $exp = "{0} {1}" -f $function.name, (($function_args.PSObject.Properties | ForEach-Object {
                                        "-{0} '{1}'" -f $_.Name, $_.Value
                                    }) -join " ")
                            Write-Verbose "calling function with arguments: $exp"
                            $call_response = Invoke-Expression $exp

                            $tool_output += @{
                                tool_call_id = $call_id
                                output       = $call_response
                            }
                        }
                    }
                    [AssistantResource]::new($this.client, ("threads/{0}/runs/{1}/submit_tool_outputs" -f $this.id, $this.last_run_id), $null ).create(@{tool_outputs = $tool_output })

                }
        
                Start-Sleep -Milliseconds 500
                $run = [AssistantResource]::new($this.client, ("threads/{0}/runs" -f $this.id), $null ).get($this.last_run_id)
            }


            $message = [AssistantResource]::new($this.client, ("threads/{0}/messages?limit=1" -f $this.id), $null).list() | Select-Object id, role, content -First 1

            return $message.content.text.value
        }

        return $null
    }
}
class Thread:AssistantResource {
    Thread([OpenAIClient]$client): base($client, "threads", "ThreadObject") {}

    [psobject[]]list() {
        return @{
            error = "It is not implemented yet, you can't get all the thread information."
        }
    }

    [ThreadObject]create([string]$assistantId) {
        $result = $this.create()
        $result | Add-Member -MemberType NoteProperty -Name assistant_id -Value $assistantId
        return $result
    }
}

class Vector_storeObject:AssistantResourceObject {
    Vector_storeObject([psobject]$data):base($data) {}

    [string[]]file_ids() {
        return $this.client.web("vector_stores/$($this.id)/files").data | Select-Object -ExpandProperty id
    }
}

class Vector_store:AssistantResource {
    Vector_store([OpenAIClient]$client): base($client, "vector_stores", "Vector_storeObject") {}

    [psobject]create([hashtable]$body) {
        <#
            .SYNOPSIS
                Create a new vector store
            .DESCRIPTION
                Create a new vector store with the given name, file_ids, and days_to_expire.
            .PARAMETER body
                The body must contain 'name', 'file_ids', and 'days_to_expire' keys.
        #>


        # check if the body contains name, file_ids, and days_to_expire
        if ($body.name -and $body.file_ids -and $body.days_to_expire) {
            #replace the days_to_expire with expires_after
            $body.expires_after = @{
                "days"   = $body.days_to_expire
                "anchor" = "last_active_at"
            }
            $body.Remove("days_to_expire")
            return $this.client.web("$($this.urifragment)", "POST", $body)
        }
        
        throw "The body must contain 'name', 'file_ids', and 'days_to_expire' keys."
    }
}
# SIG # Begin signature block
# MIIc/wYJKoZIhvcNAQcCoIIc8DCCHOwCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCAtJ/mH2Uu/KLa
# 33+h7UDvnxIQ9twt/mc0fE1kQH5wIqCCAyowggMmMIICDqADAgECAhBcsg5m3zM9
# kUZxmeNzIQNjMA0GCSqGSIb3DQEBCwUAMCoxKDAmBgNVBAMMH0NIRU5YSVpIQU5H
# IC0gQ29kZSBTaWduaW5nIENlcnQwIBcNMjQwMTA4MTMwMjA0WhgPMjA5OTEyMzEx
# NjAwMDBaMCoxKDAmBgNVBAMMH0NIRU5YSVpIQU5HIC0gQ29kZSBTaWduaW5nIENl
# cnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDKDY3QG81JOKZG9jTb
# QriDMDhq6gy93Pmoqgav9wErj+CgVvXKk+lGpUu74MWVyLUrJx8/ACb4b287wsXx
# mQj8zQ3SqGn5CCjPKoAPsSbry0LOSl8bsFpwBr3YBJVL6cibhus2KLCbNu/u7sND
# wyivKXYA1Iy1uTQPNVPcBx36krZTZyyE4CmngO75YbTMEzvHEjM3BIXdKtEt673t
# iNOVSP6doh0zRwWEh2Y/eoOpv+FUokORwhKonxMtmIIET+ZPx7Ex+9aqHrliEabx
# FsN4ETnuVT3rST++7Q2fquWFnl5scDnisFhU8JL8k+OGUzpLlo/nOpiRZkbKCEkZ
# FCLhAgMBAAGjRjBEMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcD
# AzAdBgNVHQ4EFgQUwcR3UUOZ6TxpBp9MxnBygyIMhUQwDQYJKoZIhvcNAQELBQAD
# ggEBADwiE9nowKxUNN84BTk9an1ZkdU95ouj+q6MRbafH08u4XV7CxXpkPR8Za/c
# BJWTOqCuz9pMPo0TylqWPm+++Tqy1OJ7Qewvy1+DXPuFGkTqY721uZ+YsHY3CueC
# VSRZRNsWSYE9UxXXFRsjDu/M3+EvyaNDE4xQkwrP8obFJoHq7WaOCCD2wMbKjLb5
# bS/VgtOK7Yn9pU/ghrW+Em+zHOX87wNRh/I5jd+LsnY8bR6REzgdmogIyvD4dsJD
# /IZLxRtbm2BHOn/aGBdu+GpEaYEEb6VkWcJhrQnpiNjjlu43CbRz5Bw14XPWGUDH
# +EkUqkWS4h8zsRiyvR9Pnwklg6UxghkrMIIZJwIBATA+MCoxKDAmBgNVBAMMH0NI
# RU5YSVpIQU5HIC0gQ29kZSBTaWduaW5nIENlcnQCEFyyDmbfMz2RRnGZ43MhA2Mw
# DQYJYIZIAWUDBAIBBQCgfDAQBgorBgEEAYI3AgEMMQIwADAZBgkqhkiG9w0BCQMx
# DAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAvBgkq
# hkiG9w0BCQQxIgQgNi6zwHCqEoNU+Ww3IGgBpVW4LlmFoWAO1zoRxBSHvfAwDQYJ
# KoZIhvcNAQEBBQAEggEAnWfjcU+weZAy8tV4Phzwk4dHhcfp6hCXCf1xPa92eW5J
# L7YpeAqo+Kz6ms45huS1Im9DfG+YHi3fRUJmDFrhFiLdR74izWbWNcweMvmg4b9V
# 3Mh/vjz4GP3aRqZV5MrkqBsdXh/lo1vhFCOA5ibBfruGpqUFDoArAgwrvepQCwrV
# gzd58N1w/GJdlyE55rDihgkq0sh0BvXPufss0oykKHcqab2JTal2TOdPm/3WmmNl
# 72FLM2ck8tpTB4KKcsCMTQa5FWTs2OAtXiDau0JBXOUWbzDZV0qZv/YzqaZVHvou
# dQTYJQmHdbs8Cy/2WWE4SFO9+OV2kRl8Ft/B/lK9d6GCF0Awghc8BgorBgEEAYI3
# AwMBMYIXLDCCFygGCSqGSIb3DQEHAqCCFxkwghcVAgEDMQ8wDQYJYIZIAWUDBAIB
# BQAweAYLKoZIhvcNAQkQAQSgaQRnMGUCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFl
# AwQCAQUABCAkL+f4xpEuB4FTD2UuWE3Ili9EnqtFpub1xtugghSCVQIRALUw6Zbt
# sTvcvUmwziW65tAYDzIwMjQwNjI4MDg0ODI4WqCCEwkwggbCMIIEqqADAgECAhAF
# RK/zlJ0IOaa/2z9f5WEWMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcw
# FQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3Rl
# ZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjMwNzE0MDAw
# MDAwWhcNMzQxMDEzMjM1OTU5WjBIMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln
# aUNlcnQsIEluYy4xIDAeBgNVBAMTF0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDIzMIIC
# IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAo1NFhx2DjlusPlSzI+DPn9fl
# 0uddoQ4J3C9Io5d6OyqcZ9xiFVjBqZMRp82qsmrdECmKHmJjadNYnDVxvzqX65RQ
# jxwg6seaOy+WZuNp52n+W8PWKyAcwZeUtKVQgfLPywemMGjKg0La/H8JJJSkghra
# arrYO8pd3hkYhftF6g1hbJ3+cV7EBpo88MUueQ8bZlLjyNY+X9pD04T10Mf2SC1e
# RXWWdf7dEKEbg8G45lKVtUfXeCk5a+B4WZfjRCtK1ZXO7wgX6oJkTf8j48qG7rSk
# IWRw69XloNpjsy7pBe6q9iT1HbybHLK3X9/w7nZ9MZllR1WdSiQvrCuXvp/k/Xtz
# PjLuUjT71Lvr1KAsNJvj3m5kGQc3AZEPHLVRzapMZoOIaGK7vEEbeBlt5NkP4FhB
# +9ixLOFRr7StFQYU6mIIE9NpHnxkTZ0P387RXoyqq1AVybPKvNfEO2hEo6U7Qv1z
# fe7dCv95NBB+plwKWEwAPoVpdceDZNZ1zY8SdlalJPrXxGshuugfNJgvOuprAbD3
# +yqG7HtSOKmYCaFxsmxxrz64b5bV4RAT/mFHCoz+8LbH1cfebCTwv0KCyqBxPZyS
# kwS0aXAnDU+3tTbRyV8IpHCj7ArxES5k4MsiK8rxKBMhSVF+BmbTO77665E42FEH
# ypS34lCh8zrTioPLQHsCAwEAAaOCAYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNV
# HRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYG
# Z4EMAQQCMAsGCWCGSAGG/WwHATAfBgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGog
# j57IbzAdBgNVHQ4EFgQUpbbvE+fvzdBkodVWqWUxo97V40kwWgYDVR0fBFMwUTBP
# oE2gS4ZJaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0
# UlNBNDA5NlNIQTI1NlRpbWVTdGFtcGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMw
# gYAwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEF
# BQcwAoZMaHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3Rl
# ZEc0UlNBNDA5NlNIQTI1NlRpbWVTdGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsF
# AAOCAgEAgRrW3qCptZgXvHCNT4o8aJzYJf/LLOTN6l0ikuyMIgKpuM+AqNnn48Xt
# JoKKcS8Y3U623mzX4WCcK+3tPUiOuGu6fF29wmE3aEl3o+uQqhLXJ4Xzjh6S2sJA
# OJ9dyKAuJXglnSoFeoQpmLZXeY/bJlYrsPOnvTcM2Jh2T1a5UsK2nTipgedtQVyM
# adG5K8TGe8+c+njikxp2oml101DkRBK+IA2eqUTQ+OVJdwhaIcW0z5iVGlS6ubzB
# aRm6zxbygzc0brBBJt3eWpdPM43UjXd9dUWhpVgmagNF3tlQtVCMr1a9TMXhRsUo
# 063nQwBw3syYnhmJA+rUkTfvTVLzyWAhxFZH7doRS4wyw4jmWOK22z75X7BC1o/j
# F5HRqsBV44a/rCcsQdCaM0qoNtS5cpZ+l3k4SF/Kwtw9Mt911jZnWon49qfH5U81
# PAC9vpwqbHkB3NpE5jreODsHXjlY9HxzMVWggBHLFAx+rrz+pOt5Zapo1iLKO+ua
# gjVXKBbLafIymrLS2Dq4sUaGa7oX/cR3bBVsrquvczroSUa31X/MtjjA2Owc9bah
# uEMs305MfR5ocMB3CtQC4Fxguyj/OOVSWtasFyIjTvTs0xf7UGv/B3cfcZdEQcm4
# RtNsMnxYL2dHZeUbc7aZ+WssBkbvQR7w8F/g29mtkIBEr4AQQYowggauMIIElqAD
# AgECAhAHNje3JFR82Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYT
# AlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2Vy
# dC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMjAz
# MjMwMDAwMDBaFw0zNzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQK
# Ew5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBS
# U0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbSg9GeTKJtoLDM
# g/la9hGhRBVCX6SI82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9/UO0hNoR8XOx
# s+4rgISKIhjf69o9xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXnHwZljZQp09ns
# ad/ZkIdGAHvbREGJ3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0VAshaG43IbtA
# rF+y3kp9zvU5EmfvDqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4fsbVYTXn+149z
# k6wsOeKlSNbwsDETqVcplicu9Yemj052FVUmcJgmf6AaRyBD40NjgHt1biclkJg6
# OBGz9vae5jtb7IHeIhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0QCirc0PO30qh
# HGs4xSnzyqqWc0Jon7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvvmz3+DrhkKvp1
# KCRB7UK/BZxmSVJQ9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T/jnA+bIwpUzX
# 6ZhKWD7TA4j+s4/TXkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk42PgpuE+9sJ0
# sj8eCXbsq11GdeJgo1gJASgADoRU7s7pXcheMBK9Rp6103a50g5rmQzSM7TNsQID
# AQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUuhbZbU2F
# L3MpdpovdYxqII+eyG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08w
# DgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEB
# BGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsG
# AQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdp
# Y2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgG
# BmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAH1ZjsCTtm+Y
# qUQiAX5m1tghQuGwGC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxpwc8dB+k+YMjY
# C+VcW9dth/qEICU0MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIlzpVpP0d3+3J0
# FNf/q0+KLHqrhc1DX+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQcAp876i8dU+6
# WvepELJd6f8oVInw1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfeKuv2nrF5mYGj
# VoarCkXJ38SNoOeY+/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+jSbl3ZpHxcpzp
# SwJSpzd+k1OsOx0ISQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJshIUDQtxMkzdwd
# eDrknq3lNHGS1yZr5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6OOmc4d0j/R0o
# 08f56PGYX/sr2H7yRp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDwN7+YAN8gFk8n
# +2BnFqFmut1VwDophrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR81fZvAT6gt4y
# 3wSJ8ADNXcL50CN/AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2VVQrH4D6wPIO
# K+XW+6kvRBVK5xMOHds3OBqhK/bt1nz8MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv
# 21DiCEAYWjANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMM
# RGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQD
# ExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcN
# MzExMTA5MjM1OTU5WjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQg
# SW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2Vy
# dCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC
# AQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf
# 8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1
# mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe
# 7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecx
# y9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX
# 2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX
# 9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp49
# 3ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCq
# sWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFH
# dL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauG
# i0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYw
# DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08w
# HwYDVR0jBBgwFoAUReuir/SSy4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGG
# MHkGCCsGAQUFBwEBBG0wazAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNl
# cnQuY29tMEMGCCsGAQUFBzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20v
# RGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0
# dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5j
# cmwwEQYDVR0gBAowCDAGBgRVHSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXn
# OF+go3QbPbYW1/e/Vwe9mqyhhyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23
# OO/0/4C5+KH38nLeJLxSA8hO0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFI
# tJnLnU+nBgMTdydE1Od/6Fmo8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7s
# pNU96LHc/RzY9HdaXFSMb++hUD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgi
# wbJZ9VVrzyerbHbObyMt9H5xaiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cB
# qZ9Xql4o4rmUMYIDdjCCA3ICAQEwdzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMO
# RGlnaUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNB
# NDA5NiBTSEEyNTYgVGltZVN0YW1waW5nIENBAhAFRK/zlJ0IOaa/2z9f5WEWMA0G
# CWCGSAFlAwQCAQUAoIHRMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkq
# hkiG9w0BCQUxDxcNMjQwNjI4MDg0ODI4WjArBgsqhkiG9w0BCRACDDEcMBowGDAW
# BBRm8CsywsLJD4JdzqqKycZPGZzPQDAvBgkqhkiG9w0BCQQxIgQgNyZj045zM/1I
# V2bPEeZ5MzqBS/xq6ga1RXJGqR27FRYwNwYLKoZIhvcNAQkQAi8xKDAmMCQwIgQg
# 0vbkbe10IszR1EBXaEE2b4KK2lWarjMWr00amtQMeCgwDQYJKoZIhvcNAQEBBQAE
# ggIAmC4Gy+p8namk662VcdLuTzKoGNWCxRFj6Tq7lXTTtVc8r2zsOJng6sXv2Nzd
# /LNfgQAxch7B2i9fxj2dAHQbqZR29+L7GLI6A9eXchisFGloQmS+abKO3oPMl14I
# 9DvCyCRPFgEaiTxgAPTfYlMVNMHMQDNe3qjZaT59sftijespkRFtD5hpVW9ZQayV
# NXYms11ZPuM7W1hxtcShYMKtQHr5+r7QKcxXxxT1cl1AL7tLz/HyFLBidu+Vq5Fh
# YIWilkvt7EUdvCuZAZ1k5mhwlqhLnunVHIZzcDEQlReQnPGfgMZOsvHqQBWftlA8
# lQgpsCKUHYtL/ttIhoJXOSM+8R9K1Lm0/5Kickh3IjAvo84ICH9+va7mMXrE8FFO
# P4I1WOi22rz2fmjoeoGvMXpCgCRJA4WzBPzogVG2xnqdYjd2WdU9wa04HffTtHhZ
# ZTixqVVTt6qo9BHfSB/6vHOVMqIaVkx2753hYOIandE7HVJoHNHqZbKnn/F9vaLB
# 2PZfnOv2P5tA/Ih8PBYc7h8nS2MkDQQQ7GEUV6RsjlHtmVWuB0moHNZC2YkCERII
# 0MUtx0ct779Qvt7Unn9AP3Rk9JQR3yn0QAljTTwJty1gdyO078XWg2DYz0kaFpm8
# JbdJAnrXRWx8pFcRUu/BK00hx9t4r9HthollhC2Um/+pEkI=
# SIG # End signature block