Hub relays pi's own --mode json session protocol (session/agent_start/
turn_start/message_*/turn_end/agent_end -- the same event shape Claude
Code sessions use) to agent-console over WebSocket, so concurrent pi
runs inside the pod are observable as real transcripts instead of log
tails. agent-run.js wraps `pi -p --mode json` and relays its stdout
lines to the hub; each invocation gets its own session id, so N
concurrent agents show up as N sessions. Source mounted via ConfigMap
and run with `go run .` (no registry yet, same as the pi container).
Cluster-wide prometheus KongClusterPlugin (kong-metrics.yaml) +
chart-native ServiceMonitor (kong-values.yaml) expose
kong_http_requests_total/kong_latency_bucket/kong_bandwidth_bytes for
every route, LLM and otherwise. Dashboard filters to route=~"llm-.*"
for request rate, error rate, p95 upstream latency, and bandwidth.
Token-count metrics still need ai-proxy-advanced (Enterprise-only);
not attempted.
Resources (CPU/mem/restarts) + Loki logs across llm-serving, agent-pod,
and api (Kong) namespaces, mirroring the svc-*.yaml dashboard pattern.
No request-rate/latency/token panels yet -- no ServiceMonitor exists
for Kong or the KServe predictors, so there's no metric to query.
pi's openai-completions handler (pi-ai/dist/api/openai-completions.js)
reads model.headers (populated from a provider-level "headers" field
in the config), not compat.customHeaders -- that field only gets read
by the Bedrock handler. Without it, pi used the openai SDK's default
Authorization: Bearer <apiKey>, which Kong's key-auth plugin rejects
(verified via direct fetch: apikey header -> 200, Bearer -> 401,
plugin does not strip the Bearer prefix). Moved the apikey header to
the correct provider-level "headers" field.
api.riotpiao.com has no in-cluster DNS record (getaddrinfo ENOTFOUND
from inside the pod, confirmed against google.com resolving fine and
kong-proxy.api.svc.cluster.local resolving fine) -- it only resolves
via the home network's own DNS. Node's fetch/undici also silently
drops a manually-set Host header (forbidden header per WHATWG fetch
spec), so overriding Host per-request isn't viable either. hostAliases
pinning the hostname to ingress-nginx-controller's ClusterIP lets
pi's models.json baseUrl work completely unchanged -- TLS SNI/Host
still say api.riotpiao.com so cert validation and Kong's Host-based
routing both still work.
pi-coding-agent requires node >=22.19.0 (undici's
webidl.util.markAsUncloneable, added in Node 22) -- node:20-slim
installed fine but crashed on every invocation.
0.1.23 was pi's own internal version string (from local pi --version),
not the npm package's semver -- npm has no such release, so the
container crash-looped on ETARGET. Latest published is 0.84.2.
Secret was created but never listed in secret-generator.yaml's files
allowlist, so ksops never decrypted it — pod stuck on FailedMount
waiting for secret "pi-models".
Namespace + Deployment (2-4 CPU, 4-8Gi mem) running node:20-slim with
pi installed at startup, wired to the homelab-ornith/reasoning/qwen
model providers via the existing model-invoke-apikey. Placeholder
node:20-slim image for now, real harness image to follow.
StorageClass.parameters is immutable and mkfsParams was added after creation,
so every sync failed. Replace=true recreates it instead of patching. Existing
volumes keep their format; only new ones get mkfsParams.
Longhorn names its own disk key and writes storageReserved into it, so git's
default-disk never matched and selfHeal kept trying to add a second disk on the
same path. Dropped spec.disks from git, added ignoreDifferences.
Two bugs. Kong timeouts were on the Ingress; it reads them from the Service, so
its 60s default applied. Moved to the isvc, which KServe propagates.
Probes ran 'ollama list' — models on disk, not in VRAM — so the pod went Ready
before it could serve. Now 'ollama ps', and both models are warmed at startup.
Five model servers were applied by hand and tracked nowhere. Exported live,
kubectl diff empty on all five, so the first sync adopts without restarting.
prune: false — KServe copies isvc labels to its child Deployment, so ArgoCD
would prune children it does not own and KServe would recreate them.
Two independent bugs, both silent, both found while pointing an agent harness
at api.riotpiao.com.
1. Requests over ~10.6KB failed with HTTP 400
{"error":{"message":"[] is too short - 'messages'"}}.
The request-transformer plugin on the llm-chat-* routes rewrites the JSON
body, which means it reads it via kong.request.get_body(). That returns
nothing once nginx spills the body past client_body_buffer_size into a temp
file, so the plugin re-serialized a body with no `messages` and the upstream
rejected it. Measured on /v1/ornith/chat/completions: 10588 B -> 200,
11088 B -> 400. Isolated by size-sweeping /v1/embeddings, the one route with
no request-transformer, which passed an 18057 B body straight through to a
semantic 413 from TEI.
Raises the Kong http-block buffer to 16m. Any agent request carrying tool
schemas clears the old ceiling in a single turn.
2. key-auth was never applied to any model route.
The model-key-auth KongPlugin sat in namespace `api` while all five routes
that annotate it live in `llm-serving`. The ingress controller resolves
konghq.com/plugins against the annotated object's own namespace, so the
reference dangled and the plugin never bound. Verified before the fix:
unauthenticated GET /v1/models and POST /v1/ornith/chat/completions both
returned 200. A dangling plugin reference fails open and logs nothing.
Re-test both without a key after this syncs; expect 401.
Note for follow-up: llm-embeddings and llm-score carry no plugins annotation at
all, so they stay unauthenticated even after this change.
Co-Authored-By: Claude Opus 5 <[email protected]>
llm-serving-default-deny admits port 8080 only from pods carrying
llm-client=true. Kong lacked it, so every route that actually contacts an
upstream timed out. /v1/models masked the problem: request-termination answers
inside Kong and never touches an upstream, so it returned 200 throughout.
Opting in via podLabels rather than relaxing the policy — it is a compensating
control, not hygiene, since vLLM v0.11.0 is frozen on Volta and will not receive
patches for several remote/unauthenticated advisories.
podLabels land only in the pod template, not spec.selector.matchLabels, so this
is not an immutable-field change.
Kong matches routes on host/path/method/header, never on the request body, so a
single /v1/chat/completions dispatching on body.model is not expressible in Kong
OSS (ai-proxy-advanced, which does multi-target model routing, is Enterprise).
Model therefore goes in the path:
GET /v1/models static list (request-termination)
POST /v1/reasoning/chat/completions reasoning-predictor (vLLM)
POST /v1/ornith/chat/completions ornith-predictor (Ollama)
POST /v1/qwen/chat/completions ornith-predictor (Ollama, same pod)
POST /v1/embeddings embeddings-predictor (TEI)
POST /v1/rerank reranker-predictor (TEI)
POST /v1/score verifier-predictor (vLLM pooling)
- each chat route force-overwrites body.model via request-transformer add+replace:
ornith:35b and qwen2.5:3b-instruct share one Ollama pod, so without this a
client hitting /v1/qwen with "model":"ornith:35b" would silently get the 35B
- routes live in ns llm-serving, not api: an Ingress can only reference a Service
in its own namespace, and KIC watches all namespaces
- embeddings and score need no rewrite (TEI/vLLM already serve the canonical
paths); rerank does, since /v1/rerank 404s and only /rerank exists
- read/write timeouts 1h: Kong defaults to 60s, which a 32B model on Volta
exceeds mid-generation and returns 504
- nginx_proxy_proxy_buffering=off: buffered responses lump or stall SSE, and both
hops (nginx Ingress and Kong) must be unbuffered or the buffered one wins
- no auth for now, per decision; api.riotpiao.com is reachable through nginx, so
GPU time is currently unauthenticated
- namespace: PodSecurity privileged, needed for /dev/kvm + privileged QEMU
- storageclass: 1 replica, strict-local, WaitForFirstConsumer
- deployment: nodeSelector workload=imessage + matching NoSchedule toleration,
Recreate strategy (two QEMU procs on one qcow2 corrupts it), no readiness
probe (guest install is interactive and takes many minutes)
- services: ClusterIP only; VNC is an unauthenticated console, reach it with
port-forward, never an Ingress
- networkpolicy: default-deny, opt-in via sms-client=true on port 1234
argocd-server ran --insecure (plain HTTP :8080) while its Helm-managed
Ingress set ssl-passthrough: true, which sends nginx's raw TLS handshake
straight to the pod - HTTP server can't complete a TLS handshake, nginx
logged 502 (peer closed connection in SSL handshake). Compounded by a
second, conflicting Ingress for the same host in
k8s/bootstrap/ingress/ingress.yaml - two Ingress objects on one host is
undefined nginx routing behavior. Disabled the Helm-managed Ingress
(enabled: false) so ingress.yaml's passthrough Ingress is the sole
source of truth, and set server.insecure: false so argocd-server
actually terminates TLS itself, matching passthrough's requirement.
ArgoCD directory.include uses Go filepath.Match glob syntax, not shell
brace expansion - {a,b,c} silently matched nothing, only the original 2
files stayed tracked.