Which models the agents can run on, and how the lab wires them. The default
is the Anthropic ModelConfig the kagent chart renders from aiModel (see
Agents); this page covers everything beyond it: static extra
model configs, model servers on the lab host, the managed mode where
model-manager fronts those servers, and the platform's own serving on llm-d.
Beyond the default Anthropic ModelConfig, platform.extraModels in
agentlab.yaml adds more — a self-hosted OpenAI-compatible endpoint (vLLM,
llama.cpp, LM Studio), OpenRouter, Gemini, a plain GPT model, or an Ollama
host. Each entry becomes a lab-labeled ModelConfig CR in the kagent
namespace, selectable when composing an agent (the kagent UI's model dropdown,
or modelConfig on an Agent CR):
platform:
extraModels:
# A self-hosted vLLM — any OpenAI-compatible endpoint works the same way.
# No apiKeyEnv: the endpoint is keyless, a placeholder key is shipped.
- name: qwen3-8-27b
provider: OpenAI
model: qwen3-8-27b
baseUrl: https://qwen.example.internal/v1
# OpenRouter: also just an OpenAI-compatible endpoint plus a key.
- name: openrouter-deepseek
provider: OpenAI
model: deepseek/deepseek-chat
baseUrl: https://openrouter.ai/api/v1
apiKeyEnv: OPENROUTER_API_KEY
- name: gemini-flash
provider: Gemini
model: gemini-2.5-flash
apiKeyEnv: GEMINI_API_KEY
# Ollama's native API. A host with less than 24 GiB of VRAM needs
# OLLAMA_CONTEXT_LENGTH set, or agent prompts are cut (the
# context-length note below).
- name: local-llama
provider: Ollama
model: llama3.3
baseUrl: http://192.168.1.10:11434agentlab configure asks for these interactively (the "extra model configs"
confirm in the platform group); agentlab platform (or agentlab up)
applies them and waits for the kagent controller to accept each one. The CRs
are rendered at the ModelConfig group and version the lab's apiserver serves,
read from discovery — api.kagent.dev/v1alpha3 on the kagent API v2 line,
kagent.dev/v1alpha3 on 4.x, kagent.dev/v1alpha2 on a released 3.x chart
(kagent 0.10); the ModelConfig spec is the same in all three, and a lab that
serves neither group stops with an error naming both. Entries
removed from agentlab.yaml are pruned on the next run — the managed-by
label scopes the pruning to lab-created ModelConfigs, so the chart's default
one is never touched.
Key handling follows the Anthropic pattern: apiKeyEnv names a host env var
read at deploy time, and the value lands only in the Secret
kagent/kagent-<name> (created once, left alone — delete it and re-run to
rotate; never in agentlab.yaml or state/). The key inside the Secret is
provider-derived (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY)
because the kagent controller injects it as an env var of exactly that name
and the ADK runtime looks up those canonical names. That is also why keyless
endpoints still get a Secret with a placeholder value: the runtime requires
the env var to exist — an agent pod without it crashloops before ever
talking to the endpoint.
Two practical notes for self-hosted endpoints: the URL must be reachable
from inside the kind node's pods (a LAN IP or resolvable hostname —
localhost would be the pod itself), and a self-signed certificate needs
insecureTLS: true on the entry (rendered as the ModelConfig's
tls.disableVerify). Gemini takes no baseUrl (the CRD has no endpoint
field for it), and Ollama requires one (its host) and is keyless. An
OpenAI entry may set reasoningEffort (none, minimal, low,
medium, high or xhigh), rendered as the ModelConfig's
openAI.reasoningEffort, and apiFormat (chatCompletions or
responses, the key agent-platform-connectivity's kagent.modelConfigs[]
entries carry), rendered as the ModelConfig's openAI.apiFormat — responses
for a model a codex agent uses, since the codex runtime speaks the Responses
API only and its compiler refuses any other OpenAI option, tls included;
an Ollama entry may set think (true or
false, kagent 1.0.3 or newer), rendered as the ModelConfig's
ollama.think; reasoningEffort: none and think: false switch a local
model's thinking off (see
Agent proofs without an Anthropic key).
Providers needing more than a model + endpoint + key (AzureOpenAI, Bedrock,
Vertex) are out of the lab's vocabulary — create their ModelConfigs by hand.
A model server on the lab host itself is the cheapest self-hosted endpoint, and everything that can go wrong is host-side plumbing, not kagent:
-
Address: pods reach the host only through the kind docker network's gateway —
docker network inspect kindnames it, typically172.21.0.1. Pods resolvehost.agentlab.internalto it (a hosts entry in the lab's CoreDNS), and that name goes inbaseUrl;localhostwould be the agent pod itself. Use the name, not the IP: kagent injects an OpenAI-provider ModelConfig's API key only for a DNS hostname, so an IP-literalbaseUrlleaves the agentCompatible=False(credential injection requires an exact DNS hostname). The name resolves only inside the cluster; from the host, the server is on127.0.0.1. -
Docker in a VM (Docker Desktop on macOS or Windows, Colima, a podman machine): the kind gateway is a bridge address inside that VM, so it is not the machine your model server runs on and no bind address can make it one. The server is reachable as
host.docker.internalinstead (host.containers.internalunder podman), which resolves only from inside the cluster. Nothing to configure: the lab dials the gateway from inside the node, and where that does not answer it tries the alias and uses whichever does.agentlab configurereports which address it found —LM Studio api v1 on :1234 — pods reach it at host.docker.internal (172.18.0.1 is inside the container runtime's VM, not this machine)— and
agentlab platformwires that one. This applies to every host backend equally, not just LM Studio. -
Bind address: the server must listen on
0.0.0.0(or the bridge IP). The usual127.0.0.1default is unreachable from pods regardless of any firewall rule. Ollama:OLLAMA_HOST=0.0.0.0. Lemonade:lemonade config set host=0.0.0.0. LM Studio:lms server start --bind 0.0.0.0, the app's Developer → "Serve on Local Network" toggle, orLMS_SERVER_HOST=0.0.0.0. -
Host firewall: on a default-deny INPUT host, pod→host traffic arrives on the docker bridge like any other inbound connection and gets dropped — allow the server's TCP port from the docker bridge subnets (they fall inside
172.16.0.0/12). The symptom is an agent replyingConnection error.(kagent_error_code: API_ERROR) while the same URL works from the host. -
Keep-alive / eviction (Ollama): Ollama loads a model on the first
/api/chatthat names it — an agent on a not-loaded model works, its first turn pays the cold start — and evicts it when the keep-alive runs out. The keep-alive is set per request: each request'skeep_alive, else the server'sOLLAMA_KEEP_ALIVE(5m unless set), re-arms the timer on every hit. kagent sends nokeep_alive, so agent turns always re-arm the server default, and a load through model-manager or the portal (keepAlive, even-1) only pre-warms until the next agent request. The knob for what agents experience is host-side:OLLAMA_KEEP_ALIVE=30m(or-1for never) in the Ollama service environment — on a systemd hostsystemctl edit ollamawith[Service]Environment="OLLAMA_KEEP_ALIVE=30m", then restart Ollama. Nothing in model-manager changes this; itsGET /api/v1/backendreports the mechanics asloading(onDemand,idleEviction,keepAliveScope: request) so the portal can say "idle, loads on first request" instead of "not loaded". -
Context length (Ollama): unless told otherwise, Ollama sizes a model's context from the host's VRAM: 4,096 tokens below 24 GiB, 32,768 up to 48 GiB, 256k above (capped at the model's own maximum). A host without a GPU has 0 B and gets 4,096, which Ollama logs when it starts:
vram-based default context total_vram="0 B" default_num_ctx=4096. Apple silicon counts the GPU's share of unified memory, so smaller Macs are in the 4k tier too. Every model call of an agent turn sends the system prompt, all tool schemas and the conversation so far, so the prompt grows with each tool result. Once it no longer fits the 4,096, Ollama cuts it silently to half the context: the first 4 tokens and the last 2,046. The system prompt and the tool schemas at the front are gone. The agent ignores its instructions or its tools, the API response shows nothing, and the only trace is a warning in the server log:level=WARN msg="truncating input prompt" limit=2050 prompt=10735 keep=4 new=2050The fix is on the host, set the same way as the keep-alive:
OLLAMA_CONTEXT_LENGTH=32768in the Ollama service environment. On a systemd host, runsystemctl edit ollamaand addEnvironment="OLLAMA_CONTEXT_LENGTH=32768"under[Service]. On macOS, runlaunchctl setenv OLLAMA_CONTEXT_LENGTH 32768. Then restart Ollama. Once the next turn has loaded the model,ollama psshows32768in itsCONTEXTcolumn. 32,768 holds an agent's prompt plus several turns of tool results. Ollama's docs suggest 64,000 for agent work if the RAM allows: the cache for the whole context is reserved when the model loads. ForextraModelsthe host setting is the only fix. An entry carries no per-model context, and the/v1alias has no field for one. For the ModelConfigs model-manager wires, see giantswarm/model-manager#157.
All three are keyless OpenAI-compatible endpoints, so the entries are minimal:
platform:
extraModels:
# Ollama on the host, via its OpenAI-compatible /v1 alias. The alias
# takes no context size: below 24 GiB of VRAM, set
# OLLAMA_CONTEXT_LENGTH on the host (the context-length note above).
# reasoningEffort: none keeps a thinking model's answer out of its
# thinking (Agent proofs without an Anthropic key, below).
- name: ollama-local
provider: OpenAI
model: qwen3.5:9b
baseUrl: http://host.agentlab.internal:11434/v1
reasoningEffort: none
# Lemonade Server (lemonade-server.ai): local inference with NPU
# acceleration on AMD Ryzen AI (XDNA2) through its FastFlowLM backend,
# or GPU via llama.cpp. Pick a tool-calling-capable model (the model
# list labels them) — agents send tool schemas with every turn.
- name: lemonade-npu
provider: OpenAI
model: qwen3-it-4b-FLM
baseUrl: http://host.agentlab.internal:13305/v1
# LM Studio (lmstudio.ai) on the host: llama.cpp on GPU/CPU, MLX on
# Apple silicon. The model is LM Studio's own key (`lms ls`), and it
# must be one trained for tool use — LM Studio accepts `tools` for any
# model and emulates them through the prompt, which agents trip over.
- name: lmstudio-local
provider: OpenAI
model: ibm/granite-4-micro
baseUrl: http://host.agentlab.internal:1234/v1One Lemonade-specific note: its FastFlowLM models default to a 4096-token
context, which agent system prompts plus tool schemas outgrow quickly —
raise it once with lemonade config set ctx_size=16384. LM Studio has the
same trap per model (its context is a load-time setting) plus one of its own:
keep just-in-time model loading on (its default), or an agent whose
ModelConfig names a downloaded-but-unloaded model fails its first turn
instead of waiting for the load.
toolsets-test, skills-test and klaus-gateway-test run their agents on
default-model-config, the Anthropic ModelConfig. Without
$ANTHROPIC_API_KEY, pass --model-config <name> to run them on a model
on the host. A CPU does it with qwen3.5:2b (2.7 GB) on Ollama, with
the model's thinking switched off, on kagent's native Ollama provider or
on Ollama's /v1 alias:
ollama pull qwen3.5:2bplatform:
extraModels:
- name: qwen35-2b
provider: Ollama
model: qwen3.5:2b
baseUrl: http://host.agentlab.internal:11434
think: false # thinking off, see below
- name: qwen35-2b-v1
provider: OpenAI
model: qwen3.5:2b
baseUrl: http://host.agentlab.internal:11434/v1
reasoningEffort: none # thinking off, see belowagentlab platform
agentlab skills-test --model-config qwen35-2bOn a 12-core Zen 5 laptop CPU with no GPU, Ollama on 8 of its cores with
8 threads and OLLAMA_CONTEXT_LENGTH=32768, skills-test passes in under a
minute. Its turn is three model calls of 1.8k to 2.8k tokens, 3–6 s each
once the model is loaded, the whole turn 7–13 s. The two entries do equally
well: five runs each passed 4 on the native provider and 3 on the /v1
alias, and every miss was the 2B model leaving the skill's answer out of a
reply it did give. The loaded model takes 2.8 GB with the 32k context.
Apply the context-length note above first. skills-test's agent binds no
tools and stays under 4,096 tokens, but an agent with muster's tools carries
their schemas on top, and every tool result makes the conversation longer.
- Thinking has to be off. Qwen3.5, Qwen3 and Granite 4.2 think by
default under Ollama. With thinking on,
qwen3.5:2bcalls both tools correctly onskills-testbut writes its answer only into its thinking, and the agent returns an empty reply.granite4.2:3bdoes answer, but it thinks 1,500–2,000 tokens a call, which takes 2–3 minutes each on a CPU and misses the proof's 180 s turn bound.think: falseon anOllamaentry is sent as the chat request'sthinkfield; it needs kagent 1.0.3 or newer, whose ModelConfig CRD hasollama.think.reasoningEffort: noneon the/v1alias reaches Ollama asreasoning_effort; it needs the kagent line (meta chart 4.x), since the 3.x chart's kagent refusesnone. - Ollama's threads follow the host, not a core limit. Ollama starts one
thread per physical core of the host, also under
tasksetor in a container with a cpuset. Limited to 8 of 12 cores it still runs 12 threads (n_threads = 12in its log), and a call of the same turn takes 47 s to 2 min instead of 3–6 s. Give itnum_threadfor the cores it has: a Modelfile withPARAMETER num_thread 8on top of the model (ollama create), which every provider then gets. - The ModelConfigs model-manager wires use the native
Ollamaprovider withoutthink, so a thinking model answers empty there (giantswarm/model-manager#161). Use one of the entries above for the proofs. - Why
qwen3.5:2b. It passed five tool-calling cases, each run five times against Ollama on the same CPU with thinking off: pick a tool and its arguments, a three-argument call, an enum argument, no call when none is needed, and acting on a tool result.
| Model (Ollama tag) | Download | Passed | Seconds a call |
|---|---|---|---|
qwen3.5:2b |
2.7 GB | 25/25 | 3.0 |
granite4.2:3b |
2.2 GB | 25/25 | 3.3 |
qwen2.5:0.5b (models-test's default) |
0.4 GB | 22/25 | 0.8 |
granite4.2:3bis the alternative when many sessions share one long prompt. It passed the cases but has not been run through the proofs. It is a dense transformer, so Ollama reuses the cached system prompt and tool schemas from one conversation to the next. Qwen3.5 is a hybrid (most of its layers are Gated DeltaNet), and for those llama.cpp reads the whole prompt again in every new conversation.qwen2.5:0.5bis formodels-testonly. Its turn asks for "pong" and calls no tool. On tool calls the model missed the enum argument in 3 of 5 runs.
extraModels wires an endpoint and manages nothing: pulling or removing a
model is CLI-on-host, and nothing shows what is downloaded or loaded. The
managed mode puts the chart's model-manager component
(giantswarm/model-manager,
the service behind the Model Manager epic) in front of a model server on the
host — inventory of downloaded and loaded models, pull with progress,
load/unload, delete, and every pulled model wired into kagent automatically
as a ModelConfig: the native, keyless Ollama provider for an Ollama, the
OpenAI provider (placeholder key) on /api/v1 for a Lemonade Server and on
/v1 for an LM Studio. One block, which agentlab configure writes from what
answers on the machine:
platform:
agents: true # required: the ModelConfigs land in kagent
modelManager:
enabled: true
backends: [ollama, lemonade, lmstudio] # the host model servers, Ollama first; kserve needs GPUs + KServe
# endpoints: # optional; empty autodetects the host
# ollama: http://192.168.1.10:11434One model-manager fronts all of them (model-manager ≥ 0.17.0; the
chart's model-manager.backends): inventory, pull, load/unload, delete and
the auto-wired ModelConfigs work per backend, GET /api/v1/backends lists
them, every object says which backend it belongs to, and every ModelConfig
carries the model-manager.giantswarm.io/backend label. The list has an
order because the first entry is the default backend — where a request
that names none goes; the REST API takes ?backend= (reads) or "backend"
(writes), the MCP tools a backend argument. The one-backend form earlier
versions wrote (backend: + endpoint:) still reads as the one-item list.
agentlab configure detects an Ollama on :11434, a Lemonade Server on
:13305 and an LM Studio on :1234 on every run and keeps backends
current; whether managed models are on follows the discovery on the first
configure only — a later one, --defaults included, keeps the lab's
recorded choice (--model-manager[=false] pins the flag,
--model-manager-backends the list; the interactive form shows what was
found). The flag off is the chart's component off: the rendered
values state components.model-manager.enabled: false, because the meta
chart runs model-manager by default since 4.24.0 — with no backend, which in
the lab would only crash-loop on the Dex localhost address. Each endpoint is autodetected at platform time: the kind
docker network's gateway (docker network inspect kind, the same address the
section above documents for extraModels), or the container runtime's host
alias where that gateway is inside its VM — whichever answers when dialled
from inside the node. The gateway is wired as host.agentlab.internal (the
name above), so a Lemonade or LM Studio ModelConfig gets its key injected. So
nobody types 172.21.0.1. Set endpoints.<backend>
for a server the lab cannot find that way, such as one elsewhere on the LAN;
such a backend is kept whether or not one answers locally.
What agentlab platform (or up) does with it:
- Preflight, not documented traps. Before the install, a short-lived pod
in the cluster fetches each server's identifying document (
/api/versionon Ollama,/api/v1/healthon Lemonade,/api/v1/modelson LM Studio — which serves no version or health endpoint at all, so the inventory's shape is what identifies it). If that fails, the boot stops right there with the diagnosis and the two fixes from the section above spelled out — connection refused means the server listens on127.0.0.1only (OLLAMA_HOST=0.0.0.0/lemonade config set host=0.0.0.0/lms server start --bind 0.0.0.0), a timeout means the host firewall drops pod→host traffic on the docker bridge (allow the server's TCP port from the bridge subnets, inside172.16.0.0/12) — instead of a model-manager pod reporting an unhealthy backend after Helm's ten-minute wait, or ModelConfigs pointing at a dead endpoint. - The chart's
components.model-managergoes on with every listed backend (model-manager.backendsplus onemodel-manager.<backend>.endpointeach = the detected addresses; a single entry renders the chart'sbackend:form). model-manager has no route on the edge: the portal, agents and the proofs reach it through muster only, as the signed-in person — muster refuses a call without a Dex token and forwards the person's token to model-manager (auth.forwardToken). - The portal calls the
x_model-manager_*tools through muster as the signed-in user and renders one Serving group per backend of the installation (giantswarm/backstage#2264, giantswarm/backstage#2294). - muster registers the MCP endpoint (the chart's own
MCPServerCR,Connectedis waited for) and the tools surface asx_model-manager_<tool>:list_models,get_model,list_loaded_models,pull_model,load_model,unload_model,delete_model,wire_model,unwire_model,list_jobs,get_job,cancel_job,get_backend— ask Claude Code to pull a model.
The proof is agentlab models-test — one backend per run: --backend lemonade (or --backend lmstudio) proves that server through the same
model-manager (default: the first of the list). --model picks another small,
tool-calling capable model; the defaults are qwen2.5:0.5b (~400 MB) on
ollama, qwen3-4b-FLM (3.1 GB, the smallest tool-calling FastFlowLM model —
the smaller *-FLM ones cannot call tools) on lemonade and
ibm/granite-4-micro (~2 GB) on lmstudio, an LM Studio hub reference so the
download resolves the variant that fits the host (GGUF on Linux and NVIDIA,
MLX on Apple silicon); smollm2:135m pulls fine and then fails every agent
turn with "does not support tools". Every request names the backend, the
ModelConfig must carry the backend label, and the run goes through the
platform path only.
On lmstudio the run ends differently, and deliberately so. LM Studio has
no delete over its API — removing a model is lms rm on the host, which no
pod can run — so model-manager reports delete: false and the proof asserts
the refusal instead of skipping a step: delete_model must answer with
the unsupported code, the model must still be downloaded and still wired afterwards
(a refused delete that removed something would be worse than one that
refuses), and the ModelConfig must then come off through the tool that does
exist, unwire_model. The run also cross-checks the advertised
delete capability against what the server really offers, in both
directions. It is therefore the one backend that does leave something
behind — the model stays downloaded, and the last line says so.
The ollama and lemonade runs leave nothing behind:
agentlab models-test
==> Calling muster without a token -> 401
==> Logging in to Dex as admin@lab.local
==> Backend through muster with the Dex token (get_backend) -> ollama, healthy, capabilities
==> Listing models
==> Pulling qwen2.5:0.5b (progress via get_job)
==> Auto-created kagent ModelConfig -> Ollama provider, Accepted
==> dryRun on every write tool changes nothing -> manifests / plans, the lab unchanged
==> A ModelConfig Flux applies from git is never written live -> gitops_owned on wire, unwire, delete
==> Commit mode: the fake GitHub API, model-manager-gitops -> a pull request as admin, files = dry run
==> Agent turn on qwen2-5-0-5b (kagent Agent, runtime go -> host Ollama)
==> MCP tools through muster (x_model-manager_*) -> get_model
==> Unloading qwen2.5:0.5b -> gone from list_loaded_models
==> Deleting qwen2.5:0.5b -> gone from Ollama, ModelConfig gone, list_models agrees
Between the wiring and the agent turn the run proves model-manager's write
contract. Every write tool (pull_model, wire_model, unwire_model,
load_model, unload_model, delete_model, cancel_job) is called with
dryRun and must answer its manifests or plan while the ModelConfig's
resourceVersion, the backend's models and loaded models and the pull job stay
as they were; mode: commit on a tool that never commits answers
unsupported. Then, for the length of one step, the wired ModelConfig is
one Flux applies from git: the GitOps fixture is the Flux Kustomization
agent-platform/agentlab-gitops-fixture whose status.inventory lists the
ModelConfig (entry kagent_<name>_<group>_ModelConfig, as
kustomize-controller records an applied object), and the ModelConfig carries
the provenance that Kustomization stamps
(kustomize.toolkit.fluxcd.io/name|namespace). model-manager holds the labels
to the Kustomization's inventory — an object whose Kustomization is gone, or
no longer lists it, is applied by nothing and written live — so the labels
alone are not enough. The lab's Flux engine runs no kustomize-controller and
serves no Kustomization API: where none is served the fixture brings a
kustomize.toolkit.fluxcd.io/v1 CustomResourceDefinition of its own
(labelled app.kubernetes.io/managed-by: agentlab, every field kept), applies
the Kustomization suspended and writes the inventory itself; the step removes
the labels, the Kustomization and that CustomResourceDefinition again. Wire,
unwire and delete (unwire=true) must answer gitops_owned pointing at mode
commit, the object and the model untouched.
Commit mode opens a pull request as the person, so model-manager has to be
pinned to a GitHub App (the chart's github.enabled): the bearer of every call
is then the person's App user token. The platform's model-manager is not
pinned — every other proof and the portal forward the Dex token to it — so the
run applies a second, temporary release, model-manager-gitops: a copy of
the platform's model-manager HelmRelease — its chart, values and the lab's
post-renderers, the dex-localhost sidecar and a dev image included — with
auto-wire off and two pins of its own:
- the App is the lab Dex — its endpoints with the platform client, the
OAuth fixture's Secret, whose client lists muster's proxy callback — so
core_auth_login server=model-manager-gitopsand the Dex login form complete headlessly, and muster puts the person's Dex access token on every call as the "App user token"; - the GitHub API is a fake:
agentlab github-fake, the run's own binary in a container on the kind network behind the Serviceagentlab-github-api(like klaus-gateway-test's fake Slack;--github-fake-binarynames another static Linux build). It holds one repository,agentlab/gitops@main, with akustomization.yamlunderclusters/agentlab/kagentand a.sops.yamlwhose age recipient has no private key anywhere, and answers the calls gitops-commit's GitHub remote makes (refs, commits, trees, blobs, contents, pull requests) andGET /user— the login is the local part of the e-mail the bearer carries.
wire_model mode: commit against that repository, first as a dry run (the
files, the author, no pull request, nothing reaching the fake), then for real,
must open exactly one pull request authored by the person whose files are the
dry run's byte for byte (a Secret file SOPS-encrypted), with nothing written
live. The pinned release is a model-manager instance of its own, and only the
instance that created a ModelConfig writes it, so the platform's model-manager
unwires the model before the commit proof and wires it again after. Then the kagent namespace gets the Flux labels
of a HelmRelease that does not exist, what a namespace left behind by a removed
release carries, for the length of one step: wire_model mode: commit without
an explicit target must answer invalid_request naming the gone release and
asking for repository, branch and path; the namespace's own labels come
back. The HelmRelease, the Service and the container go away at the end of the
run.
Load and unload through model-manager (or the portal's Load) pre-warm and
evict; they do not change how long agent traffic keeps a model resident —
that is OLLAMA_KEEP_ALIVE on the host, see the keep-alive note in the
section above.
Both modes coexist: the static extraModels entries stay as they are
(labeled managed-by: agentlab), model-manager's ModelConfigs carry
managed-by: model-manager, and neither prunes the other's.
The serving slice a Giant Swarm installation runs, on the kind node: the
platform serves a model itself instead of fronting a server on the host.
agentlab configure --serving turns it on (the form asks too; it needs the
agents runtime, which the served model is wired into), agentlab platform
brings it up, agentlab serving-test proves it. What the switch installs:
- cert-manager (the Giant Swarm
cert-manager-app, images from gsoci), before the platform chart: the llm-d controller's webhook certificate is a cert-manager Certificate from a self-signed Issuer, and the cainjector puts the CA on its webhook configurations. Installed intocert-managerthe way the observability stack is, with the Giant Swarm-only objects a kind cluster cannot take turned off. - The chart's serving components:
kserve-llmisvc-crd(theLLMInferenceServiceCRDs),kserve-llmisvc-resources(the llm-d controller),kserve-runtime-configs(the well-knownLLMInferenceServiceConfigs the controller composes a model's pods from, their llm-d images from gsoci) and themodelServingswitch — the connectivity chart's serving namespacemodel-serving, the published presets and their discovery ConfigMap, and the models Gatewaymodels: an agentgateway Gateway of its own athttps://models.<domain>, TLS on the lab's wildcard certificate, its JWT policy against the lab Dex (a request without a Dex token is answered 401 at the Gateway), which the meta chart names to the KServe control plane as the Gateway every model's route attaches to. The llm-d controller is the platform's one KServe controller: its release renders the control plane's shared objects (theinferenceservice-config, the webhook certificate Issuer, the defaultClusterStorageContainer); the classic KServe control plane (kserve-crd,kserve-resources) is gone from the chart with the classicInferenceServicepath (giantswarm/agent-platform#574). - model-manager's
kservebackend, appended to the backends the one model-manager fronts (backends: [ollama, kserve]with a host Ollama;backend: kservealone without a host server). It composes a published preset into anLLMInferenceServiceas the caller, waits for it and wires the served model into kagent as aModelConfigwhosebaseUrlis the model's route on the models Gateway, the caller's token forwarded (the Gateway admits nothing else). - One preset of the lab's,
qwen2-5-0-5b-cpu: the node has no GPU, so the lab publishes aServingPresetnext to the chart's shipped ones — Qwen2.5 0.5B Instruct (~1 GiB of BF16 weights, tool calling through vLLM's hermes parser) on the llm-d CPU runtime,gsoci.azurecr.io/giantswarm/llm-d-cpuat the tag the well-known template pins forllm-d-cuda, requesting no GPU. The template's image is the one exception a preset makes (the well-known template names the CUDA build a kind node cannot run), and the preset's description says so; model-manager places a preset that requests no GPU on the node's CPU capacity, judged against its allocatable memory. The shipped GPU presets stay published — the portal lists them, and a fit check says why none fits here.
What the lab leaves out: the GPU pool (no taint, no node selector, no
accelerator RuntimeClass), the pre-pull DaemonSet (it selects GPU nodes by
label), the cache claim (kind's local-path volume keeps a root-owned root the
KServe storage-initializer cannot write into; a preset's weights download
into its pod's own storage at every start, a 1 GiB model) and external-dns
(the Gateway's data plane is reached in-cluster through a CoreDNS rewrite of
models.<domain>, and by the proof through a port-forward). The images the
serving pods run are side-loaded like every platform image; the well-known
templates' own images — the 17 GB CUDA runtime among them — are templates of
pods, not pods, and stay out of the pull set.
The proof, agentlab serving-test, leaves nothing behind:
agentlab serving-test
==> The serving control plane: the llm-d controller, the well-known template, the models Gateway, the lab preset
==> Calling muster without a token -> 401
==> Logging in to Dex as admin@lab.local
==> The kserve backend through muster with the Dex token
==> The published presets: the lab's qwen2-5-0-5b-cpu among the shipped ones
==> The fit: qwen2-5-0-5b-cpu against the node's CPU capacity -> fits, allocatable budget
==> Loading qwen2-5-0-5b-cpu on kserve -> LLMInferenceService composed on the CPU runtime
==> Waiting for qwen2-5-0-5b-cpu to serve -> the weights download, vLLM starts, Ready
==> The served model as model-manager reports it -> LLMInferenceService, routed on the models Gateway
==> The ModelConfig model-manager wired into kagent -> OpenAI provider at the model's route, backend label
==> A completion through the models Gateway -> 401 without a token, 200 with the person's
==> Agent turn on the wired ModelConfig -> the agent answers through the models Gateway (below)
==> Unloading qwen2-5-0-5b-cpu -> LLMInferenceService and ModelConfig gone
--preset serves another published preset (a GPU preset does not fit the
node; the fit says so and the run stops there), --skip-chat skips the agent
turn, --ready-timeout bounds the serve (default 20m: the download from the
Hugging Face Hub and the CPU runtime's start).
The agent turn answers through the models Gateway. The wired ModelConfig
sends an agent to the model's route on the models Gateway with the caller's
token, and the Gateway's certificate is the lab CA's. Substrate's egress
gateway terminates the actor's TLS and dials the Gateway itself, so the CA
that counts is the egress gateway's, not the runtime's: the lab renders its
CA into the platform values as substrate.atenetEgress.upstreamTrust.caBundle,
which the egress gateway trusts next to its image's public roots. Without it
every call fails at the egress gateway (invalid peer certificate: UnknownIssuer) and the runtime gets a 503. The turn must answer; any other
outcome fails the run.
With the LLM endpoint on (llmRouting.enabled: true in a values file of
platform.valuesFiles), the platform release publishes the LLM endpoint
document and model-manager puts the Ready model on the endpoint: the listing
reports its public name (the preset name) at the endpoint, and the ModelConfig
points at the in-cluster listener (http://agentgateway.agent-platform.svc:8081/v1)
with the public name as model. The agent turn then goes over plain HTTP and
must answer, and its tokens must raise
agentgateway_gen_ai_client_token_usage{gen_ai_request_model="<the model's id>"}
in the lab's Prometheus: the local model is metered by the same data plane as
the provider models. The completion through the models Gateway stays as above.