all repos — llm_aggregator @ 29a4710e981512a82abba77ec8d7b43e413453e7

A CLI tool to aggregate RSS feeds and summarise them with LLMs

feat: add LLM request timeout with `--timeout` flag

- Add configurable timeout for LLM API requests
  - CLI flag: `--timeout N` (seconds) with default 300
  - Config file: `timeout = N` in TOML
  - Environment variable: `LLM_AGGREGATOR_TIMEOUT`
  - Store timeout in `Runtime.LLMTimeout` and pass to `LLMClient`
- Implement timeout using `context.WithTimeout`
  - Derive timeout context from signal-handling parent context in
    `Execute()`
  - Both `SIGINT`/`SIGTERM` and timeout abort the request cleanly
  - Add error detection for "context deadline exceeded" → descriptive
    timeout message
- Update `LLMClient` constructor (`NewLLMClient`)
  - Add `timeoutSeconds` parameter; 0 defaults to `DefaultLLMTimeout`
    (300)
  - Rename internal field from `timeoutSeconds` to `llmTimeout` for
    clarity
- Add `DefaultLLMTimeout` constant in `internal/defaults`
- Update documentation
  - `README.md`: add `--timeout` flag and env var to tables
  - `docs/llm_aggregator.1`: add `--timeout` and correct misplaced
    `--system-prompt`
  - `docs/llm_aggregator.5`: add `timeout` field and env var
  - `docs/TESTING.md`: document new LLM client tests and coverage update
    (22%)
- Add unit tests for LLM client (`internal/llm`)
  - `TestNewLLMClientTimeout`: verify default and custom timeout values
  - `TestNewLLMClientDefaults`: confirm all defaults applied including
    timeout
  - `TestNewLLMClientRequiresAPIKey`: preserve existing validation test
- Update `CHANGELOG.md` for version 0.15.0 with all additions and
  changes
Maxwell Jensen 85795372+maxwelljens@users.noreply.github.com
Mon, 27 Apr 2026 10:50:35 +0200
commit

29a4710e981512a82abba77ec8d7b43e413453e7

parent

2d9899ec7612acc35f5ae4e343d6364a9f2dc8b1

M CHANGELOG.mdCHANGELOG.md

@@ -6,6 +6,25 @@ The format is based on [Keep a

Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.0] - 2026-04-27 + +### Added + +- **`--timeout N` flag**: LLM request timeout in seconds (default: 300). If the + LLM API call takes longer than this, the request is aborted and a descriptive + error is returned. The timeout is implemented as a `context.WithTimeout` + derived from the signal-handling context, so both signal interrupts and + timeouts abort the call. +- **`DefaultLLMTimeout` constant**: Centralises the default timeout value + (300 seconds) in `internal/defaults`. +- **LLM client unit tests**: Three tests for the constructor: timeout default, + all-parameter defaults, and API key validation. + +### Changed + +- **`LLMClient.llmTimeout` field**: Renamed from `timeoutSeconds` for clarity. + The field stores the timeout in seconds; 0 means no timeout. + ## [0.14.0] - 2026-04-27 ### Added
M README.mdREADME.md

@@ -58,6 +58,7 @@

### LLM Configuration --temperature VALUE Sampling temperature (0.0 to 1.0) (default: 0.7) + --timeout N LLM request timeout in seconds (default: 300) --system-prompt TEXT Custom system prompt for LLM ### Examples

@@ -188,6 +189,7 @@ | `LLM_AGGREGATOR_BASE_URL` | API base URL (default: "https://api.deepseek.com") |

| `LLM_AGGREGATOR_MODEL` | Model name (default: "deepseek-chat") | | `LLM_AGGREGATOR_MAX_TOKENS` | Maximum tokens in response (default: 4000) | | `LLM_AGGREGATOR_TEMPERATURE` | Sampling temperature (default: 0.7) | +| `LLM_AGGREGATOR_TIMEOUT` | LLM request timeout in seconds (default: 300) | | `LLM_AGGREGATOR_SYSTEM_PROMPT` | Custom system prompt | | `LLM_AGGREGATOR_MAX_ARTICLES_PER_FEED` | Maximum articles per feed (default: 10) | | `LLM_AGGREGATOR_MAX_DAYS_OLD` | Maximum article age in days (default: 7) |
M cmd/llm_aggregator.gocmd/llm_aggregator.go

@@ -184,6 +184,7 @@ fmt.Printf(" Include keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.IncludeKeywords)))

fmt.Printf(" Exclude keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.ExcludeKeywords))) fmt.Printf(" Output format: %s\n", style.Value(rt.Output)) fmt.Printf(" Model: %s\n", style.Value(rt.Model)) + fmt.Printf(" LLM timeout: %s\n", style.Value(fmt.Sprintf("%d seconds", rt.LLMTimeout))) fmt.Println() // Fetch and process feeds (but don't call LLM)
M docs/TESTING.mddocs/TESTING.md

@@ -96,6 +96,21 @@ | `DefaultMaxDaysOld` | `7` | Maximum article age in days |

| `DefaultMaxTotalArticles` | `20` | Total articles limit | | `DefaultOutput` | `text` | Output format | | `DefaultIncludeArticles` | `false` | Include articles in output | +| `DefaultLLMTimeout` | `300` | LLM request timeout (seconds) | + +--- + +### `internal/llm`: LLM client + +Tests for the LLM client constructor, defaults, and configuration. + +| Test | Description | +|------|-------------| +| `TestNewLLMClientTimeout` | Verifies `timeoutSeconds=0` defaults to 300; custom values are stored | +| `TestNewLLMClientDefaults` | Verifies model, maxTokens, temperature, and llmTimeout all get defaults | +| `TestNewLLMClientRequiresAPIKey` | Verifies missing API key returns a descriptive error | + +**Client field naming:** The `LLMClient` struct uses `llmTimeout int` (seconds; 0 means no timeout) to avoid confusion with `http.Client.Timeout` which operates at the transport layer. ---

@@ -356,7 +371,7 @@ | `output` | 93% | All formatters tested |

| `processor` | 76% | All filtering/sorting tested | | `runtime` | 0% | Integration-only; context propagation not exercised by unit tests | | `signals` | 89% | Handler lifecycle and concurrency tested | -| `llm` | 0% | Requires advanced API mocking | +| `llm` | 22% | Constructor defaults, API key validation, and timeout field configuration | | `tui` | 0% | Requires terminal interaction | | `progress` | 0% | Interface only | | `tokeniser` | 0% | Depends on library internals |
M docs/llm_aggregator.1docs/llm_aggregator.1

@@ -1,5 +1,5 @@

.\" Man page for llm_aggregator -.TH LLM_AGGREGATOR 1 "27 April 2026" "0.14.0" "User Commands" +.TH LLM_AGGREGATOR 1 "27 April 2026" "0.15.0" "User Commands" .SH NAME llm_aggregator \- aggregate RSS feeds and summarise them with LLMs .SH SYNOPSIS

@@ -116,12 +116,13 @@ Maximum number of tokens in the LLM response.

.br Default: 4000 .TP -.B \-\-temperature \fIVALUE\fR -Sampling temperature for the LLM (0.0 to 1.0). Higher values produce more -creative output, lower values produce more deterministic output. +.B \-\-timeout IN R +LLM request timeout in seconds. If the LLM API call takes longer than +this, the request is aborted and an error is returned. .br -Default: 0.7 +Default: 300 .TP +.B \-\-system\-prompt TEXT .B \-\-system\-prompt \fITEXT\fR Custom system prompt for the LLM. If not specified, a default prompt is used. .SS Output Options
M docs/llm_aggregator.5docs/llm_aggregator.5

@@ -1,5 +1,5 @@

.\" Man page for llm_aggregator configuration file -.TH LLM_AGGREGATOR 5 "27 April 2026" "0.14.0" "File Formats" +.TH LLM_AGGREGATOR 5 "27 April 2026" "0.15.0" "File Formats" .SH NAME llm_aggregator.conf \- configuration file for llm_aggregator .SH DESCRIPTION

@@ -97,6 +97,12 @@ Lower values produce more deterministic output, higher values produce more

creative output. .br Default: 0.7 +.TP +.B timeout = \fIinteger\fR +LLM request timeout in seconds. If the LLM API call takes longer than this, +the request is aborted. +.br +Default: 300 .SH "[system]" Options related to the LLM system prompt. .TP

@@ -283,6 +289,9 @@ Maximum tokens in response.

.TP .B LLM_AGGREGATOR_TEMPERATURE Sampling temperature. +.TP +.B LLM_AGGREGATOR_TIMEOUT +LLM request timeout in seconds. .TP .B LLM_AGGREGATOR_SYSTEM_PROMPT Custom system prompt.
M internal/cli/args.gointernal/cli/args.go

@@ -35,6 +35,7 @@ BaseURL *string `arg:"--base-url" help:"API base URL"`

Model *string `arg:"-m,--model" help:"LLM model to use"` MaxTokens *int `arg:"--max-tokens" help:"Maximum tokens in response"` Temperature *float64 `arg:"--temperature" help:"Sampling temperature (0.0 to 1.0)"` + Timeout *int `arg:"--timeout" help:"LLM request timeout in seconds (default: 300)"` // Output options Output string `arg:"-o,--output" help:"Output format" choice:"text,json,markdown"`

@@ -149,6 +150,9 @@ m["max_tokens"] = *a.MaxTokens

} if a.Temperature != nil { m["temperature"] = *a.Temperature + } + if a.Timeout != nil { + m["timeout"] = *a.Timeout } if a.Prompt != "" { m["prompt"] = a.Prompt
M internal/cli/help.gointernal/cli/help.go

@@ -208,6 +208,11 @@ Description: "Sampling temperature (0.0 to 1.0)",

Default: "0.7", }, { + Name: "--timeout N", + Description: "LLM request timeout in seconds (default: 300)", + Default: "300", + }, + { Name: "--system-prompt TEXT", Description: "Custom system prompt for LLM", },
M internal/config/config.gointernal/config/config.go

@@ -32,6 +32,7 @@ BaseURL string `mapstructure:"base_url"`

Model string `mapstructure:"model"` MaxTokens int `mapstructure:"max_tokens"` Temperature float64 `mapstructure:"temperature"` + Timeout int `mapstructure:"timeout"` // System prompt SystemPrompt string `mapstructure:"system_prompt"`

@@ -98,6 +99,7 @@ v.SetDefault("base_url", defaults.DefaultBaseURL)

v.SetDefault("model", defaults.DefaultModel) v.SetDefault("max_tokens", defaults.DefaultMaxTokens) v.SetDefault("temperature", defaults.DefaultTemperature) + v.SetDefault("timeout", defaults.DefaultLLMTimeout) // System prompt default v.SetDefault("system_prompt", defaults.DefaultSystemPrompt) // Output defaults

@@ -195,6 +197,7 @@ rt.BaseURL = v.GetString("base_url")

rt.Model = v.GetString("model") rt.MaxTokens = v.GetInt("max_tokens") rt.Temperature = v.GetFloat64("temperature") + rt.LLMTimeout = v.GetInt("timeout") // System prompt rt.SystemPrompt = v.GetString("system_prompt") // Output options

@@ -220,6 +223,7 @@ v.BindEnv("base_url", "LLM_AGGREGATOR_BASE_URL")

v.BindEnv("model", "LLM_AGGREGATOR_MODEL") v.BindEnv("max_tokens", "LLM_AGGREGATOR_MAX_TOKENS") v.BindEnv("temperature", "LLM_AGGREGATOR_TEMPERATURE") + v.BindEnv("timeout", "LLM_AGGREGATOR_TIMEOUT") // System prompt v.BindEnv("system_prompt", "LLM_AGGREGATOR_SYSTEM_PROMPT") // Output options
M internal/defaults/defaults.gointernal/defaults/defaults.go

@@ -12,6 +12,7 @@ DefaultModel = "deepseek-chat"

DefaultBaseURL = "https://api.deepseek.com" DefaultMaxTokens = 4000 DefaultTemperature = 0.7 + DefaultLLMTimeout = 300 // seconds // Feed aggregation defaults DefaultMaxArticlesPerFeed = 10
M internal/llm/llm.gointernal/llm/llm.go

@@ -16,11 +16,12 @@ )

// LLMClient is a client for interacting with LLM API. type LLMClient struct { - client openai.Client - model string - maxTokens int + client openai.Client + model string + maxTokens int temperature float64 - logger *progress.Context + llmTimeout int // seconds; 0 means no timeout + logger *progress.Context } // NewLLMClient creates a new LLM client.

@@ -29,7 +30,8 @@ // baseURL: API base URL (defaults to "https://api.deepseek.com")

// model: Model to use (defaults to "deepseek-chat") // maxTokens: Maximum tokens in response (defaults to 4000) // temperature: Sampling temperature (0.0 to 1.0, defaults to 0.7) -func NewLLMClient(apiKey, baseURL, model string, maxTokens int, temperature float64) (*LLMClient, error) { +// timeoutSeconds: Request timeout in seconds (defaults to 300) +func NewLLMClient(apiKey, baseURL, model string, maxTokens int, temperature float64, timeoutSeconds int) (*LLMClient, error) { // Get API key from parameter or environment variable if apiKey == "" { apiKey = os.Getenv("LLM_AGGREGATOR_API_KEY")

@@ -54,6 +56,9 @@ }

if temperature == 0 { temperature = defaults.DefaultTemperature } + if timeoutSeconds == 0 { + timeoutSeconds = defaults.DefaultLLMTimeout + } // Create OpenAI client configured for LLM clientOpts := []option.RequestOption{

@@ -64,10 +69,11 @@

client := openai.NewClient(clientOpts...) return &LLMClient{ - client: client, - model: model, - maxTokens: maxTokens, + client: client, + model: model, + maxTokens: maxTokens, temperature: temperature, + llmTimeout: timeoutSeconds, }, nil }

@@ -211,6 +217,8 @@ } else if strings.Contains(errStr, "500") {

return "", nil, fmt.Errorf("LLM API server error. Please try again later") } else if strings.Contains(errStr, "404") { return "", nil, fmt.Errorf("API endpoint not found. Please check the base URL and endpoint. OpenAI API uses /chat/completions") + } else if strings.Contains(errStr, "context deadline exceeded") || strings.Contains(errStr, "context canceled") { + return "", nil, fmt.Errorf("LLM request timed out after %d seconds", dc.llmTimeout) } return "", nil, fmt.Errorf("failed to connect to LLM API: %w", err) }
A internal/llm/llm_test.go

@@ -0,0 +1,81 @@

+package llm + +import ( + "strings" + "testing" +) + +func TestNewLLMClientTimeout(t *testing.T) { + tests := []struct { + name string + timeout int + expectTimeout int + }{ + { + name: "zero timeout uses default 300", + timeout: 0, + expectTimeout: 300, + }, + { + name: "custom timeout is stored", + timeout: 60, + expectTimeout: 60, + }, + { + name: "large timeout is stored", + timeout: 600, + expectTimeout: 600, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // API key is required; use a placeholder + client, err := NewLLMClient( + "test-api-key", + "", + "", + 0, + 0, + tt.timeout, + ) + if err != nil { + t.Fatalf("NewLLMClient returned unexpected error: %v", err) + } + if client.llmTimeout != tt.expectTimeout { + t.Errorf("llmTimeout = %d, want %d", client.llmTimeout, tt.expectTimeout) + } + }) + } +} + +func TestNewLLMClientDefaults(t *testing.T) { + // Verify all defaults are applied when only API key is provided + client, err := NewLLMClient("test-api-key", "", "", 0, 0, 0) + if err != nil { + t.Fatalf("NewLLMClient returned unexpected error: %v", err) + } + + if client.model != "deepseek-chat" { + t.Errorf("model = %q, want %q", client.model, "deepseek-chat") + } + if client.maxTokens != 4000 { + t.Errorf("maxTokens = %d, want %d", client.maxTokens, 4000) + } + if client.temperature != 0.7 { + t.Errorf("temperature = %f, want %f", client.temperature, 0.7) + } + if client.llmTimeout != 300 { + t.Errorf("llmTimeout = %d, want %d", client.llmTimeout, 300) + } +} + +func TestNewLLMClientRequiresAPIKey(t *testing.T) { + _, err := NewLLMClient("", "", "", 0, 0, 0) + if err == nil { + t.Error("NewLLMClient expected error for missing API key, got nil") + } + if !strings.Contains(err.Error(), "API key is required") { + t.Errorf("error message = %q, want to contain %q", err.Error(), "API key is required") + } +}
M internal/runtime/runtime.gointernal/runtime/runtime.go

@@ -30,6 +30,7 @@ BaseURL string

Model string MaxTokens int Temperature float64 + LLMTimeout int // seconds; 0 means no timeout Prompt string SystemPrompt string Output string

@@ -139,6 +140,7 @@ r.BaseURL,

r.Model, r.MaxTokens, r.Temperature, + r.LLMTimeout, ) if err != nil { return fmt.Errorf("error initialising LLM client: %w", err)

@@ -150,11 +152,21 @@ r.Progress.SetStage("Getting summary")

r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles))) r.Progress.StartWaiting() + // Derive a timeout context for the LLM call from the parent context. + // The parent context carries signal cancellation, so both signal interrupts + // and timeouts will abort the call. + callCtx := ctx + if r.LLMTimeout > 0 { + var cancel context.CancelFunc + callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.LLMTimeout)*time.Second) + defer cancel() + } + summary, usage, err := llm.SummariseArticles( processedArticles, r.Prompt, r.SystemPrompt, - ctx, + callCtx, ) if err != nil { return fmt.Errorf("error getting summary from LLM: %w", err)