// Package config parses Tapir's runtime configuration from environment // variables into a typed struct. No secrets are baked into code: the gateway // key and OAuth client secret are read from the environment (later resolved via // op/ESO), and the OAuth refresh token is never held here — it lives behind the // SecretStore port, addressed by the opaque TokenRef. // // Defaults target the homelab snapshot in docs/homelab-integration.md; every // value is overridable so the same binary runs standalone or in-cluster. package config import ( "fmt" "os" "path/filepath" "sort" "strconv" "strings" "time" ) // Config is Tapir's fully-resolved runtime configuration. type Config struct { // UserID is the Tapir user the run operates as. Stage 0 has exactly one. // It must be a UUID: it keys the UUID user_id/video_id columns (data-model). UserID string // GatewayURL is the OpenAI-compatible LiteLLM base URL (".../v1"). GatewayURL string // GatewayKey authorizes the gateway. Read from env, never committed. GatewayKey string // SummarizerModel is the primary summarizer alias in host/name form, tried // first on every video, e.g. "koala/phi4-mini". SummarizerModel string // FallbackModel is the LOCAL fallback alias tried when the primary fails or // returns unparseable output (ADR-022). Kept local so content stays on the // homelab stack. Default is an IGUANA model (not koala) so the fallback runs // on a different host than the koala primary — koala carries other loads, and // a different host also means a different egress IP for the (rare) fallback. // Empty disables it. FallbackModel string // CloudFallbackModel is the worst-case EXTERNAL fallback alias, tried only // after every local endpoint has failed (ADR-022). For client deployments set // this empty so content never leaves the local stack. Default a berget alias. CloudFallbackModel string // SummaryMaxTokens caps the completion budget per summary call. Small-context // models (koala/phi4-mini, 8k) overflow when prompt + max_tokens exceeds the // window; a summary needs only a few hundred tokens, so the default is small. SummaryMaxTokens int // MaxTranscriptChars bounds the transcript text sent to the model so a long // transcript does not overflow a small-context primary. 0 disables truncation. MaxTranscriptChars int // MinVideoSeconds drops videos shorter than this from discovery (Shorts and // other sub-minute clips that are noise and waste the scarce caption-fetch // budget, ADR-014/ADR-023). Enforced via a cheap Data API videos.list lookup at // discovery, never the rate-limited caption path. 0 disables the filter. MinVideoSeconds int // ChannelCaptionlessThreshold is how many consecutive no-caption results a // channel may yield before its videos are suppressed from caption fetching // (ADR-024). 0 disables the per-channel caption memory entirely. ChannelCaptionlessThreshold int // ChannelCaptionlessWindow is how long a suppressed channel stays suppressed // before one video is re-probed (auto-recovery for a channel that adds captions). ChannelCaptionlessWindow time.Duration // SummarizerTimeout bounds a single completion call. Thinking models are // slow, so the default is generous. SummarizerTimeout time.Duration // DBDSN is the Postgres DSN for the store sink. DBDSN string // YouTube OAuth app credentials (the registered client), read from env. YTClientID string YTClientSecret string // YTTokenRef is the opaque SecretStore reference under which the YouTube // refresh token is persisted/resolved. Not the token itself. YTTokenRef string // YTConnectRedirectURL is the public callback URL the web connect flow // registers with Google, e.g. "https://tapir.d-ma.be/oauth/youtube/callback". // Must be in the OAuth client's authorized redirects. Distinct from the CLI // auth command's localhost listener and from the Dex OIDC redirect. YTConnectRedirectURL string // SecretsFile is the path to the local file-backed SecretStore (0600). A // Stage-0 stand-in for op/ESO, swappable behind the SecretStore port. SecretsFile string // OAuthRedirectAddr is the host:port the `auth` command's local listener // binds for the OAuth redirect, e.g. "localhost:8080". OAuthRedirectAddr string // PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once. PollInterval time.Duration // FetchBackoff is how long the run loop waits before re-fetching a transcript // that previously returned HTTP 429 (rate_limited). Inside the window the video // is skipped without hitting the caption endpoint, saving requests; after it // expires the video is retried. Zero means "always retry" (no backoff). FetchBackoff time.Duration // FetchRate is the minimum interval between outbound caption fetches across the // whole process — the shared per-egress-IP rate gate (ADR-014 item 2). It is // the gate that makes auto-summarize-on-a-schedule safe: scheduler runners and // the web click-path serialise through it. Zero = unlimited (dev/tests). FetchRate time.Duration // AutoSummarizeWindow bounds auto-summarization to recent videos: in automatic // mode the scheduler only summarizes videos published within this window of now. // Older videos are still discovered and listed, but wait for an explicit manual // "Summarize" — so a large back-catalogue does not self-inflict 429s against the // caption rate gate. Zero disables the bound (summarize every unseen video, the // pre-recency behaviour). Default ~7 days. AutoSummarizeWindow time.Duration // OnboardSummarizeCount caps how many of a freshly-connected user's newest // videos are summarized immediately on connect (the onboarding "it works" // burst). HARD-capped at maxOnboardSummarizeCount so onboarding can never // bulk-fetch; 0 disables the burst. Every fetch still flows through the shared // caption rate gate (ADR-014) — the cap bounds count, never the pacing. Default 3. OnboardSummarizeCount int // OnboardSummarizerModel is the summarizer alias the connect-time burst leads // its chain with (ADR-028) — a stronger model is affordable on the ≤3 summaries // that form a new user's first impression. It heads a burst-specific chain; // the standard chain (ADR-022) follows as resilience. Empty (or equal to // SummarizerModel) collapses the burst back onto the shared processor — the // reversibility lever. Default iguana/gemma4-26b (the brain-validated model). OnboardSummarizerModel string // OnboardMaxVideoSeconds upper-bounds the duration of a video the onboarding // burst will pick (ADR-028), so the burst does not spend a scarce caption fetch // on a multi-hour livestream VOD that passed the live filter once it ended. Only // a KNOWN duration outside [MinVideoSeconds, this] is dropped; a NULL/unknown // duration is kept (degrade-open). 0 disables the upper bound. Default 14400 (4h). OnboardMaxVideoSeconds int // DiscoveryInterval, when > 0, makes `serve` run in-process scheduled discovery // for ALL users on that cadence (ADR-018). Zero/unset = disabled, so dev and // tests never auto-fetch. Single-replica assumption — see cmdServe. DiscoveryInterval time.Duration // HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI). HTTPAddr string // MetricsAddr is the listen address for the Prometheus /metrics endpoint // (ADR-030). A SEPARATE port from HTTPAddr so /metrics is never exposed on the // public app — only scraped in-cluster. Empty disables the metrics server. MetricsAddr string // PublicURL is the externally-reachable base URL of the deployed service, // e.g. "https://tapir.d-ma.be". Used to build absolute links handed to humans // (the `tapir invite` URL). No trailing slash is assumed — callers trim it. PublicURL string // Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls // back to the allow-all StubAuth (local dev). When set, serve uses Dex: any // Dex-authenticated subject may sign in, then registers a tapir user (ADR-012). OIDCIssuer string DexClientID string DexClientSecret string OIDCRedirectURL string SessionSecret string } // DexConfigured reports whether Dex OIDC login is wired (issuer present). When // false, `serve` uses StubAuth (dev only). func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) != "" } // Defaults (see docs/homelab-integration.md). All overridable via env. const ( defaultGatewayURL = "http://koala:30401/v1" defaultSummarizerModel = "koala/phi4-mini" defaultFallbackModel = "iguana/gemma4-26b" defaultCloudFallbackModel = "berget/mistral-small" defaultSummaryMaxTokens = 1500 defaultMaxTranscriptChars = 18000 defaultMinVideoSeconds = 60 defaultCaptionlessThreshold = 5 defaultCaptionlessWindow = 14 * 24 * time.Hour defaultSummarizerTimeout = 5 * time.Minute defaultYTTokenRef = "youtube/refresh_token" defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback" defaultOAuthRedirectAddr = "localhost:8080" defaultHTTPAddr = ":8080" defaultMetricsAddr = ":9090" defaultFetchBackoff = time.Hour defaultFetchRate = 2 * time.Second defaultPublicURL = "https://tapir.d-ma.be" defaultAutoSummarizeWindow = 7 * 24 * time.Hour defaultOnboardSummarizeCount = 3 maxOnboardSummarizeCount = 5 defaultOnboardSummarizerModel = "iguana/gemma4-26b" defaultOnboardMaxVideoSeconds = 14400 // 4h ) // Load reads the environment into a Config, applying defaults. It does not // validate that required fields are present — call ValidateForAuth or // ValidateForRun for the command being run, so each command demands only what // it needs. func Load() (Config, error) { c := Config{ UserID: os.Getenv("TAPIR_USER_ID"), GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL), GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"), SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel), OnboardSummarizerModel: lookupOr("TAPIR_ONBOARD_SUMMARIZER_MODEL", defaultOnboardSummarizerModel), FallbackModel: lookupOr("TAPIR_FALLBACK_MODEL", defaultFallbackModel), CloudFallbackModel: lookupOr("TAPIR_CLOUD_FALLBACK_MODEL", defaultCloudFallbackModel), DBDSN: os.Getenv("TAPIR_DB_DSN"), YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"), YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"), YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef), YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL), SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()), OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr), HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr), MetricsAddr: lookupOr("TAPIR_METRICS_ADDR", defaultMetricsAddr), PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL), OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"), DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"), DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"), OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"), SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"), } timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout) if err != nil { return Config{}, err } c.SummarizerTimeout = timeout interval, err := durationOr("TAPIR_POLL_INTERVAL", 0) if err != nil { return Config{}, err } c.PollInterval = interval backoff, err := durationOr("TAPIR_FETCH_BACKOFF", defaultFetchBackoff) if err != nil { return Config{}, err } c.FetchBackoff = backoff fetchRate, err := durationOr("TAPIR_FETCH_RATE", defaultFetchRate) if err != nil { return Config{}, err } c.FetchRate = fetchRate discovery, err := durationOr("TAPIR_DISCOVERY_INTERVAL", 0) if err != nil { return Config{}, err } c.DiscoveryInterval = discovery autoWindow, err := durationOr("TAPIR_AUTO_SUMMARIZE_WINDOW", defaultAutoSummarizeWindow) if err != nil { return Config{}, err } c.AutoSummarizeWindow = autoWindow summaryTokens, err := intOr("TAPIR_SUMMARY_MAX_TOKENS", defaultSummaryMaxTokens) if err != nil { return Config{}, err } c.SummaryMaxTokens = summaryTokens maxChars, err := intOr("TAPIR_MAX_TRANSCRIPT_CHARS", defaultMaxTranscriptChars) if err != nil { return Config{}, err } if maxChars < 0 { maxChars = 0 } c.MaxTranscriptChars = maxChars minVideo, err := intOr("TAPIR_MIN_VIDEO_SECONDS", defaultMinVideoSeconds) if err != nil { return Config{}, err } if minVideo < 0 { minVideo = 0 } c.MinVideoSeconds = minVideo captionThreshold, err := intOr("TAPIR_CHANNEL_CAPTIONLESS_THRESHOLD", defaultCaptionlessThreshold) if err != nil { return Config{}, err } if captionThreshold < 0 { captionThreshold = 0 } c.ChannelCaptionlessThreshold = captionThreshold captionWindow, err := durationOr("TAPIR_CHANNEL_CAPTIONLESS_WINDOW", defaultCaptionlessWindow) if err != nil { return Config{}, err } c.ChannelCaptionlessWindow = captionWindow onboard, err := intOr("TAPIR_ONBOARD_SUMMARIZE_COUNT", defaultOnboardSummarizeCount) if err != nil { return Config{}, err } if onboard < 0 { onboard = 0 } if onboard > maxOnboardSummarizeCount { onboard = maxOnboardSummarizeCount } c.OnboardSummarizeCount = onboard onboardMax, err := intOr("TAPIR_ONBOARD_MAX_VIDEO_SECONDS", defaultOnboardMaxVideoSeconds) if err != nil { return Config{}, err } if onboardMax < 0 { onboardMax = 0 } c.OnboardMaxVideoSeconds = onboardMax return c, nil } // ValidateForAuth checks the fields the `auth` command needs: the YouTube OAuth // app credentials, a place to persist the token, and the redirect listener. func (c Config) ValidateForAuth() error { return c.require(map[string]string{ "TAPIR_YT_CLIENT_ID": c.YTClientID, "TAPIR_YT_CLIENT_SECRET": c.YTClientSecret, "TAPIR_YT_TOKEN_REF": c.YTTokenRef, "TAPIR_SECRETS_FILE": c.SecretsFile, }) } // ValidateForRun checks the fields the `run` command needs end to end. func (c Config) ValidateForRun() error { return c.require(map[string]string{ "TAPIR_USER_ID": c.UserID, "TAPIR_GATEWAY_URL": c.GatewayURL, "TAPIR_SUMMARIZER_MODEL": c.SummarizerModel, "TAPIR_DB_DSN": c.DBDSN, "TAPIR_YT_CLIENT_ID": c.YTClientID, "TAPIR_YT_CLIENT_SECRET": c.YTClientSecret, "TAPIR_YT_TOKEN_REF": c.YTTokenRef, "TAPIR_SECRETS_FILE": c.SecretsFile, }) } // ValidateForServe checks the fields the `serve` command needs: a user to act // as, the store DSN, and a listen address. Auth (Dex) config is validated by the // auth layer the Conductor wires in, not here. func (c Config) ValidateForServe() error { return c.require(map[string]string{ "TAPIR_USER_ID": c.UserID, "TAPIR_DB_DSN": c.DBDSN, "TAPIR_HTTP_ADDR": c.HTTPAddr, }) } func (c Config) require(fields map[string]string) error { var missing []string for name, val := range fields { if strings.TrimSpace(val) == "" { missing = append(missing, name) } } if len(missing) > 0 { return fmt.Errorf("missing required config: %s", strings.Join(sortedMissing(missing), ", ")) } return nil } func sortedMissing(xs []string) []string { sort.Strings(xs) return xs } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // lookupOr returns the env value when the key is PRESENT (even if empty), else // fallback. Unlike envOr it lets an explicit empty value override the default — // needed to DISABLE an optional fallback model (e.g. set the cloud fallback empty // for a client deployment so content never leaves the local stack). func lookupOr(key, fallback string) string { if v, ok := os.LookupEnv(key); ok { return v } return fallback } func intOr(key string, fallback int) (int, error) { v := os.Getenv(key) if v == "" { return fallback, nil } n, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("config: %s=%q: %w", key, v, err) } return n, nil } func durationOr(key string, fallback time.Duration) (time.Duration, error) { v := os.Getenv(key) if v == "" { return fallback, nil } d, err := time.ParseDuration(v) if err != nil { return 0, fmt.Errorf("config: %s=%q: %w", key, v, err) } return d, nil } // defaultSecretsFile resolves to /tapir/secrets.json, falling // back to a cwd-relative path when the config dir is unavailable. func defaultSecretsFile() string { dir, err := os.UserConfigDir() if err != nil { return "tapir-secrets.json" } return filepath.Join(dir, "tapir", "secrets.json") }