Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions alerter/src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import (
"gopkg.in/yaml.v3"
)

// DefaultLLMMaxTokens is the output-token budget applied to reasoning
// requests when llm.max_tokens is unset or non-positive. It is generous
// enough that a reasoning model can emit a thinking block and still have
// room for the classification that follows it.
const DefaultLLMMaxTokens = 4096

// Config holds all configuration options for the alerter
type Config struct {
// Datastore connection settings
Expand Down Expand Up @@ -147,13 +153,31 @@ type CorrelationConfig struct {

// LLMConfig holds LLM provider settings
type LLMConfig struct {
EmbeddingProvider string `yaml:"embedding_provider"`
ReasoningProvider string `yaml:"reasoning_provider"`
Ollama OllamaConfig `yaml:"ollama"`
OpenAI OpenAIConfig `yaml:"openai"`
Anthropic AnthropicConfig `yaml:"anthropic"`
Voyage VoyageConfig `yaml:"voyage"`
Gemini GeminiConfig `yaml:"gemini"`
EmbeddingProvider string `yaml:"embedding_provider"`
ReasoningProvider string `yaml:"reasoning_provider"`

// MaxTokens is the output-token budget for a reasoning request.
// A reasoning model spends part of that budget on its thinking block
// before it emits any answer, so a tight cap can leave no room for
// the classification itself. Defaults to DefaultLLMMaxTokens when
// unset or non-positive.
MaxTokens int `yaml:"max_tokens"`

Ollama OllamaConfig `yaml:"ollama"`
OpenAI OpenAIConfig `yaml:"openai"`
Anthropic AnthropicConfig `yaml:"anthropic"`
Voyage VoyageConfig `yaml:"voyage"`
Gemini GeminiConfig `yaml:"gemini"`
}

// ReasoningMaxTokens returns the output-token budget for a reasoning
// request, falling back to DefaultLLMMaxTokens when the operator has not
// configured a positive llm.max_tokens.
func (l *LLMConfig) ReasoningMaxTokens() int {
if l == nil || l.MaxTokens <= 0 {
return DefaultLLMMaxTokens
}
return l.MaxTokens
}

// NotificationsConfig holds notification settings
Expand Down Expand Up @@ -340,6 +364,7 @@ func NewConfig() *Config {
LLM: LLMConfig{
EmbeddingProvider: "ollama",
ReasoningProvider: "ollama",
MaxTokens: DefaultLLMMaxTokens,
Ollama: OllamaConfig{
BaseURL: "http://localhost:11434",
EmbeddingModel: "nomic-embed-text",
Expand Down
27 changes: 27 additions & 0 deletions alerter/src/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestNewConfig(t *testing.T) {
{"correlation window", cfg.Correlation.WindowSeconds, 120},
{"llm embedding provider", cfg.LLM.EmbeddingProvider, "ollama"},
{"llm reasoning provider", cfg.LLM.ReasoningProvider, "ollama"},
{"llm max tokens", cfg.LLM.MaxTokens, DefaultLLMMaxTokens},
{"gemini embedding model", cfg.LLM.Gemini.EmbeddingModel, "gemini-embedding-001"},
{"gemini reasoning model", cfg.LLM.Gemini.ReasoningModel, "gemini-2.5-flash"},
}
Expand All @@ -64,6 +65,32 @@ func TestNewConfig(t *testing.T) {
}
}

// TestReasoningMaxTokens verifies that the reasoning budget comes from
// llm.max_tokens and falls back to DefaultLLMMaxTokens whenever the
// setting is absent or non-positive. A nil receiver is covered because
// callers may hold a nil *LLMConfig.
func TestReasoningMaxTokens(t *testing.T) {
tests := []struct {
name string
cfg *LLMConfig
want int
}{
{name: "nil config", cfg: nil, want: DefaultLLMMaxTokens},
{name: "unset", cfg: &LLMConfig{}, want: DefaultLLMMaxTokens},
{name: "zero", cfg: &LLMConfig{MaxTokens: 0}, want: DefaultLLMMaxTokens},
{name: "negative", cfg: &LLMConfig{MaxTokens: -5}, want: DefaultLLMMaxTokens},
{name: "configured", cfg: &LLMConfig{MaxTokens: 8192}, want: 8192},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.ReasoningMaxTokens(); got != tt.want {
t.Errorf("ReasoningMaxTokens() = %d, want %d", got, tt.want)
}
})
}
}

// TestNewConfigAnomalyDefaults verifies the defaults for the new
// anomaly knobs introduced for the tier-1 warmup gate and the
// hybrid variance floor. Every newly added field is asserted so
Expand Down
12 changes: 8 additions & 4 deletions alerter/src/internal/llm/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,28 +174,32 @@ func NewReasoningProvider(cfg *config.Config) (ReasoningProvider, error) {
if apiKey == "" && cfg.LLM.OpenAI.BaseURL == "" {
return nil, fmt.Errorf("openai: %w", ErrAPIKeyMissing)
}
return newLibReasoning("openai", apiKey, cfg.LLM.OpenAI.ReasoningModel, cfg.LLM.OpenAI.BaseURL)
return newLibReasoning("openai", apiKey, cfg.LLM.OpenAI.ReasoningModel,
cfg.LLM.OpenAI.BaseURL, cfg.LLM.ReasoningMaxTokens())

case "anthropic":
apiKey := cfg.GetAnthropicAPIKey()
if apiKey == "" {
return nil, fmt.Errorf("anthropic: %w", ErrAPIKeyMissing)
}
return newLibReasoning("anthropic", apiKey, cfg.LLM.Anthropic.ReasoningModel, cfg.LLM.Anthropic.BaseURL)
return newLibReasoning("anthropic", apiKey, cfg.LLM.Anthropic.ReasoningModel,
cfg.LLM.Anthropic.BaseURL, cfg.LLM.ReasoningMaxTokens())

case "gemini":
apiKey := cfg.GetGeminiAPIKey()
if apiKey == "" {
return nil, fmt.Errorf("gemini: %w", ErrAPIKeyMissing)
}
return newLibReasoning("gemini", apiKey, cfg.LLM.Gemini.ReasoningModel, cfg.LLM.Gemini.BaseURL)
return newLibReasoning("gemini", apiKey, cfg.LLM.Gemini.ReasoningModel,
cfg.LLM.Gemini.BaseURL, cfg.LLM.ReasoningMaxTokens())

case "ollama":
baseURL := cfg.LLM.Ollama.BaseURL
if baseURL == "" {
baseURL = "http://localhost:11434"
}
return newLibReasoning("ollama", "", cfg.LLM.Ollama.ReasoningModel, baseURL)
return newLibReasoning("ollama", "", cfg.LLM.Ollama.ReasoningModel, baseURL,
cfg.LLM.ReasoningMaxTokens())

case "", "none", "disabled":
return nil, nil
Expand Down
32 changes: 26 additions & 6 deletions alerter/src/internal/llm/reasoning.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
// Register all built-in LLM providers (anthropic, openai, gemini,
// ollama, voyage) so pgllm.NewClient can construct them by name.
_ "github.com/pgEdge/pgedge-go-llm-lib/llm/all"

"github.com/pgedge/ai-workbench/alerter/internal/config"
)

// defaultReasoningModels maps a provider name to the reasoning model used
Expand All @@ -35,17 +37,24 @@ var defaultReasoningModels = map[string]string{
// pgedge-go-llm-lib Client.Chat method. A single implementation serves
// every provider; the provider-specific behavior lives in the library.
type libReasoning struct {
client pgllm.Client
client pgllm.Client
maxTokens int
}

// Classify sends the classification system prompt and the supplied prompt
// to the underlying LLM and returns the concatenated text of the
// response's text blocks. Non-text content blocks are ignored.
//
// The output-token budget comes from the operator-configured
// llm.max_tokens; a response that carries no text at all is reported as an
// error rather than an empty classification, because that is what a
// reasoning model produces when it exhausts the budget on its thinking
// block before emitting an answer.
func (r *libReasoning) Classify(ctx context.Context, prompt string) (string, error) {
resp, err := r.client.Chat(ctx, pgllm.ChatRequest{
SystemPrompt: classificationSystemPrompt,
Messages: []pgllm.Message{pgllm.UserText(prompt)},
MaxTokens: pgllm.Int(500),
MaxTokens: pgllm.Int(r.maxTokens),
Temperature: pgllm.Float(0.1),
})
if err != nil {
Expand All @@ -58,7 +67,13 @@ func (r *libReasoning) Classify(ctx context.Context, prompt string) (string, err
sb.WriteString(b.Text)
}
}
return sb.String(), nil
text := sb.String()
if strings.TrimSpace(text) == "" {
return "", fmt.Errorf("LLM returned no text content; the output token "+
"budget (llm.max_tokens = %d) may be too small for this model",
r.maxTokens)
}
return text, nil
}

// ModelName returns the reasoning model configured on the client.
Expand All @@ -68,11 +83,16 @@ func (r *libReasoning) ModelName() string {

// newLibReasoning builds a libReasoning for the named provider. When model
// is empty, the per-provider default from defaultReasoningModels is used.
// apiKey and baseURL are passed straight through to the library.
func newLibReasoning(provider, apiKey, model, baseURL string) (ReasoningProvider, error) {
// apiKey and baseURL are passed straight through to the library. A
// non-positive maxTokens falls back to config.DefaultLLMMaxTokens so a
// caller that omits the setting still gets a workable budget.
func newLibReasoning(provider, apiKey, model, baseURL string, maxTokens int) (ReasoningProvider, error) {
if model == "" {
model = defaultReasoningModels[provider]
}
if maxTokens <= 0 {
maxTokens = config.DefaultLLMMaxTokens
}

client, err := pgllm.NewClient(provider, pgllm.Options{
APIKey: apiKey,
Expand All @@ -83,5 +103,5 @@ func newLibReasoning(provider, apiKey, model, baseURL string) (ReasoningProvider
return nil, fmt.Errorf("create %s reasoning client: %w", provider, err)
}

return &libReasoning{client: client}, nil
return &libReasoning{client: client, maxTokens: maxTokens}, nil
}
99 changes: 79 additions & 20 deletions alerter/src/internal/llm/reasoning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"context"
"errors"
"os"
"strings"
"testing"

pgllm "github.com/pgEdge/pgedge-go-llm-lib/llm"
Expand Down Expand Up @@ -49,7 +50,7 @@ func TestLibReasoningClassify(t *testing.T) {
{Type: pgllm.BlockText, Text: `"confidence":0.9}`},
}},
}
r := &libReasoning{client: fc}
r := &libReasoning{client: fc, maxTokens: 8192}
out, err := r.Classify(context.Background(), "analyze this anomaly")
if err != nil {
t.Fatalf("Classify: %v", err)
Expand All @@ -68,8 +69,8 @@ func TestLibReasoningClassify(t *testing.T) {
fc.gotReq.Messages[0].Content[0].Text != "analyze this anomaly" {
t.Fatalf("user message content = %+v", fc.gotReq.Messages[0].Content)
}
if fc.gotReq.MaxTokens == nil || *fc.gotReq.MaxTokens != 500 {
t.Fatalf("MaxTokens = %v, want 500", fc.gotReq.MaxTokens)
if fc.gotReq.MaxTokens == nil || *fc.gotReq.MaxTokens != 8192 {
t.Fatalf("MaxTokens = %v, want 8192", fc.gotReq.MaxTokens)
}
if fc.gotReq.Temperature == nil || *fc.gotReq.Temperature != 0.1 {
t.Fatalf("Temperature = %v, want 0.1", fc.gotReq.Temperature)
Expand All @@ -80,35 +81,93 @@ func TestLibReasoningClassify(t *testing.T) {
}

func TestLibReasoningClassifyError(t *testing.T) {
r := &libReasoning{client: &fakeChatClient{err: errors.New("boom")}}
r := &libReasoning{client: &fakeChatClient{err: errors.New("boom")}, maxTokens: 4096}
if _, err := r.Classify(context.Background(), "x"); err == nil {
t.Fatal("expected error")
}
}

// TestLibReasoningClassifyEmptyContent verifies that Classify returns ("", nil)
// when the response contains no text blocks; the caller's parser treats an
// empty string as the "no-decision" fallback.
// TestLibReasoningClassifyEmptyContent verifies that Classify reports an
// error when the response carries no text blocks, which is what a reasoning
// model produces once its thinking block exhausts the output budget. The
// error names the setting so an operator can act on it.
func TestLibReasoningClassifyEmptyContent(t *testing.T) {
fc := &fakeChatClient{
model: "gpt-4o-mini",
resp: &pgllm.ChatResponse{Content: []pgllm.ContentBlock{}},
cases := []struct {
name string
content []pgllm.ContentBlock
}{
{name: "no blocks", content: []pgllm.ContentBlock{}},
{
name: "whitespace only",
content: []pgllm.ContentBlock{
{Type: pgllm.BlockText, Text: " \n "},
},
},
{
name: "non-text block only",
content: []pgllm.ContentBlock{
{Type: pgllm.BlockToolUse},
},
},
}
r := &libReasoning{client: fc}
out, err := r.Classify(context.Background(), "prompt")
if err != nil {
t.Fatalf("Classify: %v", err)

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fc := &fakeChatClient{
model: "gpt-4o-mini",
resp: &pgllm.ChatResponse{Content: tc.content},
}
r := &libReasoning{client: fc, maxTokens: 512}
out, err := r.Classify(context.Background(), "prompt")
if err == nil {
t.Fatal("expected an error for a response with no text content")
}
if !strings.Contains(err.Error(), "llm.max_tokens = 512") {
t.Fatalf("error = %v, want it to name the configured budget", err)
}
if out != "" {
t.Fatalf("out = %q, want empty string", out)
}
})
}
}

// TestNewLibReasoningMaxTokens verifies that the configured budget is
// carried onto the provider and that a non-positive value falls back to
// config.DefaultLLMMaxTokens.
func TestNewLibReasoningMaxTokens(t *testing.T) {
cases := []struct {
name string
given int
want int
}{
{name: "explicit", given: 16384, want: 16384},
{name: "zero falls back", given: 0, want: config.DefaultLLMMaxTokens},
{name: "negative falls back", given: -1, want: config.DefaultLLMMaxTokens},
}
if out != "" {
t.Fatalf("out = %q, want empty string", out)

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r, err := newLibReasoning("openai", "test-key", "gpt-4o", "", tc.given)
if err != nil {
t.Fatalf("newLibReasoning: %v", err)
}
lr, ok := r.(*libReasoning)
if !ok {
t.Fatalf("provider type = %T, want *libReasoning", r)
}
if lr.maxTokens != tc.want {
t.Fatalf("maxTokens = %d, want %d", lr.maxTokens, tc.want)
}
})
}
}

// TestNewLibReasoningOllamaExplicitBaseURL verifies that an explicit BaseURL is
// accepted and that the default model (llama3.2) is applied when none is given.
// No network is attempted: the library constructs the client without dialing.
func TestNewLibReasoningOllamaExplicitBaseURL(t *testing.T) {
r, err := newLibReasoning("ollama", "", "", "http://localhost:11434")
r, err := newLibReasoning("ollama", "", "", "http://localhost:11434", 4096)
if err != nil {
t.Fatalf("newLibReasoning: %v", err)
}
Expand All @@ -120,7 +179,7 @@ func TestNewLibReasoningOllamaExplicitBaseURL(t *testing.T) {
// TestNewLibReasoningDefaultModel verifies that an empty model falls back
// to the per-provider default and that the resulting client reports it.
func TestNewLibReasoningDefaultModel(t *testing.T) {
r, err := newLibReasoning("openai", "test-key", "", "")
r, err := newLibReasoning("openai", "test-key", "", "", 4096)
if err != nil {
t.Fatalf("newLibReasoning: %v", err)
}
Expand All @@ -131,7 +190,7 @@ func TestNewLibReasoningDefaultModel(t *testing.T) {

// TestNewLibReasoningExplicitModel verifies an explicit model is preserved.
func TestNewLibReasoningExplicitModel(t *testing.T) {
r, err := newLibReasoning("openai", "test-key", "gpt-4o", "")
r, err := newLibReasoning("openai", "test-key", "gpt-4o", "", 4096)
if err != nil {
t.Fatalf("newLibReasoning: %v", err)
}
Expand All @@ -143,7 +202,7 @@ func TestNewLibReasoningExplicitModel(t *testing.T) {
// TestNewLibReasoningUnknownProvider verifies NewClient errors propagate
// through newLibReasoning as a wrapped error.
func TestNewLibReasoningUnknownProvider(t *testing.T) {
if _, err := newLibReasoning("does-not-exist", "k", "m", ""); err == nil {
if _, err := newLibReasoning("does-not-exist", "k", "m", "", 4096); err == nil {
t.Fatal("expected error for unknown provider")
}
}
Expand Down
13 changes: 13 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ project adheres to

### Fixed

- Honour the configured `llm.max_tokens` on the AI estate summary,
the Server Info database analysis, and the alerter's tier 3
classification, each of which previously hardcoded a 512-token
output budget. A reasoning model spends part of its budget on an
internal thinking block, so the small cap was consumed before the
model emitted any answer and the estate summary rendered as an
empty panel. Each path now reads the operator-configured budget
and falls back to `4096` when the setting is absent, and a
response that carries no text content is reported as an error
rather than being cached and rendered as an empty result. The
alerter gains a matching `llm.max_tokens` setting, which it
previously lacked. (#399)

Comment on lines +64 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

State the full fallback and empty-response behavior.

llm.max_tokens also falls back to 4096 for zero and negative values, not only when the setting is absent. Empty-response handling is path-specific: server analysis reports an error, while tier 3 classification reports a failed classification. Update this entry to match the configuration guides.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/changelog.md` around lines 64 - 76, Update the changelog entry to state
that llm.max_tokens falls back to 4096 when absent, zero, or negative. Clarify
the path-specific empty-response behavior: Server Info database analysis reports
an error, while the alerter’s tier 3 classification reports a failed
classification.

- Fix every chat request that included a tool list failing with
`anthropic (400): tools.0.custom.input_schema: Input does not
match the expected shape`, which broke Ask Ellie and the Server,
Expand Down
Loading
Loading