Skip to content

feat(modelpool): cross-pod sticky model swapping on a shared GPU slot - #1393

Merged
Defilan merged 7 commits into
defilantech:mainfrom
sylvainsf:feat/modelpool-cross-pod-swapping
Aug 10, 2026
Merged

feat(modelpool): cross-pod sticky model swapping on a shared GPU slot#1393
Defilan merged 7 commits into
defilantech:mainfrom
sylvainsf:feat/modelpool-cross-pod-swapping

Conversation

@sylvainsf

@sylvainsf sylvainsf commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

What

Adds ModelPool, a CRD plus controller that lets several single-model InferenceServices share one exclusive GPU slot. At most one member is Ready at a time; the controller drains and unloads the incumbent (freeing VRAM) before the next member loads. The ModelRouter proxy fronts the members, activates a stopped member on demand, and enforces a sticky, anti-thrash swap policy across the slot.

Four commits:

  1. feat(modelpool): add ModelPool CRD and exclusive-slot reconciler
  2. feat(router): activate ModelPool members with sticky idle-gated swapping
  3. feat(modelpool): decouple swap hold budget and self-heal residency
  4. feat(router): pin the router-proxy with spec.proxy.nodeSelector

Why

Fixes #1111.
Fixes #1123.
Refs #516.

ModelPool is tracked as its own feature issue (#1111), distinct from the in-process runtime: llamacpp-router shape in #516. ModelPool is the cross-pod shape: N independent single-model services sharing one exclusive GPU slot, so LLMKube owns the queue and swap policy in Go and the anti-thrash behavior needs no llama.cpp change. Members can also diverge in runtime (different images, context sizes, vLLM vs llama.cpp). The two shapes compose: a ModelRouter can front both transparently.

On a single-GPU node only one model can be resident, and there was no safe primitive to switch models on demand. ModelPool provides scale-from-zero on request, a VRAM-gated exclusive slot, and a sticky policy that will not thrash under mixed demand. The residency self-heal in this PR also closes the stale-resident activator bug (#1123): a member whose pod is gone no longer keeps serving stale 502s until a proxy restart.

Priority-reclaim swap mode and metal-backed members are deliberately deferred and tracked in #1394.

How

Design notes (full write-up in docs/proposals/516-modelpool-cross-pod-swapping.md):

  • Exclusive slot via Kubernetes device-plugin gating: a busy incumbent is never scaled down, and the displaced member stays Pending until the incumbent releases the device. v1 is Kubernetes-GPU-only; metal-backed members are refused with MetalSupported=False.
  • Drain-before-unload reuses the runtime idle-probe abstraction (IdleDetector.IdleProbe, /slots for llama.cpp): an unreachable idle check fails closed (the incumbent stays resident). There is no controller-side force-unload; the only bound on a waiting request is the router hold budget, surfaced as 503 plus Retry-After (never 429).
  • The activator runs swaps under a base context, not the caller context, so a client disconnecting mid-swap never aborts an in-progress load. Cross-model requests are held open as real connection state; same-model demand coalesces onto the warm member to avoid GPU ping-pong.
  • spec.swapBudget (default 300s) bounds the swap hold independently of the response-header (generation) timeout, so a large cold load does not force operators to inflate the generation cap for every request. The dispatch clock starts only after the member is resident.
  • Residency self-heal: the activator re-verifies its cached resident at a bounded interval and on a connection-level dispatch failure, so a member whose pod is gone does not keep serving stale 502s until a proxy restart.
  • Least-privilege activation RBAC: the operator provisions a per-router ServiceAccount, Role, and RoleBinding (get/list/watch/update/patch on InferenceServices) owner-referenced to the ModelRouter; the proxy runs with --enable-activation only when the router actually fronts pools.
  • spec.proxy.nodeSelector pins the router-proxy pod, which fixes a node-local-registry setup where the proxy scheduled onto a node that lacked its image.

Metrics

New ModelPool observability, exported by the router-proxy at :8081/metrics (registered via the shared AllCollectors list so nothing is silently dropped):

  • llmkube_modelpool_resident{namespace,pool,member}: which member owns the slot (1 resident, 0 not).
  • llmkube_modelpool_swaps_total{router,pool,from,to}: completed slot swaps by direction.
  • llmkube_modelpool_swap_duration_seconds{router,pool}: incumbent unload plus target load.
  • llmkube_modelpool_hold_duration_seconds{router,pool,member}: how long a request was held open waiting for activation.
  • llmkube_modelpool_coalesced_total{router,pool,member}: requests served on the warm member without a swap.
  • llmkube_modelpool_held_requests{router,pool,member}: requests currently held open per member.

Testing

Cluster validation (author, on the ModelPool functionality)

Environment: k3s v1.36.2+k3s1, two nodes (helpy control-plane; corsair agent, Ubuntu 24.04.4, kernel 6.17-oem). Single AMD Radeon 8050S iGPU on corsair (gfx1151 / Strix Halo), served via the operator's Vulkan image ghcr.io/defilantech/llmkube-llama-vulkan scheduled against devices.es/dri-render (generic-device-plugin, count 1), not amd.com/gpu or NVIDIA. Node label lab.sylvain.dev/inference=heavy on corsair. Models pre-staged on a local NVMe hostPath PVC (pvc://), no download.

Live-tested commit: ad4d8e0 (the pre-rebase "decouple swap hold budget and self-heal residency" tip). Built controller and router-proxy with podman, imported into containerd via k3s ctr images import, rolled the controller-manager onto the new image, and applied the branch's ModelPool CRD (the live CRD predated swapBudget).

ModelPool heavy-slot (namespace lab, gpu: 1, swapPolicy: sticky, default: coder, nodeSelector: {lab.sylvain.dev/inference: heavy}, swapBudget: 300s) with two Vulkan members on the single GPU:

  • coder = Qwen3.6-27B-Q6_K (about 21 GB)
  • gemma-longctx = gemma-4-31B-it-Q4_K_M (about 18 GB)

Fronted by a ModelRouter (defaultRouteStrategy: BackendNameMatch); proxy and per-backend responseHeaderTimeout initially 60s, later dropped to 15s for the decoupling proof.

Results:

  • swapBudget compiles into the proxy: the generated router-proxy ConfigMap showed "swapBudget": 300000000000 (300s) per backend pool alongside the backend "timeout", confirming ModelPool.spec.swapBudget reaches BackendPool.SwapBudget, decoupled from the dispatch timeout.
  • Cold-pool convergence and on-demand activation: coder warmed to Ready, gemma-longctx held Stopped, pool Ready with residentMember=coder.
  • Cross-model swap and drain-before-unload: requesting gemma-longctx drained coder to Stopped, then loaded gemma-longctx to Ready. First run (60s timeout): HTTP 200 in 37s, server swap_duration about 34s.
  • swapBudget decoupling (the definitive test): dropping the dispatch timeout to 15s (below the about 34s swap) and triggering coder -> gemma-longctx returned HTTP 200 in 35.2s with swap_duration about 32s. A 32s hold succeeding under a 15s generation cap is only possible because swapBudget (300s) governs the hold; under the old coupling that request would 503 at 15s.
  • Never-two-resident invariant: member phases never showed both Ready during or after swaps; the residency gauge showed exactly one resident.
  • Metrics scraped from the proxy pod IP :8081/metrics: llmkube_modelpool_swaps_total{from="coder",to="gemma-longctx"} 1, swap_duration_seconds sum about 32-34s (count 1), resident{member="coder"} 0 and resident{member="gemma-longctx"} 1; the full family (swaps_total, swap_duration, resident, coalesced_total, hold_duration, held_requests) was present.
  • Activation RBAC: the operator provisioned the proxy's ServiceAccount/Role/RoleBinding and the proxy Deployment came up with --enable-activation (after the RBAC note below).
  • Sticky: the pool held the resident member until a different member was requested; repeats did not swap.

Issues hit and worked around during the live run:

  • Stale operator ClusterRole: the deployed manager-role predated the activation rules, so the proxy Deployment was never created (operator logged serviceaccounts is forbidden ... cannot list). Applying the branch's updated config/rbac/role.yaml and restarting the operator fixed it. A fresh install from this branch's chart provisions it correctly.
  • Proxy placement: ad4d8e0 had no proxy placement field, so the proxy scheduled onto helpy, which lacked the node-local image (ImagePullBackOff). Cordoning helpy rescheduled it to corsair. This is exactly the gap spec.proxy.nodeSelector (commit 4) closes.

Not live-exercised (verified at the code/test level only, stated to avoid overclaiming):

Local validation (on the rebased branch)

This branch was rebased onto current upstream/main (0 behind), resolving the conflicts from upstream's idle-check refactor (the ModelPool drain now routes through IdleDetector.IdleProbe instead of a private /slots helper) and the deployment-update path (merged upstream's suspension-aware HPA handling with the retry-on-conflict update that absorbs concurrent ModelPool replica scaling). Manifests were regenerated with the pinned controller-gen v0.19.0 (this also dropped stale minResidency / priority drift from the ModelPool CRD) and the CRDs and ClusterRole were synced into the Helm chart.

On the rebased tip:

Checklist

  • Tests added/updated
  • make lint passes (golangci-lint v2.12.2, 0 issues on the changed packages)
  • Build and vet pass; router unit tests pass (envtest controller suite runs in CI, see Testing)
  • Commit messages follow conventional commits
  • All commits are signed off per DCO (author identity matches sign-off)
  • Documentation updated (design proposal under docs/proposals/)

@sylvainsf
sylvainsf requested a review from Defilan as a code owner August 2, 2026 08:02
@sylvainsf
sylvainsf marked this pull request as draft August 2, 2026 08:11
@sylvainsf
sylvainsf force-pushed the feat/modelpool-cross-pod-swapping branch from 7f048ba to d895dd4 Compare August 2, 2026 09:17
@sylvainsf
sylvainsf marked this pull request as ready for review August 7, 2026 05:44
@sylvainsf
sylvainsf force-pushed the feat/modelpool-cross-pod-swapping branch from 0f26245 to f8a5643 Compare August 7, 2026 18:29

@Defilan Defilan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and apologies for the slow first pass. The shape is good: the RBAC is a namespaced Role rather than a ClusterRole, scoped to inferenceservices, bound to a per-router ServiceAccount and owner-referenced so it garbage-collects with the ModelRouter. CRD, chart CRD, and RBAC are all in sync, and the test coverage is substantial.

One thing I want to resolve before merging: activation does not appear to be coordinated across proxy replicas.

Activator.mu is a single in-process mutex, and spec.proxy.replicas is user-settable with nothing constraining it when a pool is attached (replicas := int32(1) in newRouterDeployment is only a default). The member writes in activator_k8s.go use client.Patch(ctx, patched, client.MergeFrom(cur)) without WithOptimisticLock, so two proxies racing to activate different members do not conflict; the later write silently wins.

The controller-side exclusive-slot invariant still holds, so this is not corruption and two members never go resident at once. The cost is thrash: at two or more replicas, proxies receiving requests for different members each drive a swap, and every flip is a full drain plus a multi-gigabyte reload. That is the GPU ping-pong the coalescing logic exists to prevent, reappearing at replicas >= 2, with nothing in status or events to indicate it is happening.

The Activator doc comment says all swaps for a pool are serialized so at most one member is ever made resident at a time, which a reader would reasonably take as a cluster-wide guarantee rather than a per-process one.

Two resolutions I would be happy with:

  1. Validation. Reject or pin spec.proxy.replicas to 1 while a ModelPool references the router, and state the constraint in the CRD field docs. Smallest change, and honest about what the current design guarantees.
  2. Real single-writer. client.MergeFromWithOptions(cur, client.MergeFromWithOptimisticLock{}) so competing activations conflict instead of silently last-write-winning, plus a lease so one replica owns swap decisions for a pool.

Which did you have in mind? If it is 1, that is fine for this PR and 2 can be a follow-up issue.

Introduce a ModelPool CR that names a set of member InferenceServices
sharing one exclusive GPU slot on a node, with a sticky swap policy. The
reconciler enforces the exclusive-slot invariant: at most one member is
Ready at a time, and the incumbent is fully drained and unloaded (VRAM
freed) before the next member loads.

Drain-before-unload reuses the same /slots idleness contract as the
InferenceService rollout drain (factored into a shared llamaServerIdle
helper): a busy incumbent is never scaled down, and an unreachable idle
check fails closed (incumbent stays resident). Unlike a rollout there is
no idleTimeout force branch, so request traffic can never force-unload a
resident member; the bound on a waiting request is the router hold budget
(added in the follow-up router commit), never a controller-side force.

Co-residency gate for unified-memory nodes: the incoming owner is held at
replicas 0 until the incumbent's Pods are actually gone (a Pod list, not
the Stopped phase, which flips while the Pod is still Terminating). On a
unified-memory node the device plugin frees gpu:1 on Pod termination but
the allocation only releases on process exit, so gating the owner's start
on Pod termination closes the reclaim-lag window where the scheduler
could otherwise briefly co-schedule two residents and OOM. The in-flight
owner is remembered in status.PendingMember so a deferred swap does not
revert to the incumbent while the owner is held at zero.

v1 is Kubernetes-GPU-only and sticky-only (priority mode deferred to a
follow-up): on a one-GPU node the device plugin gates co-scheduling.
Metal-backed members have no such gating and are refused
(MetalSupported=False); metal-agent enforcement is a follow-up.

Status surfaces ResidentMember, PendingMember, per-member phase, and the
SlotAllocated / MembersValid / SwapDeferred (PodsBusy | IdleCheckFailed) /
MetalSupported conditions. To keep the member replica patches from racing
the InferenceService reconciler, both its status writer and its Deployment
update now treat an optimistic-lock conflict as a clean requeue / retry
(retry.RetryOnConflict with a fresh read) instead of a spurious error, so
a ModelPool scaling a member during a swap no longer produces update-
conflict log noise. Ginkgo coverage includes the exclusive-slot invariant,
drain-before-load, busy-defer, idle-check fail-closed, a fast-interleaved
swap (never two Ready), the unified-memory reclaim-lag gate, and the metal
guard; a unit test covers the Deployment-update conflict retry.

Refs defilantech#1111
Refs defilantech#516

Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
Teach the router-proxy to front ModelPool members: a request to a
non-resident member is held open (the KServe activator pattern) while the
proxy makes that member resident, then forwarded on the held connection.
The swap policy lives in the proxy, in Go, so it needs no upstream
llama.cpp change:

- Sticky residency: the resident member stays until a different member is
  requested; no automatic restore of a default.
- Coalesce until idle (anti-thrash): a cross-model request is held (never
  503'd on this path) and the slot flips only once the incumbent is fully
  idle. A busy incumbent is never preempted; same-model demand arriving
  during the hold is served on the warm member. Idleness is the entire
  anti-thrash mechanism in v1: no request counting, no timers.
- Cancel-on-timeout: when a hold ends with no remaining waiter, the
  pending activation is cancelled (member scaled back to zero) so the pool
  does not complete a useless swap. A swap load in progress is never
  aborted by a single caller giving up.
- Budget fallback: when the hold exceeds the request budget, return 503
  with Retry-After (never 429).

The proxy commits a swap by scaling its chosen member's spec.replicas;
the ModelPoolReconciler owns the idle-gated, fail-closed, never-force
drain of the incumbent. Activation is gated behind --enable-activation,
which the ModelRouter controller sets (with a dedicated ServiceAccount +
namespaced Role granting get/list/watch/patch on InferenceServices) only
when a backend resolves to a ModelPool member.

The proxy now serves Prometheus metrics on --metrics-listen (:8081) with
a metrics container port, so the already-emitted ModelPool metrics
(residency, swaps, swap/hold duration, coalescing, held-request depth)
are scrapeable by a ServiceMonitor rather than collected but invisible.

Unit coverage: BackendPool compilation (pooled and unpooled), activation
RBAC provisioning and least-privilege, deployment activation wiring
(--enable-activation / ServiceAccount / ROUTER_NAME), the pool watch
mapping, and the end-to-end 503 + Retry-After on an activation that
exceeds the request budget.

Refs defilantech#1111
Refs defilantech#516

Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
Add ModelPool.spec.swapBudget (default 300s) to bound the cross-model
swap hold independently of the router's response-header (generation)
timeout. Previously a single deadline capped both, so a large cold-load
forced operators to inflate the generation timeout for every request just
to let a swap finish. The proxy now acquires the pool slot under this
dedicated budget and starts the dispatch clock only after the member is
resident, so the swap wait no longer eats the generation budget. A pool
without an explicit budget falls back to the dispatch default, preserving
prior behavior.

Also self-heal pool residency after out-of-band changes: replace the
one-shot seed with a bounded reconcileResident resync and add
InvalidateResident, which the proxy calls on a connection-level dispatch
failure to a pooled backend so the next Acquire re-verifies residency
immediately instead of returning stale 502s until a proxy restart.

Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
ModelRouter had no way to place the router-proxy pod, so it could land on
a node that lacks the proxy image (for example a node-local registry that
only pushed the image to specific hosts), leaving the Deployment stuck
pulling. Add RouterProxySpec.NodeSelector, a passthrough map that sets the
proxy Pod's nodeSelector, so operators can pin the proxy to nodes that
have the image or co-locate it with its backends.

Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
…d CRD

The ModelPool CRD file and reconciler shipped, but the CRD was never added to config/crd/kustomization.yaml, so make install did not install it. The manager's ModelPool informer then failed to sync (no matches for kind ModelPool), blocking cache sync for every controller and leaving unrelated resources such as ModelRouter unreconciled. Add the CRD to the kustomize resource list.

Also regenerate the ModelPool CRD: the committed copy still carried a hand-written minResidency field and a priority swapPolicy enum value that are not in ModelPoolSpec, so the CRD-sync check reported drift. Config and Helm-chart CRDs now match the Go source.

Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
…ot-owner logic

Add native fake-client and pure-function unit tests for paths flagged by codecov: kubeMemberController (Activate/Deactivate/Phase/WaitReady incl. idempotent no-ops and error paths); reconcileRouterActivationRBAC (unpooled no-op plus the pooled ServiceAccount/Role/RoleBinding provisioning with owner refs and idempotency); and resolveSlotOwner with the replicasOf/memberReady/memberNames helpers across the cold/warm/pending/default slot-owner matrix.

Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
ModelPool activation serializes swaps through a single in-process lock (Activator.mu), so a second proxy replica races it: two proxies receiving requests for different members each drive a swap, thrashing the shared GPU slot with full drains and multi-gigabyte reloads. The controller-side exclusive-slot invariant still holds (no corruption), but the coalescing that exists to prevent that thrash is defeated at replicas >= 2.

Pin spec.proxy.replicas to 1 in newRouterDeployment whenever the router has pooled backends, log the override at reconcile time, and document the constraint on the CRD field. Cross-replica swap coordination (optimistic-lock writes plus a lease) is the real multi-replica fix and is tracked as a follow-up.

Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
@sylvainsf

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough pass. I went with option 1 (pin) for this PR and filed #1477 to track option 2 (the real cross-replica single-writer).

What changed:

  • The operator now pins spec.proxy.replicas to 1 whenever a router has pooled backends, in newRouterDeployment where hasPools is already known. I put it there rather than in the ModelRouter validating webhook because that webhook is pure-spec-only by design (pool resolution needs a cluster lookup, which its doc deliberately keeps reconciler-side).
  • It logs the override at reconcile time when a user asked for more than 1, and the constraint is documented on the CRD field (RouterProxySpec.Replicas), regenerated into the base CRD and the chart.
  • Test: TestNewRouterDeploymentPinsReplicasWhenPooled (pooled pins to 1 even with replicas: 3; unpooled honors it).

You are right that the Activator doc comment overreached by implying a cluster-wide guarantee. With the pin it is now an accurate per-process guarantee for the only topology the operator allows.

#1477 covers the real fix: MergeFromWithOptimisticLock member writes so competing activations conflict instead of last-write-winning, plus a Lease so one replica owns swap decisions for a pool, after which the replicas=1 pin can be lifted.

Also rebased onto latest main to clear the merge conflict. Worth flagging one resolution: the router-proxy metrics server collided with the new --metrics-bind-address work that landed on main. I resolved to main's flag name and port (9090), but kept the handler serving ctrlmetrics.Registry rather than the default registry, since that is where the router and ModelPool collectors actually register (internal/metrics init) and the default-registry handler would expose none of them.

@Defilan Defilan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option 1 is the right call for this PR, and #1477 is the right place for the
rest. Approving.

I verified each of the four claims rather than taking them:

  • The pin is real and in the right place: if hasPools { replicas = 1 } in
    newRouterDeployment, with the override logged at reconcile time when a user
    asked for more. Putting it there rather than in the validating webhook is
    correct; pool resolution needs a cluster lookup and that webhook is
    deliberately pure-spec.
  • TestNewRouterDeploymentPinsReplicasWhenPooled asserts both directions,
    pooled pinning to 1 with replicas: 3 set and unpooled still honouring 3.
    The negative half is what makes it a real test.
  • The CRD field doc carries the constraint and it is regenerated into both the
    base CRD and the chart.
  • The drain-path conflict was resolved by adapting memberIdle to main's newer
    IdleDetector interface rather than taking either side wholesale. That is the
    resolution I was most concerned about and it preserves the fail-closed
    behaviour.

On the metrics registry: you found a live bug on main, and you are right.

I checked this empirically because it is exactly the kind of thing that silently
serves an empty endpoint. Built both binaries and scraped them:

main: 38 metric families
PR:  149 metric families, including certwatcher_* and controller_runtime_*

Those are different registries. internal/metrics registers every collector
into ctrlmetrics.Registry in its init(), while main's handler is
promhttp.Handler(), which serves the Prometheus default gatherer. So on main
the llmkube_router_* collectors exist in a registry nothing exposes, and
/metrics returns Go runtime and process metrics only. #1427 is effectively
still open, and the PodMonitor in #1473 is currently scraping an endpoint with
none of the series it was added for.

Your promhttp.HandlerFor(ctrlmetrics.Registry, ...) is the correct fix.

One request, and it is not a change to this PR: that fix is small, independent,
and repairs something broken on main right now, while this PR is large and will
take longer to land. Would you mind pulling it out into its own PR against main?
Happy to do it myself and credit you if you would rather not carry another
branch. This one can keep the change too; the duplicate resolves trivially
whichever lands second.

One non-blocking observation for later, arguably pre-existing rather than yours.
GenericBackend.IdleProbe returns errIdleUnsupported when the
AnnotationIdleEndpoint annotation is absent, and memberIdle surfaces that as
an error, which the caller treats like any probe failure: defer with
ReasonIdleCheckFailed. For a generic-backend member without that annotation
that is a permanent wedge reported as a transient condition, since the probe can
never start succeeding. The six runtimes that implement IdleDetector are all
fine, so this only bites the annotation-less generic case. Worth distinguishing
"cannot ever be probed" from "probe failed this time" at some point, perhaps
alongside #1477.

@Defilan
Defilan merged commit ba7777d into defilantech:main Aug 10, 2026
38 of 39 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 9, 2026
Defilan added a commit to Defilan/LLMKube that referenced this pull request Aug 10, 2026
The endpoint served the Prometheus default registry while every llmkube
collector registers into controller-runtime's, so it exposed none of the series
it exists for. That shipped in defilantech#1457 and was fixed in defilantech#1393; nothing stops it
regressing again.

The failure is silent, which is the reason it needs a test rather than care.
The scrape returns HTTP 200 with Go runtime and process metrics, the PodMonitor
from defilantech#1473 reports a healthy target, and the dashboards are simply empty.

Touching a real counter before scraping is load-bearing: a labelled collector
emits no family until it has a child series, so an idle scrape is identical
under either registry and the obvious version of this test passes on the bug.
Verified by reintroducing promhttp.Handler() and confirming the test fails,
rather than assuming a new test must catch something.

newMetricsHandler() is extracted from main() only so there is something to
assert against.

Co-authored-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
Signed-off-by: Christopher Maher <chris@mahercode.io>
doonga pushed a commit to greyrock-labs/home-ops that referenced this pull request Aug 10, 2026
…mkube (0.9.14 ➔ 0.9.16) (#294)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/home-operations/charts-mirror/llmkube](https://github.com/defilantech/LLMKube) | patch | `0.9.14` → `0.9.16` |

---

### Release Notes

<details>
<summary>defilantech/LLMKube (ghcr.io/home-operations/charts-mirror/llmkube)</summary>

### [`v0.9.16`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0916-2026-08-10)

[Compare Source](defilantech/LLMKube@v0.9.15...v0.9.16)

##### Features

- **chart:** PodMonitor for router-proxy metrics ([#&#8203;1473](defilantech/LLMKube#1473)) ([fd7c640](defilantech/LLMKube@fd7c640))
- **foreman:** advisory two-site parity signal in the coder gate ([#&#8203;1456](defilantech/LLMKube#1456)) ([944f90d](defilantech/LLMKube@944f90d))
- **foreman:** image input, so a vision-capable coder can see what it rendered ([#&#8203;1471](defilantech/LLMKube#1471)) ([6c122be](defilantech/LLMKube@6c122be))
- **foreman:** read-only fetch\_pull\_request tool ([#&#8203;1458](defilantech/LLMKube#1458)) ([7f4910b](defilantech/LLMKube@7f4910b))
- **modelpool:** cross-pod sticky model swapping on a shared GPU slot ([#&#8203;1393](defilantech/LLMKube#1393)) ([ba7777d](defilantech/LLMKube@ba7777d))
- **router-proxy:** expose llmkube\_router\_\* metrics on a dedicated listener ([#&#8203;1457](defilantech/LLMKube#1457)) ([ca3730d](defilantech/LLMKube@ca3730d))

##### Bug Fixes

- **controller:** make the custom CA additive to the system trust store ([#&#8203;1469](defilantech/LLMKube#1469)) ([d490059](defilantech/LLMKube@d490059))
- **controller:** stage files and mmproj from an s3:// object store ([#&#8203;1475](defilantech/LLMKube#1475)) ([769b60d](defilantech/LLMKube@769b60d))
- **dashboard:** AMD dashboard vendor hygiene — remove runtime-agnostic llama.cpp panels, standardize on DS\_PROMETHEUS ([#&#8203;1446](defilantech/LLMKube#1446)) ([c6c0670](defilantech/LLMKube@c6c0670))
- **dashboard:** retarget amd-gpu-observability off amdgpu\_\*-only queries ([#&#8203;1474](defilantech/LLMKube#1474)) ([a02e8e8](defilantech/LLMKube@a02e8e8))

### [`v0.9.15`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0915-2026-08-08)

[Compare Source](defilantech/LLMKube@v0.9.14...v0.9.15)

##### Features

- **chart:** expose --ca-cert-configmap as controllerManager.caCertConfigMap ([#&#8203;1440](defilantech/LLMKube#1440)) ([606c7f9](defilantech/LLMKube@606c7f9))
- **controller:** diagnose CUDA driver/runtime mismatch from crashed pods ([#&#8203;1425](defilantech/LLMKube#1425)) ([06670f9](defilantech/LLMKube@06670f9))
- **controller:** diagnose model-transfer failures from init containers ([#&#8203;1463](defilantech/LLMKube#1463)) ([a092de3](defilantech/LLMKube@a092de3))
- **foreman:** advisory gate for command-string changes tested only by shape ([#&#8203;1421](defilantech/LLMKube#1421)) ([b580e18](defilantech/LLMKube@b580e18))
- **inferenceservice:** add spec.modelCache.persistence for an ephemeral model cache ([#&#8203;1452](defilantech/LLMKube#1452)) ([c4ae7ee](defilantech/LLMKube@c4ae7ee))

##### Bug Fixes

- **api:** reject unservable speculativeDecoding type draft at admission ([#&#8203;1455](defilantech/LLMKube#1455)) ([5d3ab73](defilantech/LLMKube@5d3ab73))
- **chart:** default the router-proxy image tag to the chart appVersion ([#&#8203;1431](defilantech/LLMKube#1431)) ([3e07b81](defilantech/LLMKube@3e07b81))
- **controller:** clean up orphaned .tmp files from interrupted model transfers ([#&#8203;1459](defilantech/LLMKube#1459)) ([2f453d6](defilantech/LLMKube@2f453d6))
- **controller:** emit draft-simple for speculativeDecoding type draft ([#&#8203;1417](defilantech/LLMKube#1417)) ([ccccd02](defilantech/LLMKube@ccccd02))
- **controller:** publish model downloads atomically so an interrupted transfer is never cached ([#&#8203;1432](defilantech/LLMKube#1432)) ([206b6c3](defilantech/LLMKube@206b6c3))
- **controller:** route s3:// model sources to the runtime-resolved path ([#&#8203;1450](defilantech/LLMKube#1450)) ([f054960](defilantech/LLMKube@f054960))
- **controller:** set TerminationMessagePolicy on generated init containers ([#&#8203;1460](defilantech/LLMKube#1460)) ([bc732b3](defilantech/LLMKube@bc732b3))
- **dashboard:** select the latency success series by le!="" so llmkube-slo error-budget panels render for any threshold ([#&#8203;1444](defilantech/LLMKube#1444)) ([d381112](defilantech/LLMKube@d381112))
- **foreman:** add chart validation to the coder gate, and install helm so it can run ([#&#8203;1442](defilantech/LLMKube#1442)) ([b26c6ae](defilantech/LLMKube@b26c6ae))
- **foreman:** detect assertion-value churn in the test-dilution gate ([#&#8203;1416](defilantech/LLMKube#1416)) ([b53df14](defilantech/LLMKube@b53df14))
- **foreman:** preserve the coder Job name's uniqueness suffix on truncation ([#&#8203;1412](defilantech/LLMKube#1412)) ([929f0a8](defilantech/LLMKube@929f0a8))
- ground the PR body summary against the branch diff ([#&#8203;1448](defilantech/LLMKube#1448)) ([6f24527](defilantech/LLMKube@6f24527))
- **metrics:** correct the AMD doc's metric contract and guard prose docs ([#&#8203;1420](defilantech/LLMKube#1420)) ([36631a9](defilantech/LLMKube@36631a9))
- **runtime:** disable the llama.cpp prompt cache for embedding and rerank ([#&#8203;1413](defilantech/LLMKube#1413)) ([407de59](defilantech/LLMKube@407de59))

##### Documentation

- **b200:** record GB10 partial-proxy reachability and why NVLink/MIG/sm\_100 stay gated ([#&#8203;1422](defilantech/LLMKube#1422)) ([e6ab2e9](defilantech/LLMKube@e6ab2e9))
- **chart:** dashboard datasources examples must use the datasource UID, not its display name ([#&#8203;1433](defilantech/LLMKube#1433)) ([a57d499](defilantech/LLMKube@a57d499))

</details>

---

### Configuration

📅 **Schedule**: (in timezone America/New_York)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC43LjQiLCJ1cGRhdGVkSW5WZXIiOiI0NC43LjQiLCJ0YXJnZXRCcmFuY2giOiJtYWluIiwibGFiZWxzIjpbInJlbm92YXRlL2NvbnRhaW5lciIsInR5cGUvcGF0Y2giXX0=-->

Reviewed-on: https://git.greyrock.io/greyrock-labs/home-ops/pulls/294
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants