Classes/OpenAiChat.psm1

using module "..\Private\OutHelper.psm1"
using namespace System
using namespace System.Text
using namespace System.IO
using namespace System.Net.Http
using namespace System.Net.Http.Formatting
using namespace System.Net.Http.Headers
using namespace System.Web
using namespace System.Web.Extensions

class OpenAiChatMessage {
    [string]$Role
    [string]$Content

    OpenAiChatMessage([string]$role, [string]$content) {
        $this.Role = $role
        $this.Content = $content
    }

    static [OpenAiChatMessage] ToAssistant([string]$message) {
        return [OpenAiChatMessage]::new("user", $message)
    }

    static [OpenAiChatMessage] FromAssistant([string]$message) {
        return [OpenAiChatMessage]::new("assistant", $message)
    }
}

class OpenAiChat {
    [string]$AuthToken
    [string]$Model = "gpt-3.5-turbo"
    [decimal]$Temperature
    [decimal]$Top_p
    [int]$N
    [HttpClient]$httpClient
    [string]$httpContentType = "application/json"

    OpenAiChat([string]$authToken) {
        $this.AuthToken = $authToken
        if(!$this.AuthToken) {
            throw "OpenAiChat requires an auth token (authToken argument on constructor)"
        }
    }

    # messages is an array of objects with 'role' and 'content' properties
    # 'content' is url encoded before sending to the API
    [object] Invoke([object]$messages, [bool]$useStream) {
        $encoded = @()
        $messages | ForEach-Object {
            $encoded += @{ "role" = $_.role; "content" = [HttpUtility]::UrlEncode($_.content); }
# $encoded += @{ "role" = $_.role; "content" = $_.content; }
        }

        # construct body
        $body = @{
            "model" = $this.Model
            "messages" = $encoded
        }

        # set optional parameters for the request
        if($this.Temperature) { $body.temperature = $this.Temperature }
        if($this.Top_p) { $body.top_p = $this.Top_p }
        if($this.N) { $body.n = $this.N }

        return $this.InvokeRequestObject($body, $useStream)
    }

    # requestObject is a object/hashtable with the full request body
    [object] InvokeRequestObject([object]$requestObject, [bool]$useStream) {
        # $useStream = $true
        $url = "https://api.openai.com/v1/chat/completions"

        $headers = @{
            "Authorization" = "Bearer $($this.AuthToken)"
        }

        if($useStream) {
            $requestObject.stream=$true
        }
        $body = $requestObject | ConvertTo-Json -Depth 10

        $response = $null
        try {
            Write-Debug "Request:`n$body"
            # [OutHelper]::Gpt("Request:`n$body")

            if($useStream) {
                return $this.InvokeRequestObjectStream($url, $headers, $body)
            } else {
                $headers["Content-Type"] = $this.httpContentType
                $response = Invoke-RestMethod -Method 'POST' -Uri $url -Headers $headers -Body $body
            }

            Write-Debug "`n`nResponse:`n$($response | ConvertTo-Json -Depth 10)"
            # [OutHelper]::Gpt("`n`nResponse:`n$($response | ConvertTo-Json -Depth 10)")
            return $response
        } catch {
            [OutHelper]::NonCriticalError("$($_.Exception)")
            [OutHelper]::NonCriticalError("Request:`n$body")
            [OutHelper]::NonCriticalError("Response:`n$($response | ConvertTo-Json -Depth 10)")
            return $null
        }
    }

    [object] InvokeRequestObjectStream($url, $headers, $body) {
        Write-Debug "Streaming response"

        if(!$this.httpClient) {
            $this.httpClient = [HttpClient]::new()
        }

        # create an HTTP request with a range header to receive only a specific chunk of data
        $request = [HttpRequestMessage]::new([HttpMethod]::Post, [Uri]::new($url))
        foreach($key in $headers.Keys) {
            $request.Headers.Add($key, $headers[$key])
        }
        $request.Headers.Range = [RangeHeaderValue]::new(0, 1024)
        $request.Content = [StringContent]::new($body, [Encoding]::UTF8, $this.httpContentType)

        # send the HTTP request and get the response
        $response = $this.httpClient.SendAsync($request, [HttpCompletionOption]::ResponseHeadersRead).Result
        if(!$response.IsSuccessStatusCode) {
            throw "An error occurred: $($response.StatusCode)"
        }

        $choices = @{}
        [OutHelper]::GptDelta("", $true) # writes GPT:

        $streamReader = [StreamReader]::new($response.Content.ReadAsStreamAsync().Result)
        try {
            # read the response content as a stream of JSON data
            $dataPrefix = "data: "
            while (!$streamReader.EndOfStream)
            {
                # each line will begin with "data: ", the final line will be "data: [DONE]"
                $line = $streamReader.ReadLine()
                if (!$line.StartsWith($dataPrefix)) {
                    continue
                }
                $line = $line.Substring($dataPrefix.Length)
                if($line -eq "[DONE]") {
                    break
                }
                # [OutHelper]::Gpt($line)

                $chunk = $line | ConvertFrom-Json
                if(!$chunk.choices -or $chunk.choices.Count -ne 1) {
                    continue
                }

                # update the choices array with the new choice
                $delta = $chunk.choices[0].delta # {"content":" you"}
                $index = "i$($chunk.choices[0].index)" # 0

                if(!$choices[$index]) {
                    $choices[$index] = @{}
                }

                $initalValue = $false
                foreach($nameValue in $delta.PSObject.Properties) {
                    $key = $nameValue.Name
                    $value = $nameValue.Value

                    if(!$choices[$index].$key) {
                        $choices[$index].$key = $value
                        $initalValue = $true
                    } else {
                        $choices[$index].$key += $value
                    }

                    # output new content (delta) to user
                    if($index -eq "i0" -and $key -eq "content") {
                        if($initalValue) {
                            $value = $value.TrimStart()
                        }
                        $value = [HttpUtility]::UrlDecode($value)
                        [OutHelper]::GptDelta($value, $false)
                    }
                }
            }
        } finally {
            $streamReader.Dispose()
        }
        [OutHelper]::GptDelta("`n", $false)

        # [OutHelper]::Gpt(($choices | ConvertTo-Json -Depth 10))

        $answers = @()
        foreach($choice in $choices.Values) {
            $answers += [HttpUtility]::UrlDecode($choice.content.Trim())
        }
        # [OutHelper]::Gpt(($answers | ConvertTo-Json -AsArray -Depth 10))

        return $answers
    }

    [string] Ask([string]$message) {
        return $this.GetAnswer(@([OpenAiChatMessage]::ToAssistant($message)))
    }

    [object] GetStreamedAnswer([object]$messages) {
        $response = $this.Invoke($messages, $true)
        if($null -ne $response) {
            if($response.Length -gt 1) {
                return $response
            } else {
                return $response[0]
            }
        } else {
            return $null
        }
    }

    [object] GetAnswer([object]$messages) {
        $response = $this.Invoke($messages, $false)
        if($null -ne $response) {
            # if multiple choices are requested, return an array of strings
            if($this.N -gt 1) {
                Write-Debug "N=$($this.N), returning array of $($response.choices.length) strings"
                return $response.choices | ForEach-Object { [HttpUtility]::UrlDecode($_.message.content.Trim()) }
            }

            return [HttpUtility]::UrlDecode($response.choices.message.content.Trim())
        } else {
            return $null
        }
    }
}