Files
tapir/internal/config/config.go
T
mathiasandClaude Opus 4.8 cd461b95f8
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 11s
feat(observability): instrument AI + HTTP paths, serve /metrics on a side port (ADR-030, #15)
Wire the metrics package into the live paths and serve it:
- summarizer: per-endpoint latency by model/outcome(success|error|parse_error)/fallback + slog.
- youtube.FetchTranscript: latency by outcome (captions|none|rate_limited) + slog.
- chat: answer latency by model + slog.
- llm usage hook → token counts (prompt|completion) per model, wired in buildSummarizer/buildChat.
- oidc callback: login counter.
- cmdServe: wrap Router in metrics.HTTPMiddleware (request count + latency by bounded
  route pattern) and serve /metrics on TAPIR_METRICS_ADDR (default :9090), a SEPARATE
  port — never on the public app mux.

BDD: observability.feature scenarios un-pended + mapped. TDD: summarizer wiring tested
black-box via the /metrics scrape; metrics-not-on-public-mux asserted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:49:51 +02:00

431 lines
16 KiB
Go

// 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 <user-config-dir>/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")
}