PowerShellAssistant.psm1
using namespace OpenAI using namespace System.Net.Http using namespace System.Net.Http.Headers using namespace System.Collections.Generic using namespace System.Management.Automation using namespace System.Reflection using namespace Microsoft.PowerShell $ErrorActionPreference = 'Stop' #TODO: This should be better $debugBinPath = Join-Path $PSScriptRoot '/bin/Debug/net7.0' if (Test-Path $debugBinPath) { Write-Warning "Debug build detected. Using assemblies at $debugBinPath" Add-Type -Path $debugBinPath/*.dll } else { Add-Type -Path $PSScriptRoot/*.dll } #These are the cheapest models for testing, opt into more powerful models $SCRIPT:aiDefaultModel = 'ada' $SCRIPT:aiDefaultChatModel = 'gpt-3.5-turbo' $SCRIPT:aiDefaultCodeModel = 'code-davinci-002' #region Public function Connect-AI { [CmdletBinding()] param( # Provide your API Key as the password, and optionally your organization ID as the username [string]$APIKey, # By default, this uses the OpenAI API. Specify this if you want to use GitHub Copilot (UNSUPPORTED) [switch]$GitHubCopilot, # Don't set this client as the default client. You can pass the client to the various commands instead. Implies -PassThru [switch]$NoDefault, # Return the client for use in other commands [switch]$PassThru, #Replace the existing default client if it exists [switch]$Force ) if ($SCRIPT:aiClient -and (-not $NoDefault -and -not $Force)) { Write-Warning 'Already connected to an AI engine. You can use -NoDefault to not set this client as the default client, or -Force to replace the existing default client.' return } if (-not $APIKey -and $env:OPENAI_API_KEY) { Write-Verbose 'Using API key from environment variable OPENAI_API_KEY' $APIKey = $env:OPENAI_API_KEY } $client = New-AIClient @newAIClientParams -APIKey $APIKey -GithubCopilot:$GitHubCopilot if ($NoDefault) { $PassThru = $true } else { $SCRIPT:aiClient = $client } if ($PassThru) { return $client } } filter Get-AIModel { [OutputType([OpenAI.Model])] [CmdletBinding()] param( # The ID of the model to get. If not specified, returns all models. [Parameter(ValueFromPipeline)][string]$Id, [ValidateNotNullOrEmpty()][OpenAI.Client]$Client = $SCRIPT:aiClient ) if (-not $Client) { Assert-Connected $Client = $SCRIPT:aiClient } if ($Id) { return $Client.RetrieveModel($Id) } $Client.ListModels().Data } function Get-AIEngine { [OutputType([OpenAI.Engine])] [CmdletBinding()] param( [ValidateNotNullOrEmpty()][OpenAI.Client]$Client = $SCRIPT:aiClient ) Write-Warning 'Engines are deprecated. Use Get-AIModel instead.' if (-not $Client) { Assert-Connected $Client = $SCRIPT:aiClient } $Client.ListEngines() | ConvertFrom-ListResponse } function Get-AICompletion { [CmdletBinding()] [OutputType([OpenAI.CreateCompletionResponse])] param( [Parameter(Mandatory)]$Prompt, #The name of the model to use. [ValidateSet([AvailableModels])][String]$Model = $SCRIPT:aiDefaultModel, [ValidateNotNullOrEmpty()][OpenAI.Client]$Client = $SCRIPT:aiClient, [ValidateNotNullOrEmpty()][uint]$MaxTokens = 1000, [ValidateNotNullOrEmpty()][uint]$Temperature = 0 ) if (-not $Client) { Assert-Connected $Client = $SCRIPT:aiClient } $request = [CreateCompletionRequest]@{ Prompt = $Prompt Stream = $false Model = $Model Max_tokens = $MaxTokens Temperature = $Temperature } $Client.CreateCompletion($request) } function Get-AICode { <# .SYNOPSIS Utilizes the Codex models to fetch a code completion given a prompt. .LINK https://platform.openai.com/docs/guides/code/introduction #> [OutputType([OpenAI.CreateCompletionResponse])] [CmdletBinding()] param( [string[]]$Prompt, #The name of the model to use. $Language = 'PowerShell 7', [ValidateSet([AvailableModels])][String]$Model = $SCRIPT:aiDefaultCodeModel, [ValidateNotNullOrEmpty()][OpenAI.Client]$Client = $SCRIPT:aiClient, [ValidateNotNullOrEmpty()][uint]$MaxTokens = 1000, [ValidateNotNullOrEmpty()][uint]$Temperature = 0 ) if (-not $Client) { Assert-Connected $Client = $SCRIPT:aiClient } #Add a language specifier to the prompt $Prompt.Insert(0, "#$Language") Get-AICompletion -Prompt $Prompt -Model $Model -MaxTokens $MaxTokens -Temperature $Temperature } function Get-AIChat { [OutputType([OpenAI.ChatConversation])] [CmdletBinding(DefaultParameterSetName = 'Prompt')] param( #Include one or more prompts to start the conversation [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Prompt')] [Parameter(ParameterSetName = 'ChatSession')] [OpenAI.ChatCompletionRequestMessage[]]$Prompt, #Supply a previous chat session to add new responses to it [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ChatSession')] [Parameter(ParameterSetName = 'Prompt')] [OpenAI.ChatConversation]$ChatSession, #The name of the model to use. [ValidateSet([AvailableModels])] [String]$Model = $SCRIPT:aiDefaultChatModel, [ValidateNotNullOrEmpty()] [OpenAI.Client]$Client = $SCRIPT:aiClient, [ValidateNotNullOrEmpty()] [uint]$MaxTokens = 1000, [ValidateNotNullOrEmpty()] [uint]$Temperature = 0, #Stream the response. You will lose syntax highlighting and usage info. [switch]$Stream ) if (-not $Client) { Assert-Connected $Client = $SCRIPT:aiClient } $ChatSession ??= [ChatConversation]@{ Request = @{ Messages = [List[ChatCompletionRequestMessage]]@() Stream = $false Model = $Model Max_tokens = $MaxTokens Temperature = $Temperature } } #Append any response to the initial request. This is the continuation of a chat. $responseChoices = $ChatSession.Response.Choices $requestMessages = $ChatSession.Request.Messages if ($responseChoices.Count -gt 0) { if ($responseChoices.count -gt 1) { Write-Error 'The previous chat response contained more than one choice. Continuing a conversation with multiple choices is not supported.' -Category 'NotImplemented' return } $requestMessages.Add($responseChoices[0].Message) } foreach ($PromptItem in $Prompt) { $requestMessages.Add( $PromptItem ) } if ($Stream) { $Client.CreateChatCompletionAsStream($ChatSession.Request) | ForEach-Object { $PSItem } Write-Host return } $chatResponse = $Client.CreateChatCompletion($ChatSession.Request) $chatSession.Response = $chatResponse $price = Get-UsagePrice -Model $chatResponse.Model -Total $chatResponse.Usage.Total_tokens Write-Verbose "Chat usage - $($chatResponse.Usage) $($price ? "$price " : $null)for Id $($chatResponse.Id)" return $chatSession #Stream the response } #endregion Public #Region Private function New-AIClient { [OutputType([OpenAI.Client])] param( [string]$ApiKey, [Switch]$GithubCopilot ) if (-not $APIKey) { Write-Error 'You must supply an OpenAI API key via the -APIKey parameter or by setting the OPENAI_API_KEY variable' return } if ($SCRIPT:client -and -not $Force) { Write-Warning 'Assistant is already connected. Please use -Force to reset the client.' return } $httpClient = [HttpClient]::new() $httpClient.DefaultRequestHeaders.Authorization = [AuthenticationHeaderValue]::new('Bearer', $APIKey) $aiClient = [Client]::new($httpClient) if ($GitHubCopilot) { $aiClient.BaseUrl = 'https://copilot-proxy.githubusercontent.com' } return $aiClient } function Assert-Connected { if (-not $SCRIPT:aiClient) { Connect-AI } } #If the returned result was a list, return the actual data filter ConvertFrom-ListResponse { if ($PSItem.Object -ne 'list') { return } return $PSItem.Data } #endregion Private # function Connect-Copilot { # [CmdletBinding()] # param( # # Provide your Copilot API Key as the password, and optionally your organization ID as the username # [string]$Token, # #Reset if a client already exists # [Switch]$Force # ) # $ErrorActionPreference = 'Stop' # if ($SCRIPT:GHClient -and -not $Force) { # Write-Warning 'Copilot is already connected. Please use -Force to reset the client.' # return # } # if ($SCRIPT:GHCopilotToken -and -not $Force) { # Write-Warning 'GitHub Copilot is already connected. Please use -Force to reset the client.' # return # } # $SCRIPT:GHCopilotToken = if (-not $Token) { # #Try to autodiscover it from GitHub Copilot CLI # if (-not (Test-Path $HOME/.copilot-cli-access-token)) { # Write-Error "To use PowerShell Assistant with GitHub Copilot, you must install GitHub Copilot CLI and run 'github-copilot-cli auth' at least once to generate a Copilot Personal Access Token (PAT)" # return # } # Get-Content $HOME/.copilot-cli-access-token # } else { # $Token # } # $config = [OpenAIOptions]@{ # ApiKey = Update-GitHubCopilotToken $SCRIPT:GHCopilotToken # BaseDomain = 'https://copilot-proxy.githubusercontent.com' # DefaultEngineId = 'copilot-labs-codex' # } # $SCRIPT:GHClient = [OpenAIService]::new($config) # } # function Get-CopilotSuggestion { # [CmdletBinding()] # param( # [Parameter(Mandatory)][string]$prompt, # [ValidateNotNullOrEmpty()]$client = $SCRIPT:GHClient # ) # if (-not $SCRIPT:GHClient) { Connect-Copilot } # $request = [CompletionCreateRequest]@{ # N = 1 # StopAsList = [string[]]@('---', '\n') # MaxTokens = 256 # Temperature = 0 # TopP = 1 # Prompt = $prompt # Stream = $true # } # $resultStream = $client.Completions.CreateCompletionAsStream($request).GetAwaiter.GetResult() # foreach ($resultItem in $resultStream) { # Write-Host -NoNewline 'NEW TOKEN' # #This gives us intellisense in vscode # [CompletionCreateResponse]$result = $resultItem # if ($result.Error) { # Write-Error $result.Error # return # } # $token = $result.Choices[0].Text # Write-Host -NoNewline -fore DarkGray $token # } # Write-Host 'DONE' # } # function Assert-Connected { # if (-not $SCRIPT:client) { Connect-Assistant } # } function Update-GitHubCopilotToken { <# .SYNOPSIS Fetches the latest token for GitHub Copilot #> param( [ValidateNotNullOrEmpty()] $GitHubToken = $SCRIPT:GHCopilotToken ) $ErrorActionPreference = 'Stop' $response = Invoke-RestMethod 'https://api.github.com/copilot_internal/v2/token' -Headers @{ Authorization = "token $($GitHubToken.trim())" } return $response.token } function Get-Chat { <# .SYNOPSIS Provides an interactive assistant for PowerShell. Mostly a frontend to Get-AIChat #> [CmdletBinding()] param( #Provide a chat prompt to initiate the conversation [string[]]$chatPrompt, [ValidateNotNullOrEmpty()] #Specify a prompt that guides Chat how to behave. By default, it is told to prefer PowerShell as a language. [string]$SystemPrompt = 'Prefer PowerShell syntax and always use fenced code blocks with the language specified.', #Maximum tokens to generate. Defaults to 500 to minimize accidental API billing [ValidateNotNullOrEmpty()] [uint]$MaxTokens = 500, [ValidateSet([AvailableModels])] [string]$Model, #Specify this to add the latest recommended codeblock to your psreadline history [switch]$AddToHistory, #By default, the latest code recommended is added to your clipboard for easy pasting. [switch]$NoClipboard, #If you just want the result and don't want to be prompted for further replies, specify this [Switch]$NoReply, #Code blocks in these languages will be copied to the clipboard or added to history. By default, this is just PowerShell. This only affects code copying, you will need to specify a new -SystemPrompt if you want non-powershell recommendations. [string[]]$languages = 'powershell', #By default, chat operates inline. Specify this to use the alternate screen buffer which will operate chat in a separate "screen" and then return you to your original prompt once the chat is complete. [switch]$UseAltScreenBuffer ) begin { $ErrorActionPreference = 'Stop' Assert-Connected [List[ChatCompletionRequestMessage]]$chatHistory = @( [ChatCompletionRequestMessage]@{ Role = [ChatCompletionRequestMessageRole]::System Content = $SystemPrompt } ) } process { if ($UseAltScreenBuffer) { $inChatSession = $true Write-Host -NoNewline "`e[?1049h" } do { $chatPrompt ??= Read-Host -Prompt 'You' foreach ($promptItem in $chatPrompt) { $chatHistory.Add( ([ChatCompletionRequestMessage]$promptItem) ) } $chatParams = @{ Prompt = $chatHistory MaxTokens = $MaxTokens Stream = $true } if ($Model) { $chatParams.Model = $Model } [List[CreateChatCompletionChunkedResponse]]$streamedResponse = @() [Text.StringBuilder]$chatStream = '' Get-AIChat @chatParams | ForEach-Object { [CreateChatCompletionChunkedResponse]$response = $PSItem $streamedResponse.Add($response) [DeltaChoice]$firstChoice = $response.Choices[0] [string]$firstChoiceContent = $firstChoice.Delta.Content [void]$chatStream.Append($firstChoiceContent) $markdownCodeFenceRegex = '```(?<lang>\w+)?\s*(?<code>[\s\S]*?)```' #Start recording if a code block occurs, and if it does, reformat it and copy it to clipboard #TODO: This could maybe be faster by watching the stream for the starting and trailing backticks if ($chatStream -match $markdownCodeFenceRegex) { $codeblock = $matches[0] $code = $matches.code $lang = $matches.lang $codeBlockLineCount = ($codeBlock -split '\r?\n').Count - 1 #Use ANSI Codes Move the cursor up to the start of the code block to overwrite it Write-Host -NoNewline "`e[${codeBlockLineCount}F" Write-Host -NoNewline "`e[0J" #Two newlines replace where the previous code was $formattedCodeBlock = [Environment]::NewLine + (Show-Markdown -InputObject $codeBlock) + [Environment]::NewLine if ($lang -in $languages) { if ($AddToHistory) { [PSConsoleReadline]::AddToHistory($code.Trim()) } if (-not $NoClipboard) { Set-Clipboard -Value ". {$($code.Trim())}" } } Write-Host -NoNewline ($formattedCodeBlock) #Update the stringbuilder #TODO: Add the start index which generally should not be necessary but probably smart [void]$chatStream.Replace($codeBlock, $formattedCodeBlock) } else { Write-Host -NoNewline ($PSStyle.Foreground.BrightBlack + $firstChoice.Delta.Content) } #Matches single code blocks $markdownCodeRegex = '(?<!`)(`)(?!`)[^\n]+?\1' if ($chatStream -match $markdownCodeRegex) { $code = $matches[0] $formattedCode = (Show-Markdown -InputObject $code).Trim() #Move cursor back to the start of the code block and clear text after it Write-Host -NoNewline "`e[$($code.Length)D" Write-Host -NoNewline "`e[0J" Write-Host -NoNewline $formattedCode [void]$chatStream.Replace($code, $formattedCode) } if ($firstChoice.Finish_reason -eq 'length') { Write-Host -ForegroundColor $PSStyle.Formatting.Warning '[END]' Write-Warning "Response truncated due to length. Consider setting -MaxTokens greater than $MaxTokens" } } $message = [ChatCompletionRequestMessage]::new( [string]::Concat($streamedResponse.Choices.Delta.Content), [ChatCompletionRequestMessageRole]::Assistant ) $chatHistory.Add($message) #TODO: Move this into the formatter # switch ($aiResponse.FinishReason) { # 'stop' {} #This is the normal response # 'length' { # Write-Warning "$MaxTokens tokens reached. Consider increasing the value of -MaxTokens for longer responses." # } # $null { # Write-Debug 'Null FinishReason received. This seems to occur on occasion and may or may not be a bug.' # } # default { # Write-Warning "Chat response finished abruply due to: $($aiResponse.FinishReason)" # } # } $chatPrompt = $null if (-not $NoReply) { Write-Host ($PSStyle.Foreground.Cyan + '<Ctrl-C to exit>' + $PSStyle.Reset) } } while ( -not $NoReply ) } clean { if ($inChatSession) { #Closes the alternate screen buffer Write-Host "`e[?1049l" } } } filter Convert-ChatCodeToClipboard { <# .SYNOPSIS Given a string, take the last occurance of text surrounded by a fenced code block, and copy it to the clipboard. It will also pass through the string for further filtering #> $fencedCodeBlockRegex = '(?s)```[\r|\n|powershell]+(.+?)```' $matchResult = $PSItem -match $fencedCodeBlockRegex $savedMatches = $matches $cbMatch = $savedMatches.($savedMatches.Keys | Sort-Object | Select-Object -Last 1) if (-not $matchResult) { Write-Debug 'No code block detected, skipping this step' return $PSItem } Write-Verbose "Copying last suggested code block to clipboard:`n$cbMatch" Set-Clipboard -Value $cbMatch return $PSItem } class AvailableModels : IValidateSetValuesGenerator { [String[]] GetValidValues() { trap { Write-Host ''; Write-Host -NoNewline -ForegroundColor Red "Validation Error: $PSItem" } $models = Get-AIModel return $models.Id } } filter Format-ChatCode { <# .SYNOPSIS Given a string, for any occurance of text surrounded by backticks, replace the backticks with ANSI escape codes #> $codeBlockRegex = '(?s)```[\r|\n|powershell]+(.+?)```' $codeSnippetRegex = '(?s)`(.+?)`' $boldSelectedText = ($PSStyle.Italic + '$1' + $PSStyle.ItalicOff) $PSItem -replace $codeBlockRegex, $boldSelectedText -replace $codeSnippetRegex, $boldSelectedText } filter Format-ChatMessage { param( [Parameter(ValueFromPipeline)]$message, #Notes that the content should be streamed rather than returned line by line [switch]$Stream ) $role = $message.Role $content = $message.Content $roleColor = switch ($role) { 'System' { 'DarkYellow' } 'Assistant' { 'Green' } 'User' { 'DarkCyan' } default { 'DarkGray' } } if ($Stream) { if ($role) { return "$($PSStyle.Foreground.$roleColor)$role`:$($PSStyle.Reset) " } elseif ($content) { return "$($PSStyle.Foreground.BrightBlack)$content$($PSStyle.Reset)" } else { #Blank entry, we might want to throw here just in case tho it is technically allowed. return } } $formattedMessage = $content.Trim() | Format-ChatCode return "$($PSStyle.Foreground.$roleColor)$role`:$($PSStyle.Reset) $($PSStyle.ForeGround.BrightBlack)$formattedMessage" } function Format-CreateChatCompletionChunkedResponse { param( [Parameter(ValueFromPipeline)][CreateChatCompletionChunkedResponse]$response ) Format-ChatMessage -Stream $response.Choices[0].Delta } function Format-Choices2 { [AssemblyMetadata('Format-Custom', 'Choices2')] param( [Choices2]$choice ) $PSStyle.Foreground.BrightCyan + "Choice $([int]$choice.Index + 1): " + (Format-ChatMessage $choice.Message) } filter Format-CreateChatCompletionRequest { [AssemblyMetadata('Format-Custom', 'OpenAI.CreateChatCompletionRequest')] param( [Parameter(ValueFromPipeline)][CreateChatCompletionRequest]$request ) $request.messages | Format-ChatMessage } filter Format-CreateChatCompletionResponse { [AssemblyMetadata('Format-Custom', 'OpenAI.CreateChatCompletionResponse')] param( [Parameter(ValueFromPipeline)][CreateChatCompletionResponse]$response ) if ($response.Choices.Count -eq 1) { Format-ChatMessage $response.Choices[0].Message } else { $Response.Choices } } function Format-ChatConversation { param( [ChatConversation]$conversation ) $messages = @() $messages += $conversation.Request | Format-CreateChatCompletionRequest $messages += $conversation.Response | Format-CreateChatCompletionResponse return $messages -join ($PSStyle.Reset + [Environment]::NewLine) } function Get-UsagePrice { param( [string]$Model, [int]$Total ) #Taken from: https://openai.com/pricing $pricePerToken = @{ 'code' = 0 'gpt-3.5-turbo' = .002 / 1000 'ada' = .0004 / 1000 'babbage' = .0005 / 1000 'curie' = .002 / 1000 'davinci' = .002 / 1000 } foreach ($priceItem in $pricePerToken.GetEnumerator()) { if ($Model.Contains($priceItem.key)) { #Will return the first match $totalPrice = $total * $priceItem.Value #Formats as currency ($3.2629) and strips trailing zeroes return $totalPrice.ToString('C15').TrimEnd('0') } } #Return an empty string if no pricing engine found. return [string]::Empty } |