diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index f514ba7f0..1183f5618 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -7,12 +7,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. type ClaudeCliProvider struct { command string workspace string + timeout time.Duration } // 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. func (p *ClaudeCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { + if p.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } + prompt := p.buildStdinPrompt(messages, tools) args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} @@ -46,6 +63,9 @@ func (p *ClaudeCliProvider) Chat( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("claude cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } stderrStr := strings.TrimSpace(stderr.String()) stdoutStr := strings.TrimSpace(stdout.String()) switch { diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 13f53ad9e..7d347aa54 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -8,12 +8,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. type CodexCliProvider struct { command string workspace string + timeout time.Duration } // 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. func (p *CodexCliProvider) Chat( 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") } + if p.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } + prompt := p.buildPrompt(messages, tools) args := []string{ @@ -69,6 +86,9 @@ func (p *CodexCliProvider) Chat( } if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("codex cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } if ctx.Err() == context.Canceled { return nil, ctx.Err() } diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..46d3b4c82 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -2,6 +2,7 @@ package providers import ( "context" + "errors" "regexp" "strings" ) @@ -114,7 +115,7 @@ func ClassifyError(err error, provider, model string) *FailoverError { } // Context deadline exceeded: treat as timeout, always fallback. - if err == context.DeadlineExceeded { + if errors.Is(err, context.DeadlineExceeded) { return &FailoverError{ Reason: FailoverTimeout, Provider: provider, diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a460dec99..034f8b9c2 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -180,6 +180,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewClaudeCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewClaudeCliProvider(workspace), modelID, nil case "codex-cli", "codexcli": @@ -187,6 +190,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewCodexCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewCodexCliProvider(workspace), modelID, nil case "gemini-cli", "geminicli": @@ -194,6 +200,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewGeminiCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewGeminiCliProvider(workspace), modelID, nil case "github-copilot", "copilot": diff --git a/pkg/providers/gemini_cli_provider.go b/pkg/providers/gemini_cli_provider.go index 0042535f0..1a974754f 100644 --- a/pkg/providers/gemini_cli_provider.go +++ b/pkg/providers/gemini_cli_provider.go @@ -7,12 +7,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // GeminiCliProvider implements LLMProvider using the gemini CLI as a subprocess. type GeminiCliProvider struct { command string workspace string + timeout time.Duration } // 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. func (p *GeminiCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*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 "" triggers non-interactive stdin mode; the empty string is appended to stdin input. @@ -46,6 +63,9 @@ func (p *GeminiCliProvider) Chat( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("gemini cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } stderrStr := strings.TrimSpace(stderr.String()) stdoutStr := strings.TrimSpace(stdout.String()) switch {