AI / LLM stack¶
Self-hosted LLM stack in the ai namespace, fronted by LiteLLM. Patterns adapted from
joryirving/home-ops,
re-targeted to this cluster: NVIDIA L4 GPUs + llama.cpp (llmkube).
Components¶
| App | Role | Status |
|---|---|---|
litellm |
OpenAI-compatible gateway: routing, fallbacks, cache, metrics, MCP | live |
llmkube |
llama.cpp model-serving operator (CUDA); 2 models active | live |
open-webui |
chat UI | live |
toolhive |
MCP servers (8 read-only servers) wired into LiteLLM | live |
memini |
agent long-term memory (SQLite + CPU embed/rerank) | live |
hermes |
NousResearch hermes-agent gateway + dashboard (memini-backed) | live |
hermeswebui |
chat web frontend for hermes (via its API server) | live |
LiteLLM persists to CNPG postgres18 (litellm db) and caches in Dragonfly. Internal-only route
(litellm.${SECRET_DOMAIN} on envoy-internal).
Model serving (llmkube)¶
llmkube is the sole GPU inference tier. Each model is
declared as a Model CR (weights source + hardware) plus an InferenceService CR (the serving
pod), one file per model under kubernetes/apps/ai/llmkube/models/.
Active models:
| LiteLLM model name | InferenceService | Notes |
|---|---|---|
self-hosted |
llama-nvidia |
Default; vision-capable via mmproj |
self-hosted-uncensored |
llama-uncensored |
Abliterated variant; no cloud fallback |
Weight files are declared as hf:// URIs pointing to single-file public GGUFs on Hugging Face.
llmkube downloads and caches them on the shared CephFS RWX modelCache PVC (ceph-filesystem
storage class), so a cold start auto-heals without manual staging.
Anti-affinity (podAntiAffinity) keeps one resident model per L4: the cluster has 3 cards but
runs 2 models, preserving one card for other GPU workloads (Plex/Jellyfin transcodes, Whisper).
No model swapping occurs during normal operation. The gpu-preemptible PriorityClass is set on
all llmkube pods so higher-priority workloads can evict them if needed.
self-hosted is vision-enabled: the InferenceService mounts a mmproj-F16.gguf multimodal
projector alongside the main GGUF. This is the model that loupe (image analysis) consumes via
LiteLLM.
To add a model: drop a Model + InferenceService manifest under llmkube/models/, add a
model_list entry to litellm/app/configmap.yaml, and commit. Flux reconciles both.
Model groups¶
LiteLLM model_name groups make the serving tier transparent to clients:
self-hosted:llama-nvidia(order 1), with a live cloud fallback toopenrouter/autoviarouter_settings.fallbacks(the OpenRouter key is in thelitellmExternalSecret).self-hosted-uncensored:llama-uncensored(order 1); no cloud fallback by design (a cloud model would reintroduce refusals).
In-cluster consumers¶
Three in-cluster apps route through LiteLLM using the standardized OpenAI env contract:
| App | Namespace | LiteLLM model |
|---|---|---|
contracthound |
default |
self-hosted |
subspy |
default |
self-hosted |
loupe |
custom |
self-hosted |
All three consume:
LLM_PROVIDER=openaiOPENAI_API_BASE_URL=http://litellm.ai.svc.cluster.local:4000/v1OPENAI_MODEL=self-hostedOPENAI_API_KEYfrom thelitellm1Password item via ExternalSecret
Rollout (staged)¶
- LiteLLM uplift: model-groups + router fallbacks + commented hooks for MCP / embeddings / cloud providers.
- ToolHive + MCP: operator + curated MCP servers (kubectl, flux, talos, searxng) wired into
LiteLLM
mcp_servers. - memini: agent memory; embeddings + rerank via tiny CPU llama.cpp servers, consolidation via LiteLLM.
- llmkube: operator + CephFS modelCache; 2 active models (
self-hosted,self-hosted-uncensored). - Ollama decommission: Ollama removed; contracthound/subspy/loupe repointed to LiteLLM.
Each layer is a separate commit on one branch (one PR). mcp_servers and
mcp_semantic_tool_filter are now fully active in litellm/app/configmap.yaml, and one cloud
provider is live: openrouter/auto is an active model_list entry serving as the self-hosted
group's fallback. The remaining cloud-provider stubs are still commented out.
Since ported from Jory's repository:
hermes(plus ahermeswebuichat frontend). Still not ported:openclaw(agent runtime) andagentmemory(memini covers agent memory here).
How to extend LiteLLM¶
- Add a backend to a group: add a
model_listentry with an existingmodel_nameand the nextorder:. LiteLLM balances / fails over within the group. - Add a cloud provider: add the key to the
litellm1Password item, add a line toexternalsecret.yaml'starget.template.data, then uncomment the matching stub inconfigmap.yaml. Don't reference anos.environ/KEYthat isn't in the secret. The pod env read fails at startup. - Fallbacks:
router_settings.fallbacksis a list of{model_name: [fallback, …]}.
Consuming the stack from a workstation (opencode)¶
Any OpenAI-compatible client can drive the self-hosted models through the gateway: internal route
https://litellm.${SECRET_DOMAIN}/v1, models self-hosted and self-hosted-uncensored.
opencode is wired this way as a custom provider:
- Provider:
@ai-sdk/openai-compatible,baseURL: https://litellm.${SECRET_DOMAIN}/v1. Put the LiteLLM key in the client's own credential store (opencode:opencode auth login→~/.local/share/opencode/auth.json), never as a literal in a shared or committed config. - MCP tools: point a remote MCP server at LiteLLM's MCP gateway,
https://litellm.${SECRET_DOMAIN}/mcp/(thelitellm-mcp-server), with the LiteLLM key asAuthorization: Bearer …. Curate the server set with thex-mcp-serversheader (e.g.kubectl,flux,talos,searxng). Requesting all servers times out, and the full tool list bloats every request (heavy on the small-context local models, so prefer a frontier model for tool-heavy work). - Gotchas: the self-hosted models are Qwen3 thinking models (send
think: false, or/no_thinkin the prompt, for non-reasoning output); switching between the two local models forces a ~17Gi VRAM model-swap (seconds, longer on a cold replica). See the context-budget note below for opencode's window requirements.
Prefer a scoped LiteLLM virtual key (/key/generate, limited to the self-hosted* models) over
the master key for any workstation client: it's revocable on its own.
opencode and the context budget¶
opencode's agent sends ~41k tokens before any user input: its system prompt plus built-in tool
schemas (measured; LiteLLM is not injecting MCP tools, a plain request is ~18 tokens and one with a
tool is ~130). That exceeds Qwen3-30B-A3B's native context, so self-hosted-uncensored is served at
64k via YaRN specifically to host opencode. self-hosted is only ~10.9k usable
(contextSize 32768 ÷ 3 slots), too small for the tool-heavy agent. Use the uncensored group for
opencode. The serving knobs live in llmkube/models/qwen3-30b-abliterated.yaml: contextSize 65536,
parallelSlots 1, ropeScaling {yarn, factor 2.0, originalContext 32768}.
llama.cpp cap gotcha: the pinned server-cuda build hard-caps each slot to the model's trained
context (this GGUF reports 40960) even with correct YaRN args, so the served window silently caps
at 40960 (llama.cpp#22140). The fix is the
extra arg --override-kv qwen3moe.context_length=int:65536, which raises the value the server caps
against (rope interpolation still comes from ropeScaling). A Ready phase and a clean
kustomize build do not prove the served window: confirm with /props
(.default_generation_settings.n_ctx == 65536) and the absence of a capping log line.
MCP tools (ToolHive)¶
Layer 2 runs the StackLok ToolHive operator (separate
CRDs + operator charts, see toolhive/app/ocirepository.yaml for the current pin) in ai, an
MCPGroup (mcp-tools), and these MCP servers, all wired into LiteLLM's mcp_servers:
| Server | Source | Access |
|---|---|---|
kubectl |
kubectl-mcp-server | cluster read-only, secrets excluded |
flux |
flux-operator-mcp | Flux read-only (write is opt-in) |
talos |
talos-mcp | Talos os:reader (talosconfig SA) |
searxng |
mcp-searxng → searxng.default |
web search |
github |
github-mcp-server | GitHub read-only (GITHUB_READ_ONLY, fine-grained PAT) |
grafana |
grafana/mcp-grafana | Grafana Viewer SA token (read-only) |
arr |
mcp-arr | Sonarr / Radarr / Prowlarr tools (per-app API keys) |
seerr |
overseerr-mcp | Overseerr request + discovery tools |
kubectl + flux share one read-only ClusterRole (kubectl-mcp-readonly) built from this cluster's
API groups with core secrets omitted. Keep it in sync with kubectl api-resources as you add
CRDs. The talos MCP mounts a talos.dev ServiceAccount-minted os:reader talosconfig.
The mcp_semantic_tool_filter is on (top_k 8, embeddings via the all-minilm model on the
CPU llama-embed pod): with 8 servers' worth of tools it trims each request to the most
relevant ones. github + grafana are read-only, via a fine-grained PAT (toolhive-github) and a
Grafana Viewer service-account token (toolhive-grafana).
Deferred (add later): the VirtualMCPServer aggregate + EmbeddingServer (a single
mcp.<domain> endpoint for non-LiteLLM clients, which is what pulls in a Dragonfly + embedder).
Enabling flux-mcp write access¶
The flux MCP is read-only by default. To let it (and therefore any model behind LiteLLM)
reconcile / suspend / resume / apply / delete Flux objects, append to
toolhive/mcp-servers/flux/rbac.yaml (no kustomization.yaml change needed, rbac.yaml is
already listed there):
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: flux-mcp-write
rules:
- apiGroups:
- fluxcd.controlplane.io
- helm.toolkit.fluxcd.io
- image.toolkit.fluxcd.io
- kustomize.toolkit.fluxcd.io
- notification.toolkit.fluxcd.io
- source.toolkit.fluxcd.io
resources: ["*"]
verbs: ["create", "patch", "update", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: flux-mcp-write
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: flux-mcp-write
subjects:
- kind: ServiceAccount
name: flux-mcp
namespace: ai
This grants an LLM mutate access to the cluster's GitOps controller. Enable only if you trust the calling chain.
Adding an MCP server¶
Drop an MCPServer (operator-managed) or MCPServerEntry (remote URL) with groupRef: mcp-tools
under toolhive/mcp-servers/<name>/, list it in that dir's kustomization.yaml, then add its
endpoint to LiteLLM's mcp_servers. The service name depends on the transport:
- Native
streamable-httptransport (e.g. kubectl, flux): ToolHive createsmcp-<name>on the spec'smcpPort. stdiotransport withproxyMode: streamable-http(e.g. github, grafana): ToolHive createsmcp-<name>-proxyon the spec'sproxyPort(typically 8080).
Agent memory (memini)¶
Layer 3 runs memini (SQLite backend) for agent long-term
memory, plus two tiny CPU llama.cpp model servers in ai:
llama-embed: all-MiniLM-L6-v2 (384-dim),--embeddings, OpenAI/v1.llama-rerank: Qwen3-Reranker-0.6B,--rerank.
Both run on CPU (ghcr.io/ggml-org/llama.cpp:server, GPU/Vulkan bits stripped). The L4s are
spoken for by llmkube, and these models are small (~30 MB / ~600 MB). memini's consolidation LLM is
LiteLLM's self-hosted group.
Secrets: a generated MEMINI_API_KEY (Talos vault item memini) + LITELLM_MASTER_KEY (reused
from the litellm item). Data PVC via the volsync component (10Gi). Route:
memini.${SECRET_DOMAIN} (envoy-internal).
To move embeddings onto the GPU later, swap llama-embed/llama-rerank for llmkube
InferenceServices and repoint MEMINI_EMBED_BASE_URL / MEMINI_RERANK.
Gotchas¶
- Public repository: no LAN IPs / internal hostnames in Git (see
CLAUDE.md). Cluster service DNS and${SECRET_*}placeholders are fine. - Metrics:
require_auth_for_metrics_endpoint: falseand ServiceMonitor path/metrics/(trailing slash, no redirect-follow) are both required for in-cluster Prometheus scraping. - CephFS dependency: llmkube's shared
modelCachePVC requiresceph-filesystem(RWX). Without it, multi-replicaInferenceServicepods fail to schedule (only one pod can hold an RWO volume at a time). Theceph-filesystemstorage class is provisioned by Rook-Ceph. - ConfigMap reloads: the
litellmcontroller is annotatedreloader.stakater.com/auto, so Stakater Reloader restarts it automatically when the configmap changes. - Cross-namespace netpol:
kubernetes/apps/ai/netpol.yamlallows ingress to theainamespace from thenetworknamespace (gateway), plus a secondCiliumNetworkPolicy(allow-litellm-from-consumers) that grants thedefaultandcustomnamespaces ingress to thelitellmendpoint specifically. A new consumer namespace needs adding to that policy'sfromEndpointslist before its calls tolitellm.ai.svc.cluster.local:4000will connect.