Files
llm-testbench/internal/config/config.go
T
lukaszraczylo ace50757e4 fix: floor token budgets and surface truncation, add live progress
Reasoning models burn thousands of completion tokens before the
answer. Small per-test caps caused silent truncation and zero scores.
The runner now floors max_tokens at the config default (12000). The
client parses finish_reason. Reports mark truncated cells and the
JSON format carries response text for debugging. A stderr progress
reporter prints one line per completed request; --quiet disables it.
2026-08-17 01:55:14 +01:00

139 lines
4.4 KiB
Go

// Package config loads and validates the llm-testbench YAML configuration.
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// apiKeyEnvVar names the environment variable that overrides Config.APIKey.
//
//nolint:gosec // this is an env var *name*, not a credential value
const apiKeyEnvVar = "LLMTB_API_KEY" // #nosec G101 -- this is an env var *name*, not a credential value
// Defaults applied when the corresponding YAML key is omitted (or explicit
// zero). max_tokens_default was raised from 4000 to 12000 after a live run
// showed reasoning models spend thousands of completion tokens on
// reasoning_content before writing an answer to content: a small
// per-test/default budget truncates the answer (finish_reason=length)
// before it is ever written, scoring a correct model as 0.
const (
defaultConcurrency = 8
defaultRequestTimeout = 300 * time.Second
defaultMaxTokens = 12000
)
// Config is the top-level configuration loaded from config.yaml.
type Config struct {
Endpoint string `yaml:"endpoint"`
APIKey string `yaml:"api_key"`
Models []string `yaml:"models"`
Concurrency int `yaml:"concurrency"`
RequestTimeout time.Duration `yaml:"request_timeout"`
MaxTokensDefault int `yaml:"max_tokens_default"`
}
// rawConfig mirrors Config but with RequestTimeout as a string, since
// yaml.v3 does not natively decode Go duration strings.
type rawConfig struct {
Endpoint string `yaml:"endpoint"`
APIKey string `yaml:"api_key"`
RequestTimeout string `yaml:"request_timeout"`
Models []string `yaml:"models"`
Concurrency int `yaml:"concurrency"`
MaxTokensDefault int `yaml:"max_tokens_default"`
}
// Load reads and parses the YAML config at path, applies the LLMTB_API_KEY
// environment override (which always wins over the file value), and
// validates the result.
func Load(path string) (Config, error) {
// #nosec G304 -- path is the operator's own --config flag value (a
// local CLI tool reading its own config file), not externally supplied
// or network input.
data, err := os.ReadFile(path) //nolint:gosec // path is the operator's own --config flag value, not external input
if err != nil {
return Config{}, fmt.Errorf("config: read %s: %w", path, err)
}
return parse(data)
}
// parse decodes YAML bytes into a validated Config. Split out from Load for
// direct, file-free unit testing.
func parse(data []byte) (Config, error) {
var raw rawConfig
if err := yaml.Unmarshal(data, &raw); err != nil {
return Config{}, fmt.Errorf("config: parse yaml: %w", err)
}
cfg := Config{
Endpoint: raw.Endpoint,
APIKey: raw.APIKey,
Models: raw.Models,
Concurrency: raw.Concurrency,
MaxTokensDefault: raw.MaxTokensDefault,
}
if raw.RequestTimeout != "" {
d, err := time.ParseDuration(raw.RequestTimeout)
if err != nil {
return Config{}, fmt.Errorf("config: parse request_timeout %q: %w", raw.RequestTimeout, err)
}
cfg.RequestTimeout = d
}
applyDefaults(&cfg)
if envKey := os.Getenv(apiKeyEnvVar); envKey != "" {
cfg.APIKey = envKey
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
// applyDefaults fills in Concurrency, RequestTimeout, and MaxTokensDefault
// when the YAML omitted them (or gave an explicit zero), so a minimal
// config.yaml still runs with sane, generous-enough values.
func applyDefaults(cfg *Config) {
if cfg.Concurrency <= 0 {
cfg.Concurrency = defaultConcurrency
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = defaultRequestTimeout
}
if cfg.MaxTokensDefault <= 0 {
cfg.MaxTokensDefault = defaultMaxTokens
}
}
// Validate checks that required fields are present and sane.
func (c Config) Validate() error {
if c.Endpoint == "" {
return fmt.Errorf("config: endpoint must not be empty")
}
if len(c.Models) == 0 {
return fmt.Errorf("config: models must list at least one model")
}
for i, m := range c.Models {
if m == "" {
return fmt.Errorf("config: models[%d] must not be empty", i)
}
}
if c.Concurrency <= 0 {
return fmt.Errorf("config: concurrency must be > 0, got %d", c.Concurrency)
}
if c.RequestTimeout <= 0 {
return fmt.Errorf("config: request_timeout must be > 0, got %s", c.RequestTimeout)
}
if c.MaxTokensDefault <= 0 {
return fmt.Errorf("config: max_tokens_default must be > 0, got %d", c.MaxTokensDefault)
}
return nil
}