Private/Invoke-OpenAIHttpRequest.ps1
|
using namespace System.Collections function Invoke-OpenAIHttpRequest { [CmdletBinding()] param ( [Parameter()] [string]$Method = 'Post', [Parameter(Mandatory)] [System.Uri]$Uri, [Parameter()] [string]$ContentType = 'application/json', [Parameter()] [securestring]$ApiKey, [Parameter()] [IDictionary]$AdditionalQuery, [Parameter()] [AllowEmptyString()] [string]$Organization, [Parameter()] [object]$Body, [Parameter()] [object]$AdditionalBody, [Parameter()] [IDictionary]$Headers, [Parameter()] [IDictionary]$AdditionalHeaders, [Parameter()] [int]$TimeoutSec = 0, [Parameter()] [int]$MaxRetryCount = 0, [Parameter()] [int]$RetryCount = 0, [Parameter()] [bool]$ReturnRawResponse = $false, [Parameter()] [switch]$Stream, [Parameter()] [string]$OutFile, [Parameter()] [uint64]$First = [uint64]::MaxValue, [Parameter()] [Generic.List[Tuple[regex, string]]]$MaskPatterns = @() ) $InternalParams = Initialize-OpenAIAPIRequestParam @PSBoundParameters $ServiceName = $InternalParams.ServiceName $PlainToken = if ($null -ne $ApiKey) { DecryptSecureString $ApiKey } if (-not [string]::IsNullOrWhiteSpace($PlainToken)) { $MaskPatterns.Add([Tuple[regex, string]]::new([regex]::Escape($PlainToken), '<OpenAI API Key>')) } if (-not [string]::IsNullOrWhiteSpace($Organization)) { $MaskPatterns.Add([Tuple[regex, string]]::new([regex]::Escape($Organization), '<OpenAI Organization ID>')) } $MaskPatterns.Add([Tuple[regex, string]]::new('(Authorization:\s*Bearer\s+)[a-zA-Z0-9\-_.~+/]+', '$1********')) try { while ($true) { $RequestMessage = $null $HttpResponse = $null $StreamReader = $null $cts = [System.Threading.CancellationTokenSource]::new() $Delay = $null try { if ($TimeoutSec -gt 0) { $cts.CancelAfter([timespan]::FromSeconds($TimeoutSec)) } $CancelToken = $cts.Token $RequestMessage = [System.Net.Http.HttpRequestMessage]::new($InternalParams.Method.ToUpperInvariant(), $InternalParams.Uri) # .NET Framework uses HTTP/1.1; modern .NET can negotiate down from HTTP/2. if ($PSVersionTable.PSVersion.Major -ge 7) { $RequestMessage.Version = [version]::new(2, 0) } if ($null -ne $InternalParams.Body -and $InternalParams.Method -ne 'Get') { if ($InternalParams.ContentType -match 'application/json') { $Bytes = [System.Text.Encoding]::UTF8.GetBytes(($InternalParams.Body | ConvertTo-Json -Compress -Depth 100 -ErrorAction Stop)) } elseif ($InternalParams.Body -is [byte[]]) { $Bytes = $InternalParams.Body } else { $Bytes = [System.Text.Encoding]::UTF8.GetBytes([string]$InternalParams.Body) } $RequestMessage.Content = [System.Net.Http.ByteArrayContent]::new($Bytes) $null = $RequestMessage.Content.Headers.TryAddWithoutValidation('Content-Type', $InternalParams.ContentType) } $RequestHeaders = @{} foreach ($h in $InternalParams.Headers.GetEnumerator()) { $RequestHeaders[$h.Key] = $h.Value } $RequestHeaders['User-Agent'] = $InternalParams.UserAgent if ($null -ne $ApiKey) { $RequestHeaders['Authorization'] = "Bearer $PlainToken" if (-not [string]::IsNullOrWhiteSpace($Organization)) { $RequestHeaders['OpenAI-Organization'] = $Organization.Trim() } } foreach ($h in $RequestHeaders.GetEnumerator()) { if (-not $RequestMessage.Headers.TryAddWithoutValidation($h.Key, [string[]]@($h.Value))) { if ($InternalParams.Method -eq 'Get' -and $h.Key -eq 'Content-Type') { continue } if ($null -eq $RequestMessage.Content) { $RequestMessage.Content = [System.Net.Http.ByteArrayContent]::new([byte[]]@()) } $null = $RequestMessage.Content.Headers.Remove($h.Key) if (-not $RequestMessage.Content.Headers.TryAddWithoutValidation($h.Key, [string[]]@($h.Value))) { throw [ArgumentException]::new("Invalid HTTP header: $($h.Key)") } } } Write-Verbose (("Request to $ServiceName API: " + $RequestMessage.Method + ' ' + $RequestMessage.RequestUri) | Get-MaskedString -MaskPatterns $MaskPatterns) if ($InternalParams.IsDebug) { Write-Debug (($RequestMessage.ToString()) | Get-MaskedString -MaskPatterns $MaskPatterns) } $HttpResponse = Send-OpenAIHttpRequest -Request $RequestMessage -CancellationToken $CancelToken Write-Verbose ("$ServiceName API response: HTTP/{0} {1}" -f $HttpResponse.Version, [int]$HttpResponse.StatusCode) if ($InternalParams.IsDebug) { Write-Debug (($HttpResponse.ToString()) | Get-MaskedString -MaskPatterns $MaskPatterns) } if (-not $HttpResponse.IsSuccessStatusCode) { $ResponseBody = Wait-OpenAIHttpTask -Task ($HttpResponse.Content.ReadAsStringAsync()) -CancellationToken $CancelToken $ErrorContent = try { $ResponseBody | ConvertFrom-Json -ErrorAction Stop } catch { $null } $Reason = if ($HttpResponse.ReasonPhrase) { $HttpResponse.ReasonPhrase } else { $HttpResponse.StatusCode.ToString() } $ErrorObject = Parse-WebExceptionResponse -ErrorCode ([int]$HttpResponse.StatusCode) -ErrorReason $Reason -ErrorResponse $HttpResponse -ErrorContent $ErrorContent -ServiceName $ServiceName if (Should-Retry -ErrorCode $ErrorObject.StatusCode -ErrorMessage $ErrorObject.Message -Headers $ErrorObject.Response.Headers -RetryCount $RetryCount -MaxRetryCount $MaxRetryCount) { $Delay = Get-RetryDelay -RetryCount $RetryCount -ResponseHeaders $ErrorObject.Response.Headers Write-Warning $ErrorObject.Message Write-Warning ('Retry the request after waiting {0} ms (retry count: {1})' -f $Delay, $RetryCount) } else { $er = [System.Management.Automation.ErrorRecord]::new( $ErrorObject, ('PSOpenAI.APIRequest.{0}' -f $ErrorObject.GetType().Name), [System.Management.Automation.ErrorCategory]::InvalidOperation, $null ) $er.ErrorDetails = $ErrorObject.Message $PSCmdlet.ThrowTerminatingError($er) } } elseif ($Stream) { $ResponseStream = Wait-OpenAIHttpTask -Task ($HttpResponse.Content.ReadAsStreamAsync()) -CancellationToken $CancelToken $StreamReader = [System.IO.StreamReader]::new($ResponseStream, [System.Text.Encoding]::UTF8) [uint64]$DataCounter = 0 while ($DataCounter -lt $First) { $data = Wait-OpenAIHttpTask -Task ($StreamReader.ReadLineAsync()) -CancellationToken $CancelToken if ($null -eq $data) { break } if ([string]::IsNullOrWhiteSpace($data)) { continue } if ($InternalParams.IsDebug) { Write-Debug ($data | Get-MaskedString -MaskPatterns $MaskPatterns) } if ($ReturnRawResponse) { Write-Output $data continue } if ($data.StartsWith('event: ', [StringComparison]::Ordinal)) { Write-Verbose $data } elseif ($data.StartsWith('data: ', [StringComparison]::Ordinal)) { if ($data -eq 'data: [DONE]') { break } $DataCounter++ Write-Output $data.Substring(6) } } return } else { $MediaType = $HttpResponse.Content.Headers.ContentType.MediaType if (-not $OutFile -and $MediaType -match '^(text/|application/(.*\+)?(json|xml|javascript))') { $Content = Wait-OpenAIHttpTask -Task ($HttpResponse.Content.ReadAsStringAsync()) -CancellationToken $CancelToken } else { $Content = Wait-OpenAIHttpTask -Task ($HttpResponse.Content.ReadAsByteArrayAsync()) -CancellationToken $CancelToken } if ($InternalParams.IsDebug) { if ($Content -is [byte[]]) { Write-Debug ("API response body: <binary data> (length: {0} bytes)" -f $Content.Length) } else { Write-Debug (('API response body: ' + $Content) | Get-MaskedString -MaskPatterns $MaskPatterns) } } if ($OutFile) { Write-ByteContent -OutFile $OutFile -Bytes $Content } elseif ($ReturnRawResponse) { # Private response snapshot: no live stream escapes the request lifetime. $ResponseHeaders = @{} foreach ($h in $HttpResponse.Headers) { $ResponseHeaders[$h.Key] = $h.Value } foreach ($h in $HttpResponse.Content.Headers) { $ResponseHeaders[$h.Key] = $h.Value } [pscustomobject]@{ StatusCode = [int]$HttpResponse.StatusCode StatusDescription = $HttpResponse.ReasonPhrase Headers = $ResponseHeaders Content = $Content } } else { Write-Output $Content } return } } catch { $Exception = $_.Exception while ($Exception.InnerException -and $Exception -isnot [OperationCanceledException]) { $Exception = $Exception.InnerException } if ($Exception -is [OperationCanceledException]) { $er = [System.Management.Automation.ErrorRecord]::new( [TimeoutException]::new('The operation was canceled due to timeout.', $Exception), 'PSOpenAI.APIRequest.TimeoutException', [System.Management.Automation.ErrorCategory]::OperationTimeout, $null ) $PSCmdlet.ThrowTerminatingError($er) } $PSCmdlet.ThrowTerminatingError($_) } finally { # Each attempt owns its messages/readers; the module owns the client. $cts.Cancel() if ($null -ne $StreamReader) { $StreamReader.Dispose() } if ($null -ne $HttpResponse) { $HttpResponse.Dispose() } if ($null -ne $RequestMessage) { $RequestMessage.Dispose() } $cts.Dispose() } Start-Sleep -Milliseconds $Delay $RetryCount++ } } finally { $PlainToken = $null } } |