Public/Responses/Request-Response.ps1

function Request-Response {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param (
        [Parameter(Mandatory = $false, Position = 0, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [Alias('input')]
        [Alias('UserMessage')]
        [string]$Message,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [Completions('user', 'system', 'developer', 'assistant')]
        [string][LowerCaseTransformation()]$Role = 'user',

        [Parameter(ValueFromPipelineByPropertyName)]
        [Completions(
            'gpt-3.5-turbo',
            'gpt-4',
            'gpt-4o',
            'gpt-4o-mini',
            'gpt-3.5-turbo-16k',
            'gpt-4-turbo',
            'gpt-4.5-preview',
            'o1',
            'o3-mini',
            'computer-use-preview'
        )]
        [string]$Model = 'gpt-4o-mini',

        #region System messages
        [Parameter()]
        [AllowEmptyString()]
        [Alias('system')]
        [string[]]$SystemMessage,

        [Parameter()]
        [AllowEmptyString()]
        [string[]]$DeveloperMessage,

        [Parameter()]
        [string]$Instructions,
        #endregion System messages

        #region Image input
        [Parameter()]
        [string[]]$Images,

        [Parameter()]
        [ValidateSet('auto', 'low', 'high')]
        [string][LowerCaseTransformation()]$ImageDetail = 'auto',
        #endregion Image input

        #region File input
        [Parameter()]
        [string[]]$Files,
        #endregion Image input

        #region Tools
        [Parameter()]
        [Alias('tool_choice')]
        [Completions('none', 'auto', 'required')]
        [object]$ToolChoice,

        [Parameter()]
        [Alias('parallel_tool_calls')]
        [bool]$ParallelToolCalls,

        #region Function calling
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.Collections.IDictionary[]]$Functions,

        [Parameter()]
        [ValidateSet('None', 'Auto', 'Confirm')]
        [string]$InvokeFunction = 'None',
        #endregion Function calling

        # Built-in tools
        #region File Search
        [Parameter()]
        [switch]$UseFileSearch,

        [Parameter(DontShow)]
        [string]$FileSearchType = 'file_search', # Currently, only 'file_search' is acceptable.

        [Parameter()]
        [string[]]$FileSearchVectorStoreIds,

        [Parameter()]
        [ValidateRange(1, 50)]
        [string[]]$FileSearchMaxNumberOfResults,

        [Parameter()]
        [string]$FileSearchFilters,

        [Parameter()]
        [Completions('auto')]
        [string]$FileSearchRanker = 'auto',

        [Parameter()]
        [ValidateRange(0.0, 1.0)]
        [double]$FileSearchScoreThreshold = 0.0,
        #endregion File Search

        #region Web Search
        [Parameter()]
        [switch]$UseWebSearch,

        [Parameter()]
        [Completions('web_search_preview')]
        [string]$WebSearchType = 'web_search_preview',

        [Parameter()]
        [ValidateSet('low', 'medium', 'high')]
        [string][LowerCaseTransformation()]$WebSearchContextSize,

        [Parameter(DontShow)]
        [string]$WebSearchUserLocationType = 'approximate', # Currently, only 'approximate' is acceptable.

        [Parameter()]
        [string]$WebSearchUserLocationCity,

        [Parameter()]
        [string]$WebSearchUserLocationCountry,

        [Parameter()]
        [string]$WebSearchUserLocationRegion,

        [Parameter()]
        [string]$WebSearchUserLocationTimeZone,
        #endregion Web Search

        #region Computer use
        [Parameter()]
        [switch]$UseComputerUse,

        [Parameter(DontShow)]
        [string]$ComputerUseType = 'computer_use_preview', # Currently, only 'computer_use_preview' is acceptable.

        [Parameter()]
        [string]$ComputerUseEnvironment,

        [Parameter()]
        [int]$ComputerUseDisplayHeight,

        [Parameter()]
        [int]$ComputerUseDisplayWidth,
        #endregion Computer use
        #endregion Tools

        [Parameter()]
        [Alias('previous_response_id')]
        [string]$PreviousResponseId,

        [Parameter()]
        [AllowEmptyCollection()]
        [string[]]$Include,

        [Parameter()]
        [Completions('auto', 'disabled')]
        [string]$Truncation,

        [Parameter()]
        [ValidateRange(0.0, 2.0)]
        [double]$Temperature,

        [Parameter()]
        [ValidateRange(0.0, 1.0)]
        [Alias('top_p')]
        [double]$TopP,

        [Parameter()]
        [switch]$Store = $false,

        #region Stream
        [Parameter()]
        [switch]$Stream = $false,

        [Parameter()]
        [ValidateSet('text', 'object')]
        [string]$StreamOutputType = 'text',
        #endregion Stream

        #region Reasoning
        [Parameter()]
        [Completions('low', 'medium', 'high')]
        [string]$ReasoningEffort = 'medium',

        [Parameter()]
        [Completions('concise', 'detailed')]
        [string]$ReasoningGenerateSummary,
        #endregion Reasoning

        [Parameter()]
        [System.Collections.IDictionary]$MetaData,

        [Parameter()]
        [Alias('max_output_tokens')]
        [int]$MaxOutputTokens,

        [Parameter()]
        [Completions('text', 'json_schema', 'json_object')]
        [object]$TextOutputFormat = 'text',

        #region Structured Outputs
        [Parameter()]
        [string]$JsonSchema,

        [Parameter()]
        [string]$JsonSchemaName,

        [Parameter()]
        [string]$JsonSchemaDescription,

        [Parameter()]
        [bool]$JsonSchemaStrict,
        #endregion Structured Outputs

        [Parameter()]
        [string]$User,

        [Parameter()]
        [switch]$AsBatch,

        [Parameter()]
        [string]$CustomBatchId,

        [Parameter()]
        [switch]$OutputRawResponse,

        [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 'Responses' -Parameters $PSBoundParameters -Engine $Model -ErrorAction Stop
    }

    process {
        #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('Include')) {
            $PostBody.include = @($Include)
        }
        if ($PSBoundParameters.ContainsKey('Instructions')) {
            $PostBody.instructions = $Instructions
        }
        if ($PSBoundParameters.ContainsKey('MaxOutputTokens')) {
            $PostBody.max_output_tokens = $MaxOutputTokens
        }
        if ($PSBoundParameters.ContainsKey('Truncation')) {
            $PostBody.truncation = $Truncation
        }
        if ($PSBoundParameters.ContainsKey('MetaData')) {
            $PostBody.metadata = $MetaData
        }
        if ($PSBoundParameters.ContainsKey('PreviousResponseId')) {
            $PostBody.previous_response_id = $PreviousResponseId
        }
        if ($PSBoundParameters.ContainsKey('ToolChoice')) {
            $PostBody.tool_choice = $ToolChoice
        }
        if ($PSBoundParameters.ContainsKey('ParallelToolCalls')) {
            $PostBody.parallel_tool_calls = $ParallelToolCalls
        }
        if ($PSBoundParameters.ContainsKey('TopP')) {
            $PostBody.top_p = $TopP
        }
        if ($PSBoundParameters.ContainsKey('Temperature')) {
            $PostBody.temperature = $Temperature
        }
        if ($Store.IsPresent) {
            $PostBody.store = $Store.ToBool()
        }
        if ($PSBoundParameters.ContainsKey('User')) {
            $PostBody.user = $User
        }
        if ($Stream) {
            $PostBody.stream = [bool]$Stream
        }

        # Reasoning
        $ReasoningOptions = @{}
        if ($PSBoundParameters.ContainsKey('ReasoningEffort')) {
            $ReasoningOptions.reasoning_effort = $ReasoningEffort
        }
        if ($PSBoundParameters.ContainsKey('ReasoningGenerateSummary')) {
            $ReasoningOptions.generate_summary = $ReasoningGenerateSummary
        }
        if ($ReasoningOptions.Keys.Count -gt 0) {
            $PostBody.reasoning = $ReasoningOptions
        }

        # Text Output options
        $TextOutputOptions = @{}
        if ($PSBoundParameters.ContainsKey('TextOutputFormat')) {
            if ($TextOutputFormat -is [type]) {
                # Structured Outputs
                $typeSchema = ConvertTo-JsonSchema $TextOutputFormat
                $TextOutputOptions.format = @{
                    'type'   = 'json_schema'
                    'name'   = $TextOutputFormat.Name
                    'strict' = $true
                    'schema' = $typeSchema
                }
            }
            elseif ($TextOutputFormat -eq 'text') {
                $TextOutputOptions.format = @{'type' = 'text' }
            }
            elseif ($TextOutputFormat -eq 'json_object') {
                $TextOutputOptions.format = @{'type' = 'json_object' }
            }
            elseif ($TextOutputFormat -eq 'json_schema') {
                # Structured Outputs
                $PostBody.response_format = @{'type' = $Format }
                if ($Format -eq 'json_schema') {
                    if (-not $JsonSchema) {
                        Write-Error -Exception ([System.ArgumentException]::new('JsonSchema must be specified.'))
                    }
                    else {
                        $TextOutputOptions.format = @{
                            'type'   = 'json_schema'
                            'schema' = (ConvertFrom-Json $JsonSchema)
                        }
                        if ($PSBoundParameters.ContainsKey('JsonSchemaName')) {
                            $TextOutputOptions.format.name = $JsonSchemaName
                        }
                        if ($PSBoundParameters.ContainsKey('JsonSchemaDescription')) {
                            $TextOutputOptions.format.description = $JsonSchemaDescription
                        }
                        if ($PSBoundParameters.ContainsKey('JsonSchemaStrict')) {
                            $TextOutputOptions.format.strict = $JsonSchemaStrict
                        }
                    }
                }
            }
        }
        if ($TextOutputOptions.Keys.Count -gt 0) {
            $PostBody.text = $TextOutputOptions
        }

        # Tools
        $Tools = @()
        if ($PSBoundParameters.ContainsKey('Functions')) {
            $Tools += $Functions
        }

        # File Search
        if ($UseFileSearch) {
            if ($FileSearchVectorStoreIds.Count -eq 0) {
                Write-Error 'VectorStore Ids must be specified.'
            }
            else {
                $RankingOptions = @{}
                $FileSearchTool = @{
                    type             = $FileSearchType
                    vector_store_ids = $FileSearchVectorStoreIds
                }
                if ($PSBoundParameters.ContainsKey('FileSearchMaxNumberOfResults')) {
                    $FileSearchTool.max_num_results = $FileSearchMaxNumberOfResults
                }
                if ($PSBoundParameters.ContainsKey('FileSearchFilters')) {
                    $FileSearchTool.filters = $FileSearchFilters
                }
                if ($PSBoundParameters.ContainsKey('FileSearchRanker')) {
                    $RankingOptions.ranker = $FileSearchRanker
                }
                if ($PSBoundParameters.ContainsKey('FileSearchScoreThreshold')) {
                    $RankingOptions.score_threshold = $FileSearchScoreThreshold
                }
                if ($RankingOptions.Keys.Count -gt 0) {
                    $FileSearchTool.ranking_options = $RankingOptions
                }
                $Tools += $FileSearchTool
            }
        }

        # Web Search
        if ($UseWebSearch) {
            $UserLocation = @{}
            $WebSearchTool = @{
                type = $WebSearchType
            }
            if ($PSBoundParameters.ContainsKey('WebSearchContextSize')) {
                $WebSearchTool.search_context_size = $WebSearchContextSize
            }
            if ($PSBoundParameters.ContainsKey('WebSearchUserLocationCity')) {
                $UserLocation.city = $WebSearchUserLocationCity
            }
            if ($PSBoundParameters.ContainsKey('WebSearchUserLocationCountry')) {
                $UserLocation.country = $WebSearchUserLocationCountry
            }
            if ($PSBoundParameters.ContainsKey('WebSearchUserLocationRegion')) {
                $UserLocation.region = $WebSearchUserLocationRegion
            }
            if ($PSBoundParameters.ContainsKey('WebSearchUserLocationTimeZone')) {
                $UserLocation.timezone = $WebSearchUserLocationTimeZone
            }

            if ($UserLocation.Keys.Count -gt 0) {
                $UserLocation.type = $WebSearchUserLocationType
                $WebSearchTool.user_location = $UserLocation
            }

            $Tools += $WebSearchTool
        }

        # Computer Use
        if ($UseComputerUse) {
            $ComputerUseTool = @{
                type = $ComputerUseType
            }
            if ($PSBoundParameters.ContainsKey('ComputerUseEnvironment')) {
                $ComputerUseTool.environment = $ComputerUseEnvironment
            }
            else {
                Write-Error 'ComputerUseEnvironment must be specified.'
            }
            if ($PSBoundParameters.ContainsKey('ComputerUseDisplayHeight')) {
                $ComputerUseTool.display_height = $ComputerUseDisplayHeight
            }
            else {
                Write-Error 'ComputerUseDisplayHeight must be specified.'
            }
            if ($PSBoundParameters.ContainsKey('ComputerUseDisplayWidth')) {
                $ComputerUseTool.display_width = $ComputerUseDisplayWidth
            }
            else {
                Write-Error 'ComputerUseDisplayWidth must be specified.'
            }
            $Tools += $ComputerUseTool
        }

        #region Construct messages
        $Messages = [System.Collections.Generic.List[object]]::new()
        # Append past conversations
        foreach ($pastmsg in $History) {
            $Messages.Add($pastmsg)
        }
        # Specifies system messages (only if specified)
        foreach ($sysmsg in $SystemMessage) {
            if (-not [string]::IsNullOrWhiteSpace($sysmsg)) {
                $Messages.Add([ordered]@{
                        role    = 'system'
                        content = $sysmsg
                    })
            }
        }
        # Specifies developer messages (only if specified)
        foreach ($devmsg in $DeveloperMessage) {
            if (-not [string]::IsNullOrWhiteSpace($devmsg)) {
                $Messages.Add([ordered]@{
                        role    = 'developer'
                        content = $devmsg
                    })
            }
        }
        #region Add user messages
        $usermsg = [ordered]@{
            role    = 'user'
            content = @()
        }

        # Text message
        if (-not [string]::IsNullOrWhiteSpace($Message)) {
            $usermsg.content += @{type = 'text'; text = $Message }
        }

        # File input
        if ($PSBoundParameters.ContainsKey('Files')) {
            foreach ($file in $Files) {
                if ([string]::IsNullOrWhiteSpace($file)) { continue }
                $fileContent = $null

                if (Test-Path -LiteralPath $file -PathType Leaf) {
                    # local file
                    $fileItem = Get-Item -LiteralPath $file
                    $fileContent = @{
                        type      = 'input_file'
                        filename  = $fileItem.Name
                        file_data = ([System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($fileItem.FullName)))
                    }
                }
                elseif ($file -match '[\\/]') {
                    continue
                }
                else {
                    # file id
                    $fileContent = @{
                        type    = 'input_file'
                        file_id = $file
                    }
                }
                $usermsg.content += $fileContent
            }
        }

        # Image input
        if ($PSBoundParameters.ContainsKey('Images')) {
            foreach ($image in $Images) {
                if ([string]::IsNullOrWhiteSpace($image)) { continue }
                $imageContent = $null

                if (Test-Path -LiteralPath $image -PathType Leaf) {
                    # local file
                    $imageContent = @{
                        type      = 'input_image'
                        image_url = (Convert-ImageToDataURL $image)
                    }
                }
                else {
                    # file id
                    $imageContent = @{
                        type    = 'input_image'
                        file_id = $file
                    }
                }
                if ($PSBoundParameters.ContainsKey('ImageDetail')) {
                    $imageContent.detail = $ImageDetail
                }
                $usermsg.content += $imageContent
            }
        }

        if ($usermsg.content.Count -ge 1) {
            $Messages.Add($usermsg)
        }
        #endregion

        # Error if message is empty.
        if ($Messages.Count -eq 0) {
            Write-Error 'No message is specified. You must specify one or more messages.'
            return
        }

        $PostBody.input = $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 ($OutputRawResponse) {
                        $_
                    }
                    else {
                        try {
                            $_ | ConvertFrom-Json -ErrorAction Stop
                        }
                        catch {
                            Write-Error -Exception $_.Exception
                        }
                    }
                } | ForEach-Object -Process {
                    if ($OutputRawResponse) {
                        Write-Output $_
                    }
                    elseif ($StreamOutputType -eq 'text') {
                        if ($_.type -cne 'response.output_text.delta') {
                            continue
                        }
                        Write-Output $_.delta
                    }
                    else {
                        Write-Output $_
                    }
                }

            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 ($OutputRawResponse) {
                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.output).Count -ge 1) {
            $Messages.Add(@($Response.output)[0])
        }
        #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
        ParseChatCompletionObject $Response -Messages $Messages -OutputType $Format
        #endregion
    }

    end {

    }
}