fix(settings): preserve stored LLM api_key when field is empty on save - #17807
juanmichelini wants to merge 6 commits into
Conversation
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>
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
left a comment
There was a problem hiding this comment.
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_keyis dropped from the payload instead of forwarded as"", so the agent-serverdeep_mergepreserves the stored key; a non-empty key is still trimmed and sent. Bothsrc/routes/llm-settings.tsxandllm-settings-local-view.tsxalready 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.tspasses on the head (7 passed), and the same file run againstorigin/mainin a separate worktree fails the "drops an empty api_key" case withexpected '' to be undefined. The test reaches the changed behavior and would catch a regression. - CI:
test-and-build (ubuntu)andtest-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 attachedpr-17807-repro-evidence.pngand theHUMAN:note are a Pythondeep_mergereproduction 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 (profileapi_key_setstays true) and that switching to it mid-conversation no longer raisesLLMAuthenticationError. 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
FAILUREbecause an earlierValidate PR descriptionrun from the pre-edit body failed, while a later run on the same head passed. That leavesmergeStateStatus: 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
all-hands-bot
left a comment
There was a problem hiding this comment.
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_keyis dropped from the payload instead of forwarded as"", so the agent-serverdeep_mergepreserves the stored key; a non-empty key is still trimmed and sent. Bothsrc/routes/llm-settings.tsx(buildPayload) andsrc/components/features/settings/llm-profiles/llm-settings-local-view.tsxalready 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.tspasses on the head (7 passed), and the same test file run againstorigin/mainin a separate worktree fails the "drops an empty api_key" case withAssertionError: expected '' to be undefined. The test reaches the changed behavior and would catch a regression. - CI for this head:
test-and-build (windows)passed andValidate PR descriptionnow 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 attachedpr-17807-repro-evidence.pngand theHUMAN:note are a Pythondeep_mergereproduction 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 (profileapi_key_setstays true) and that switching to it mid-conversation no longer raisesLLMAuthenticationError. 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
left a comment
There was a problem hiding this comment.
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_keyis now deleted from the payload instead of forwarded as"", so the agent-serverdeep_mergepreserves the stored key; a non-empty key is still trimmed and sent.src/routes/llm-settings.tsx(buildPayload) andllm-settings-local-view.tsxalready 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 sendapi_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.tspasses on this head (7 passed), and the same file run againstorigin/mainin a separate worktree fails the "drops an empty api_key" case withexpected '' 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 isSUCCESS.reviewDecisionisREVIEW_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/settingscalls against the local agent-server: it shows the backenddeep_mergesemantics (api_key: ""wipes the stored key tonull, an omittedapi_keypreserves it), andpr-17807-repro-evidence.pngpairs a real/settings/llmpage 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""vsnulldistinction 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/llmsave a model plus a valid key, reopen, change only the model, save, and show the stored key is cleared (then the switch failing withLLMAuthenticationError); repeat on this head and show the key survives (profileapi_key_setstays 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
left a comment
There was a problem hiding this comment.
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_keyis deleted from the payload instead of forwarded as"", so the agent-serverdeep_mergepreserves the stored key; a non-empty key is still trimmed and sent.src/routes/llm-settings.tsx(buildPayload) andllm-settings-local-view.tsxalready treat an empty key as "no change" (there is no clear-key affordance), and the other writers (settings-form.tsx,agent-settings.tsx) never sendapi_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.tspasses on this head (7 passed), and the same file run againstorigin/mainin a separate worktree fails the "drops an empty api_key" case withAssertionError: 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 isSUCCESS.reviewDecisionisREVIEW_REQUIRED, so this review is the remaining gate, not a CI failure.
Evidence
- The updated
pr-17807-repro-evidence.pngpairs a real/settings/llmcapture (running from this branch against the live local agent-server) with a live-backend before/after: the buggy payload (api_key: "") leavesagent_settings.llm.api_keywiped toNoneafter a model-only save, while the fixed payload (noapi_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
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:
: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.PATCH /api/settingspayloads the buggy vs. fixed frontend send (see "How to Test" / screenshots). Before: a model-only save forwardsapi_key: ""and the stored key is wiped toNone. After: the fixed frontend drops the emptyapi_keyand the stored key stays set (**********).use-save-settingsvitest suite: 7 passed; the new "drops an empty api_key" case fails onorigin/mainand passes on this head.Before
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.tsforwarded that""to the backend, and the backenddeep_mergetreats onlyNoneas "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 withLLMAuthenticationError.Summary
src/hooks/mutation/use-save-settings.ts, dropapi_keyfrom theagent_settings_diffwhen 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.Issue Number
Fixes #17806
How to Test
npm ci && npm run make-i18nnpx vitest run __tests__/hooks/mutation/use-save-settings.test.ts→ 7 passed.deep_merge):npm run devfrom this branch, open/settings/llm, save a model + valid key, reopen, change only the model and save; confirm the storedapi_keyis preserved (not cleared) and switching to that profile mid-conversation no longer errors.Video/Screenshots
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/settingspayloads the buggy vs. fixed frontend send (backend = local agent-server at127.0.0.1:8000, model =openhands/glm-5.2):api_key: ""): after a model-only save the storedagent_settings.llm.api_keyis wiped toNone— switching to that profile mid-conversation raisesLLMAuthenticationError.api_keyfrom 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
Notes
Scoped to the frontend source of the bad payload. A complementary backend hardening (treating
api_key == ""as "no change" indeep_merge/Settings.update, consistent with the existingbase_url == ''special case inresolve_llm_base_url) would protect every client and can be tracked separately. Related but distinct: #17206 (provider connection not resolved onswitch_llm).🐳 Docker images for this PR
• GHCR package: https://github.com/OpenHands/OpenHands/pkgs/container/agent-canvas
ghcr.io/openhands/agent-canvasghcr.io/openhands/agent-server:1.50.1-pythonopenhands-automation==1.16.001bd91482c4ae89251e164f56b96e9b7dfbfa1dcPull (multi-arch manifest)
# Multi-arch manifest — Docker automatically pulls the correct architecture docker pull ghcr.io/openhands/agent-canvas:sha-01bd914Run
All tags pushed for this build
About Multi-Architecture Support
sha-01bd914) is a multi-arch manifest supporting both amd64 and arm64sha-01bd914-amd64) are also available if needed