Private/Invoke-AICompletion.ps1
|
function Invoke-AICompletion { <# .SYNOPSIS Provider-agnostic LLM API caller supporting Anthropic, OpenAI, Ollama, and custom endpoints. .DESCRIPTION Sends a prompt to the configured AI provider and returns the completion text. Handles authentication, retries on rate limits, and provider-specific API formats. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Prompt, [Parameter()] [ValidateSet('Anthropic', 'OpenAI', 'Ollama', 'Custom')] [string]$Provider = 'Anthropic', [Parameter()] [string]$ApiKey, [Parameter()] [string]$Model, [Parameter()] [string]$Endpoint, [Parameter()] [string]$SystemPrompt, [Parameter()] [double]$Temperature = 0.1, [Parameter()] [int]$MaxTokens = 4096, [Parameter()] [int]$MaxRetries = 3 ) # Resolve API key from environment if not provided if (-not $ApiKey) { $ApiKey = switch ($Provider) { 'Anthropic' { if ($env:LIVINGDOC_API_KEY) { $env:LIVINGDOC_API_KEY } elseif ($env:ANTHROPIC_API_KEY) { $env:ANTHROPIC_API_KEY } else { $null } } 'OpenAI' { if ($env:LIVINGDOC_API_KEY) { $env:LIVINGDOC_API_KEY } elseif ($env:OPENAI_API_KEY) { $env:OPENAI_API_KEY } else { $null } } default { $null } } } # Resolve default model per provider if (-not $Model) { $Model = switch ($Provider) { 'Anthropic' { 'claude-sonnet-4-20250514' } 'OpenAI' { 'gpt-4o' } 'Ollama' { 'llama3.1:8b' } 'Custom' { 'default' } } } # Resolve endpoint if (-not $Endpoint) { $Endpoint = switch ($Provider) { 'Anthropic' { 'https://api.anthropic.com/v1/messages' } 'OpenAI' { 'https://api.openai.com/v1/chat/completions' } 'Ollama' { 'http://localhost:11434/api/chat' } 'Custom' { throw 'Custom provider requires -Endpoint parameter.' } } } # Validate API key for cloud providers if ($Provider -in @('Anthropic', 'OpenAI') -and -not $ApiKey) { throw "No API key found for $Provider. Provide -ApiKey, set `$env:LIVINGDOC_API_KEY, or set the provider-specific environment variable." } # Build request based on provider $headers = @{} $body = $null switch ($Provider) { 'Anthropic' { $headers = @{ 'x-api-key' = $ApiKey 'anthropic-version' = '2023-06-01' 'Content-Type' = 'application/json' } $messageList = @( @{ role = 'user'; content = $Prompt } ) $bodyObj = @{ model = $Model max_tokens = $MaxTokens temperature = $Temperature messages = $messageList } if ($SystemPrompt) { $bodyObj['system'] = $SystemPrompt } $body = $bodyObj | ConvertTo-Json -Depth 10 -Compress } 'OpenAI' { $headers = @{ 'Authorization' = "Bearer $ApiKey" 'Content-Type' = 'application/json' } $messages = @() if ($SystemPrompt) { $messages += @{ role = 'system'; content = $SystemPrompt } } $messages += @{ role = 'user'; content = $Prompt } $bodyObj = @{ model = $Model temperature = $Temperature max_tokens = $MaxTokens messages = $messages } $body = $bodyObj | ConvertTo-Json -Depth 10 -Compress } 'Ollama' { $headers = @{ 'Content-Type' = 'application/json' } $messages = @() if ($SystemPrompt) { $messages += @{ role = 'system'; content = $SystemPrompt } } $messages += @{ role = 'user'; content = $Prompt } $bodyObj = @{ model = $Model messages = $messages stream = $false options = @{ temperature = $Temperature } } $body = $bodyObj | ConvertTo-Json -Depth 10 -Compress } 'Custom' { # Custom uses OpenAI-compatible format by default $headers = @{ 'Content-Type' = 'application/json' } if ($ApiKey) { $headers['Authorization'] = "Bearer $ApiKey" } $messages = @() if ($SystemPrompt) { $messages += @{ role = 'system'; content = $SystemPrompt } } $messages += @{ role = 'user'; content = $Prompt } $bodyObj = @{ model = $Model temperature = $Temperature max_tokens = $MaxTokens messages = $messages } $body = $bodyObj | ConvertTo-Json -Depth 10 -Compress } } # Execute with retry logic $attempt = 0 $lastError = $null while ($attempt -lt $MaxRetries) { $attempt++ try { Write-Verbose "AI request attempt $attempt/$MaxRetries to $Provider ($Model)" $splat = @{ Uri = $Endpoint Method = 'POST' Headers = $headers Body = $body ErrorAction = 'Stop' } # PowerShell 5.1 needs explicit UTF-8 encoding if ($PSVersionTable.PSVersion.Major -le 5) { $splat['Body'] = [System.Text.Encoding]::UTF8.GetBytes($body) } $response = Invoke-RestMethod @splat # Extract text based on provider response format $resultText = switch ($Provider) { 'Anthropic' { if ($response.content -and $response.content.Count -gt 0) { $response.content[0].text } else { throw 'Anthropic response contained no content.' } } 'OpenAI' { if ($response.choices -and $response.choices.Count -gt 0) { $response.choices[0].message.content } else { throw 'OpenAI response contained no choices.' } } 'Ollama' { if ($response.message) { $response.message.content } else { throw 'Ollama response contained no message.' } } 'Custom' { # Try OpenAI format first, then raw if ($response.choices -and $response.choices.Count -gt 0) { $response.choices[0].message.content } elseif ($response.content -and $response.content.Count -gt 0) { $response.content[0].text } elseif ($response.message) { $response.message.content } elseif ($response.text) { $response.text } else { throw 'Custom endpoint response format not recognized.' } } } Write-Verbose "AI response received: $($resultText.Length) characters" return $resultText } catch { $lastError = $_ $statusCode = $null # Extract HTTP status code from exception if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } switch ($statusCode) { 401 { throw "Authentication failed for $Provider. Check your API key or credentials. Error: $($_.Exception.Message)" } 403 { throw "Access denied for $Provider. Check API key permissions. Error: $($_.Exception.Message)" } 429 { # Rate limited - extract retry-after or use exponential backoff $retryAfter = 0 if ($_.Exception.Response.Headers) { $retryHeader = $_.Exception.Response.Headers | Where-Object { $_.Key -eq 'Retry-After' } if ($retryHeader) { $retryAfter = [int]$retryHeader.Value[0] } } if ($retryAfter -lt 1) { $retryAfter = [math]::Pow(2, $attempt) * 2 } Write-Warning "Rate limited by $Provider. Waiting $retryAfter seconds before retry ($attempt/$MaxRetries)." Start-Sleep -Seconds $retryAfter } default { if ($attempt -lt $MaxRetries) { $backoff = [math]::Pow(2, $attempt) Write-Warning "AI request failed (attempt $attempt/$MaxRetries). Retrying in $backoff seconds. Error: $($_.Exception.Message)" Start-Sleep -Seconds $backoff } } } } } throw "AI request failed after $MaxRetries attempts. Last error: $($lastError.Exception.Message)" } |