summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian <2320560+florianbuetow@users.noreply.github.com>2026-01-31 23:48:38 +0100
committerFlorian <2320560+florianbuetow@users.noreply.github.com>2026-01-31 23:48:38 +0100
commitde37689f2b52665ca87224d6c22b1ebe2280c811 (patch)
treee69b810f813955e24b79409bc27ff6b86adf11e3
parent28e2d1a7729e4d434e47006a1932eeb75821aadc (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.example1
-rw-r--r--docs/configuration.md2
-rw-r--r--internal/appconfig/config.go11
-rw-r--r--internal/llm/anthropic.go9
-rw-r--r--internal/llm/copilot.go9
-rw-r--r--internal/llm/ollama.go9
-rw-r--r--internal/llm/openai.go9
-rw-r--r--internal/llm/openrouter.go9
-rw-r--r--internal/llm/provider.go13
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)
}