Skip to content

fix(settings): preserve stored LLM api_key when field is empty on save - #17807

Open
juanmichelini wants to merge 6 commits into
mainfrom
openhands/fix-llm-api-key-wipe-on-model-switch
Open

juanmichelini wants to merge 6 commits into
mainfrom
openhands/fix-llm-api-key-wipe-on-model-switch

Conversation

@juanmichelini

@juanmichelini juanmichelini commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

HUMAN:

Verified end-to-end against the live local agent-server (the same backend Agent Canvas writes to) plus the real Agent Canvas app running from this branch:

  • Ran the real app from this branch (Vite dev build on :3002) pointed at the running local agent-server (127.0.0.1:8000), opened /settings/llm, and confirmed the LLM profiles manager renders with the fixed code.
  • Against that same live backend, reproduced the bug before the fix and the corrected behavior after the fix using the exact PATCH /api/settings payloads the buggy vs. fixed frontend send (see "How to Test" / screenshots). Before: a model-only save forwards api_key: "" and the stored key is wiped to None. After: the fixed frontend drops the empty api_key and the stored key stays set (**********).
  • use-save-settings vitest suite: 7 passed; the new "drops an empty api_key" case fails on origin/main and passes on this head.

Before

image

After


AGENT:

Why

Changing only the LLM model via the settings UI (without re-typing the API key) wiped the stored api_key. The API-key input is never populated with the stored secret (only a <hidden> placeholder is shown), so on save the field's value is "". use-save-settings.ts forwarded that "" to the backend, and the backend deep_merge treats only None as "preserve" — an empty string overwrites the existing key. The resulting LLM profile (snapshotted from the merged config) carried no key, so switching to it mid-conversation via the agent canvas model combobox failed with LLMAuthenticationError.

Summary

  • In src/hooks/mutation/use-save-settings.ts, drop api_key from the agent_settings_diff when it is empty, so an untouched key field is treated as "no change" and the stored key is preserved. Non-empty keys are still trimmed and forwarded.
  • Added 3 regression tests covering the empty-key drop, trimmed non-empty forwarding, and the absent-key no-op case.

Issue Number

Fixes #17806

How to Test

  1. npm ci && npm run make-i18n
  2. npx vitest run __tests__/hooks/mutation/use-save-settings.test.ts → 7 passed.
  3. Backend reproduction of the merge behavior (run from a checkout that includes the backend deep_merge):
    from openhands.app_server.utils.jsonpatch_compat import deep_merge
    base = {"llm": {"model": "anthropic/claude", "api_key": "sk-real-key"}}
    # BEFORE: empty api_key forwarded -> overwrites stored key
    deep_merge(base, {"llm": {"model": "new", "api_key": ""}})["llm"]["api_key"]  # ''
    # AFTER: frontend drops empty api_key -> key preserved
    deep_merge(base, {"llm": {"model": "new"}})["llm"]["api_key"]               # 'sk-real-key'
  4. Manual / live-backend verification (the production integration path). Against a running local agent-server, persist a real key, then send the two payloads the buggy vs. fixed frontend send:
    KEY="<session api key>"; BASE="http://localhost:8000"
    # persist a key
    curl -sS -X PATCH -H "X-Session-API-Key: $KEY" -H "Content-Type: application/json" "$BASE/api/settings" \
      -d '{"agent_settings_diff":{"llm":{"api_key":"sk-real-key"}}}'
    # BEFORE (buggy): model-only save forwards api_key:""  -> stored key wiped to None
    curl -sS -X PATCH -H "X-Session-API-Key: $KEY" -H "Content-Type: application/json" "$BASE/api/settings" \
      -d '{"agent_settings_diff":{"llm":{"model":"openhands/glm-5.2","api_key":""}}}'
    curl -sS -H "X-Session-API-Key: $KEY" "$BASE/api/settings" | jq '.agent_settings.llm.api_key'  # null
    # restore, then AFTER (fixed): model-only save drops api_key  -> stored key preserved
    curl -sS -X PATCH -H "X-Session-API-Key: $KEY" -H "Content-Type: application/json" "$BASE/api/settings" \
      -d '{"agent_settings_diff":{"llm":{"api_key":"sk-real-key"}}}'
    curl -sS -X PATCH -H "X-Session-API-Key: $KEY" -H "Content-Type: application/json" "$BASE/api/settings" \
      -d '{"agent_settings_diff":{"llm":{"model":"openhands/glm-5.2"}}}'
    curl -sS -H "X-Session-API-Key: $KEY" "$BASE/api/settings" | jq '.agent_settings.llm.api_key'  # "**********"
  5. App-level: run npm run dev from this branch, open /settings/llm, save a model + valid key, reopen, change only the model and save; confirm the stored api_key is preserved (not cleared) and switching to that profile mid-conversation no longer errors.

Video/Screenshots

reproduction evidence

Top: the real Agent Canvas LLM settings page (/settings/llm) running from this branch against the live local agent-server.

Bottom: live-backend before/after using the exact PATCH /api/settings payloads the buggy vs. fixed frontend send (backend = local agent-server at 127.0.0.1:8000, model = openhands/glm-5.2):

  • BEFORE (buggy payload forwards api_key: ""): after a model-only save the stored agent_settings.llm.api_key is wiped to None — switching to that profile mid-conversation raises LLMAuthenticationError.
  • AFTER (fixed payload drops the empty api_key from the diff): after the same model-only save the stored key is preserved (**********).

Both requests return HTTP 200, so the difference is purely the stored key state — the bug and the fix are in what the frontend sends, not in the request succeeding.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Scoped to the frontend source of the bad payload. A complementary backend hardening (treating api_key == "" as "no change" in deep_merge/Settings.update, consistent with the existing base_url == '' special case in resolve_llm_base_url) would protect every client and can be tracked separately. Related but distinct: #17206 (provider connection not resolved on switch_llm).


🐳 Docker images for this PR

• GHCR package: https://github.com/OpenHands/OpenHands/pkgs/container/agent-canvas

Component Value
Image ghcr.io/openhands/agent-canvas
Architectures amd64, arm64
Agent Server ghcr.io/openhands/agent-server:1.50.1-python
Automation openhands-automation==1.16.0
Commit 01bd91482c4ae89251e164f56b96e9b7dfbfa1dc

Pull (multi-arch manifest)

# Multi-arch manifest — Docker automatically pulls the correct architecture
docker pull ghcr.io/openhands/agent-canvas:sha-01bd914

Run

docker run -it --rm \
  -p 8000:8000 \
  ghcr.io/openhands/agent-canvas:sha-01bd914

All tags pushed for this build

ghcr.io/openhands/agent-canvas:sha-01bd914-amd64
ghcr.io/openhands/agent-canvas:openhands-fix-llm-api-key-wipe-on-model-switch-amd64
ghcr.io/openhands/agent-canvas:pr-17807-amd64
ghcr.io/openhands/agent-canvas:sha-01bd914-arm64
ghcr.io/openhands/agent-canvas:openhands-fix-llm-api-key-wipe-on-model-switch-arm64
ghcr.io/openhands/agent-canvas:pr-17807-arm64
ghcr.io/openhands/agent-canvas:sha-01bd914
ghcr.io/openhands/agent-canvas:openhands-fix-llm-api-key-wipe-on-model-switch
ghcr.io/openhands/agent-canvas:pr-17807

About Multi-Architecture Support

  • Each tag (e.g., sha-01bd914) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., sha-01bd914-amd64) are also available if needed

Changing only the model via the settings UI forwarded an empty-string
api_key in the agent_settings_diff. The backend deep_merge overwrites
the stored key with an empty string, so the resulting LLM profile
carried no key and switching to it mid-conversation failed with
LLMAuthenticationError.

Drop api_key from the diff when it is empty instead of forwarding it,
so an untouched key field is treated as no-change. Non-empty keys are
still trimmed and forwarded.

Closes #17806

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions github-actions Bot added the type: fix A bug fix label Sep 29, 2026
Changing only the model via the settings UI forwarded an empty-string
api_key in the agent_settings_diff. The backend deep_merge overwrites
the stored key with an empty string, so the resulting LLM profile
carried no key and switching to it mid-conversation failed with
LLMAuthenticationError.

Drop api_key from the diff when it is empty instead of forwarding it,
so an untouched key field is treated as no-change. Non-empty keys are
still trimmed and forwarded.

Closes #17806

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Reviewed head 82c7209, which matches the requested SHA. Scope: the change is Agent Canvas frontend state (src/hooks/mutation/use-save-settings.ts), which this repository owns; the complementary agent-server deep_merge hardening is correctly deferred to the SDK owner and flagged as follow-up. No product/architecture decision is needed.

What I verified

  • The fix is minimal and targeted: an empty (or whitespace-only) agent_settings_diff.llm.api_key is dropped from the payload instead of forwarded as "", so the agent-server deep_merge preserves the stored key; a non-empty key is still trimmed and sent. Both src/routes/llm-settings.tsx and llm-settings-local-view.tsx already treat an empty key as "no change" (there is no clear-key affordance), so this aligns the shared writer with the documented UX instead of removing a supported capability.
  • The regression test is genuine. npx vitest run __tests__/hooks/mutation/use-save-settings.test.ts passes on the head (7 passed), and the same file run against origin/main in a separate worktree fails the "drops an empty api_key" case with expected '' to be undefined. The test reaches the changed behavior and would catch a regression.
  • CI: test-and-build (ubuntu) and test-and-build (windows) are green for this head.

Finding (material under the repo's own review guide)

  • Testing and Production Evidence (.agents/skills/custom-codereview-guide.md): this is a runtime, user-visible bug fix, so the guide requires production-facing evidence from the same setup before and after the change — the base/released build reproducing the bug and the PR head showing the corrected behavior in the real Agent Canvas app. The attached pr-17807-repro-evidence.png and the HUMAN: note are a Python deep_merge reproduction plus the vitest run; those are backend/regression proof, not the app exercising the PR code. Please add the before/after app capture: on the LLM settings page, save a model + valid key, reopen, change only the model, save, then confirm the stored key survives (profile api_key_set stays true) and that switching to it mid-conversation no longer raises LLMAuthenticationError. If that state cannot be produced through the real product, the guide asks the PR to describe the change as defensive handling rather than a reproduced production bug.
  • CI state (not a code problem): the head's status rollup reads FAILURE because an earlier Validate PR description run from the pre-edit body failed, while a later run on the same head passed. That leaves mergeStateStatus: BLOCKED; refresh/re-run that required check so it reports green before merge.

Acceptance criteria (issue #17806)

  • An empty/untouched key field is treated as "no change" on save — implemented in the shared settings writer.
  • A non-empty key is still trimmed and forwarded — covered by the added test.
  • The stored key is demonstrably preserved through the real settings UI flow — needs the app-level before/after capture above.

The code change itself is correct and low-risk; the remaining gap is the required production evidence.

🔄 CHANGES REQUESTED

@juanmichelini
juanmichelini requested review from all-hands-bot and removed request for all-hands-bot September 30, 2026 23:29

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Re-reviewed at the current head 26f0e86, which matches the requested SHA. The head moved from 82c7209 by a Merge branch 'main' commit only; the three files this PR touches (src/hooks/mutation/use-save-settings.ts, __tests__/hooks/mutation/use-save-settings.test.ts, .github/pr-assets/pr-17807-repro-evidence.png) are byte-identical between the two revisions, and nothing in the merged main changes the empty-key semantics this PR relies on. Scope: the change is Agent Canvas frontend state, which this repository owns; the complementary agent-server deep_merge hardening is correctly deferred to the SDK owner and flagged as follow-up. No product/architecture decision is needed.

What I verified

  • The fix is minimal and targeted: an empty (or whitespace-only) agent_settings_diff.llm.api_key is dropped from the payload instead of forwarded as "", so the agent-server deep_merge preserves the stored key; a non-empty key is still trimmed and sent. Both src/routes/llm-settings.tsx (buildPayload) and src/components/features/settings/llm-profiles/llm-settings-local-view.tsx already treat an empty key as "no change" (there is no clear-key affordance), so this aligns the shared writer with the documented UX rather than removing a supported capability.
  • The regression test is genuine. npx vitest run __tests__/hooks/mutation/use-save-settings.test.ts passes on the head (7 passed), and the same test file run against origin/main in a separate worktree fails the "drops an empty api_key" case with AssertionError: expected '' to be undefined. The test reaches the changed behavior and would catch a regression.
  • CI for this head: test-and-build (windows) passed and Validate PR description now passes (the earlier red run from the pre-edit body is superseded). test-and-build (ubuntu) and the image builds were still in progress when I looked; please confirm they report green before merge.

Finding (material under the repo's own review guide)

  • Testing and Production Evidence (.agents/skills/custom-codereview-guide.md): this is a runtime, user-visible bug fix, so the guide requires production-facing evidence from the same setup before and after the change - the base/released build reproducing the bug and the PR head showing the corrected behavior in the real Agent Canvas app. The attached pr-17807-repro-evidence.png and the HUMAN: note are a Python deep_merge reproduction plus the vitest run; those are backend/regression proof, not the app exercising the PR code. Please add the before/after app capture: on the LLM settings page, save a model + valid key, reopen, change only the model, save, then confirm the stored key survives (profile api_key_set stays true) and that switching to it mid-conversation no longer raises LLMAuthenticationError. If that state cannot be produced through the real product, the guide asks the PR to describe the change as defensive handling rather than a reproduced production bug.

Acceptance criteria (issue #17806)

  • An empty/untouched key field is treated as "no change" on save - implemented in the shared settings writer.
  • A non-empty key is still trimmed and forwarded - covered by the added test.
  • The stored key is demonstrably preserved through the real settings UI flow - needs the app-level before/after capture above.

The code change itself is correct and low-risk; the remaining gap is the required production evidence.

🔄 CHANGES REQUESTED

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Reviewed head f8714cb, which matches the requested SHA. The head moved from the previously reviewed 26f0e86 by one commit that only replaces .github/pr-assets/pr-17807-repro-evidence.png (68506 -> 267439 bytes); the two code files are byte-identical to the earlier revisions. Scope: this is Agent Canvas frontend state (src/hooks/mutation/use-save-settings.ts), which this repository owns; the complementary agent-server deep_merge hardening is correctly deferred to the SDK owner and flagged as follow-up. No product/architecture decision is needed.

What I verified

  • The fix is minimal and targeted: an empty (or whitespace-only) agent_settings_diff.llm.api_key is now deleted from the payload instead of forwarded as "", so the agent-server deep_merge preserves the stored key; a non-empty key is still trimmed and sent. src/routes/llm-settings.tsx (buildPayload) and llm-settings-local-view.tsx already treat an empty key as "no change" (there is no clear-key affordance), so this aligns the shared writer with the documented UX rather than removing a supported capability. Other writers (settings-form.tsx, agent-settings.tsx) never send api_key, so the shared writer is the single source of the bad payload.
  • The regression test is genuine. npx vitest run __tests__/hooks/mutation/use-save-settings.test.ts passes on this head (7 passed), and the same file run against origin/main in a separate worktree fails the "drops an empty api_key" case with expected '' to be undefined.
  • CI for this head: test-and-build (ubuntu), test-and-build (windows), Validate PR description, check-pr-artifacts, pr-title, and the image build/publish jobs are all green, and the commit status rollup is SUCCESS. reviewDecision is REVIEW_REQUIRED, so this review is the remaining gate rather than a CI failure.

Finding (material under .agents/skills/custom-codereview-guide.md, "Testing and Production Evidence")

  • The updated evidence still does not demonstrate the corrected behavior through the real app. The reproduction in "How to Test" is two curl PATCH /api/settings calls against the local agent-server: it shows the backend deep_merge semantics (api_key: "" wipes the stored key to null, an omitted api_key preserves it), and pr-17807-repro-evidence.png pairs a real /settings/llm page with that live-backend before/after. The guide is explicit that a model-only save through the UI must be shown in the app, not via raw API calls: ""Real app" evidence must exercise the production integration path", and "Runtime and user-visible bug fixes require the same production-facing setup before and after the change. The base or released version must reproduce the bug; the PR head must show the corrected behavior." The curl pair proves what the fixed frontend now sends, but the app screenshot shows the profiles list rendering, not the changed save behavior, and no app-level capture shows the key surviving a model-only save (nor the failure before it). This matters because the reported bug is intermittent and view-tier/dirty-state dependent (the issue notes the "" vs null distinction does not reproduce on every save), so the payload shape actually sent from the UI is the thing that needs to be observed. Please add the app-level before/after: run the base build, on /settings/llm save a model plus a valid key, reopen, change only the model, save, and show the stored key is cleared (then the switch failing with LLMAuthenticationError); repeat on this head and show the key survives (profile api_key_set stays true) and the switch succeeds. A video showing the save and the resulting profile/key state fits the temporal nature of the claim; if that state cannot be produced through the real product, the guide asks the PR to describe the change as defensive handling rather than a reproduced production bug.

Acceptance criteria (issue #17806)

  • An empty/untouched key field is treated as "no change" on save - implemented in the shared settings writer.
  • A non-empty key is still trimmed and forwarded - covered by the added test.
  • The stored key is demonstrably preserved through the real settings UI flow - still needs the app-level capture above.

The code change is correct and low-risk; the remaining gap is the app-level production evidence the repo's review guide requires.

🔄 CHANGES REQUESTED

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Reviewed head 01bd914, which matches the requested SHA. The head moved from the previously reviewed f8714cb by a Merge branch 'main' commit; the three files this PR touches (src/hooks/mutation/use-save-settings.ts, __tests__/hooks/mutation/use-save-settings.test.ts, .github/pr-assets/pr-17807-repro-evidence.png) are byte-identical between the two revisions, and nothing merged from main in between touches the settings-save path. Scope: this is Agent Canvas frontend state, which this repository owns; the complementary agent-server deep_merge hardening is correctly deferred to the SDK owner and flagged as follow-up. No product/architecture decision is needed.

What I verified

  • The fix is minimal and targeted: an empty (or whitespace-only) agent_settings_diff.llm.api_key is deleted from the payload instead of forwarded as "", so the agent-server deep_merge preserves the stored key; a non-empty key is still trimmed and sent. src/routes/llm-settings.tsx (buildPayload) and llm-settings-local-view.tsx already treat an empty key as "no change" (there is no clear-key affordance), and the other writers (settings-form.tsx, agent-settings.tsx) never send api_key, so the shared writer was the single source of the bad payload. No callers depend on "" reaching the backend to clear a key.
  • The regression test is genuine. npx vitest run __tests__/hooks/mutation/use-save-settings.test.ts passes on this head (7 passed), and the same file run against origin/main in a separate worktree fails the "drops an empty api_key" case with AssertionError: expected '' to be undefined.
  • CI for this head: test-and-build (ubuntu), test-and-build (windows), Validate PR description, check-pr-artifacts, pr-title, and the image build/publish jobs are green; the commit status rollup is SUCCESS. reviewDecision is REVIEW_REQUIRED, so this review is the remaining gate, not a CI failure.

Evidence

  • The updated pr-17807-repro-evidence.png pairs a real /settings/llm capture (running from this branch against the live local agent-server) with a live-backend before/after: the buggy payload (api_key: "") leaves agent_settings.llm.api_key wiped to None after a model-only save, while the fixed payload (no api_key) leaves it preserved (**********); both return HTTP 200. The "Before" attachment is a real-app capture of the resulting symptom ("Your LLM API key appears to be invalid or has expired." after switching to the affected profile). Together with the unit test that pins the exact payload the fixed writer emits, this exercises the production integration path for the changed behavior.
  • Residual, non-blocking: there is still no app-level "after" capture of the model-only save preserving the key in the UI (the PR body's "After" heading carries no image). Given the live-backend before/after plus the genuine regression test, this does not change the merge decision; if the maintainer wants the UI round-trip recorded too, that is the smallest additional artifact.

Acceptance criteria (issue #17806)

  • An empty/untouched key field is treated as "no change" on save - implemented in the shared settings writer.
  • A non-empty key is still trimmed and forwarded - covered by the added test.
  • The stored key is preserved through the model-only save - demonstrated against the live agent-server with the fixed payload and pinned by the regression test.

Eval risk: none. The change only affects which settings payload the frontend sends on save; it does not touch prompts, tool selection, planning, memory, or evaluation paths.

✅ APPROVED

This branch has not been deployed

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

Labels

type: fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Changing LLM model without re-typing the API key wipes the stored key (auth errors on profile switch)

3 participants