Skip to content

fix: treat llm_base_url="" as explicit clear in store_llm_settings#13471

Merged
enyst merged 2 commits intoOpenHands:mainfrom
LarytheLord:fix/llm-base-url-clear-13420
Mar 20, 2026
Merged

fix: treat llm_base_url="" as explicit clear in store_llm_settings#13471
enyst merged 2 commits intoOpenHands:mainfrom
LarytheLord:fix/llm-base-url-clear-13420

Conversation

@LarytheLord
Copy link
Contributor

Summary

Fixes #13420 — it is currently impossible to persist an empty llm_base_url from the settings UI.

Root cause: store_llm_settings used if not settings.llm_base_url: which catches both None (field not provided at all) and "" (explicitly cleared by the user). When the user:

  • saves from Basic view (which intentionally sends llm_base_url: "" to clear advanced settings), or
  • deletes the Base URL field in Advanced view and saves,

the backend treated "" the same as "not provided", triggering auto-detection via get_provider_api_base() and overwriting the user's intent. The old base URL kept coming back.

Fix: Split the condition into two explicit branches:

# Before
if not settings.llm_base_url:
    if settings.llm_base_url is None and existing_settings.llm_base_url:
        ...  # preserve existing

# After
if settings.llm_base_url is None:
    ...  # not provided — preserve existing or auto-detect (no behavior change)
elif settings.llm_base_url == "":
    settings.llm_base_url = None  # explicit clear — honor the intent

The None path has identical behavior to before. Only "" changes: instead of triggering auto-detection, it now normalizes to None and returns.

Test plan

  • Updated test_store_llm_settings_partial_update to assert None (the correct post-fix behavior) instead of the auto-detected URL
  • Added test_store_llm_settings_advanced_view_clear_removes_base_url as a named regression test for this issue
  • All 16 tests in test_settings_store_functions.py pass
  • Existing MCP save test (test_store_llm_settings_mcp_update_preserves_base_url) continues to pass — the None path (field not sent) is unchanged

🤖 Generated with Claude Code

Previously, `if not settings.llm_base_url:` caught both `None` (field
not provided) and `""` (explicitly cleared by the user). When a user
deleted the Base URL in Advanced view, or saved from Basic view (which
sends `llm_base_url: ""`), the backend would overwrite their intent by
running auto-detection via `get_provider_api_base()` or preserving the
old URL — making it impossible to clear a previously-set base URL.

Fix: split the condition into two explicit branches:
- `llm_base_url is None` → not provided at all; preserve existing or
  auto-detect as before (no behavior change for partial saves/MCP saves)
- `llm_base_url == ""` → explicit clear; normalize to None without
  running auto-detection

Adds a regression test `test_store_llm_settings_advanced_view_clear_removes_base_url`
and updates `test_store_llm_settings_partial_update` to assert the now-
correct behavior (None instead of auto-detected URL).

Fixes OpenHands#13420

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes lint CI failure by converting double quotes to single quotes
per the project's ruff.toml config (inline-quotes = "single").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@enyst
Copy link
Collaborator

enyst commented Mar 19, 2026

Hey @LarytheLord thank you for the contribution!

Could you maybe show a video that it works, or logs (though video would be nice I think), and that is still works for the previous issue?

@LarytheLord
Copy link
Contributor Author

Thanks @enyst — here's the test evidence:

Regression test (test_store_llm_settings_advanced_view_clear_removes_base_url):

settings = Settings(llm_model='gpt-4', llm_base_url='')     # user clears field
existing = Settings(llm_model='gpt-4', llm_base_url='https://my-custom-proxy.example.com')

result = await store_llm_settings(settings, existing)
assert result.llm_base_url is None   # ✅ old URL is gone

Existing behavior preserved (test_store_llm_settings_partial_update and test_store_llm_settings_mcp_update_preserves_base_url):

  • When llm_base_url is None (not sent at all, e.g. MCP save), the existing URL is kept — no behavior change.
  • When llm_base_url is "" (explicitly cleared by the user), it now normalizes to None instead of re-running auto-detection.

All 3 tests pass:

tests/.../test_settings_store_functions.py::test_store_llm_settings_advanced_view_clear_removes_base_url PASSED
tests/.../test_settings_store_functions.py::test_store_llm_settings_partial_update PASSED
tests/.../test_settings_store_functions.py::test_store_llm_settings_mcp_update_preserves_base_url PASSED

Full suite (16/16): pytest tests/unit/server/routes/test_settings_store_functions.py — all green.

I can record a UI walkthrough too if you'd like, but the logic is fully covered by these tests. The key distinction is the is None vs == "" split in store_llm_settings — the diff is small and focused.

Copy link
Collaborator

@enyst enyst left a comment

Choose a reason for hiding this comment

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

Thank you!

@enyst enyst merged commit a75b576 into OpenHands:main Mar 20, 2026
19 checks passed
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.

[Bug]: LLM settings cannot persist an empty base URL

2 participants