fix(providers): honour request_timeout for CLI providers
claude-cli, codex-cli, and gemini-cli ignored the request_timeout config field entirely — the factory created them without any timeout and the subprocesses ran indefinitely. This is particularly problematic for agentic tasks (e.g. cron jobs) where a runaway Claude Code session could block the agent loop forever. Each CLI provider gains a timeout field and a WithTimeout constructor. When request_timeout > 0 in the model config, Chat() wraps the incoming context with context.WithTimeout before passing it to exec.CommandContext, so the subprocess is killed and an error is returned if the deadline is exceeded. A value of 0 (the default) leaves behaviour unchanged — no timeout is applied. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
321edcfa1a
commit
df967fe63c
4 changed files with 60 additions and 0 deletions
|
|
@ -7,12 +7,14 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
|
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
|
||||||
type ClaudeCliProvider struct {
|
type ClaudeCliProvider struct {
|
||||||
command string
|
command string
|
||||||
workspace string
|
workspace string
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClaudeCliProvider creates a new Claude CLI provider.
|
// NewClaudeCliProvider creates a new Claude CLI provider.
|
||||||
|
|
@ -23,10 +25,25 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewClaudeCliProviderWithTimeout creates a new Claude CLI provider with a request timeout.
|
||||||
|
func NewClaudeCliProviderWithTimeout(workspace string, timeout time.Duration) *ClaudeCliProvider {
|
||||||
|
return &ClaudeCliProvider{
|
||||||
|
command: "claude",
|
||||||
|
workspace: workspace,
|
||||||
|
timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chat implements LLMProvider.Chat by executing the claude CLI.
|
// Chat implements LLMProvider.Chat by executing the claude CLI.
|
||||||
func (p *ClaudeCliProvider) Chat(
|
func (p *ClaudeCliProvider) Chat(
|
||||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||||
) (*LLMResponse, error) {
|
) (*LLMResponse, error) {
|
||||||
|
if p.timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
prompt := p.buildStdinPrompt(messages, tools)
|
prompt := p.buildStdinPrompt(messages, tools)
|
||||||
|
|
||||||
args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"}
|
args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
|
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
|
||||||
type CodexCliProvider struct {
|
type CodexCliProvider struct {
|
||||||
command string
|
command string
|
||||||
workspace string
|
workspace string
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCodexCliProvider creates a new Codex CLI provider.
|
// NewCodexCliProvider creates a new Codex CLI provider.
|
||||||
|
|
@ -24,6 +26,15 @@ func NewCodexCliProvider(workspace string) *CodexCliProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewCodexCliProviderWithTimeout creates a new Codex CLI provider with a request timeout.
|
||||||
|
func NewCodexCliProviderWithTimeout(workspace string, timeout time.Duration) *CodexCliProvider {
|
||||||
|
return &CodexCliProvider{
|
||||||
|
command: "codex",
|
||||||
|
workspace: workspace,
|
||||||
|
timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
|
// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
|
||||||
func (p *CodexCliProvider) Chat(
|
func (p *CodexCliProvider) Chat(
|
||||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||||
|
|
@ -32,6 +43,12 @@ func (p *CodexCliProvider) Chat(
|
||||||
return nil, fmt.Errorf("codex command not configured")
|
return nil, fmt.Errorf("codex command not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, tools)
|
prompt := p.buildPrompt(messages, tools)
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
workspace = "."
|
workspace = "."
|
||||||
}
|
}
|
||||||
|
if cfg.RequestTimeout > 0 {
|
||||||
|
return NewClaudeCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil
|
||||||
|
}
|
||||||
return NewClaudeCliProvider(workspace), modelID, nil
|
return NewClaudeCliProvider(workspace), modelID, nil
|
||||||
|
|
||||||
case "codex-cli", "codexcli":
|
case "codex-cli", "codexcli":
|
||||||
|
|
@ -187,6 +190,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
workspace = "."
|
workspace = "."
|
||||||
}
|
}
|
||||||
|
if cfg.RequestTimeout > 0 {
|
||||||
|
return NewCodexCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil
|
||||||
|
}
|
||||||
return NewCodexCliProvider(workspace), modelID, nil
|
return NewCodexCliProvider(workspace), modelID, nil
|
||||||
|
|
||||||
case "gemini-cli", "geminicli":
|
case "gemini-cli", "geminicli":
|
||||||
|
|
@ -194,6 +200,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
workspace = "."
|
workspace = "."
|
||||||
}
|
}
|
||||||
|
if cfg.RequestTimeout > 0 {
|
||||||
|
return NewGeminiCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil
|
||||||
|
}
|
||||||
return NewGeminiCliProvider(workspace), modelID, nil
|
return NewGeminiCliProvider(workspace), modelID, nil
|
||||||
|
|
||||||
case "github-copilot", "copilot":
|
case "github-copilot", "copilot":
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GeminiCliProvider implements LLMProvider using the gemini CLI as a subprocess.
|
// GeminiCliProvider implements LLMProvider using the gemini CLI as a subprocess.
|
||||||
type GeminiCliProvider struct {
|
type GeminiCliProvider struct {
|
||||||
command string
|
command string
|
||||||
workspace string
|
workspace string
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGeminiCliProvider creates a new Gemini CLI provider.
|
// NewGeminiCliProvider creates a new Gemini CLI provider.
|
||||||
|
|
@ -23,10 +25,25 @@ func NewGeminiCliProvider(workspace string) *GeminiCliProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewGeminiCliProviderWithTimeout creates a new Gemini CLI provider with a request timeout.
|
||||||
|
func NewGeminiCliProviderWithTimeout(workspace string, timeout time.Duration) *GeminiCliProvider {
|
||||||
|
return &GeminiCliProvider{
|
||||||
|
command: "gemini",
|
||||||
|
workspace: workspace,
|
||||||
|
timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chat implements LLMProvider.Chat by executing the gemini CLI.
|
// Chat implements LLMProvider.Chat by executing the gemini CLI.
|
||||||
func (p *GeminiCliProvider) Chat(
|
func (p *GeminiCliProvider) Chat(
|
||||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||||
) (*LLMResponse, error) {
|
) (*LLMResponse, error) {
|
||||||
|
if p.timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, tools)
|
prompt := p.buildPrompt(messages, tools)
|
||||||
|
|
||||||
// --prompt "" triggers non-interactive stdin mode; the empty string is appended to stdin input.
|
// --prompt "" triggers non-interactive stdin mode; the empty string is appended to stdin input.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue