Private/Invoke-AnthropicReview.ps1

function Invoke-AnthropicReview {
    <#
    .SYNOPSIS
    Invoke code review using Anthropic Claude API
    
    .DESCRIPTION
    Calls Claude API with retry logic and returns parsed violations.
    
    .PARAMETER ReviewContext
    Array of file diffs
    
    .PARAMETER Rules
    Review rules markdown
    
    .PARAMETER ApiKey
    Anthropic API key
    
    .PARAMETER Config
    Provider configuration
    
    .PARAMETER MaxTokens
    Maximum output tokens
    
    .OUTPUTS
    System.Array - Array of violations
    
    .NOTES
    Author: waldo
    Version: 1.0.0
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [array]$ReviewContext,
        
        [Parameter(Mandatory = $true)]
        [string]$Rules,
        
        [Parameter(Mandatory = $true)]
        [string]$ApiKey,
        
        [Parameter(Mandatory = $true)]
        [object]$Config,
        
        [Parameter(Mandatory = $false)]
        [int]$MaxTokens = 4000,
        
        [Parameter(Mandatory = $false)]
        [string]$SystemPromptText
    )
    
    begin {
        Write-Verbose "Starting $($MyInvocation.MyCommand.Name)"
    }
    
    process {
        try {
            $prompts = Build-ReviewPrompt -Rules $Rules -ReviewContext $ReviewContext -SystemPromptText $SystemPromptText
            
            Write-Host "Model: $($Config.model)"
            
            # Estimate tokens
            $estimatedInputTokens = [int](($prompts.SystemPrompt.Length + $prompts.UserPrompt.Length) / 4)
            Write-Host "Estimated input tokens: $estimatedInputTokens"
            
            $headers = @{
                "x-api-key" = $ApiKey
                "anthropic-version" = "2023-06-01"
                "content-type" = "application/json"
            }
            
            $body = @{
                model = $Config.model
                max_tokens = $MaxTokens
                system = $prompts.SystemPrompt
                messages = @(
                    @{
                        role = "user"
                        content = $prompts.UserPrompt
                    }
                )
            } | ConvertTo-Json -Depth 10
            
            # Call API with retry logic
            $maxRetries = 3
            $attempt = 0
            $violations = $null
            
            while ($attempt -lt $maxRetries -and $null -eq $violations) {
                try {
                    $attempt++
                    Write-Verbose "Calling Claude API (attempt $attempt/$maxRetries)..."
                    
                    $response = Invoke-RestMethod -Uri "https://api.anthropic.com/v1/messages" `
                        -Method Post `
                        -Headers $headers `
                        -Body $body `
                        -TimeoutSec 600

                    # Safety classifiers can decline (HTTP 200, stop_reason refusal): no review was produced,
                    # and retrying the same request will not change that - fail the step instead of reporting Clean.
                    if ($response.stop_reason -eq 'refusal') {
                        $attempt = $maxRetries
                        $category = if ($response.stop_details) { " ($($response.stop_details.category))" } else { '' }
                        throw "Claude declined to review this diff (stop_reason refusal$category); no review produced"
                    }
                    if ($response.stop_reason -eq 'max_tokens') {
                        Write-Warning "Claude's answer was cut off at max_tokens=$MaxTokens; the violations list may be incomplete"
                    }

                    # The response can start with a thinking block; the review is in the text block(s).
                    $responseText = (@($response.content) | Where-Object { $_.type -eq 'text' } | ForEach-Object { $_.text }) -join "`n"
                    Write-Verbose "Received response: $($responseText.Length) characters"

                    # No text block at all (seen once in 104 Sonnet 5.5 replay runs): there is no review to parse.
                    # Say what came back instead of failing on the parser's empty-string binding.
                    if ([string]::IsNullOrWhiteSpace($responseText)) {
                        $blockTypes = (@($response.content) | ForEach-Object { $_.type }) -join ', '
                        throw "Claude returned no review text (stop_reason $($response.stop_reason), content blocks: $(if ($blockTypes) { $blockTypes } else { 'none' }), output tokens: $($response.usage.output_tokens))"
                    }

                    $parsed = Parse-AIResponse -ResponseText $responseText
                    $violations = $parsed.Violations
                    $prCommentText = $parsed.PRCommentText
                    
                    if ($null -ne $violations) {
                        Write-Host "✅ Successfully received $($violations.Count) violation(s)"
                        
                        # Log actual usage
                        if ($response.usage) {
                            Write-Host "Actual tokens: $($response.usage.input_tokens) input, $($response.usage.output_tokens) output"
                            # Sonnet 5.5 list price: $2 / $10 per million input / output tokens
                            $cost = ($response.usage.input_tokens / 1000000) * 2.0 + ($response.usage.output_tokens / 1000000) * 10.0
                            Write-Host "Estimated cost: `$$([math]::Round($cost, 4))"
                        }
                    }
                }
                catch {
                    $errMessage = $_.Exception.Message
                    
                    # Try to extract the actual Anthropic error body for better diagnostics
                    $responseBody = $null
                    if ($_.Exception.Response) {
                        try {
                            $stream = $_.Exception.Response.GetResponseStream()
                            $reader = [System.IO.StreamReader]::new($stream)
                            $responseBody = $reader.ReadToEnd()
                            $reader.Close()
                        } catch {}
                    }
                    
                    Write-Warning "API call failed (attempt $attempt): $errMessage"
                    if ($responseBody) {
                        Write-Warning "Anthropic response body: $responseBody"
                    }
                    
                    if ($attempt -ge $maxRetries) {
                        throw "Failed after $maxRetries attempts: $errMessage$(if ($responseBody) { "`nResponse: $responseBody" })"
                    }
                    
                    $sleepSeconds = [Math]::Pow(2, $attempt)
                    Write-Host "Retrying in $sleepSeconds seconds..."
                    Start-Sleep -Seconds $sleepSeconds
                }
            }
            
            return @{
                Violations = $violations
                PRCommentText = $prCommentText
                RawResponse = $responseText
                TokenUsage = $response.usage
            }
        }
        catch {
            Write-Error "Error in $($MyInvocation.MyCommand.Name): $_"
            throw
        }
    }
    
    end {
        Write-Verbose "Completed $($MyInvocation.MyCommand.Name)"
    }
}