The first friendly-pilot live run produced zero summaries: koala/phi4-mini hit three silent failure modes — 8k context overflow on long transcripts (HTTP 400), intermittent malformed JSON (highlights as a bare string), and no fallback wired at all (summarizer.New(primary, nil)). Keep phi4-mini as the fast primary and add resilience around it: - Ordered endpoint chain (summarizer.NewChain): phi4-mini → koala/phi4-14b (local) → berget/mistral-small (worst-case external). All reached through the one LiteLLM gateway by alias. - A parse failure now advances the chain like a transport error — the old Primary→Fallback shape returned the parse error without trying anyone else. - Tolerant parse: highlights/takeaways coerce string→[]string, absorbing the common small-model quirk without spending a fallback round-trip. - Transcript truncation (TAPIR_MAX_TRANSCRIPT_CHARS=18000) prevents the overflow rather than recovering from it; validated to fit phi4-mini's 8k window. - Bounded completion budget (TAPIR_SUMMARY_MAX_TOKENS=1500) — the old 8192 budget itself contributed to the overflow. Local-first guarantee preserved by ordering: external endpoint is tried only after every local one fails. TAPIR_CLOUD_FALLBACK_MODEL="" disables it entirely for client/NDA deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
6.6 KiB
Go
217 lines
6.6 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// unset removes an env key for the duration of the test, restoring it after.
|
|
// Needed to observe a default for a key read with LookupEnv (where present-empty
|
|
// means "explicitly disabled", not "use default").
|
|
func unset(t *testing.T, key string) {
|
|
t.Helper()
|
|
if old, ok := os.LookupEnv(key); ok {
|
|
t.Cleanup(func() {
|
|
if err := os.Setenv(key, old); err != nil {
|
|
t.Fatalf("restore %s: %v", key, err)
|
|
}
|
|
})
|
|
}
|
|
if err := os.Unsetenv(key); err != nil {
|
|
t.Fatalf("unset %s: %v", key, err)
|
|
}
|
|
}
|
|
|
|
// setEnv sets env vars for the test and clears them afterward, so cases don't
|
|
// leak into one another. t.Setenv handles restoration.
|
|
func setEnv(t *testing.T, kv map[string]string) {
|
|
t.Helper()
|
|
for k, v := range kv {
|
|
t.Setenv(k, v)
|
|
}
|
|
}
|
|
|
|
func TestLoad_AppliesDefaults(t *testing.T) {
|
|
// Clear the ones with defaults so we observe the fallback, not the host env.
|
|
setEnv(t, map[string]string{
|
|
"TAPIR_GATEWAY_URL": "",
|
|
"TAPIR_SUMMARIZER_MODEL": "",
|
|
"TAPIR_YT_TOKEN_REF": "",
|
|
"TAPIR_OAUTH_REDIRECT_ADDR": "",
|
|
"TAPIR_SUMMARIZER_TIMEOUT": "",
|
|
"TAPIR_POLL_INTERVAL": "",
|
|
})
|
|
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.GatewayURL != defaultGatewayURL {
|
|
t.Errorf("GatewayURL = %q, want default %q", c.GatewayURL, defaultGatewayURL)
|
|
}
|
|
if c.SummarizerModel != defaultSummarizerModel {
|
|
t.Errorf("SummarizerModel = %q, want default %q", c.SummarizerModel, defaultSummarizerModel)
|
|
}
|
|
if c.YTTokenRef != defaultYTTokenRef {
|
|
t.Errorf("YTTokenRef = %q, want default %q", c.YTTokenRef, defaultYTTokenRef)
|
|
}
|
|
if c.SummarizerTimeout != defaultSummarizerTimeout {
|
|
t.Errorf("SummarizerTimeout = %v, want default %v", c.SummarizerTimeout, defaultSummarizerTimeout)
|
|
}
|
|
if c.PollInterval != 0 {
|
|
t.Errorf("PollInterval = %v, want 0 (run once)", c.PollInterval)
|
|
}
|
|
if c.FetchBackoff != defaultFetchBackoff {
|
|
t.Errorf("FetchBackoff = %v, want default %v", c.FetchBackoff, defaultFetchBackoff)
|
|
}
|
|
if c.AutoSummarizeWindow != defaultAutoSummarizeWindow {
|
|
t.Errorf("AutoSummarizeWindow = %v, want default %v", c.AutoSummarizeWindow, defaultAutoSummarizeWindow)
|
|
}
|
|
}
|
|
|
|
func TestLoad_SummarizerChainDefaults(t *testing.T) {
|
|
setEnv(t, map[string]string{
|
|
"TAPIR_SUMMARIZER_MODEL": "",
|
|
"TAPIR_SUMMARY_MAX_TOKENS": "",
|
|
"TAPIR_MAX_TRANSCRIPT_CHARS": "",
|
|
})
|
|
unset(t, "TAPIR_FALLBACK_MODEL")
|
|
unset(t, "TAPIR_CLOUD_FALLBACK_MODEL")
|
|
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.FallbackModel != defaultFallbackModel {
|
|
t.Errorf("FallbackModel = %q, want %q", c.FallbackModel, defaultFallbackModel)
|
|
}
|
|
if c.CloudFallbackModel != defaultCloudFallbackModel {
|
|
t.Errorf("CloudFallbackModel = %q, want %q", c.CloudFallbackModel, defaultCloudFallbackModel)
|
|
}
|
|
if c.SummaryMaxTokens != defaultSummaryMaxTokens {
|
|
t.Errorf("SummaryMaxTokens = %d, want %d", c.SummaryMaxTokens, defaultSummaryMaxTokens)
|
|
}
|
|
if c.MaxTranscriptChars != defaultMaxTranscriptChars {
|
|
t.Errorf("MaxTranscriptChars = %d, want %d", c.MaxTranscriptChars, defaultMaxTranscriptChars)
|
|
}
|
|
}
|
|
|
|
// An explicitly empty cloud-fallback env disables external routing — the lever a
|
|
// client deployment pulls so content never leaves the local stack.
|
|
func TestLoad_EmptyCloudFallbackDisables(t *testing.T) {
|
|
t.Setenv("TAPIR_CLOUD_FALLBACK_MODEL", "")
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.CloudFallbackModel != "" {
|
|
t.Errorf("CloudFallbackModel = %q, want empty (disabled)", c.CloudFallbackModel)
|
|
}
|
|
}
|
|
|
|
func TestLoad_OnboardSummarizeCount(t *testing.T) {
|
|
cases := []struct {
|
|
name, env string
|
|
want int
|
|
}{
|
|
{"default", "", defaultOnboardSummarizeCount},
|
|
{"explicit", "4", 4},
|
|
{"zero disables", "0", 0},
|
|
{"clamped to hard cap", "50", maxOnboardSummarizeCount},
|
|
{"negative clamps to zero", "-3", 0},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
setEnv(t, map[string]string{"TAPIR_ONBOARD_SUMMARIZE_COUNT": c.env})
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.OnboardSummarizeCount != c.want {
|
|
t.Fatalf("OnboardSummarizeCount = %d, want %d", cfg.OnboardSummarizeCount, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoad_OnboardSummarizeCountInvalid(t *testing.T) {
|
|
setEnv(t, map[string]string{"TAPIR_ONBOARD_SUMMARIZE_COUNT": "three"})
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("Load: want error for non-numeric TAPIR_ONBOARD_SUMMARIZE_COUNT")
|
|
}
|
|
}
|
|
|
|
func TestLoad_ParsesValues(t *testing.T) {
|
|
setEnv(t, map[string]string{
|
|
"TAPIR_USER_ID": "11111111-1111-1111-1111-111111111111",
|
|
"TAPIR_GATEWAY_URL": "http://example/v1",
|
|
"TAPIR_GATEWAY_KEY": "sk-test",
|
|
"TAPIR_SUMMARIZER_MODEL": "iguana/deepseek-r1-14b",
|
|
"TAPIR_SUMMARIZER_TIMEOUT": "90s",
|
|
"TAPIR_DB_DSN": "postgres://x",
|
|
"TAPIR_POLL_INTERVAL": "10m",
|
|
"TAPIR_FETCH_BACKOFF": "30m",
|
|
"TAPIR_AUTO_SUMMARIZE_WINDOW": "48h",
|
|
})
|
|
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.UserID != "11111111-1111-1111-1111-111111111111" {
|
|
t.Errorf("UserID = %q", c.UserID)
|
|
}
|
|
if c.GatewayKey != "sk-test" {
|
|
t.Errorf("GatewayKey = %q", c.GatewayKey)
|
|
}
|
|
if c.SummarizerModel != "iguana/deepseek-r1-14b" {
|
|
t.Errorf("SummarizerModel = %q", c.SummarizerModel)
|
|
}
|
|
if c.SummarizerTimeout != 90*time.Second {
|
|
t.Errorf("SummarizerTimeout = %v, want 90s", c.SummarizerTimeout)
|
|
}
|
|
if c.PollInterval != 10*time.Minute {
|
|
t.Errorf("PollInterval = %v, want 10m", c.PollInterval)
|
|
}
|
|
if c.FetchBackoff != 30*time.Minute {
|
|
t.Errorf("FetchBackoff = %v, want 30m", c.FetchBackoff)
|
|
}
|
|
if c.AutoSummarizeWindow != 48*time.Hour {
|
|
t.Errorf("AutoSummarizeWindow = %v, want 48h", c.AutoSummarizeWindow)
|
|
}
|
|
}
|
|
|
|
func TestLoad_RejectsBadDuration(t *testing.T) {
|
|
setEnv(t, map[string]string{"TAPIR_SUMMARIZER_TIMEOUT": "not-a-duration"})
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("want error on unparsable duration, got nil")
|
|
}
|
|
}
|
|
|
|
func TestValidateForRun_ReportsMissing(t *testing.T) {
|
|
c := Config{} // nothing set
|
|
err := c.ValidateForRun()
|
|
if err == nil {
|
|
t.Fatal("want error when required run fields are missing")
|
|
}
|
|
for _, want := range []string{"TAPIR_USER_ID", "TAPIR_DB_DSN", "TAPIR_YT_CLIENT_ID"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error %q must name missing field %q", err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateForAuth_PassesWhenComplete(t *testing.T) {
|
|
c := Config{
|
|
YTClientID: "id",
|
|
YTClientSecret: "secret",
|
|
YTTokenRef: "youtube/refresh_token",
|
|
SecretsFile: "/tmp/secrets.json",
|
|
}
|
|
if err := c.ValidateForAuth(); err != nil {
|
|
t.Errorf("ValidateForAuth: unexpected error %v", err)
|
|
}
|
|
}
|