Providers/DeepSeek.ps1
|
function Invoke-DeepSeekProvider { <# .SYNOPSIS Runs DeepSeek Responses requests with local PowerShell function tools. .PARAMETER ModelName A model returned by DeepSeek's model catalog, such as deepseek-flash. .PARAMETER Messages Messages or Responses input items. Supply the previous Conversation plus a new user message to continue a stateless conversation, including reasoning. .PARAMETER Tools PowerShell command names or function schemas. Only registered tools may run. Command names require Register-Tool (provided by PSAI). .PARAMETER MaxIterations Maximum model requests, including tool-calling rounds and the final answer. .PARAMETER EffortLevel Reasoning effort. none disables thinking; compatible values map to low, high, or max according to DeepSeek's documented mapping. .PARAMETER ToolRoot Allowed root for built-in Get-ChildItem and Get-Content tools. Defaults to the current directory. Links and access outside this root are rejected. .PARAMETER ToolPolicy Local execution-policy options: configurable resource limits and an optional BeforeInvoke scriptblock. This policy is never sent to or set by the model. .NOTES Uses https://api.deepseek.com/responses and the DeepSeekKey environment variable. Returns text and conversation metadata to Invoke-ChatCompletion. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ModelName, [Parameter(Mandatory)] [object[]]$Messages, [object[]]$Tools, [ValidateRange(1, 100)] [int]$MaxIterations = 5, [ValidateSet('none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max')] [string]$EffortLevel, [ValidateNotNullOrEmpty()] [string]$ToolRoot = (Get-Location).Path, [hashtable]$ToolPolicy ) if ([string]::IsNullOrWhiteSpace($env:DeepSeekKey)) { throw 'Set the DeepSeekKey environment variable before using DeepSeek.' } $executionPolicy = New-AgentToolPolicy -Root $ToolRoot -Options $ToolPolicy $ToolRoot = $executionPolicy.Root $registeredTools = @{} $responseTools = @() foreach ($tool in $Tools) { $definition = if ($tool -is [string]) { if (-not (Get-Command Register-Tool -ErrorAction SilentlyContinue)) { throw 'Command-name tools require Register-Tool from PSAI. Install PSAI or supply function schemas.' } Register-Tool $tool -ErrorAction Stop } else { $tool } $schemas = @(ConvertTo-ProviderToolSchema -Tools @($definition) -Provider openai -ErrorAction Stop) if ($schemas.Count -ne 1) { throw 'Each DeepSeek tool must have a valid function schema.' } $schema = if ($schemas[0].function) { $schemas[0].function } else { $schemas[0] } $name = [string]$schema.name if ($name -cnotmatch '^[a-zA-Z0-9_-]{1,128}$') { throw "Invalid DeepSeek tool name: $name" } if ($registeredTools.ContainsKey($name)) { throw "Duplicate DeepSeek tool name: $name" } if (-not (Get-Command -Name $name -ErrorAction SilentlyContinue)) { throw "Tool command not found: $name" } $parameters = if ($schema.parameters) { $schema.parameters } else { @{ type = 'object'; properties = @{} } } $registeredTools[$name] = $parameters $responseTools += @{ type = 'function' name = $name description = if ($schema.description) { $schema.description } else { $name } parameters = $parameters } } $instructionMessages = @($Messages | Where-Object { $_.role -in @('system', 'developer') }) $body = @{ model = $ModelName input = @($Messages | Where-Object { $_.role -notin @('system', 'developer') }) stream = $false } $instructions = (@($instructionMessages | ForEach-Object { ConvertTo-OpenAIInstructionText -Content $_.content }) -join "`n`n") $instructions += "`n`nTool execution context: ToolRoot is '$ToolRoot'. Relative file paths resolve there. The file tools can access only this directory and its children. If there are no relevant files, report that result; choosing another root requires the user. Tool-policy denials do not grant permission to expand the scope." if ($instructions) { $body.instructions = $instructions } if ($responseTools.Count -gt 0) { $body.tools = $responseTools $body.tool_choice = 'auto' } $reasoningEffort = if ($EffortLevel) { switch ($EffortLevel) { 'none' { 'none' } { $_ -in @('minimal', 'low') } { 'low' } 'max' { 'max' } default { 'high' } } } if ($reasoningEffort) { $body.reasoning = @{ effort = $reasoningEffort } } $headers = @{ Authorization = "Bearer $env:DeepSeekKey"; 'Content-Type' = 'application/json' } $activity = New-AgentActivity -Provider 'DeepSeek' -Model $ModelName -MaxIterations $MaxIterations $seenCalls = @{} foreach ($item in $body.input) { if ($item.type -eq 'function_call' -and $item.call_id) { $seenCalls[[string]$item.call_id] = $true } } try { for ($round = 1; $round -le $MaxIterations; $round++) { Write-AgentActivity -Activity $activity -Message 'Waiting for response' -Round $round try { $response = Invoke-RestMethod -Uri 'https://api.deepseek.com/responses' -Method Post -Headers $headers -Body ($body | ConvertTo-Json -Depth 50) -ErrorAction Stop } catch [System.Management.Automation.PipelineStoppedException] { throw } catch { $detail = if ($_.ErrorDetails.Message) { $_.ErrorDetails.Message } else { $_.Exception.Message } throw "DeepSeek API request failed: $detail" } if ($response.error -or $response.status -eq 'failed') { throw "DeepSeek response failed: $($response.error.message)" } if ($response.status -eq 'incomplete') { Write-AgentActivity -Activity $activity -Message "Stopped: incomplete response ($($response.incomplete_details.reason))" -Completed throw "DeepSeek response is incomplete ($($response.incomplete_details.reason)). No tools from this response were executed." } if ($response.status -ne 'completed') { throw "Unexpected DeepSeek response status: $($response.status)" } $output = @($response.output) if ($output.Count -eq 0 -or $null -eq $output[0]) { throw 'DeepSeek returned no response content.' } $calls = @($output | Where-Object type -eq 'function_call') # Validate the whole batch before executing anything; do not replay IDs. foreach ($call in $calls) { if ([string]::IsNullOrWhiteSpace($call.call_id) -or $seenCalls.ContainsKey([string]$call.call_id)) { throw 'DeepSeek returned a missing or duplicate tool call ID; no tools from this response were executed.' } $seenCalls[[string]$call.call_id] = $true } $body.input = @($body.input) + $output if ($calls.Count -gt 0) { Write-AgentActivity -Activity $activity -Message "Model requested $($calls.Count) tool call(s)" foreach ($call in $calls) { $timer = [System.Diagnostics.Stopwatch]::StartNew() Write-AgentActivity -Activity $activity -Message "Tool started: $($call.name)" $toolResult = Invoke-RegisteredAgentTool -Name $call.name -Arguments $call.arguments -RegisteredTools $registeredTools -Policy $executionPolicy $timer.Stop() $state = if ($toolResult.Failed) { 'Tool failed' } else { 'Tool completed' } Write-AgentActivity -Activity $activity -Message "$($state): $($call.name) ($($timer.ElapsedMilliseconds) ms)" $body.input += @{ type = 'function_call_output'; call_id = $call.call_id; output = $toolResult.Output } } continue } $text = (@($output | Where-Object type -eq 'message' | ForEach-Object { $_.content | Where-Object type -eq 'output_text' | ForEach-Object text }) -join '') if ([string]::IsNullOrWhiteSpace($text)) { throw 'DeepSeek completed without answer text or function calls.' } Write-AgentActivity -Activity $activity -Message 'Response completed' -Completed return [pscustomobject]@{ Text = $text Conversation = @($instructionMessages) + @($body.input) ToolRoot = $ToolRoot IterationsUsed = $round MaxIterations = $MaxIterations RequestedEffortLevel = $EffortLevel ReasoningEffort = $reasoningEffort RequestedSpeedLevel = $null ServiceTier = $null } } Write-AgentActivity -Activity $activity -Message 'Stopped: maximum iterations reached' -Completed throw "Maximum iterations reached without completing the DeepSeek response after $MaxIterations iterations." } catch [System.Management.Automation.PipelineStoppedException] { throw } catch { Write-AgentActivity -Activity $activity -Message 'Request failed' -Completed throw } finally { Write-AgentActivity -Activity $activity -Message 'Request stopped' -Completed } } |