Public/Request-ChatCompletion.ps1
function Request-ChatCompletion { [CmdletBinding()] [OutputType([pscustomobject])] [Alias('Request-ChatGPT')] param ( [Parameter(Mandatory = $false, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('Text')] [ValidateNotNullOrEmpty()] [string]$Message, [Parameter()] [ValidateNotNullOrEmpty()] [Completions('user', 'system', 'developer', 'function')] [string][LowerCaseTransformation()]$Role = 'user', [Parameter()] [ValidatePattern('^[a-zA-Z0-9_-]{1,64}$')] # May contain a-z, A-Z, 0-9, hyphens, and underscores, with a maximum length of 64 characters. [string]$Name, [Parameter()] [Completions( 'gpt-3.5-turbo', 'gpt-4', 'gpt-4o', 'gpt-4o-mini', 'chatgpt-4o-latest', 'gpt-4o-audio-preview', 'gpt-4o-mini-audio-preview', 'gpt-3.5-turbo-16k', 'gpt-3.5-turbo-1106', 'gpt-3.5-turbo-0125', 'gpt-4-0613', 'gpt-4-32k', 'gpt-4-32k-0613', 'gpt-4-turbo', 'gpt-4-turbo-2024-04-09', 'o1', 'o1-mini', 'o1-preview' )] [string]$Model = 'gpt-3.5-turbo', [Parameter()] [AllowEmptyString()] [Alias('system')] [Alias('RolePrompt')] [string[]]$SystemMessage, [Parameter()] [AllowEmptyString()] [string[]]$DeveloperMessage, # For Audio [Parameter()] [ValidateSet('text', 'audio')] [string[]]$Modalities, [Parameter()] [Completions('alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse')] [string][LowerCaseTransformation()]$Voice = 'alloy', [Parameter()] [Alias('input_audio')] [string]$InputAudio, [Parameter()] [Completions('wav', 'mp3')] [string][LowerCaseTransformation()]$InputAudioFormat, [Parameter()] [ValidateNotNullOrEmpty()] [string]$AudioOutFile, [Parameter()] [Completions('wav', 'mp3', 'flac', 'opus', 'pcm16')] [string][LowerCaseTransformation()]$OutputAudioFormat = 'mp3', # For Vison [Parameter()] [string[]]$Images, [Parameter()] [ValidateSet('auto', 'low', 'high')] [string][LowerCaseTransformation()]$ImageDetail = 'auto', #region Function call params [Parameter()] [ValidateNotNullOrEmpty()] [System.Collections.IDictionary[]]$Tools, [Parameter()] [Alias('tool_choice')] [Completions('none', 'auto', 'required')] [object]$ToolChoice, [Parameter()] [Alias('parallel_tool_calls')] [switch]$ParallelToolCalls, [Parameter()] [Alias('InvokeFunctionOnCallMode')] # For backward compatibilty [ValidateSet('None', 'Auto', 'Confirm')] [string]$InvokeTools = 'None', #endregion Function call params [Parameter()] [ValidateNotNullOrEmpty()] [string]$Prediction, [Parameter()] [ValidateRange(0.0, 2.0)] [double]$Temperature, [Parameter()] [ValidateRange(0.0, 1.0)] [Alias('top_p')] [double]$TopP, [Parameter()] [Alias('n')] [uint16]$NumberOfAnswers, [Parameter()] [switch]$Stream = $false, [Parameter()] [switch]$Store = $false, [Parameter()] [Alias('reasoning_effort')] [Completions('low', 'medium', 'high')] [string]$ReasoningEffort = 'medium', [Parameter()] [System.Collections.IDictionary]$MetaData, [Parameter()] [ValidateCount(1, 4)] [Alias('stop')] [string[]]$StopSequence, [Parameter()] [System.Obsolete('The MaxTokens is now deprecated in favor of MaxCompletionTokens')] [ValidateRange(0, 2147483647)] [Alias('max_tokens')] [int]$MaxTokens, [Parameter()] [ValidateRange(0, 2147483647)] [Alias('max_completion_tokens')] [int]$MaxCompletionTokens, [Parameter()] [ValidateRange(-2.0, 2.0)] [Alias('presence_penalty')] [double]$PresencePenalty, [Parameter()] [ValidateRange(-2.0, 2.0)] [Alias('frequency_penalty')] [double]$FrequencyPenalty, [Parameter()] [Alias('logit_bias')] [System.Collections.IDictionary]$LogitBias, [Parameter()] [bool]$LogProbs, [Parameter()] [ValidateRange(0, 20)] [Alias('top_logprobs')] [uint16]$TopLogProbs, [Parameter()] [Alias('response_format')] [Completions('text', 'json_object', 'json_schema', 'raw_response')] [object]$Format = 'text', [Parameter()] [string]$JsonSchema, [Parameter()] [int64]$Seed, [Parameter()] [Alias('service_tier')] [Completions('auto', 'default')] [string]$ServiceTier, [Parameter()] [string]$User, [Parameter()] [switch]$AsBatch, [Parameter()] [string]$CustomBatchId, [Parameter()] [int]$TimeoutSec = 0, [Parameter()] [ValidateRange(0, 100)] [int]$MaxRetryCount = 0, [Parameter()] [OpenAIApiType]$ApiType = [OpenAIApiType]::OpenAI, [Parameter()] [System.Uri]$ApiBase, [Parameter(DontShow)] [string]$ApiVersion, [Parameter()] [ValidateSet('openai', 'azure', 'azure_ad')] [string]$AuthType = 'openai', [Parameter()] [securestring][SecureStringTransformation()]$ApiKey, [Parameter()] [Alias('OrgId')] [string]$Organization, [Parameter(ValueFromPipelineByPropertyName)] [object[]]$History, [Parameter()] [System.Collections.IDictionary]$AdditionalQuery, [Parameter()] [System.Collections.IDictionary]$AdditionalHeaders, [Parameter()] [object]$AdditionalBody ) begin { # Get API context $OpenAIParameter = Get-OpenAIAPIParameter -EndpointName 'Chat.Completion' -Parameters $PSBoundParameters -Engine $Model -ErrorAction Stop if ($OpenAIParameter.ApiType -eq [OpenAIApiType]::Azure) { # Temporal engine name for Azure $Engine = 'gpt-3.5-turbo' } else { $Engine = $Model } } process { #region Parameter Validation # Error if ([string]::IsNullOrEmpty($Name) -and $Role -eq 'function') { Write-Error 'Messages with role "function" must have a name.' return } # Warning if ($PSBoundParameters.ContainsKey('Name') -and (-not $PSBoundParameters.ContainsKey('Message'))) { Write-Warning 'Name parameter is ignored because the Message parameter is not specified.' } #endregion #region Tools paramter validation if ($PSBoundParameters.ContainsKey('Tools')) { $tmpTools = [System.Collections.IDictionary[]]::new($Tools.Count) for ($i = 0; $i -lt $Tools.Count; $i++) { if ($Tools[$i].Contains('type')) { $tmpTools[$i] = $Tools[$i] } else { $tmpTools[$i] = @{ 'type' = 'function' 'function' = $Tools[$i] } } } $Tools = $tmpTools } elseif ($PSBoundParameters.ContainsKey('Functions')) { $tmpTools = [System.Collections.IDictionary[]]::new($Functions.Count) for ($i = 0; $i -lt $Functions.Count; $i++) { $tmpTools[$i] = @{ 'type' = 'function' 'function' = $Functions[$i] } } $Tools = $tmpTools } #endregion #region Construct parameters for API request $Response = $null $PostBody = [System.Collections.Specialized.OrderedDictionary]::new() if ($OpenAIParameter.ApiType -eq [OpenAIApiType]::OpenAI -or $AsBatch) { $PostBody.model = $Model } if ($PSBoundParameters.ContainsKey('Modalities')) { $PostBody.modalities = $Modalities if ($Modalities -contains 'audio') { $PostBody.audio = @{ 'voice' = $Voice 'format' = $OutputAudioFormat } } } if ($PSBoundParameters.ContainsKey('Tools')) { $PostBody.tools = @($Tools) } if ($PSBoundParameters.ContainsKey('ToolChoice')) { $PostBody.tool_choice = $ToolChoice } if ($PSBoundParameters.ContainsKey('ParallelToolCalls')) { $PostBody.parallel_tool_calls = [bool]$ParallelToolCalls } if ($PSBoundParameters.ContainsKey('Prediction')) { $PostBody.prediction = @{ 'type' = 'content' 'content' = $Prediction } } if ($PSBoundParameters.ContainsKey('Temperature')) { $PostBody.temperature = $Temperature } if ($PSBoundParameters.ContainsKey('TopP')) { $PostBody.top_p = $TopP } if ($PSBoundParameters.ContainsKey('NumberOfAnswers')) { $PostBody.n = $NumberOfAnswers } if ($Store.IsPresent) { $PostBody.store = $Store.ToBool() } if ($PSBoundParameters.ContainsKey('ReasoningEffort')) { $PostBody.reasoning_effort = $ReasoningEffort } if ($PSBoundParameters.ContainsKey('MetaData')) { $PostBody.metadata = $MetaData } if ($PSBoundParameters.ContainsKey('StopSequence')) { $PostBody.stop = $StopSequence } if ($PSBoundParameters.ContainsKey('MaxTokens')) { $PostBody.max_tokens = $MaxTokens } if ($PSBoundParameters.ContainsKey('MaxCompletionTokens')) { $PostBody.max_completion_tokens = $MaxCompletionTokens } if ($PSBoundParameters.ContainsKey('PresencePenalty')) { $PostBody.presence_penalty = $PresencePenalty } if ($PSBoundParameters.ContainsKey('FrequencyPenalty')) { $PostBody.frequency_penalty = $FrequencyPenalty } if ($PSBoundParameters.ContainsKey('LogitBias')) { $PostBody.logit_bias = Convert-LogitBiasDictionary -InputObject $LogitBias -Model $Engine } if ($PSBoundParameters.ContainsKey('LogProbs')) { $PostBody.logprobs = $LogProbs if ($LogProbs -and $PSBoundParameters.ContainsKey('TopLogProbs')) { $PostBody.top_logprobs = $TopLogProbs } } if ($PSBoundParameters.ContainsKey('Format')) { if ($Format -is [type]) { # Structured Outputs $typeSchema = ConvertTo-JsonSchema $Format $PostBody.response_format = @{ 'type' = 'json_schema' 'json_schema' = @{ 'name' = $Format.Name 'strict' = $true 'schema' = $typeSchema } } } elseif ($Format -eq 'raw_response') { # Nothing to do } else { $PostBody.response_format = @{'type' = $Format } if ($Format -eq 'json_schema') { if (-not $JsonSchema) { Write-Error -Exception ([System.ArgumentException]::new('JsonSchema must be specified.')) } else { $PostBody.response_format.json_schema = ConvertFrom-Json $JsonSchema } } } } if ($PSBoundParameters.ContainsKey('Seed')) { $PostBody.seed = $Seed } if ($PSBoundParameters.ContainsKey('ServiceTier')) { $PostBody.service_tier = $ServiceTier } if ($PSBoundParameters.ContainsKey('User')) { $PostBody.user = $User } if ($Stream) { $PostBody.stream = [bool]$Stream # When using the Stream option, limit NumberOfAnswers to 1 to optimize output. (this limit may be relaxed in the future) $PostBody.n = 1 } #endregion #region Construct messages $Messages = [System.Collections.Generic.List[object]]::new() # Append past conversations foreach ($msg in $History) { if ($msg.role) { $tm = [ordered]@{ role = [string]$msg.role } if ($msg.content) { $tm.content = $msg.content } # audio if ($msg.audio.id) { $tm.audio = @{id = $msg.audio.id } } # name is optional if ($msg.name) { $tm.name = [string]$msg.name } # tool_calls is optional if ($msg.tool_calls) { $tm.tool_calls = $msg.tool_calls } # tool_call_id is optional if ($msg.tool_call_id) { $tm.tool_call_id = $msg.tool_call_id } $Messages.Add($tm) } } # Specifies system messages (only if specified) foreach ($rp in $SystemMessage) { if (-not [string]::IsNullOrWhiteSpace($rp)) { $Messages.Add([ordered]@{ role = 'system' content = $rp.Trim() }) } } # Specifies developer messages (only if specified) foreach ($dp in $DeveloperMessage) { if (-not [string]::IsNullOrWhiteSpace($dp)) { $Messages.Add([ordered]@{ role = 'developer' content = $dp.Trim() }) } } # Add user message (question) if (-not [string]::IsNullOrWhiteSpace($Message) -or -not [string]::IsNullOrEmpty($InputAudio)) { $um = [ordered]@{ role = $Role content = $Message.Trim() } # For Audio if ($InputAudio) { $um.content = @() if (-not [string]::IsNullOrWhiteSpace($Message)) { $um.content += @{type = 'text'; text = $Message.Trim() } } $auc = $null if (-not (Test-Path -LiteralPath $InputAudio -PathType Leaf)) { Write-Error -Exception ([System.IO.FileNotFoundException]::new("Could not find file '$InputAudio'", $InputAudio)) } else { $audioItem = Get-Item -LiteralPath $InputAudio if ($InputAudioFormat) { $audioformat = $InputAudioFormat } else { $audioformat = $audioItem.Extension.ToLower().TrimStart([char]'.') } $auc = @{ type = 'input_audio' input_audio = @{ data = ([System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($audioItem.FullName))) format = $audioformat } } } if ($null -ne $auc) { $um.content += $auc } } # For Vison if ($PSBoundParameters.ContainsKey('Images')) { if ($um.content -isnot [array]) { $um.content = @() if (-not [string]::IsNullOrWhiteSpace($Message)) { $um.content += @{type = 'text'; text = $Message.Trim() } } } foreach ($image in $Images) { $imc = $null if (Test-Path -LiteralPath $image -PathType Leaf) { $imc = @{type = 'image_url'; image_url = @{url = (Convert-ImageToDataURL $image) } } } else { $imc = @{type = 'image_url'; image_url = @{url = $image } } } if ($null -eq $imc) { continue } if ($PSBoundParameters.ContainsKey('ImageDetail')) { $imc.image_url.detail = $ImageDetail } $um.content += $imc } } # name poperty is optional if (-not [string]::IsNullOrWhiteSpace($Name)) { $um.name = $Name.Trim() } $Messages.Add($um) } # Error if messages is empty. if ($Messages.Count -eq 0) { Write-Error 'No message is specified. You must specify one or more messages.' return } $PostBody.messages = $Messages.ToArray() #endregion # As Batch if ($AsBatch) { if ([string]::IsNullOrEmpty($CustomBatchId)) { $CustomBatchId = 'request-{0:x4}' -f (Get-Random -Maximum 65535) } $batchInputObject = [pscustomobject]@{ 'custom_id' = $CustomBatchId 'method' = 'POST' 'url' = $OpenAIParameter.BatchEndpoint 'body' = [pscustomobject]$PostBody } $batchInputObject.PSObject.TypeNames.Insert(0, 'PSOpenAI.Batch.Input') return $batchInputObject } #region Send API Request (Stream) if ($Stream) { # Stream output $splat = @{ Method = $OpenAIParameter.Method Uri = $OpenAIParameter.Uri ContentType = $OpenAIParameter.ContentType TimeoutSec = $OpenAIParameter.TimeoutSec MaxRetryCount = $OpenAIParameter.MaxRetryCount ApiKey = $OpenAIParameter.ApiKey AuthType = $OpenAIParameter.AuthType Organization = $OpenAIParameter.Organization Body = $PostBody Stream = $Stream AdditionalQuery = $AdditionalQuery AdditionalHeaders = $AdditionalHeaders AdditionalBody = $AdditionalBody } Invoke-OpenAIAPIRequest @splat | Where-Object { -not [string]::IsNullOrEmpty($_) } | ForEach-Object { if ($Format -eq 'raw_response') { $_ } else { try { $_ | ConvertFrom-Json -ErrorAction Stop } catch { Write-Error -Exception $_.Exception } } } | Where-Object { $Format -eq 'raw_response' -or ($null -ne $_.choices -and ($_.choices[0].delta.content -is [string])) } | ForEach-Object -Process { if ($Format -eq 'raw_response') { Write-Output $_ } else { # Writes content to both the Information stream(6>) and the Standard output stream(1>). $InfoMsg = [System.Management.Automation.HostInformationMessage]::new() $InfoMsg.Message = $_.choices[0].delta.content $InfoMsg.NoNewLine = $true Write-Information $InfoMsg Write-Output $InfoMsg.Message } } return } #endregion #region Send API Request (No Stream) else { $splat = @{ Method = $OpenAIParameter.Method Uri = $OpenAIParameter.Uri ContentType = $OpenAIParameter.ContentType TimeoutSec = $OpenAIParameter.TimeoutSec MaxRetryCount = $OpenAIParameter.MaxRetryCount ApiKey = $OpenAIParameter.ApiKey AuthType = $OpenAIParameter.AuthType Organization = $OpenAIParameter.Organization Body = $PostBody AdditionalQuery = $AdditionalQuery AdditionalHeaders = $AdditionalHeaders AdditionalBody = $AdditionalBody } $Response = Invoke-OpenAIAPIRequest @splat # error check if ($null -eq $Response) { return } # Parse response object if ($Format -eq 'raw_response') { Write-Output $Response return } try { $Response = $Response | ConvertFrom-Json -ErrorAction Stop } catch { Write-Error -Exception $_.Exception return } } #endregion #region For history, add AI response to messages list. if (@($Response.choices.message).Count -ge 1) { $tr = @($Response.choices.message)[0] $rcm = [ordered]@{ role = $tr.role content = $tr.content } if ($tr.audio) { $rcm.Add('audio', (@{id = $tr.audio.id })) } if ($tr.tool_calls) { $rcm.Add('tool_calls', $tr.tool_calls) } $Messages.Add($rcm) } #endregion #region Function call if ($null -ne $Response.choices -and $Response.choices[0].finish_reason -eq 'tool_calls') { $ToolCallResults = @() $fCalls = @($Response.choices[0].message.tool_calls) foreach ($fCall in $fCalls) { if ($fCall.type -ne 'function') { continue } if ($fCall.function.name -notin $Tools.Where({ $_.type -eq 'function' }).function.name) { Write-Error ('"{0}" does not matches the list of functions. This command should not be executed.' -f $fCall.function.name) continue } Write-Verbose ('AI assistant preferes to call a function. (function:{0}, arguments:{1})' -f $fCall.function.name, ($fCall.function.arguments -replace '[\r\n]', '')) $fCommandResult = $null try { # Execute command $fCommandResult = Invoke-ChatCompletionFunction -Name $fCall.function.name -Arguments $fCall.function.arguments -InvokeFunctionOnCallMode $InvokeTools -ErrorAction Stop } catch { Write-Error -ErrorRecord $_ $fCommandResult = '[ERROR] ' + $_.Exception.Message } if ($null -eq $fCommandResult) { continue } $ToolCallResults += @{ role = 'tool' content = $(if ($fCommandResult -is [string]) { $fCommandResult }else { (ConvertTo-Json $fCommandResult) }) tool_call_id = $fCall.id } } # Second request if ($ToolCallResults.Count -gt 0) { Write-Verbose 'The function has been executed. The result of the execution is sent to the API.' $SecondRequestParam = $PSBoundParameters $null = $SecondRequestParam.Remove('Message') $null = $SecondRequestParam.Remove('Role') $null = $SecondRequestParam.Remove('Name') $null = $SecondRequestParam.Remove('SystemMessage') $null = $SecondRequestParam.Remove('DeveloperMessage') $Messages.AddRange($ToolCallResults) $SecondRequestParam.History = $Messages.ToArray() Request-ChatCompletion @SecondRequestParam return } } #endregion #region Output # Save audio to file if ($PSBoundParameters.ContainsKey('AudioOutFile')) { foreach ($choice in $Response.choices) { if ($null -eq $choice.message.audio.data) { continue } try { $audioData = [System.Convert]::FromBase64String($choice.message.audio.data) } catch { Write-Error -Exception $_.Exception } Write-ByteContent -OutFile $AudioOutFile -Bytes $audioData break } } ParseChatCompletionObject $Response -Messages $Messages -OutputType $Format #endregion } end { } } |