Skip to content

fix(ui): fix /ui/chat 404 — Dockerfile pre-restructure + heuristic false-positive#24247

Open
Asseel-Naji wants to merge 2 commits intoBerriAI:mainfrom
Asseel-Naji:fix/ui-chat-404-html-fallback
Open

fix(ui): fix /ui/chat 404 — Dockerfile pre-restructure + heuristic false-positive#24247
Asseel-Naji wants to merge 2 commits intoBerriAI:mainfrom
Asseel-Naji:fix/ui-chat-404-html-fallback

Conversation

@Asseel-Naji
Copy link

Root Cause

The Chat UI added in #22937 (and routing fixed in #22945) returns 404 in the published Docker images (main-stable, main-latest).

Why it breaks

  1. Next.js static export generates _experimental/out/chat.html for the new /chat route, but generates login/index.html, playground/index.html etc. for older routes (mixed output structure).

  2. Starlette StaticFiles(html=True) serves extensionless routes by looking for {route}/index.html. It does not try {route}.html, so /ui/chat → 404.

  3. _restructure_ui_html_files() already exists to move {page}.html → {page}/index.html at runtime — but it is gated by _is_ui_pre_restructured().

  4. _is_ui_pre_restructured() fires a false-positive: it returns True as soon as it finds any {dir}/index.html (e.g. login/index.html), concluding the UI is fully restructured. This skips the restructure step and leaves chat.html in place.

  5. The main Dockerfile (used for published images) never runs the pre-restructure step that Dockerfile.non_root does, so no .litellm_ui_ready marker is written either.

Fixes

Dockerfile — add the same pre-restructure step as Dockerfile.non_root:

RUN cd litellm/proxy/_experimental/out && \
    for html_file in *.html; do \
      if [ "$html_file" != "index.html" ] && [ "$html_file" != "404.html" ] && [ -f "$html_file" ]; then \
        folder_name="${html_file%.html}" && \
        mkdir -p "$folder_name" && \
        mv "$html_file" "$folder_name/index.html"; \
      fi; \
    done && \
    touch .litellm_ui_ready

This moves chat.html → chat/index.html at image build time and writes the marker, so the proxy skips the heuristic entirely.

proxy_server.py — tighten the _is_ui_pre_restructured() fallback heuristic: when a restructured route is found, also scan for orphaned root-level .html files; return False if any exist so that _restructure_ui_html_files() can run.

Testing

# Before fix
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/ui/chat
# → 404

# After fix
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/ui/chat
# → 200 (served from chat/index.html)

Related

…e-restructure in Dockerfile

Starlette StaticFiles(html=True) serves extensionless routes (e.g. /ui/chat)
by looking for {route}/index.html. Next.js static export generates login/index.html
for older pages but chat.html (root-level) for the new Chat UI page added in BerriAI#22937.

The _is_ui_pre_restructured() fallback heuristic returned True as soon as it found
any {dir}/index.html (e.g. login/index.html), skipping the restructure step and
leaving chat.html in place — causing /ui/chat to 404.

Fix 1: proxy_server.py — tighten the heuristic to also scan for orphaned root-level
.html files; return False if any exist so restructuring runs.

Fix 2: Dockerfile — add pre-restructure step (matching Dockerfile.non_root) so the
main published image pre-moves all {page}.html → {page}/index.html and writes the
.litellm_ui_ready marker, bypassing the heuristic entirely on subsequent starts.
@vercel
Copy link

vercel bot commented Mar 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 20, 2026 9:21pm

Request Review

@CLAassistant
Copy link

CLAassistant commented Mar 20, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ Asseel-Naji
❌ Asseel Naji


Asseel Naji seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@codspeed-hq
Copy link
Contributor

codspeed-hq bot commented Mar 20, 2026

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing Asseel-Naji:fix/ui-chat-404-html-fallback (0706c28) with main (72c307d)

Open in CodSpeed

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 20, 2026

Greptile Summary

This PR fixes /ui/chat returning 404 in the published Docker images by addressing two independent root causes: a missing pre-restructure build step in the main Dockerfile, and a false-positive in the _is_ui_pre_restructured() heuristic that caused it to declare the UI "ready" as soon as any {dir}/index.html was found, even while orphaned root-level .html files (like chat.html) still existed.

Key changes:

  • Dockerfile — adds the same RUN pre-restructure loop already present in Dockerfile.non_root, moving {page}.html → {page}/index.html at image build time and writing the .litellm_ui_ready marker so the proxy skips the heuristic entirely in Docker deployments.
  • litellm/proxy/proxy_server.py — the _is_ui_pre_restructured() fallback heuristic now performs a secondary scan for orphaned root-level .html files (excluding index.html and 404.html) whenever a restructured subdirectory is found; it returns False if any exist, allowing _restructure_ui_html_files() to run and fix them.
  • The automated test file (test_ui_path_detection.py) does not include a test for the specific mixed-output scenario (restructured login/index.html alongside orphaned chat.html) that this PR fixes, which leaves the new heuristic branch without regression coverage.

Confidence Score: 4/5

  • Safe to merge — both changes are narrowly scoped bug fixes with no backwards-incompatible behaviour; the Dockerfile step is a no-op for images that never had chat.html, and the Python heuristic change only adds a guard that prevents a false-positive early-return.
  • Both fixes are correct and well-reasoned. The Dockerfile step mirrors the already-proven pattern in Dockerfile.non_root, and the heuristic tightening in _is_ui_pre_restructured is logically sound. Score is 4 rather than 5 because: (1) the silent-failure risk in the Dockerfile shell loop (already flagged in a prior thread) is unaddressed, and (2) the specific mixed-output scenario that motivated the fix has no automated regression test, making it easier to reintroduce in future.
  • No files require special attention beyond what was already flagged in prior threads.

Important Files Changed

Filename Overview
Dockerfile Adds a RUN step after build_admin_ui.sh to move root-level {page}.html files into {page}/index.html and write .litellm_ui_ready marker — mirrors the existing step in Dockerfile.non_root. The shell loop lacks set -e / `
litellm/proxy/proxy_server.py Tightens _is_ui_pre_restructured() to scan for root-level orphaned .html files whenever a restructured directory is found; returns False if any exist so _restructure_ui_html_files() can run. The inner os.scandir is called inside the outer loop (redundant scan noted in prior thread) and silently swallows OSError into an empty list — both minor but no logic bugs in the happy path.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Docker build: build_admin_ui.sh\nGenerates out/ with mixed structure:\n  login/index.html  ✓\n  chat.html         ✗"] --> B["NEW: Pre-restructure RUN step\nmv chat.html → chat/index.html\ntouch .litellm_ui_ready"]
    B --> C["python -m build\nWheel baked with restructured\nfiles + marker"]

    C --> D["Proxy server startup\n_is_ui_pre_restructured(ui_path)"]

    D --> E{".litellm_ui_ready\nmarker exists?"}
    E -- "Yes (Docker image path)" --> F["Return True\nSkip restructure"]
    E -- "No (source / older image)" --> G{"Any {dir}/index.html\nfound?"}

    G -- "No" --> H["Return False\nRun _restructure_ui_html_files()"]
    G -- "Yes" --> I{"NEW: Any orphaned\n*.html at root?\n(excl. index.html, 404.html)"}

    I -- "Yes (e.g. chat.html)" --> H
    I -- "No" --> F

    F --> J["StaticFiles(html=True)\nServes /ui/chat → chat/index.html ✓"]
    H --> K["_restructure_ui_html_files()\nmoves remaining *.html → */index.html"] --> J
Loading

Comments Outside Diff (1)

  1. tests/proxy_unit_tests/test_ui_path_detection.py, line 119-136 (link)

    P2 No test coverage for the heuristic false-positive scenario

    The test_structural_routes_exist test verifies a fully restructured state (all routes as {route}/index.html), but the specific scenario this PR fixes — a mixed output where some routes are pre-restructured (e.g. login/index.html) and at least one new route is still a root-level .html file (e.g. chat.html) — is not exercised by any test.

    Without a test for this case, a future refactor of _is_ui_pre_restructured could silently reintroduce the false-positive. Consider adding a test case:

    def test_mixed_state_returns_not_pre_restructured(self):
        """Mixed state: some routes restructured, but orphaned chat.html still present."""
        # Simulate login already restructured
        login_dir = os.path.join(self.temp_dir, "login")
        os.makedirs(login_dir)
        Path(os.path.join(login_dir, "index.html")).touch()
        # Simulate chat still as a root-level .html (the bug scenario)
        Path(os.path.join(self.temp_dir, "index.html")).touch()
        Path(os.path.join(self.temp_dir, "chat.html")).touch()
    
        orphaned = [
            e.name
            for e in os.scandir(self.temp_dir)
            if os.path.isfile(os.path.join(self.temp_dir, e.name))
            and e.name.endswith(".html")
            and e.name not in ("index.html", "404.html")
        ]
        assert orphaned == ["chat.html"], "chat.html should be detected as orphaned"

    Rule Used: What: Ensure that any PR claiming to fix an issue ... (source)

Last reviewed commit: "Merge branch 'main' ..."

Comment on lines +30 to +38
RUN cd litellm/proxy/_experimental/out && \
for html_file in *.html; do \
if [ "$html_file" != "index.html" ] && [ "$html_file" != "404.html" ] && [ -f "$html_file" ]; then \
folder_name="${html_file%.html}" && \
mkdir -p "$folder_name" && \
mv "$html_file" "$folder_name/index.html"; \
fi; \
done && \
touch .litellm_ui_ready
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 Silent partial restructure with marker file written

If any individual mv operation fails (e.g. a pre-existing {page}/index.html conflicts, or a filesystem error occurs), the for loop continues because shell for loops do not propagate inner command failures to the outer flow. The final touch .litellm_ui_ready then still executes, marking the UI as fully restructured even if some HTML files were not moved.

At runtime _is_ui_pre_restructured() sees the marker file and returns True early, skipping the Python fallback, so the unprocessed route will still 404.

Consider adding set -e at the top of the RUN or using || exit 1 after the move to ensure the build fails loudly rather than silently producing a partial result:

Suggested change
RUN cd litellm/proxy/_experimental/out && \
for html_file in *.html; do \
if [ "$html_file" != "index.html" ] && [ "$html_file" != "404.html" ] && [ -f "$html_file" ]; then \
folder_name="${html_file%.html}" && \
mkdir -p "$folder_name" && \
mv "$html_file" "$folder_name/index.html"; \
fi; \
done && \
touch .litellm_ui_ready
RUN set -e && cd litellm/proxy/_experimental/out && \
for html_file in *.html; do \
if [ "$html_file" != "index.html" ] && [ "$html_file" != "404.html" ] && [ -f "$html_file" ]; then \
folder_name="${html_file%.html}" && \
mkdir -p "$folder_name" && \
mv "$html_file" "$folder_name/index.html"; \
fi; \
done && \
touch .litellm_ui_ready

Comment on lines +1211 to +1226
try:
orphaned = [
e.name
for e in os.scandir(ui_dir)
if e.is_file()
and e.name.endswith(".html")
and e.name not in ("index.html", "404.html")
]
except (PermissionError, OSError):
orphaned = []
if orphaned:
verbose_proxy_logger.debug(
f"Found un-restructured HTML files at root: {orphaned}. "
f"Restructuring needed despite existing {entry.name}/index.html."
)
return False
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 Redundant directory scan on every matched entry

The inner os.scandir(ui_dir) is launched inside the outer for entry in os.scandir(ui_dir) loop, effectively scanning the same directory twice. In the current implementation the function always returns on the first matching entry, so the double-scan is harmless, but it is an implicit invariant that future readers may not notice.

Consider hoisting the orphan check outside the loop so it is only performed once after confirming at least one restructured directory exists:

restructured_found = None
for entry in os.scandir(ui_dir):
    if entry.is_dir() and not entry.name.startswith("_"):
        index_path = os.path.join(entry.path, "index.html")
        if os.path.exists(index_path):
            restructured_found = entry.name
            break

if restructured_found:
    try:
        orphaned = [
            e.name
            for e in os.scandir(ui_dir)
            if e.is_file()
            and e.name.endswith(".html")
            and e.name not in ("index.html", "404.html")
        ]
    except (PermissionError, OSError):
        orphaned = []
    if orphaned:
        verbose_proxy_logger.debug(...)
        return False
    verbose_proxy_logger.debug(...)
    return True

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.

2 participants