Private/Invoke-AICompletion.ps1

function Invoke-AICompletion {
    <#
    .SYNOPSIS
        Provider-agnostic LLM API caller.
    .DESCRIPTION
        Sends prompts to various LLM providers (Anthropic, OpenAI, Ollama, Custom)
        and returns the text response. Handles rate limiting with exponential backoff,
        authentication errors, and timeouts.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Anthropic', 'OpenAI', 'Ollama', 'Custom')]
        [string]$Provider,

        [Parameter()]
        [string]$ApiKey,

        [Parameter()]
        [string]$Model,

        [Parameter()]
        [string]$Endpoint,

        [Parameter()]
        [string]$SystemPrompt,

        [Parameter(Mandatory)]
        [string]$UserPrompt,

        [Parameter()]
        [int]$MaxTokens = 4096,

        [Parameter()]
        [double]$Temperature = 0.1
    )

    # Resolve API key from environment if not provided
    if (-not $ApiKey -and $Provider -in @('Anthropic', 'OpenAI', 'Custom')) {
        $ApiKey = $env:LIVINGDOC_API_KEY
        if (-not $ApiKey) {
            switch ($Provider) {
                'Anthropic' { $ApiKey = $env:ANTHROPIC_API_KEY }
                'OpenAI'    { $ApiKey = $env:OPENAI_API_KEY }
            }
        }
        if (-not $ApiKey -and $Provider -ne 'Custom') {
            throw "No API key provided. Set -ApiKey parameter or `$env:LIVINGDOC_API_KEY / `$env:$($Provider.ToUpper())_API_KEY"
        }
    }

    # Resolve default models
    if (-not $Model) {
        $Model = switch ($Provider) {
            'Anthropic' { 'claude-sonnet-4-5-20250929' }
            'OpenAI'    { 'gpt-4o' }
            'Ollama'    { 'llama3' }
            'Custom'    { 'default' }
        }
    }

    # Build request based on provider
    $headers = @{}
    $body = $null
    $uri = $null

    switch ($Provider) {
        'Anthropic' {
            $uri = 'https://api.anthropic.com/v1/messages'
            $headers = @{
                'x-api-key'         = $ApiKey
                'anthropic-version' = '2023-06-01'
                'Content-Type'      = 'application/json'
            }
            $messages = @(
                @{ role = 'user'; content = $UserPrompt }
            )
            $bodyObj = @{
                model      = $Model
                max_tokens = $MaxTokens
                temperature = $Temperature
                messages   = $messages
            }
            if ($SystemPrompt) {
                $bodyObj['system'] = $SystemPrompt
            }
            $body = $bodyObj | ConvertTo-Json -Depth 10
        }

        'OpenAI' {
            $uri = 'https://api.openai.com/v1/chat/completions'
            $headers = @{
                'Authorization' = "Bearer $ApiKey"
                'Content-Type'  = 'application/json'
            }
            $messages = @()
            if ($SystemPrompt) {
                $messages += @{ role = 'system'; content = $SystemPrompt }
            }
            $messages += @{ role = 'user'; content = $UserPrompt }
            $bodyObj = @{
                model       = $Model
                max_tokens  = $MaxTokens
                temperature = $Temperature
                messages    = $messages
            }
            $body = $bodyObj | ConvertTo-Json -Depth 10
        }

        'Ollama' {
            $uri = if ($Endpoint) { "$($Endpoint.TrimEnd('/'))/api/generate" } else { 'http://localhost:11434/api/generate' }
            $headers = @{
                'Content-Type' = 'application/json'
            }
            $prompt = ''
            if ($SystemPrompt) {
                $prompt = "System: $SystemPrompt`n`nUser: $UserPrompt"
            }
            else {
                $prompt = $UserPrompt
            }
            $bodyObj = @{
                model       = $Model
                prompt      = $prompt
                stream      = $false
                options     = @{
                    temperature = $Temperature
                    num_predict = $MaxTokens
                }
            }
            $body = $bodyObj | ConvertTo-Json -Depth 10
        }

        'Custom' {
            if (-not $Endpoint) {
                throw "Custom provider requires -Endpoint parameter."
            }
            $uri = $Endpoint
            $headers = @{
                'Content-Type' = 'application/json'
            }
            if ($ApiKey) {
                $headers['Authorization'] = "Bearer $ApiKey"
            }
            $messages = @()
            if ($SystemPrompt) {
                $messages += @{ role = 'system'; content = $SystemPrompt }
            }
            $messages += @{ role = 'user'; content = $UserPrompt }
            $bodyObj = @{
                model       = $Model
                max_tokens  = $MaxTokens
                temperature = $Temperature
                messages    = $messages
            }
            $body = $bodyObj | ConvertTo-Json -Depth 10
        }
    }

    # Execute request with retry logic
    $maxRetries = 3
    $retryCount = 0
    $baseDelay = 2

    while ($retryCount -le $maxRetries) {
        try {
            Write-Verbose "Sending request to $Provider ($uri) with model $Model..."

            $splat = @{
                Uri             = $uri
                Method          = 'POST'
                Headers         = $headers
                Body            = $body
                TimeoutSec      = 120
                UseBasicParsing = $true
            }

            # PowerShell 5.1 compatibility: handle encoding
            if ($PSVersionTable.PSVersion.Major -le 5) {
                $splat['Body'] = [System.Text.Encoding]::UTF8.GetBytes($body)
            }

            $response = Invoke-RestMethod @splat

            # Parse response based on provider
            $result = switch ($Provider) {
                'Anthropic' {
                    if ($response.content -and $response.content.Count -gt 0) {
                        $response.content[0].text
                    }
                    else {
                        throw "Unexpected Anthropic response format."
                    }
                }
                'OpenAI' {
                    if ($response.choices -and $response.choices.Count -gt 0) {
                        $response.choices[0].message.content
                    }
                    else {
                        throw "Unexpected OpenAI response format."
                    }
                }
                'Ollama' {
                    if ($response.response) {
                        $response.response
                    }
                    else {
                        throw "Unexpected Ollama response format."
                    }
                }
                '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.response) {
                        $response.response
                    }
                    else {
                        $response | ConvertTo-Json -Depth 5
                    }
                }
            }

            Write-Verbose "Received response from $Provider ($([Math]::Min($result.Length, 100)) chars)."
            return $result
        }
        catch {
            $errorMsg = $_.Exception.Message

            # Check for rate limit (429)
            if ($errorMsg -match '429|rate.?limit|too.?many.?requests') {
                $retryCount++
                if ($retryCount -le $maxRetries) {
                    $delay = [Math]::Pow($baseDelay, $retryCount)
                    Write-Warning "Rate limited by $Provider. Retrying in $delay seconds (attempt $retryCount of $maxRetries)..."
                    Start-Sleep -Seconds $delay
                    continue
                }
            }

            # Check for auth errors (401/403)
            if ($errorMsg -match '401|403|unauthorized|forbidden|invalid.?api.?key') {
                throw "Authentication failed for $Provider. Please check your API key. Error: $errorMsg"
            }

            # Check for timeout
            if ($errorMsg -match 'timeout|timed.?out') {
                $retryCount++
                if ($retryCount -le $maxRetries) {
                    $delay = [Math]::Pow($baseDelay, $retryCount)
                    Write-Warning "Request to $Provider timed out. Retrying in $delay seconds (attempt $retryCount of $maxRetries)..."
                    Start-Sleep -Seconds $delay
                    continue
                }
            }

            # For any other error or exhausted retries, throw
            throw "AI API call to $Provider failed: $errorMsg"
        }
    }

    throw "AI API call to $Provider failed after $maxRetries retries."
}