diff options
| author | Florian <2320560+florianbuetow@users.noreply.github.com> | 2026-01-31 23:48:38 +0100 |
|---|---|---|
| committer | Florian <2320560+florianbuetow@users.noreply.github.com> | 2026-01-31 23:48:38 +0100 |
| commit | 22009e90a4576764687328ed3cf81efbd2813d77 (patch) | |
| tree | e69b810f813955e24b79409bc27ff6b86adf11e3 | |
| parent | 7194696eb8c4c5bd50f69df96e9a6b87cec1f049 (diff) | |
feat: add configurable request timeout for LLM calls
Local LLMs (LM Studio, Ollama, etc.) often need more than the default
30-second timeout. Added request_timeout config option (in seconds)
to [general] section and HEXAI_REQUEST_TIMEOUT env var.
Original constructor signatures preserved via *WithTimeout variants,
so no test changes required.
| -rw-r--r-- | config.toml.example | 1 | ||||
| -rw-r--r-- | docs/configuration.md | 2 | ||||
| -rw-r--r-- | internal/appconfig/config.go | 11 | ||||
| -rw-r--r-- | internal/llm/anthropic.go | 9 | ||||
| -rw-r--r-- | internal/llm/copilot.go | 9 | ||||
| -rw-r--r-- | internal/llm/ollama.go | 9 | ||||
| -rw-r--r-- | internal/llm/openai.go | 9 | ||||
| -rw-r--r-- | internal/llm/openrouter.go | 9 | ||||
| -rw-r--r-- | internal/llm/provider.go | 13 |
9 files changed, 60 insertions, 12 deletions
diff --git a/config.toml.example b/config.toml.example index ae8110a..cc34e04 100644 --- a/config.toml.example +++ b/config.toml.example @@ -3,6 +3,7 @@ [general] max_tokens = 4000 max_context_tokens = 4000 +request_timeout = 30 # LLM request timeout in seconds # context_mode controls how much of the current document is sent as extra context: # - minimal: no additional context beyond the request payload. # - window: include a sliding window of ~context_window_lines around the cursor. diff --git a/docs/configuration.md b/docs/configuration.md index 50dfbcb..54ac85f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,7 @@ Environment overrides - All options can be overridden by environment variables prefixed with `HEXAI_`. - Env values take precedence over the config file. - Examples: - - `HEXAI_PROVIDER`, `HEXAI_MAX_TOKENS`, `HEXAI_CONTEXT_MODE`, `HEXAI_CONTEXT_WINDOW_LINES`, `HEXAI_MAX_CONTEXT_TOKENS`, `HEXAI_LOG_PREVIEW_LIMIT` + - `HEXAI_PROVIDER`, `HEXAI_MAX_TOKENS`, `HEXAI_CONTEXT_MODE`, `HEXAI_CONTEXT_WINDOW_LINES`, `HEXAI_MAX_CONTEXT_TOKENS`, `HEXAI_LOG_PREVIEW_LIMIT`, `HEXAI_REQUEST_TIMEOUT` - `HEXAI_CODING_TEMPERATURE` - `HEXAI_COMPLETION_DEBOUNCE_MS`, `HEXAI_COMPLETION_THROTTLE_MS` - `HEXAI_TRIGGER_CHARACTERS` (comma-separated, e.g., `".,:,_ , "`) diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 78237be..b17c5d4 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -27,6 +27,7 @@ type App struct { ContextWindowLines int `json:"context_window_lines" toml:"context_window_lines"` MaxContextTokens int `json:"max_context_tokens" toml:"max_context_tokens"` LogPreviewLimit int `json:"log_preview_limit" toml:"log_preview_limit"` + RequestTimeout int `json:"request_timeout" toml:"request_timeout"` // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. CodingTemperature *float64 `json:"coding_temperature" toml:"coding_temperature"` // Minimum identifier characters required for manual (TriggerKind=1) invoke @@ -141,6 +142,7 @@ func newDefaultConfig() App { ContextWindowLines: 120, MaxContextTokens: 4000, LogPreviewLimit: 100, + RequestTimeout: 30, CodingTemperature: &t, OpenAITemperature: &t, OllamaTemperature: &t, @@ -256,6 +258,7 @@ type sectionGeneral struct { ContextWindowLines int `toml:"context_window_lines"` MaxContextTokens int `toml:"max_context_tokens"` CodingTemperature *float64 `toml:"coding_temperature"` + RequestTimeout int `toml:"request_timeout"` } type sectionLogging struct { @@ -419,6 +422,7 @@ func (fc *fileConfig) toApp() App { ContextWindowLines: fc.General.ContextWindowLines, MaxContextTokens: fc.General.MaxContextTokens, CodingTemperature: fc.General.CodingTemperature, + RequestTimeout: fc.General.RequestTimeout, } out.mergeBasics(&tmp) } @@ -883,6 +887,9 @@ func (a *App) mergeBasics(other *App) { if other.LogPreviewLimit >= 0 { a.LogPreviewLimit = other.LogPreviewLimit } + if other.RequestTimeout > 0 { + a.RequestTimeout = other.RequestTimeout + } if other.CodingTemperature != nil { // allow explicit 0.0 a.CodingTemperature = other.CodingTemperature } @@ -1185,6 +1192,10 @@ func loadFromEnv(logger *log.Logger) *App { out.LogPreviewLimit = n any = true } + if n, ok := parseInt("HEXAI_REQUEST_TIMEOUT"); ok { + out.RequestTimeout = n + any = true + } if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { out.ManualInvokeMinPrefix = n any = true diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index c0cdc9a..a6c1454 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -90,14 +90,21 @@ var ( // newAnthropic constructs an Anthropic client using explicit configuration values. // The apiKey may be empty; calls will fail until a valid key is supplied. func newAnthropic(baseURL, model, apiKey string, defaultTemp *float64) Client { + return newAnthropicWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +} + +func newAnthropicWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client { if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.anthropic.com/v1" } if strings.TrimSpace(model) == "" { model = "claude-3-5-sonnet-20241022" } + if timeoutSec <= 0 { + timeoutSec = 30 + } return anthropicClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, apiKey: apiKey, baseURL: baseURL, defaultModel: model, diff --git a/internal/llm/copilot.go b/internal/llm/copilot.go index b439ed3..43419ea 100644 --- a/internal/llm/copilot.go +++ b/internal/llm/copilot.go @@ -64,6 +64,10 @@ type copilotChatResponse struct { // Constructor (kept among the first functions by convention) func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client { + return newCopilotWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +} + +func newCopilotWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client { if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.githubcopilot.com" } @@ -72,8 +76,11 @@ func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client { // Default to a broadly available, cost-effective option. model = "gpt-4o-mini" } + if timeoutSec <= 0 { + timeoutSec = 30 + } return copilotClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, apiKey: apiKey, baseURL: strings.TrimRight(baseURL, "/"), defaultModel: model, diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go index f355166..a22dd7b 100644 --- a/internal/llm/ollama.go +++ b/internal/llm/ollama.go @@ -42,14 +42,21 @@ type ollamaChatResponse struct { // Constructor (kept among the first functions by convention) func newOllama(baseURL, model string, defaultTemp *float64) Client { + return newOllamaWithTimeout(baseURL, model, defaultTemp, 0) +} + +func newOllamaWithTimeout(baseURL, model string, defaultTemp *float64, timeoutSec int) Client { if strings.TrimSpace(baseURL) == "" { baseURL = "http://localhost:11434" } if strings.TrimSpace(model) == "" { model = "qwen3-coder:30b-a3b-q4_K_M" } + if timeoutSec <= 0 { + timeoutSec = 30 + } return ollamaClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, baseURL: strings.TrimRight(baseURL, "/"), defaultModel: model, chatLogger: logging.NewChatLogger("ollama"), diff --git a/internal/llm/openai.go b/internal/llm/openai.go index b97111d..6bc3a7c 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -77,14 +77,21 @@ type oaStreamChunk struct { // newOpenAI constructs an OpenAI client using explicit configuration values. // The apiKey may be empty; calls will fail until a valid key is supplied. func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client { + return newOpenAIWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +} + +func newOpenAIWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client { if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com/v1" } if strings.TrimSpace(model) == "" { model = "gpt-4.1" } + if timeoutSec <= 0 { + timeoutSec = 30 + } return openAIClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, apiKey: apiKey, baseURL: baseURL, defaultModel: model, diff --git a/internal/llm/openrouter.go b/internal/llm/openrouter.go index 4aae398..21e3102 100644 --- a/internal/llm/openrouter.go +++ b/internal/llm/openrouter.go @@ -23,14 +23,21 @@ type openRouterClient struct { } func newOpenRouter(baseURL, model, apiKey string, defaultTemp *float64) Client { + return newOpenRouterWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +} + +func newOpenRouterWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client { if strings.TrimSpace(baseURL) == "" { baseURL = "https://openrouter.ai/api/v1" } if strings.TrimSpace(model) == "" { model = "openrouter/auto" } + if timeoutSec <= 0 { + timeoutSec = 30 + } return openRouterClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, apiKey: apiKey, baseURL: baseURL, defaultModel: model, diff --git a/internal/llm/provider.go b/internal/llm/provider.go index ae840b0..297f1f3 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -64,7 +64,8 @@ func WithStop(stop ...string) RequestOption { // Config defines provider configuration read from the Hexai config file. type Config struct { - Provider string + Provider string + RequestTimeout int // seconds; 0 means use default (30s) // OpenAI options OpenAIBaseURL string OpenAIModel string @@ -119,7 +120,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey, an v := 0.2 cfg.OpenAITemperature = &v } - return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil + return newOpenAIWithTimeout(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature, cfg.RequestTimeout), nil case "openrouter": if strings.TrimSpace(openRouterAPIKey) == "" { return nil, errors.New("missing OPENROUTER_API_KEY for provider openrouter") @@ -128,13 +129,13 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey, an t := 0.2 cfg.OpenRouterTemperature = &t } - return newOpenRouter(cfg.OpenRouterBaseURL, cfg.OpenRouterModel, openRouterAPIKey, cfg.OpenRouterTemperature), nil + return newOpenRouterWithTimeout(cfg.OpenRouterBaseURL, cfg.OpenRouterModel, openRouterAPIKey, cfg.OpenRouterTemperature, cfg.RequestTimeout), nil case "ollama": if cfg.OllamaTemperature == nil { t := 0.2 cfg.OllamaTemperature = &t } - return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil + return newOllamaWithTimeout(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature, cfg.RequestTimeout), nil case "copilot": if strings.TrimSpace(copilotAPIKey) == "" { return nil, errors.New("missing COPILOT_API_KEY for provider copilot") @@ -143,7 +144,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey, an t := 0.2 cfg.CopilotTemperature = &t } - return newCopilot(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature), nil + return newCopilotWithTimeout(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature, cfg.RequestTimeout), nil case "anthropic": if strings.TrimSpace(anthropicAPIKey) == "" { return nil, errors.New("missing ANTHROPIC_API_KEY for provider anthropic") @@ -152,7 +153,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, copilotAPIKey, an t := 0.2 cfg.AnthropicTemperature = &t } - return newAnthropic(cfg.AnthropicBaseURL, cfg.AnthropicModel, anthropicAPIKey, cfg.AnthropicTemperature), nil + return newAnthropicWithTimeout(cfg.AnthropicBaseURL, cfg.AnthropicModel, anthropicAPIKey, cfg.AnthropicTemperature, cfg.RequestTimeout), nil default: return nil, errors.New("unknown LLM provider: " + p) } |
