Merge fix/cli-provider-request-timeout: honour request_timeout for CLI providers with clear timeout errors and fallback

This commit is contained in:
Eric Jacksch 2026-03-20 17:14:10 -04:00
commit 1e13c55272
5 changed files with 71 additions and 1 deletions

View file

@ -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"}
@ -46,6 +63,9 @@ func (p *ClaudeCliProvider) Chat(
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { 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()) stderrStr := strings.TrimSpace(stderr.String())
stdoutStr := strings.TrimSpace(stdout.String()) stdoutStr := strings.TrimSpace(stdout.String())
switch { switch {

View file

@ -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{
@ -69,6 +86,9 @@ func (p *CodexCliProvider) Chat(
} }
if err != nil { 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 { if ctx.Err() == context.Canceled {
return nil, ctx.Err() return nil, ctx.Err()
} }

View file

@ -2,6 +2,7 @@ package providers
import ( import (
"context" "context"
"errors"
"regexp" "regexp"
"strings" "strings"
) )
@ -114,7 +115,7 @@ func ClassifyError(err error, provider, model string) *FailoverError {
} }
// Context deadline exceeded: treat as timeout, always fallback. // Context deadline exceeded: treat as timeout, always fallback.
if err == context.DeadlineExceeded { if errors.Is(err, context.DeadlineExceeded) {
return &FailoverError{ return &FailoverError{
Reason: FailoverTimeout, Reason: FailoverTimeout,
Provider: provider, Provider: provider,

View file

@ -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":

View file

@ -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.
@ -46,6 +63,9 @@ func (p *GeminiCliProvider) Chat(
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { 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()) stderrStr := strings.TrimSpace(stderr.String())
stdoutStr := strings.TrimSpace(stdout.String()) stdoutStr := strings.TrimSpace(stdout.String())
switch { switch {