mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
Retry with exponential backoff and jitter, and honour Retry-After. Retry empty-choice responses and HTTP 408. Add max_retries, retry_timeouts, model_timeouts and model_concurrency settings for slow local models. Add opt-in SSE streaming with an idle timeout. A hung server fails early, and a slow but active generation continues. Send an optional seed and record the backend fingerprint of each answer. Stamp saved artifacts with run metadata and write them atomically. Warn in compare when two runs differ. Save partial results on Ctrl-C. Turn an evaluator panic into an error row. Reject duplicate models and non-http endpoints in the config.
262 lines
7.7 KiB
Go
262 lines
7.7 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const validYAML = `
|
|
endpoint: https://llm-gateway.example.com/v1
|
|
api_key: ""
|
|
models:
|
|
- uni/deepseek-v4-flash-0731
|
|
- uni/qwen3.6-27b
|
|
concurrency: 4
|
|
request_timeout: 120s
|
|
max_tokens_default: 4000
|
|
`
|
|
|
|
func TestParse_Valid(t *testing.T) {
|
|
cfg, err := parse([]byte(validYAML))
|
|
if err != nil {
|
|
t.Fatalf("parse() error = %v", err)
|
|
}
|
|
if cfg.Endpoint != "https://llm-gateway.example.com/v1" {
|
|
t.Errorf("Endpoint = %q", cfg.Endpoint)
|
|
}
|
|
if len(cfg.Models) != 2 {
|
|
t.Errorf("Models = %v, want 2 entries", cfg.Models)
|
|
}
|
|
if cfg.Concurrency != 4 {
|
|
t.Errorf("Concurrency = %d, want 4", cfg.Concurrency)
|
|
}
|
|
if cfg.RequestTimeout != 120*time.Second {
|
|
t.Errorf("RequestTimeout = %v, want 120s", cfg.RequestTimeout)
|
|
}
|
|
if cfg.MaxTokensDefault != 4000 {
|
|
t.Errorf("MaxTokensDefault = %d, want 4000", cfg.MaxTokensDefault)
|
|
}
|
|
}
|
|
|
|
func TestParse_EnvOverridesAPIKey(t *testing.T) {
|
|
t.Setenv(apiKeyEnvVar, "env-secret")
|
|
cfg, err := parse([]byte(validYAML))
|
|
if err != nil {
|
|
t.Fatalf("parse() error = %v", err)
|
|
}
|
|
if cfg.APIKey != "env-secret" {
|
|
t.Errorf("APIKey = %q, want env override", cfg.APIKey)
|
|
}
|
|
}
|
|
|
|
func TestParse_FileAPIKeyUsedWhenNoEnv(t *testing.T) {
|
|
// S10: guarantee no env var leakage from the operator's real shell -
|
|
// without this, a machine with LLMTB_API_KEY already set would make
|
|
// this test assert the wrong thing regardless of parse()'s behavior.
|
|
t.Setenv(apiKeyEnvVar, "")
|
|
|
|
yaml := `
|
|
endpoint: https://llm-gateway.example.com/v1
|
|
api_key: "file-key"
|
|
models:
|
|
- uni/deepseek-v4-flash-0731
|
|
concurrency: 4
|
|
request_timeout: 120s
|
|
max_tokens_default: 4000
|
|
`
|
|
cfg, err := parse([]byte(yaml))
|
|
if err != nil {
|
|
t.Fatalf("parse() error = %v", err)
|
|
}
|
|
if cfg.APIKey != "file-key" {
|
|
t.Errorf("APIKey = %q, want file-key", cfg.APIKey)
|
|
}
|
|
}
|
|
|
|
func TestParse_DefaultsAppliedWhenOmitted(t *testing.T) {
|
|
yaml := `
|
|
endpoint: https://llm-gateway.example.com/v1
|
|
models:
|
|
- uni/deepseek-v4-flash-0731
|
|
`
|
|
cfg, err := parse([]byte(yaml))
|
|
if err != nil {
|
|
t.Fatalf("parse() error = %v", err)
|
|
}
|
|
if cfg.Concurrency != defaultConcurrency {
|
|
t.Errorf("Concurrency = %d, want default %d", cfg.Concurrency, defaultConcurrency)
|
|
}
|
|
if cfg.RequestTimeout != defaultRequestTimeout {
|
|
t.Errorf("RequestTimeout = %v, want default %v", cfg.RequestTimeout, defaultRequestTimeout)
|
|
}
|
|
if cfg.MaxTokensDefault != defaultMaxTokens {
|
|
t.Errorf("MaxTokensDefault = %d, want default %d", cfg.MaxTokensDefault, defaultMaxTokens)
|
|
}
|
|
}
|
|
|
|
func TestParse_DefaultsDoNotOverrideExplicitValues(t *testing.T) {
|
|
cfg, err := parse([]byte(validYAML))
|
|
if err != nil {
|
|
t.Fatalf("parse() error = %v", err)
|
|
}
|
|
// validYAML sets concurrency=4, request_timeout=120s,
|
|
// max_tokens_default=4000, all below the new defaults; an explicit
|
|
// value, even a small one, must still win over the default floor
|
|
// applied by applyDefaults.
|
|
if cfg.Concurrency != 4 {
|
|
t.Errorf("Concurrency = %d, want the explicit 4, not the default", cfg.Concurrency)
|
|
}
|
|
if cfg.RequestTimeout != 120*time.Second {
|
|
t.Errorf("RequestTimeout = %v, want the explicit 120s, not the default", cfg.RequestTimeout)
|
|
}
|
|
if cfg.MaxTokensDefault != 4000 {
|
|
t.Errorf("MaxTokensDefault = %d, want the explicit 4000, not the default", cfg.MaxTokensDefault)
|
|
}
|
|
}
|
|
|
|
func TestParse_InvalidYAML(t *testing.T) {
|
|
_, err := parse([]byte("not: [valid: yaml"))
|
|
if err == nil {
|
|
t.Fatal("parse() error = nil, want error for invalid YAML")
|
|
}
|
|
}
|
|
|
|
func TestParse_InvalidDuration(t *testing.T) {
|
|
yaml := `
|
|
endpoint: https://x
|
|
models: [m]
|
|
concurrency: 1
|
|
request_timeout: not-a-duration
|
|
max_tokens_default: 100
|
|
`
|
|
_, err := parse([]byte(yaml))
|
|
if err == nil {
|
|
t.Fatal("parse() error = nil, want error for invalid duration")
|
|
}
|
|
}
|
|
|
|
func TestValidate(t *testing.T) {
|
|
base := Config{
|
|
Endpoint: "https://x",
|
|
Models: []string{"m"},
|
|
Concurrency: 1,
|
|
RequestTimeout: time.Second,
|
|
MaxTokensDefault: 100,
|
|
}
|
|
|
|
if err := base.Validate(); err != nil {
|
|
t.Fatalf("Validate() on valid config error = %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
mutate func(c Config) Config
|
|
name string
|
|
}{
|
|
{name: "empty endpoint", mutate: func(c Config) Config { c.Endpoint = ""; return c }},
|
|
{name: "no models", mutate: func(c Config) Config { c.Models = nil; return c }},
|
|
{name: "empty model entry", mutate: func(c Config) Config { c.Models = []string{""}; return c }},
|
|
{name: "zero concurrency", mutate: func(c Config) Config { c.Concurrency = 0; return c }},
|
|
{name: "negative concurrency", mutate: func(c Config) Config { c.Concurrency = -1; return c }},
|
|
{name: "zero timeout", mutate: func(c Config) Config { c.RequestTimeout = 0; return c }},
|
|
{name: "non-http endpoint", mutate: func(c Config) Config { c.Endpoint = "llm-gateway.example.com/v1"; return c }},
|
|
{name: "duplicate model", mutate: func(c Config) Config { c.Models = []string{"m", "m"}; return c }},
|
|
{name: "negative retries", mutate: func(c Config) Config { c.MaxRetries = -1; return c }},
|
|
{name: "zero max tokens", mutate: func(c Config) Config { c.MaxTokensDefault = 0; return c }},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := tt.mutate(base)
|
|
if err := c.Validate(); err == nil {
|
|
t.Errorf("Validate() on %s: error = nil, want error", tt.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoad_ReadsFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
if err := os.WriteFile(path, []byte(validYAML), 0o600); err != nil {
|
|
t.Fatalf("os.WriteFile() error = %v", err)
|
|
}
|
|
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
if cfg.Endpoint == "" {
|
|
t.Error("Load() returned empty Endpoint")
|
|
}
|
|
}
|
|
|
|
func TestLoad_MissingFile(t *testing.T) {
|
|
_, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
|
if err == nil {
|
|
t.Fatal("Load() error = nil, want error for missing file")
|
|
}
|
|
}
|
|
|
|
func TestParse_RetriesAndSeed(t *testing.T) {
|
|
base := "endpoint: https://x\nmodels: [m]\n"
|
|
|
|
cfg, err := parse([]byte(base))
|
|
if err != nil {
|
|
t.Fatalf("parse() error = %v", err)
|
|
}
|
|
if cfg.MaxRetries != defaultMaxRetries || cfg.Seed != nil {
|
|
t.Errorf("defaults = retries %d seed %v, want %d nil", cfg.MaxRetries, cfg.Seed, defaultMaxRetries)
|
|
}
|
|
|
|
cfg, err = parse([]byte(base + "max_retries: 0\nseed: 7\n"))
|
|
if err != nil {
|
|
t.Fatalf("parse() error = %v", err)
|
|
}
|
|
if cfg.MaxRetries != 0 {
|
|
t.Errorf("explicit max_retries 0 = %d, want 0 (retrying disabled)", cfg.MaxRetries)
|
|
}
|
|
if cfg.Seed == nil || *cfg.Seed != 7 {
|
|
t.Errorf("Seed = %v, want 7", cfg.Seed)
|
|
}
|
|
}
|
|
|
|
func TestParse_ModelOverrides(t *testing.T) {
|
|
cfg, err := parse([]byte(`
|
|
endpoint: https://x
|
|
models: [a, b]
|
|
retry_timeouts: false
|
|
model_timeouts: {a: 45m}
|
|
model_concurrency: {a: 1}
|
|
`))
|
|
if err != nil {
|
|
t.Fatalf("parse() error = %v", err)
|
|
}
|
|
if cfg.RetryTimeouts || cfg.ModelTimeouts["a"] != 45*time.Minute || cfg.ModelConcurrency["a"] != 1 {
|
|
t.Errorf("got %+v", cfg)
|
|
}
|
|
def, _ := parse([]byte("endpoint: https://x\nmodels: [a]\n"))
|
|
if !def.RetryTimeouts {
|
|
t.Error("retry_timeouts default = false, want true (legacy behavior)")
|
|
}
|
|
for _, bad := range []string{"model_timeouts: {a: nope}", "model_timeouts: {a: 0s}", "model_concurrency: {a: 0}"} {
|
|
if _, err := parse([]byte("endpoint: https://x\nmodels: [a]\n" + bad)); err == nil {
|
|
t.Errorf("parse(%q) error = nil, want error", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParse_Streaming(t *testing.T) {
|
|
cfg, err := parse([]byte("endpoint: https://x\nmodels: [a]\nstream: true\nidle_timeout: 10m\n"))
|
|
if err != nil || !cfg.Stream || cfg.IdleTimeout != 10*time.Minute {
|
|
t.Fatalf("cfg = %+v, err = %v", cfg, err)
|
|
}
|
|
def, _ := parse([]byte("endpoint: https://x\nmodels: [a]\n"))
|
|
if def.Stream {
|
|
t.Error("stream default = true, want false")
|
|
}
|
|
if _, err := parse([]byte("endpoint: https://x\nmodels: [a]\nidle_timeout: nope\n")); err == nil {
|
|
t.Error("bad idle_timeout accepted")
|
|
}
|
|
}
|