Files
tapir/docs/homelab-integration.md
mathias 9298e0c686
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
docs(homelab): TAPIR_METRICS_ADDR + key metric series (ADR-030, #15)
2026-06-12 08:50:36 +02:00

17 KiB

Tapir — Homelab Integration Facts

The concrete endpoints, conventions, and identifiers Tapir depends on, so an independent session doesn't have to rediscover them. Verify anything marked "confirm" before relying on it — endpoints and aliases drift, and this file is a snapshot (2026-06-06), not a live source.

Local AI (the Primary in llm.Router)

  • LiteLLM gateway: OpenAI-compatible, lives in k3s namespace ai-stack (verified 2026-06-02 from koala via kubectl get svc -n ai-stack):
    • In-cluster: http://litellm.ai-stack.svc.cluster.local:4000/v1/ (ClusterIP 10.43.159.89:4000).
    • Off-cluster (host / NodePort): http://koala:30401/v1/ — service litellm-nodeport (4000:30401).
    • NOTE: the earlier 31234 was wrong31234 is the llama-swap NodePort (8080:31234), not LiteLLM. Use 30401 for off-cluster LiteLLM. confirm resolved.
  • Auth key: treat as a secret via the SecretStore port; do not hardcode in committed code. NOTE: the documented sk-local-123 returned 401 on 30401/v1/models (2026-06-02) — it is stale or the gateway now enforces the LITELLM_MASTER_KEY (1Password op://HomeLab/LITELLM_MASTER_KEY). Resolve the live key from the vault when wiring; sk-local-123 is no longer valid. confirm partially resolved.
  • Model alias format: host/name, e.g. koala/qwen3-coder-30b, koala/phi4-mini, iguana/devstral, iguana/deepseek-r1-14b. Not the ollama/ prefix form.
  • Which alias for summarization: alias-as-config, confirm resolved (2026-06-02, Worker F). The alias is never hardcoded: it is TAPIR_SUMMARIZER_MODEL (format host/name), wired through the summarizer's Endpoint.Model. Default: koala/phi4-mini — a non-thinking instruct model chosen for safety: it cannot fall into the empty-content trap below, so the demo summarizes even if no one tunes it. It is provisional and overridable; final live alias selection happens at demo time when the gateway is reachable, where a more capable model (e.g. iguana/deepseek-r1-14b) is preferred for summary quality if its latency/output is acceptable. The max_tokens fix below means thinking models no longer return empty content, so they are now viable choices, not blocked ones. Do not assume a coder alias is right for prose.
  • Summarizer fallback chain (ADR-022). The primary alias is the first of an ordered chain; on failure or unparseable output the summarizer advances to the next model. All reached through the same gateway by alias.
    • TAPIR_FALLBACK_MODEL — local fallback. Default iguana/gemma4-26b — on iguana, NOT koala, so the fallback does not compete with koala's other GPU loads (and runs from a different egress IP). Empty disables it.
    • TAPIR_CLOUD_FALLBACK_MODEL — worst-case EXTERNAL fallback. Default berget/mistral-small. Set this empty ("") for any client/NDA deployment so content never leaves the local stack — the chain then contains only local endpoints.
    • TAPIR_SUMMARY_MAX_TOKENS — per-summary completion budget. Default 1500. Small on purpose: with the old 8192 budget, prompt + completion overflowed phi4-mini's 8k window.
    • TAPIR_MAX_TRANSCRIPT_CHARS — transcript truncation budget sent to the model. Default 18000 (~fits an 8k-context model). 0 disables truncation. Prevents the context-overflow HTTP 400 a long transcript caused on phi4-mini.
  • Discovery low-value filter (ADR-023). TAPIR_MIN_VIDEO_SECONDSdefault 60. At discovery, NewVideos enriches candidates with one cheap videos.list call (quota API, NOT the timedtext 429 path) and drops videos shorter than this plus any live/upcoming broadcast, so the scarce caption-fetch budget isn't spent on Shorts. 0 disables the filter. The paste-a-URL path is never filtered.
  • Per-channel caption memory (ADR-024). TAPIR_CHANNEL_CAPTIONLESS_THRESHOLDdefault 5 consecutive no-caption results before a channel is suppressed (its videos listed but not caption-fetched). TAPIR_CHANNEL_CAPTIONLESS_WINDOWdefault 336h (14d) suppression before one video is re-probed. THRESHOLD=0 disables. A successful fetch resets the channel; a 429 does not count; an explicit manual request bypasses suppression.
  • Thinking models need an explicit max_tokens. qwen3 / deepseek-r1 spend the budget on reasoning and return empty content if max_tokens is too low (or unset). The summarizer's parser treats an empty summary as an error for exactly this reason. Done (2026-06-02, Worker F): the copied llm.Client now sends a generous max_tokens (8192) on every request per ADR-004, so thinking models no longer return empty content. A non-thinking instruct model remains the safe default (koala/phi4-mini), but thinking aliases are now viable.

This maps directly onto the copied llm package: Client is the OpenAI-compatible caller, Router.Primary points at this gateway with a chosen alias, Router.Fallback is the user's BYO.

BYO AI (the Fallback in llm.Router)

  • Per-user, opt-in: Anthropic / OpenAI / Gemini. All reachable as OpenAI-compatible or via a thin adapter. The user's key is stored only as a secret reference (data-model AI_CREDENTIAL).
  • Used only when Primary fails and the user has configured a provider (see docs/use-cases/ai_routing.feature). With no BYO, work is queued for retry — content is never sent externally.

Brain sink (optional delivery target)

  • Reached via brain-mcp over HTTP, calling the brain_ingest tool (ADR-005). Do not use the filesystem brain package from hyperguild/ingestion.
  • brain-mcp base URL: confirm — not pinned in this snapshot. The homelab convention is *-mcp.d-ma.be ingress hostnames (e.g. git-mcp.d-ma.be, brain-mcp.d-ma.be is the likely form), but verify before wiring.
  • brain distinguishes knowledge/ (session-derived) from wiki/ (stable, manually promoted). Tapir summaries are session-derived signal → knowledge/-style ingestion. brain_ingest runs the LLM extraction pipeline; there is a raw-ingest path (brain_ingest_raw) and a reliable brain_write fallback when the local extraction model is unavailable — relevant if ingestion reliability becomes an issue.

Auth / identity (Stage 1+, dormant at Stage 0)

  • Dex (auth.d-ma.be) is the homelab OIDC provider. When Tapir needs real user identity (Stage 1, Future B), it authenticates via Dex — not a new auth system (ADR-002).
  • If Tapir ever exposes an MCP surface, use the mcp-chassis shared Go library (gitea.d-ma.be/mathias/mcp-chassis) for the Dex-JWT validator + Bearer middleware + RFC 9728 metadata, rather than hand-rolling it.

Secrets (OAuth tokens + BYO keys)

  • Convention: ESO + 1Password (the homelab's External Secrets Operator against a 1Password vault). Tables store only an opaque *_secret_ref; the secret material lives in the vault and is surfaced to the workload via ESO.
  • Exact ref format / vault item naming for Tapir: confirm / decide during build. Follow the pattern existing services use (e.g. how gitea-mcp references GITEA_MCP_DEFAULT_TOKEN) rather than inventing a new scheme.
    • Adapter status (YouTube): the YouTube VideoSource adapter does not assume a scheme. The OAuth refresh-token reference is an opaque Config.TokenSecretRef resolved through the SecretStore port (youtube.New(cfg, secrets)). Pinning the actual vault-item name only changes wiring/config, not the adapter — so this confirm does not block the adapter. Decide the name when wiring the live connection and record it here.
    • Per-user token-ref scheme (Stage 1 web connect): the web connect flow (/oauth/youtube/connect/oauth/youtube/callback) persists each user's refresh token under a per-user ref youtube/<userID>/refresh_token (web.YouTubeTokenRef), not the Stage-0 single youtube/refresh_token. This is what keeps tokens isolated across tenants behind the SecretStore port; the video_connections row stores only this opaque token_ref, never the token. The connect callback URL is TAPIR_YT_CONNECT_REDIRECT_URL (default https://tapir.d-ma.be/oauth/youtube/callback) and must be in the Google OAuth client's authorized redirects for live connect.

Hosts (for reference)

  • koala — Arch, RTX 5070, k3s single-node, Gitea, GPU inference. Where Tapir most likely runs and where the local models live.
  • iguana — Mac Studio M2 Ultra, ollama + mlx-whisper, also serves models via the gateway.
  • piguard — Pi, NPM perimeter (public HTTPS edge). Note: the May-2026 architecture review has LiteLLM relocating from piguard into k3s/ai-stack on koala — confirm current location if latency or endpoint matters.

Network egress the build and runtime assume

If the koala act_runner (or a dev environment) has locked-down egress, these must be reachable or the build/run fails. Split into build-time and runtime because they're different lists and fail at different stages.

Build-time (CI check/build, and local go/task work)

  • proxy.golang.org, sum.golang.org — Go module proxy + checksum DB. The default GOPROXY. If a homelab GOPROXY (e.g. Athens) exists, point GOPROXY at it and this pair is not needed directly — confirm whether one is run.
  • go.dev, dl.google.com — Go toolchain download (the CI setup-go step / go-version-file). Not needed if the runner already has the matching Go installed.
  • raw.githubusercontent.com — the CI installs golangci-lint via its install script from here (.gitea/workflows/ci.yml).
  • github.com, codeload.github.com — any module not served by the proxy (incl. golangci-lint and, later, golang.org/x/oauth2 and its deps) is fetched from GitHub.
  • objects.githubusercontent.com — GitHub release asset downloads (some installers redirect here).

A configured GOPROXY collapses most of the module-fetch entries into one host. Recording the homelab's actual GOPROXY/GONOSUMCHECK policy here once known would remove the confirm.

Runtime (the running service)

  • koala:4000 (or the NodePort / post-relocation address) — LiteLLM gateway, the Primary.
  • brain-mcp host (*-mcp.d-ma.be, confirm) — only when the brain sink is enabled.
  • www.googleapis.com / YouTube Data API + oauth2.googleapis.com (token exchange/refresh) — the YouTube provider adapter (internal/adapters/youtube). OAuth is hand-rolled on golang.org/x/oauth2 (ADR-006); the Google token endpoint is hardcoded inline to avoid the heavy golang.org/x/oauth2/google dependency.
    • confirm — captions.download ownership limitation. The Data API captions.download endpoint only authorizes the owner of the video; for arbitrary subscribed channels it typically returns 403. The adapter lists tracks via captions.list (no track → SourceNone, ADR-007) and downloads the selected track as WebVTT; a download failure surfaces as an error (retryable), not a silent skip. If the owner-only limit proves blocking in practice, the fallback is the unofficial timedtext endpoint or the deferred STT path (ADR-007) — a new ADR, not a default. Verify against a real non-owned video before relying on live caption download.
  • api.vimeo.com — the Vimeo provider adapter.
  • BYO-AI endpoints, per configured provider and only when a user opts in: api.anthropic.com, api.openai.com, generativelanguage.googleapis.com.
  • 1Password / ESO reach for secret resolution follows the homelab's existing ESO egress, not Tapir-specific — no new rule expected.

Note: a user with no BYO configured means the service never needs the third-party AI hosts (the local-first guarantee, docs/use-cases/ai_routing.feature). Don't allow them by default; allow per-provider when a user connects one.

The claude.ai chat sandbox (where planning sessions run code) has its own separate egress allowlist — unrelated to koala. It blocked go.dev during scaffolding, which is why the scaffold was reviewed but not compile-verified in-chat; verification belongs on koala via task check regardless.

Deployment / GitOps (when Tapir reaches deploy)

  • The homelab is Flux GitOps: manifests in mathias/infra under k3s/, Flux watches main.
  • Tapir's k3s manifests will live in infra/k3s/apps/tapir/ (by convention) — not in this repo. This repo is the application; infra is the deployment source of truth.
  • Multi-tenancy primitives (NetworkPolicy per namespace, Kyverno, postgres role-per-tenant, tenant= label) exist in the architecture review (SC7/P6) and apply at Stage 1 — not Stage 0.

Snapshot date 2026-06-06. Items marked confirm were not verified to a pinned source at snapshot time — check brain or the live cluster before depending on them.

Stage 1 — multi-user facts (verified 2026-06-03)

Postgres RLS (ADR-012)

  • The deployed DSN MUST connect as a non-superuser, non-BYPASSRLS role. The app uses the tapir role (table owner, non-superuser). FORCE ROW LEVEL SECURITY is applied on all user-owned tables; a superuser DSN silently bypasses FORCE and isolation is dead in prod. Verify: SELECT rolsuper FROM pg_roles WHERE rolname = 'tapir' must return f.
  • Scoping is via set_config('tapir.current_user_id', $userID, true) (transaction- local, auto-resets on commit — never leaks across a pooled connection).

Per-user YouTube token persistence

  • Stage-1 uses the file-backed SecretStore at TAPIR_SECRETS_FILE=/data/secrets.json mounted from a PVC (tapir-secrets, 64Mi, RWO). Tokens survive pod restarts. Upgrading to an ESO-backed per-user SecretStore is backlog (infra#86).
  • Per-user token ref scheme: youtube/<userID>/refresh_token (Worker C, ADR-006). The Stage-0 single ref youtube/refresh_token is no longer used by serve; it remains valid for the CLI tapir run (single-user, host-side).

Web YouTube connect

  • Redirect URI (registered in Google OAuth client, type Web): https://tapir.d-ma.be/oauth/youtube/callback.
  • Config env: TAPIR_YT_CONNECT_REDIRECT_URL=https://tapir.d-ma.be/oauth/youtube/callback. TAPIR_YT_CLIENT_ID / TAPIR_YT_CLIENT_SECRET from the Web client (not the Desktop client used for the CLI).

Identity resolution

  • user_identities(dex_subject → user_id) table is intentionally NOT RLS-enabled (it's auth plumbing, holds no user data; data isolation is on the user-owned tables). All data access after subject resolution goes through withUser.

Scheduled discovery (ADR-018, verified 2026-06-05)

tapir serve runs discovery for all users in-process on a timer (no CronJob). Three env knobs plus one load-bearing deployment constraint:

  • TAPIR_DISCOVERY_INTERVAL — Go duration, e.g. 2h. The cadence the serve process runs a discovery pass for every registered user (run-once-on-startup, then every interval). Unset or 0 = disabled (dev/tests never auto-fetch).
  • TAPIR_USAGE_GATE_STARTYYYY-MM-DD, default 2026-06-11 (the morning the pilot was unblocked and summaries started flowing). tapir report counts return-usage (distinct active weeks, ADR-016) only from this date, so pre-launch testing and the blocked period are excluded.
  • TAPIR_METRICS_ADDR — listen address for the Prometheus /metrics endpoint (ADR-030). Default :9090 — a SEPARATE port from TAPIR_HTTP_ADDR so metrics are never on the public app; scraped in-cluster only (PodMonitor). Empty disables the metrics server. Key series: tapir_summarize_duration_seconds{model,outcome,fallback}, tapir_caption_fetch_duration_seconds{outcome}, tapir_chat_duration_seconds{model}, tapir_llm_tokens_total{model,kind}, tapir_http_request_duration_seconds{method,route}, tapir_logins_total.
  • TAPIR_FETCH_RATE — Go duration, default 2s. The process-wide per-egress-IP caption-fetch rate gate (ADR-014 item 2). Every caption fetch — scheduler runners and the web "Summarize" click-path — serialises through this one limiter so the pod cannot collectively trip 429s. 0 = unlimited (dev/tests). This is the precondition that makes auto-summarize-on-a-schedule safe; do not raise it aggressively without watching for 429s.
  • TAPIR_FETCH_BACKOFF=4h — per-video rate-limit retry window; default 1h. A video that returns HTTP 429 on a caption fetch is skipped for this duration before being retried. The scheduler checks NOW() - rate_limited_at > TAPIR_FETCH_BACKOFF before attempting to fetch a video marked transcript_status = rate_limited. Longer values reduce 429 pressure at the cost of slower recovery after a throttling episode.
  • SINGLE-REPLICA WARNING (load-bearing). The scheduler lives in the web process, so replicas: 1 in the deployment manifest is load-bearing: running tapir serve at >1 replica makes every replica run the discovery loop → every user fetched in parallel from the same egress IP (429s + duplicate work). Do not scale serve past 1 replica without first moving discovery to a k8s CronJob or adding leader election. The process logs a Warn at startup when scheduled discovery is enabled, as a reminder.