Skip to content

feat(helm): make extra ingress paths configurable in the componentized chart - #35700

Open
yassin-berriai wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_helm_ingress_extra_paths
Open

feat(helm): make extra ingress paths configurable in the componentized chart#35700
yassin-berriai wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_helm_ingress_extra_paths

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Chart's ingress path list is hardcoded; no values knob
  • Passthrough routes it omits 404 at the backend catch-all
  • /watsonx is unreachable through this chart today
  • Custom pass_through_endpoints paths can never ship a rule
  • Only workaround is forking the ingress template

How it solves it:

  • New ingress.extraPaths list in values.yaml
  • Entries render after every built-in path, never in place of one
  • An entry repeating a path the chart routes is rejected
  • service picks gateway (default), backend, or ui
  • pathType defaults to Prefix

Relevant issues

Linear ticket

Resolves LIT-5135

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Live cluster: the routing actually changes

kind + ingress-nginx, running this chart with ealen/echo-server standing in for the three component images so the pods come up and answer the chart's own probes. echo-server echoes environment.HOSTNAME, which is the pod name, and pod names carry -gateway- / -backend- / -ui-, so the response says which component served the request.

Cluster legs were captured at a16d553bf3; the head is now fe451d70aa, which changed only the extra-path validation and moved the built-in UI paths from six literal blocks into one range. The Ingress object rendered from these values is identical at every commit on the branch, verified by parsing each render and comparing the objects, so the capture stands at the head.

values-base.yaml (PORT matches each chart containerPort: gateway 4000, backend 4001, ui 3000):

migrationJob: { enabled: false }
database:
  writer: { host: postgres.example.com, dbname: litellm }
ingress:
  enabled: true
  className: nginx
  host: litellm.local
gateway:
  image: { repository: ealen/echo-server, tag: latest }
  extraEnv: [{ name: PORT, value: "4000" }]
  resources: { requests: { cpu: 50m, memory: 64Mi }, limits: { cpu: 500m, memory: 256Mi } }
  hpa: { enabled: false }
backend:
  image: { repository: ealen/echo-server, tag: latest }
  extraEnv: [{ name: PORT, value: "4001" }]
  resources: { requests: { cpu: 50m, memory: 64Mi }, limits: { cpu: 500m, memory: 256Mi } }
  hpa: { enabled: false }
ui:
  image: { repository: ealen/echo-server, tag: latest }
  extraEnv: [{ name: PORT, value: "3000" }]
  resources: { requests: { cpu: 50m, memory: 64Mi }, limits: { cpu: 500m, memory: 256Mi } }
  hpa: { enabled: false }

values-extrapaths.yaml, exercising all three selectors, with /watsonx deliberately omitting pathType so the run also proves the Prefix default:

ingress:
  extraPaths:
    - path: /watsonx
      service: gateway
    - path: /my-passthrough
      pathType: Prefix
      service: backend
    - path: /brand.txt
      pathType: ImplementationSpecific
      service: ui

1. Cluster and controller

$ kind create cluster --name litellm-ingress-extrapaths --config kind-cluster.yaml
$ export KUBECONFIG=$PWD/kubeconfig && kind get kubeconfig --name litellm-ingress-extrapaths > "$KUBECONFIG"
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.2/deploy/static/provider/kind/deploy.yaml
$ kubectl wait -n ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=300s
pod/ingress-nginx-controller-6b7cdd4bf6-l8qgb condition met

2. Install without extraPaths

$ git log --oneline -1
a16d553bf3 feat(helm): make extra ingress paths configurable in the componentized chart

$ kubectl create namespace litellm-extrapaths
$ kubectl -n litellm-extrapaths create secret generic litellm-master-key-secret --from-literal=master-key=sk-proof-1234
$ kubectl -n litellm-extrapaths create secret generic litellm-writer-secret --from-literal=username=litellm --from-literal=password=dummy
$ helm install litellm ./helm/litellm -n litellm-extrapaths -f values-base.yaml --wait --timeout 5m

$ kubectl -n litellm-extrapaths get ingress litellm-litellm -o yaml | grep -cE 'watsonx|my-passthrough|brand\.txt'
0

$ for p in / /ui/login /v1/chat/completions /v1/messages /key/generate /user/info /watsonx /my-passthrough /brand.txt; do
    code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: litellm.local" "http://localhost:18080$p")
    pod=$(curl -s -H "Host: litellm.local" "http://localhost:18080$p" | jq -r .environment.HOSTNAME)
    printf '%-24s %s  %s\n' "$p" "$code" "$pod"
  done
/                        200  litellm-litellm-ui-5d5c6fd5f4-7n62p
/ui/login                200  litellm-litellm-ui-5d5c6fd5f4-7n62p
/v1/chat/completions     200  litellm-litellm-gateway-69988d8bd6-pkbdk
/v1/messages             200  litellm-litellm-gateway-69988d8bd6-pkbdk
/key/generate            200  litellm-litellm-backend-7c5dfb7b96-xzftt
/user/info               200  litellm-litellm-backend-7c5dfb7b96-xzftt
/watsonx                 200  litellm-litellm-backend-7c5dfb7b96-xzftt
/my-passthrough          200  litellm-litellm-backend-7c5dfb7b96-xzftt
/brand.txt               200  litellm-litellm-backend-7c5dfb7b96-xzftt

All three target paths fall into the / catch-all and land on the backend, which serves none of them

3. Upgrade with extraPaths

$ helm upgrade litellm ./helm/litellm -n litellm-extrapaths -f values-base.yaml -f values-extrapaths.yaml --wait --timeout 5m
Release "litellm" has been upgraded. Happy Helming!
STATUS: deployed
REVISION: 2

$ kubectl -n litellm-extrapaths get ingress litellm-litellm -o yaml | grep -B5 -A1 -E 'path: /(watsonx|my-passthrough|brand\.txt)$'
      - backend:
          service:
            name: litellm-litellm-gateway
            port:
              number: 4000
        path: /watsonx
        pathType: Prefix
      - backend:
          service:
            name: litellm-litellm-backend
            port:
              number: 4001
        path: /my-passthrough
        pathType: Prefix
      - backend:
          service:
            name: litellm-litellm-ui
            port:
              number: 3000
        path: /brand.txt
        pathType: ImplementationSpecific

/watsonx rendered pathType: Prefix without being asked, which is the documented default

4. Same matrix, after

$ for p in / /ui/login /v1/chat/completions /v1/messages /key/generate /user/info /watsonx /my-passthrough /brand.txt; do ... done
/                        200  litellm-litellm-ui-5d5c6fd5f4-7n62p
/ui/login                200  litellm-litellm-ui-5d5c6fd5f4-7n62p
/v1/chat/completions     200  litellm-litellm-gateway-69988d8bd6-pkbdk
/v1/messages             200  litellm-litellm-gateway-69988d8bd6-pkbdk
/key/generate            200  litellm-litellm-backend-7c5dfb7b96-xzftt
/user/info               200  litellm-litellm-backend-7c5dfb7b96-xzftt
/watsonx                 200  litellm-litellm-gateway-69988d8bd6-pkbdk
/my-passthrough          200  litellm-litellm-backend-7c5dfb7b96-xzftt
/brand.txt               200  litellm-litellm-ui-5d5c6fd5f4-7n62p

/watsonx moved backend to gateway and /brand.txt moved backend to ui. Every built-in path is identical to the before state, on the same pods, which never restarted (0 restarts, 38s after the upgrade)

5. The backend selector needs a second form of evidence

service: backend targets the same component the catch-all already targets, so a response cannot tell "matched my rule" from "fell through". The controller's generated config can: each entry gets its own location block with the right upstream.

$ NGINX=$(kubectl -n ingress-nginx get pod -l app.kubernetes.io/component=controller -o name | head -1)
$ kubectl -n ingress-nginx exec "$NGINX" -- cat /etc/nginx/nginx.conf > nginx.conf
$ for n in my-passthrough watsonx brand.txt; do
    awk -v pat="location = /$n {" 'index($0,pat){f=1} f&&/set \$proxy_upstream_name/{print; exit}' nginx.conf
  done
			set $proxy_upstream_name "litellm-extrapaths-litellm-litellm-backend-4001";
			set $proxy_upstream_name "litellm-extrapaths-litellm-litellm-gateway-4000";
			set $proxy_upstream_name "litellm-extrapaths-litellm-litellm-ui-3000";

Combined with the before-leg grep -c returning 0, that is the backend entry demonstrably taking effect rather than being absorbed by the catch-all

6. Cleanup

$ kind delete cluster --name litellm-ingress-extrapaths

What this run does not prove

ingress-nginx matches by longest prefix, so /watsonx Prefix beats the / catch-all wherever it sits in the list. This run proves the routing delta and all three service selectors; it does not prove the manifest-position argument, which is specific to controllers that honour manifest order and is covered by the helm-unittest ordering assertions instead.

Render aborts on a bad entry

Captured at fe451d70aa:

$ helm template litellm helm/litellm -f values.yaml --set-json 'ingress.extraPaths=["/watsonx"]' -s templates/ingress.yaml
Error: execution error at (litellm/templates/ingress.yaml:157:14): ingress.extraPaths[0]: each entry must be a mapping with a 'path' key

$ helm template litellm helm/litellm -f values.yaml --set-json 'ingress.extraPaths=[{"service":"gateway"}]' -s templates/ingress.yaml
Error: execution error at (litellm/templates/ingress.yaml:160:14): ingress.extraPaths[0]: 'path' is required

$ helm template litellm helm/litellm -f values.yaml --set-json 'ingress.extraPaths=[{"path":"/watsonx","service":"proxy"}]' -s templates/ingress.yaml
Error: execution error at (litellm/templates/ingress.yaml:165:14): ingress.extraPaths[0] (path /watsonx): unknown service "proxy", expected one of backend, gateway, ui

$ helm template litellm helm/litellm -f values.yaml --set-json 'ingress.extraPaths=[{"path":"/watsonx","pathType":"prefix"}]' -s templates/ingress.yaml
Error: execution error at (litellm/templates/ingress.yaml:169:14): ingress.extraPaths[0] (path /watsonx): unknown pathType "prefix", expected one of Exact, ImplementationSpecific, Prefix

$ helm template litellm helm/litellm -f values.yaml --set-json 'ingress.extraPaths=[{"path":"/","pathType":"ImplementationSpecific","service":"gateway"}]' -s templates/ingress.yaml
Error: execution error at (litellm/templates/ingress.yaml:172:14): ingress.extraPaths[0]: path / is already routed in both directions, Exact to ui and Prefix to backend, so no pathType leaves a request for an entry here to capture

$ helm template litellm helm/litellm -f values.yaml --set-json 'ingress.extraPaths=[{"path":"/ui","service":"gateway"}]' -s templates/ingress.yaml
Error: execution error at (litellm/templates/ingress.yaml:175:14): ingress.extraPaths[0]: path /ui with pathType Prefix is already routed by this chart, and a duplicate would take it over rather than add to it

Type

🆕 New Feature

Changes

helm/litellm/templates/ingress.yaml renders a fixed path set: the UI paths, a hardcoded $gatewayPrefixes list mirroring gateway/routes/allowlist.py, and a / Prefix catch-all to the backend. values.yaml exposed no path knob, so an operator needing a route the chart does not know about had to fork the template.

That prefix list is a snapshot of the data plane at chart release time and drifts from it. /watsonx is in GATEWAY_PATH_PREFIXES (gateway/routes/allowlist.py:110, backing the real /watsonx/{endpoint:path} passthrough route) but has no ingress rule and no backend prefix, so /watsonx/* falls into the catch-all, reaches the backend, and 404s. A provider passthrough prefix added upstream after the chart version an operator runs has the same shape, and a custom general_settings.pass_through_endpoints route has a path only the operator knows, so the chart can never ship a rule for it at all.

ingress.extraPaths takes a list of {path, service, pathType} entries rendered in addition to the built-in paths, never in place of them. They render after every built-in path and before the backend catch-all, and two classes of entry are rejected at render time rather than shipped. An entry repeating a path + pathType the chart already routes would be resolved by position or by controller-specific tie breaking, so an entry at / Prefix would swallow the whole backend management API rather than add to it. And any entry at / is rejected whatever its pathType, because the chart routes the root in both directions already (Exact to ui, Prefix to backend): every request either matches the Exact rule or falls into the Prefix one, and no rule at / can be longer or more specific than those, so such an entry renders and can never capture a request. An entry that differs from a built-in only by pathType is still allowed elsewhere, since that is a distinct Kubernetes rule.

service picks the component Service (gateway by default, or backend or ui) so an operator does not have to reconstruct release-templated Service names, and pathType defaults to Prefix. A non-mapping entry, an entry with no path, an unknown service, an unknown pathType, and a duplicate each abort the render naming the offending index rather than emitting an Ingress that misroutes traffic. Leading-slash validation is deliberately left to the API server, which enforces it per pathType and would be the authority a chart-side copy could drift from.

The duplicate check needs the built-in paths as data, so the six UI paths move from literal YAML blocks into a $uiPaths list rendered by one range, and their explanatory comments move from the rendered manifest into template comments. The key set derives from that list plus $gatewayPrefixes plus /test|Exact and /|Prefix, so a built-in path added later extends the check automatically. The Ingress object is unchanged: the same 92 paths in the same order with the same backends, verified by parsing the render at every commit on this branch and comparing the objects.

Where position matters at all: the AWS Load Balancer Controller this chart targets sorts Exact paths first and Prefix paths longest-first, and keeps ImplementationSpecific paths in manifest order, which is what the existing /*.txt rule already depends on.

The target component still answers only the paths its own route allowlist keeps, so this makes a route routable, not servable. Wiring a custom pass_through_endpoints path through the gateway's route trim is separate work.

Tests are helm/litellm/tests/ingress_extra_paths_tests.yaml, run by the existing helm_unit_test workflow. Seventeen cases covering the additive contract, position relative to the catch-all, both selectors and their defaults, the component ports, every render abort, one built-in path per family rejected as a duplicate, the root rejected under all three pathTypes, and the different-pathType case that must still be allowed. Fifteen mutants were run against them and each was killed: flipping the default service, flipping the default pathType, dropping each of the six guards, exempting ImplementationSpecific from the root guard, emptying the built-in key seed, skipping the UI paths or the gateway prefixes when building the key set, two forms of keying on path alone, moving the extras below the catch-all, and reverting the template to the branch point. The chart's other 71 tests still pass unchanged

osv-scan is red on a file this diff does not touch: cryptography 48.0.1 in uv.lock, where git diff base...HEAD -- uv.lock is empty. It is a newly published advisory against the version pinned on staging, it fails the same way on #35690, and #35686 merged with it red. It needs a dependency bump of its own rather than anything here

Docs companion: BerriAI/litellm-docs#745, to merge after this

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The componentized Helm chart now supports configurable ingress paths targeting the gateway, backend, or UI.

  • Adds validated ingress.extraPaths rendering before the backend catch-all.
  • Rejects malformed entries, unsupported selectors, built-in duplicates, and all root-path entries.
  • Refactors built-in UI paths into shared template data and adds focused Helm tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current guard rejects root entries before rendering for Prefix, Exact, and ImplementationSpecific, resolving both previous findings.

Important Files Changed

Filename Overview
helm/litellm/templates/ingress.yaml Adds extra-path routing and validation; the unconditional root-path guard fully resolves both previously reported ingress failures.
helm/litellm/tests/ingress_extra_paths_tests.yaml Covers routing defaults, service selection, ordering, malformed configuration, built-in duplicates, and all three root path types.
helm/litellm/values.yaml Defines and documents the additive ingress.extraPaths configuration with safe defaults.

Reviews (4): Last reviewed commit: "feat(helm): make extra ingress paths con..." | Re-trigger Greptile

Comment thread helm/litellm/templates/ingress.yaml
@yassin-berriai
yassin-berriai force-pushed the litellm_helm_ingress_extra_paths branch 3 times, most recently from a16d553 to 02b4057 Compare August 3, 2026 20:55
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 02b4057

You were right, and the fix went further than the root case. An entry at / Prefix rendered ahead of the backend catch-all and took it over, so the "never takes precedence over a default" line in values.yaml was false as written. The same hole existed for / Exact against the UI root, and for /ui, /test, /*.txt and every gateway prefix; a duplicate inside one rule is resolved by position or by controller-specific tie breaking either way.

The chart now rejects any entry whose path + pathType the chart already routes, at render time. To key that check off real data rather than a hand-maintained copy, the six UI paths moved into a $uiPaths list rendered by one range, and $builtinPathKeys is derived from that list plus $gatewayPrefixes plus /test|Exact and /|Prefix. Adding a built-in path in future automatically extends the check. The rendered Ingress is unchanged: same 92 paths, same order, same backends, verified by parsing both renders and comparing the objects, and the existing 80 chart tests still pass.

An entry that differs only by pathType is still allowed, since that is a distinct Kubernetes rule rather than a duplicate.

Seven new cases cover it, one per built-in family plus the allowed different-pathType case. Six mutants were run against them and each was killed: dropping the guard, emptying the key seed, skipping the UI paths, skipping the gateway prefixes, and two variants of keying on path alone.

Note CI is red for a reason unrelated to this diff, which contains no Python. litellm_internal_staging currently fails import litellm with NameError: name 'Union' is not defined at llms/openai/common_utils.py:138; #35702 fixes it, and this PR needs a rebase once that lands.

@yassin-berriai
yassin-berriai force-pushed the litellm_helm_ingress_extra_paths branch from 02b4057 to ef38acb Compare August 3, 2026 22:06
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head ef38acb

Rebased onto current staging, which was failing import litellm on NameError: name 'Union' is not defined and reddening every job on this PR even though the diff contains no Python; #35706 fixed that. The rebase carries no code changes, so your 5/5 on 02b4057 applies to identical content, but the score is pinned to a commit that is no longer in the PR. The rendered Ingress is byte-identical before and after the rebase, verified by parsing both, and the chart suite is 87/87.

Comment thread helm/litellm/templates/ingress.yaml
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…d chart

The componentized chart's Ingress renders a fixed path set: the UI paths, a
hardcoded gateway prefix list mirroring gateway/routes/allowlist.py, and a `/`
Prefix catch-all to the backend. values.yaml exposes no path knob, so an
operator who needs a route the chart does not know about has to fork the
template.

That prefix list is a snapshot of the data plane at release time and drifts
from it. `/watsonx` is in GATEWAY_PATH_PREFIXES with no ingress rule and no
backend prefix, so `/watsonx/*` falls into the catch-all, reaches the backend,
and 404s. A provider passthrough prefix added upstream after the chart version
an operator runs has the same shape, and a custom
general_settings.pass_through_endpoints route has a path only the operator
knows, so the chart can never ship a rule for it at all.

ingress.extraPaths takes a list of {path, service, pathType} entries rendered
in addition to the built-in paths, never in place of them. They render after
every built-in path and before the backend catch-all, and an entry repeating a
path the chart already routes is rejected: duplicates within one rule are
resolved by position or by controller-specific tie breaking, so an entry at
`/` Prefix would swallow the whole backend management API rather than add to
it. `service` picks the component Service — gateway by default, or backend or
ui — so an operator does not have to reconstruct release-templated Service
names, and `pathType` defaults to Prefix. A non-mapping entry, an entry with
no path, an unknown service, an unknown pathType, and a duplicate each abort
the render naming the offending index rather than emitting an Ingress that
misroutes traffic.

The duplicate check needs the built-in paths as data, so the UI paths move
from six literal YAML blocks into a $uiPaths list rendered by one range, and
their explanatory comments move from the rendered manifest into template
comments. The Ingress object this produces is unchanged: the same 92 paths in
the same order with the same backends, verified by parsing both renders.

The target component still answers only the paths its own route allowlist
keeps, so this makes a route routable, not servable.
@yassin-berriai
yassin-berriai force-pushed the litellm_helm_ingress_extra_paths branch from ef38acb to fe451d7 Compare August 3, 2026 22:21
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head fe451d7

Right again, and the general form is stronger than the case you named. A root entry is dead under every pathType, not just ImplementationSpecific: the chart routes / in both directions already, Exact to ui and Prefix to backend, so every request either matches the Exact rule or falls into the Prefix one, and no rule at / can be longer or more specific than those. So / is now rejected outright whatever its pathType, which also replaces the two narrower duplicate errors it used to produce with one message that explains why.

Non-root entries are unaffected; the exact-pair duplicate check still governs those, and an entry differing from a built-in only by pathType is still allowed there since it is a genuinely distinct rule.

Three cases cover the root, one per pathType, and two mutants were run against them: dropping the root guard, and exempting ImplementationSpecific from it, which is precisely the hole you found. Both killed. The rendered Ingress is unchanged, verified by parsing, and the chart suite is 88/88.

Separately, osv-scan is red on cryptography 48.0.1 in uv.lock, a file this diff does not touch (git diff base...HEAD -- uv.lock is empty). #35690 fails the same check, so it is a newly published advisory against the pinned version on staging rather than anything in this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants