Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion api/v1alpha1/modelrouter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -766,19 +766,63 @@ type MCPServerSpec struct {
Enabled bool `json:"enabled,omitempty"`
}

// AgentGatewayRouting selects what a dataPlane: AgentGateway ModelRouter
// compiles to.
type AgentGatewayRouting string

const (
AgentGatewayRoutingInferencePool AgentGatewayRouting = "InferencePool"
AgentGatewayRoutingModel AgentGatewayRouting = "Model"
)

// AgentGatewayReference identifies the pre-installed agentgateway data plane a
// dataPlane: AgentGateway ModelRouter compiles onto. The Gateway is the
// gateway.networking.k8s.io Gateway whose GatewayClass is agentgateway; the
// EndpointPicker is the Endpoint Picker (EPP) Service that selects a model
// server endpoint per request for each generated InferencePool. The Gateway,
// the Gateway API Inference Extension CRDs, and a running EPP are documented
// prerequisites that LLMKube does not install or own.
// prerequisites that LLMKube does not install or own. With routing: Model only
// the Gateway and agentgateway v1.6.0 or later are.
// +kubebuilder:validation:XValidation:rule="has(self.endpointPicker) == has(self.endpointPickerPort)",message="endpointPicker and endpointPickerPort must be set together"
// +kubebuilder:validation:XValidation:rule="!(has(self.routing) && self.routing == 'Model') || !has(self.endpointPicker)",message="endpointPicker and endpointPickerPort apply only to routing: InferencePool"
// +kubebuilder:validation:XValidation:rule="!(has(self.routing) && self.routing == 'Model') || has(self.sectionName) || has(self.hostnames)",message="routing: Model requires sectionName or hostnames: its HTTPRoute matches every path, so it must not take over a whole Gateway"
// +kubebuilder:validation:XValidation:rule="(has(self.routing) && self.routing == 'Model') || (!has(self.sectionName) && !has(self.hostnames))",message="sectionName and hostnames apply only to routing: Model"
type AgentGatewayReference struct {
// Name is the agentgateway Gateway's name.
// +kubebuilder:validation:Required
Name string `json:"name"`

// Routing selects the compile target. "InferencePool" (the default) is the
// Gateway API Inference Extension path described on spec.agentGatewayRef.
// "Model" uses agentgateway's model router (v1.6.0+): one AgentgatewayModel
// per backend, matched on its displayName (else name), behind one
// catch-all HTTPRoute. It needs no Inference Extension CRDs or Endpoint
// Picker. spec.rules, spec.defaultRoute, external backends, resolution:
// endpoint, and per-backend endpoint pickers are refused in this mode.
// +kubebuilder:validation:Enum=InferencePool;Model
// +kubebuilder:default=InferencePool
// +optional
Routing AgentGatewayRouting `json:"routing,omitempty"`

// SectionName names the Gateway listener the generated HTTPRoute attaches
// to. routing: Model only, where it or hostnames is required, because the
// route takes every path on its listeners.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
// +optional
SectionName string `json:"sectionName,omitempty"`

// Hostnames limit the generated HTTPRoute to these hosts. routing: Model
// only, where it or sectionName is required.
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:MaxItems=16
// +kubebuilder:validation:items:MinLength=1
// +kubebuilder:validation:items:MaxLength=253
// +kubebuilder:validation:items:Pattern=`^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
// +optional
Hostnames []string `json:"hostnames,omitempty"`

// Namespace is the Gateway's namespace. Empty means the ModelRouter's own
// namespace. A different namespace is supported: the generated
// model-extraction AgentgatewayPolicy then lives in the Gateway's
Expand Down
7 changes: 6 additions & 1 deletion api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions charts/llmkube/templates/clusterrole.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ rules:
- apiGroups:
- agentgateway.dev
resources:
- agentgatewaymodels
- agentgatewaypolicies
verbs:
- create
Expand Down
46 changes: 46 additions & 0 deletions charts/llmkube/templates/crds/modelrouters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,18 @@ spec:
maximum: 65535
minimum: 1
type: integer
hostnames:
description: |-
Hostnames limit the generated HTTPRoute to these hosts. routing: Model
only, where it or sectionName is required.
items:
maxLength: 253
minLength: 1
pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
maxItems: 16
minItems: 1
type: array
name:
description: Name is the agentgateway Gateway's name.
type: string
Expand All @@ -124,12 +136,46 @@ spec:
EndpointPicker Service all live in this ModelRouter's namespace
regardless.
type: string
routing:
default: InferencePool
description: |-
Routing selects the compile target. "InferencePool" (the default) is the
Gateway API Inference Extension path described on spec.agentGatewayRef.
"Model" uses agentgateway's model router (v1.6.0+): one AgentgatewayModel
per backend, matched on its displayName (else name), behind one
catch-all HTTPRoute. It needs no Inference Extension CRDs or Endpoint
Picker. spec.rules, spec.defaultRoute, external backends, resolution:
endpoint, and per-backend endpoint pickers are refused in this mode.
enum:
- InferencePool
- Model
type: string
sectionName:
description: |-
SectionName names the Gateway listener the generated HTTPRoute attaches
to. routing: Model only, where it or hostnames is required, because the
route takes every path on its listeners.
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
required:
- name
type: object
x-kubernetes-validations:
- message: endpointPicker and endpointPickerPort must be set together
rule: has(self.endpointPicker) == has(self.endpointPickerPort)
- message: 'endpointPicker and endpointPickerPort apply only to routing:
InferencePool'
rule: '!(has(self.routing) && self.routing == ''Model'') || !has(self.endpointPicker)'
- message: 'routing: Model requires sectionName or hostnames: its
HTTPRoute matches every path, so it must not take over a whole
Gateway'
rule: '!(has(self.routing) && self.routing == ''Model'') || has(self.sectionName)
|| has(self.hostnames)'
- message: 'sectionName and hostnames apply only to routing: Model'
rule: (has(self.routing) && self.routing == 'Model') || (!has(self.sectionName)
&& !has(self.hostnames))
backends:
description: |-
Backends are the candidate destinations the router can dispatch to.
Expand Down
46 changes: 46 additions & 0 deletions config/crd/bases/inference.llmkube.dev_modelrouters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ spec:
maximum: 65535
minimum: 1
type: integer
hostnames:
description: |-
Hostnames limit the generated HTTPRoute to these hosts. routing: Model
only, where it or sectionName is required.
items:
maxLength: 253
minLength: 1
pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
maxItems: 16
minItems: 1
type: array
name:
description: Name is the agentgateway Gateway's name.
type: string
Expand All @@ -120,12 +132,46 @@ spec:
EndpointPicker Service all live in this ModelRouter's namespace
regardless.
type: string
routing:
default: InferencePool
description: |-
Routing selects the compile target. "InferencePool" (the default) is the
Gateway API Inference Extension path described on spec.agentGatewayRef.
"Model" uses agentgateway's model router (v1.6.0+): one AgentgatewayModel
per backend, matched on its displayName (else name), behind one
catch-all HTTPRoute. It needs no Inference Extension CRDs or Endpoint
Picker. spec.rules, spec.defaultRoute, external backends, resolution:
endpoint, and per-backend endpoint pickers are refused in this mode.
enum:
- InferencePool
- Model
type: string
sectionName:
description: |-
SectionName names the Gateway listener the generated HTTPRoute attaches
to. routing: Model only, where it or hostnames is required, because the
route takes every path on its listeners.
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
required:
- name
type: object
x-kubernetes-validations:
- message: endpointPicker and endpointPickerPort must be set together
rule: has(self.endpointPicker) == has(self.endpointPickerPort)
- message: 'endpointPicker and endpointPickerPort apply only to routing:
InferencePool'
rule: '!(has(self.routing) && self.routing == ''Model'') || !has(self.endpointPicker)'
- message: 'routing: Model requires sectionName or hostnames: its
HTTPRoute matches every path, so it must not take over a whole
Gateway'
rule: '!(has(self.routing) && self.routing == ''Model'') || has(self.sectionName)
|| has(self.hostnames)'
- message: 'sectionName and hostnames apply only to routing: Model'
rule: (has(self.routing) && self.routing == 'Model') || (!has(self.sectionName)
&& !has(self.hostnames))
backends:
description: |-
Backends are the candidate destinations the router can dispatch to.
Expand Down
1 change: 1 addition & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ rules:
- apiGroups:
- agentgateway.dev
resources:
- agentgatewaymodels
- agentgatewaypolicies
verbs:
- create
Expand Down
57 changes: 57 additions & 0 deletions docs/site/concepts/model-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,63 @@ Each `InferencePool` is named after its `InferenceService`, and a router never a

`primary-fallback` routes each rule to its first healthy backend. When that backend's `InferenceService` has no ready replica, the next reconcile moves the rule to the next backend. Failover follows replica readiness, not individual request errors: a primary that is up but failing requests keeps its traffic.

## Model routing on dataPlane: AgentGateway

`agentGatewayRef.routing: Model` compiles the router onto agentgateway's own model router instead of the Inference Extension. It needs agentgateway v1.6.0 or later, which ships the `AgentgatewayModel` CRD, and a Gateway. It needs no Gateway API Inference Extension CRDs, no Endpoint Picker, and no Gateway-wide policy, which suits single-replica `InferenceService`s reached through their Service.

```yaml
apiVersion: inference.llmkube.dev/v1alpha1
kind: ModelRouter
metadata:
name: llm-router
spec:
dataPlane: AgentGateway
agentGatewayRef:
name: ai-gateway
routing: Model
sectionName: llm
backends:
- name: chat
displayName: chat-large
inferenceServiceRef:
name: chat-vllm
- name: chat-small
inferenceServiceRef:
name: chat-llama
- name: reranker
inferenceServiceRef:
name: reranker
```

The router generates, all in its own namespace and owned by it:

- one `HTTPRoute` named after the router, attached to the Gateway, with a single rule named `models` at `/` whose only backend is `AgentgatewayModel` `*`. The rule sits at `/` because agentgateway strips the rule's path prefix before it classifies the endpoint: under `/v1`, `/v1/embeddings` would parse as a chat completion;
- one `AgentgatewayModel` per backend, named `<router>-<backend>` (cut and hashed past 63 characters), attached to that rule. Its `match.model` is the backend's `displayName`, else its `name`, so served names such as `Qwen3.5-2B` or `org/model` work. It reaches the `InferenceService`'s Service through a custom provider, and the request keeps its model name, so the served name must be one the runtime answers to: llama.cpp ignores it, and vLLM needs a matching `--served-model-name`.

The provider and API formats come from the `InferenceService`:

| Runtime | `spec.mode` (or inferred) | `providerOverride` | Formats |
|---|---|---|---|
| `llamacpp` (or unset) | chat | `llamacpp` | `Completions` |
| `vllm` | chat | `vllm` | `Completions`, `Messages`, `AnthropicTokenCount`, `Responses` |
| `llamacpp`, `vllm` | embedding | as above | `Embeddings` |
| `llamacpp`, `vllm` | rerank | as above | `Rerank` |

agentgateway then routes each request on the body's `model`, answers `GET /v1/models` itself, and returns a JSON 404 `model_not_found` for a model no backend serves.

A rule at `/` takes every path on the listeners it attaches to, so this mode requires `agentGatewayRef.sectionName` (one listener), `agentGatewayRef.hostnames`, or both. Your own routes still share the listener: a rule with a longer match, such as a `PathPrefix` `/v1` with a header match, outranks the router's `/`. Use one `routing: Model` router per listener and hostname. Two routers' `/` rules tie, Gateway API gives the tie to the older route, and the newer router's models get no traffic.

The router reports `AgentGatewayReady=False` and generates nothing when:

- the `AgentgatewayModel` CRD is not installed (`AgentgatewayModelCRDNotInstalled`);
- the spec has `rules`, a `defaultRoute`, an `external` backend, `resolution: endpoint`, a backend `endpointPicker`, a served name containing `*`, or two backends serving one name (`UnsupportedInModelRouting`; the webhook rejects these at apply time). Fallback between backends and external backends need an `AgentgatewayBackend`, which this mode does not generate yet;
- a backend's `InferenceService` runs a runtime other than `llamacpp` or `vllm` (`UnsupportedRuntimeInModelRouting`);
- a generated name is held by an object LLMKube did not create (`GeneratedResourceNameTaken`); delete or rename that object.

When you replace hand-written `AgentgatewayModel`s with a router, remove the hand-written ones for the same model names on that listener, so each name has one model.

Removing a backend deletes its model. Switching `routing` in either direction deletes the other mode's objects, including the `InferencePool` mode's extraction policy in the Gateway's namespace.

## Composition with LiteLLM

LLMKube does not replace [LiteLLM](https://github.com/BerriAI/litellm). For organizations already running a LiteLLM proxy as their cloud-provider abstraction, point a `ModelRouter` external backend at it:
Expand Down
28 changes: 18 additions & 10 deletions internal/controller/gateway_resources_agentgateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,15 +209,6 @@ func newAgentGatewayHTTPRoute(
rules []agentGatewayRuleResource,
backends []agentGatewayBackendResource,
) *unstructured.Unstructured {
parentRef := map[string]interface{}{
metadataNameField: ref.Name,
"kind": "Gateway",
"group": gatewayBackendRefGroupAPI,
}
if ref.Namespace != "" {
parentRef["namespace"] = ref.Namespace
}

compiledRules := make([]interface{}, 0, len(rules))
poolFor := make(map[string]string, len(backends))
for _, b := range backends {
Expand All @@ -235,12 +226,29 @@ func newAgentGatewayHTTPRoute(
// leaving-AgentGateway sweep can find it (mirrors the extraction policy).
u.SetLabels(map[string]string{agentGatewayRouterUIDLabel: string(mr.UID)})
u.Object["spec"] = map[string]interface{}{
"parentRefs": []interface{}{parentRef},
"parentRefs": []interface{}{agentGatewayRouteParentRef(ref)},
"rules": compiledRules,
}
return u
}

// agentGatewayRouteParentRef only carries a sectionName with routing: Model,
// where the CRD allows it, so the InferencePool route renders unchanged.
func agentGatewayRouteParentRef(ref *inferencev1alpha1.AgentGatewayReference) map[string]interface{} {
parentRef := map[string]interface{}{
metadataNameField: ref.Name,
"kind": "Gateway",
"group": gatewayBackendRefGroupAPI,
}
if ref.Namespace != "" {
parentRef["namespace"] = ref.Namespace
}
if ref.SectionName != "" {
parentRef["sectionName"] = ref.SectionName
}
return parentRef
}

// compileAgentGatewayRouteRule builds one HTTPRoute rule: the matches block (a
// match per model name, ANDed with header matches; a header-only match when the
// rule declares no models, e.g. a catch-all) and the backendRefs block.
Expand Down
Loading
Loading