Private/Translation/Invoke-GoogleTranslation.ps1
|
function Invoke-GoogleTranslation { <# .SYNOPSIS Calls the Google Gemini generateContent API with retry and exponential backoff. .OUTPUTS PSCustomObject: Content, InputTokens, OutputTokens, FinishReason, Model, RetryCount #> [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string] $SystemPrompt, [Parameter(Mandatory)] [string] $UserContent, [Parameter(Mandatory)] [TranslationProvider] $Provider, [Parameter(Mandatory)] [SecureString] $ApiKey, [int] $MaxRetries = 3, # When supplied, the call is attempted over the streaming path first so the # caller can report progress while the model is still writing; any streaming # failure falls back to the buffered request below. [scriptblock] $StreamCallback ) $plainKey = [System.Net.NetworkCredential]::new('', $ApiKey).Password $body = @{ systemInstruction = @{ parts = @(@{ text = $SystemPrompt }) } contents = @( @{ role = 'user' parts = @(@{ text = $UserContent }) } ) generationConfig = @{ temperature = [double]$Provider.Temperature maxOutputTokens = $Provider.MaxOutputTokens } } # Auth via header, not '?key=' query string - a plaintext key in the URI can # leak into exception messages, verbose transcripts, and captured error bodies. $headers = @{ 'x-goog-api-key' = $plainKey } $uri = '{0}/models/{1}:generateContent' -f $Provider.BaseUrl, $Provider.Model if ($StreamCallback) { # Gemini streams from a different method name entirely, and needs alt=sse to # emit server-sent events rather than a JSON array. $streamUri = '{0}/models/{1}:streamGenerateContent?alt=sse' -f $Provider.BaseUrl, $Provider.Model $streamed = Invoke-TranslationStreamAttempt -Uri $streamUri -Headers $headers -Body $body ` -Shape 'Google' -ProviderLabel 'Google' -Model $Provider.Model ` -StreamCallback $StreamCallback -JsonDepth 8 if ($streamed) { return $streamed } } $result = Invoke-TranslationApiRequest -Uri $uri -Method Post ` -Body $body -Headers $headers -ProviderLabel 'Google' -MaxRetries $MaxRetries -JsonDepth 8 if (-not $result.Success) { return [PSCustomObject]@{ Content = $result.ErrorMessage InputTokens = 0 OutputTokens = 0 FinishReason = 'error' Model = $Provider.Model RetryCount = $result.RetryCount } } $response = $result.Response # A safety-filtered prompt yields a 200 OK with NO candidates at all (the block # reason lives in promptFeedback.blockReason instead). Calling .ToLower() on the # missing finishReason previously threw *inside* the try, so the generic catch # swallowed it as a retryable failure and eventually returned a raw .NET # exception string as if it were translated text. Surface it as a clear, # non-retryable error instead. if (-not $response.candidates -or $response.candidates.Count -eq 0) { $blockReason = $response.promptFeedback.blockReason $reasonText = if ($blockReason) { $blockReason } else { 'unknown reason' } return [PSCustomObject]@{ Content = "Google Gemini blocked the request (no candidates returned): $reasonText" InputTokens = $response.usageMetadata.promptTokenCount OutputTokens = 0 FinishReason = 'error' Model = $Provider.Model RetryCount = $result.RetryCount } } $candidate = $response.candidates[0] $text = $candidate.content.parts[0].text $inTok = $response.usageMetadata.promptTokenCount $outTok = $response.usageMetadata.candidatesTokenCount $finish = $candidate.finishReason # Defensive: finishReason can still be null/absent even when candidates exist. $finishLower = if ($finish) { $finish.ToLower() } else { 'unknown' } return [PSCustomObject]@{ Content = $text InputTokens = $inTok OutputTokens = $outTok FinishReason = $finishLower Model = $Provider.Model RetryCount = $result.RetryCount } } |