EN / field notes OpenHands feature map

OpenHands / F11

Provider connections

A provider connection is a named, shared credential (provider, API key, optional base URL) that many LLM profiles link to instead of carrying their own key. Under Settings → LLM, below Available Profiles, a user lists connections with their linked-model count, adds them, edits them (rename, provider, base URL, key rotation with a blank key meaning "keep"), bulk-adds linked profiles from a row's menu, and deletes them; the server refuses to delete a connection that a profile or the active settings still reference.

21 mapped behaviors · 23 recipes and supporting checks · source snapshot 9 October 2026
From upstream main at 8793c111. Read the maintained source.

How to get to it

  • Sidebar settings gear (backend-selector-settings-link), then LLM in the settings navigation (sidebar-settings-/settings/llm); the section sits below Available Profiles.
  • Direct URL /settings/llm.
  • Command menu (Control+k/Meta+k, or command-menu-trigger): search LLM, choose LLM profiles. Searching provider connection finds nothing.
  • Add connection (add-provider-connection) opens the create modal; each row's ... (provider-connection-menu-trigger) opens Bulk add / Edit / Delete.
  • Elsewhere (other families): Model Router's editor has its own Add connection (meta-profile-add-provider-connection, see F12); the LLM profile editor links a profile to a connection and Add from provider connections bulk-adds without a preselected connection (see F10).

Before you start

Start with the common launch and health checks, then follow this family’s preconditions in order. Recipes share the fixtures and state named below.

Preconditions:

  • A fresh control-openhands launch --new, doctored, onboard --skip done, desktop viewport. Do not run llm preset deepseek before this family: the recipes need no LLM profile and no connection (control-openhands api GET /api/profiles lists none, control-openhands api GET /api/llm/provider-connections is []), so the profile added in Bulk add becomes the active one.
  • A DeepSeek key in a file (<key file>). Pass it only with browser fill ... --value-file <key file>; never on the command line.
  • F11.agent-uses-connection and F11.rotate start three tiny conversations on deepseek/deepseek-flash.
  • F11.load-error is arranged by making the run's own connections store unreadable (a shell arrange step that writes only $OH_VERIFY_RUN/private/provider-connections/provider_connections.json, restored afterwards); service stop agent-server does not work because it replaces the whole page with agent-server-onboarding-screen.
  • Blocked here: F11.cloud (needs a Cloud account with and without an organization, as owner and as member).

Behavior inventory

21 stable behavior IDs and their expected behavior
  • F11.list-empty with no connections the section shows the heading Provider connections, the subline Share one API key across multiple models., Add connection and No provider connections yet. Add one to share an API key across models. Read recipe ↓
  • F11.list each row shows the name, the provider, <n> model(s) (profiles linked to it), a green key-set icon and a ... menu labelled Provider connection menu; the section is reachable from the settings nav, the command menu and the URL. Read recipe ↓
  • F11.create-validation in Add provider connection focus starts on Name; Save stays disabled until Name, a picked Provider and API Key are non-blank (whitespace does not count); a provider typed but not picked is cleared; Enter does not submit. Read recipe ↓
  • F11.provider-picker the Provider combobox lists Verified Models (OpenHands, Anthropic, OpenAI, …, deepseek, …) and Other Models groups and filters as you type. Read recipe ↓
  • F11.create Save creates the connection (toast Connection "<name>" created); the row persists after a reload and a backend restart; the key is stored but never shown. Read recipe ↓
  • F11.create-cancel Cancel, Escape and a backdrop click close the modal without creating anything; reopening starts empty. Read recipe ↓
  • F11.create-error a server rejection (for example a name over 128 characters) keeps the modal open and toasts a readable message. Read recipe ↓
  • F11.row-menu the ... menu offers Bulk add, Edit, Delete; Escape, a click outside or a second trigger click close it; ArrowUp/ArrowDown move and wrap; Tab closes it. Read recipe ↓
  • F11.row-menu-focus opening the menu moves focus to its first item (Bulk add). Read recipe ↓
  • F11.edit Edit opens Edit provider connection prefilled (key blank with placeholder <hidden> and hint Leave blank to keep the current key.); rename, provider and base URL changes persist (toast Connection "<name>" updated), a cleared base URL is stored as none, a blank name disables Save, and a rename shows in the linked profiles' group header. Read recipe ↓
  • F11.add-models Bulk add opens Add models as profiles with the row's provider and connection preselected; the profiles it adds are linked and the row's count goes up. Read recipe ↓
  • F11.agent-uses-connection a conversation on a profile linked to the connection runs with the connection's key; an Edit with the key left blank keeps it working. Read recipe ↓
  • F11.rotate typing a new key rotates it for new conversations: an invalid key makes the next conversation fail with the provider's authentication error and no reply; rotating back restores replies. Read recipe ↓
  • F11.delete Delete asks Are you sure you want to delete the connection "<name>"? (focus on Cancel); Cancel and Escape keep it; Delete removes it (toast Connection "<name>" deleted). Read recipe ↓
  • F11.delete-referenced deleting a connection that profiles or the active settings reference is refused with the server's message naming them; the dialog stays open and the row stays. Read recipe ↓
  • F11.delete-stale-reference once its last linked profile is deleted (row shows 0 model(s)) the connection can be deleted, or the UI says how to clear the remaining reference. Read recipe ↓
  • F11.phone at 390 px the rows, the row menu and the modals fit without horizontal overflow. Read recipe ↓
  • F11.load-error if listing connections fails (for example an unreadable connections store, GET /api/llm/provider-connections 400), the section shows Failed to load provider connections. (provider-connections-load-error) in red while the profiles list and Add connection still render. Read recipe ↓
  • F11.long-name a name at the 128-character limit is accepted; the row truncates the name and provider with an ellipsis, shows the full name as a hover tooltip (title) and keeps the ... trigger in view at desktop and phone width. Read recipe ↓
  • F11.edit-unlisted-provider a connection whose provider is not in the provider catalog (created through the API) opens in Edit with that provider preselected and listed under Other Models, and saving keeps it. Read recipe ↓
  • F11.cloud on Cloud the section appears only with an organization bound and for members allowed to manage profiles; local users always see it.

Readable recipes

Read each script from top to bottom. Code is copied from the map; prose gives the action, expected observation, and conditions. <id>, <run> and similar placeholders stand for values from your own run. Short forms such as browser count continue the same control-openhands invocation; they are kept as documented.

Expected observations describe the recipe’s contract. Captures below selected recipes show representative real states from this snapshot; they do not mark every mapped behavior as passed. Follow cleanup before moving to another family.

Empty state #

  1. Check
    control-openhands browser click 'testid=backend-selector-settings-link' --expect-url '/settings'
  2. Check
    control-openhands browser click 'testid=sidebar-settings-/settings/llm' --expect-url '/settings/llm'
  3. Check
    control-openhands browser text 'testid=provider-connections-empty'
  4. Check
    control-openhands browser snapshot main
  5. Expect
    The text is No provider connections yet. Add one to share an API key across models.; the snapshot shows heading Provider connections, paragraph Share one API key across multiple models. and button Add connection (screenshot --feature F11.list-empty --name empty).

Other entry points #

  1. Do
    control-openhands browser goto /

    (Empty state ends on /settings/llm, where the URL wait would pass at once),

  2. Do
    control-openhands browser press Control+k
  3. Do
    control-openhands browser type 'testid=command-menu >> role=combobox' LLM

    (browser snapshot 'testid=command-menu' shows the selected option LLM profiles Manage models, providers, and API keys. Go),

  4. Do
    control-openhands browser press Enter
  5. Wait
    control-openhands browser wait-url '/settings/llm'
  6. Check
    control-openhands browser count 'testid=add-provider-connection'

    (1).

  7. Do
    control-openhands browser goto /settings/llm

    shows the same section.

Validation and provider picker #

  1. Do
    control-openhands browser click 'testid=add-provider-connection'
  2. Check
    control-openhands browser snapshot 'role=dialog'

    shows Add provider connection with Name, Provider, API Key, Base URL Optional and Save [disabled], and

  3. Do
    control-openhands browser eval "document.activeElement.getAttribute('data-testid')"

    is provider-connection-name-input.

  4. Note
    Fill
  5. Do
    control-openhands browser fill 'testid=provider-connection-name-input' QA_conn
  6. Check
    control-openhands browser enabled 'testid=provider-connection-submit'
  7. Note
    stays false.
  8. Do
    control-openhands browser click 'testid=provider-connection-provider-input'
  9. Check
    control-openhands browser snapshot 'role=listbox'
  10. Note
    : groups Verified Models (OpenHands, Anthropic, OpenAI, Mistral AI, Gemini, deepseek, Moonshot, minimax, glm, nvidia, qwen, OpenRouter; OpenRouter is listed since 2026-10-08 or earlier) and Other Models.
  11. Expect
    The field shows the provider's label, not its id: picking testid=provider-item-openai makes browser value 'testid=provider-connection-provider-input' OpenAI.
  12. Do
    control-openhands browser fill 'testid=provider-connection-provider-input' deep
  13. Note
    narrows it to deepseek (verified) and deepgram, DeepInfra; then
  14. Do
    control-openhands browser click 'testid=provider-item-deepseek'
  15. Check
    control-openhands browser value 'testid=provider-connection-provider-input'

    is deepseek.

  16. Note
    Save is still disabled;
  17. Do
    control-openhands browser fill 'testid=provider-connection-api-key-input' ' '
  18. Note
    keeps it disabled, and a name of only spaces does too.
  19. Expect
    A provider typed but not picked is cleared:
  20. Do
    control-openhands browser fill 'testid=provider-connection-provider-input' deepse
  21. Do
    control-openhands browser click 'testid=provider-connection-name-input'
  22. Note
    then browser value 'testid=provider-connection-provider-input' is empty (pick deepseek again afterwards).
  23. Note
    Enter does not submit: with every field valid (enabled true),
  24. Do
    control-openhands browser press Enter
  25. Note
    in the Name field leaves browser count 'testid=provider-connection-modal' at 1 and creates nothing (api GET /api/llm/provider-connections).
Add provider connection dialog with Name, Provider, API Key, optional Base URL and a disabled Save button.
Name, provider and a nonempty key are required before Save becomes available. CLI capture · 1440 × 1000 · 9 October 2026 · Canvas 8793c111

Add provider connection dialog with Name, Provider, API Key, optional Base URL and a disabled Save button.

Representative documentation capture, not a complete run of this recipe or family. Captured on an isolated local backend at desktop viewport using the current main checkout.

How this screenshot was taken

canvas: 1.26.0 · agent server: 1.53.0 · sdk: 1.53.0 · automation: 1.19.0

OH_VERIFY_RUN="$OH_VERIFY_RUN" control-openhands browser goto /settings/llm
OH_VERIFY_RUN="$OH_VERIFY_RUN" control-openhands browser click testid=add-provider-connection
OH_VERIFY_RUN="$OH_VERIFY_RUN" control-openhands browser snapshot role=dialog
OH_VERIFY_RUN="$OH_VERIFY_RUN" control-openhands browser screenshot --feature F11.create-validation --name provider-connection-dialog

Create #

  1. Note
    In the same modal run
  2. Check
    control-openhands browser fill 'testid=provider-connection-api-key-input' --value-file <key file>

    (enabled turns true, browser attr 'testid=provider-connection-api-key-input' type is password),

  3. Do
    control-openhands browser click 'testid=provider-connection-submit'
  4. Wait
    control-openhands browser wait-text 'Connection "QA_conn" created'
  5. Check
    control-openhands browser count 'testid=provider-connection-modal'

    is 0.

  6. Note
    After
  7. Do
    control-openhands browser reload
  8. Check
    control-openhands browser text 'testid=provider-connection-row >> has-text=QA_conn'

    is QA_conn\ndeepseek\n0 model(s) and

  9. Check
    control-openhands api GET /api/llm/provider-connections

    lists it with "api_key_set": true and "base_url": null (the key itself is never returned).

Row #

  1. Check
    control-openhands browser count 'testid=provider-connection-row >> has-text=QA_conn >> testid=set-indicator'

    (1),

  2. Check
    control-openhands browser attr 'testid=provider-connection-row >> has-text=QA_conn >> testid=provider-connection-menu-trigger' aria-label

    (Provider connection menu) and

  3. Do
    control-openhands browser screenshot 'testid=provider-connection-row' --feature F11.list --name row

    (name, muted provider, 0 model(s), green check, vertical dots).

Cancel #

  1. Do
    control-openhands browser click 'testid=add-provider-connection'
  2. Do
    control-openhands browser click 'role=dialog >> role=button[name="Cancel"]'
  3. Note
    browser count 'testid=provider-connection-modal' is 0.
  4. Note
    Reopen,
  5. Do
    control-openhands browser fill 'testid=provider-connection-name-input' QA_escape
  6. Do
    control-openhands browser press Escape

    (count 0); reopen:

  7. Check
    control-openhands browser value 'testid=provider-connection-name-input'

    is empty;

  8. Do
    control-openhands browser mouse-click 50 500
  9. Note
    closes it too. api GET /api/llm/provider-connections lists no QA_escape.

Server error #

  1. Do
    control-openhands browser click 'testid=add-provider-connection'
  2. Do
    control-openhands browser fill 'testid=provider-connection-name-input' "QA_$(printf 'x%.0s' $(seq 1 127))"

    (130 characters), pick deepseek as above,

  3. Do
    control-openhands browser fill 'testid=provider-connection-api-key-input' dummy-key-3
  4. Do
    control-openhands browser click 'testid=provider-connection-submit'
  5. Check
    control-openhands browser toasts
  6. Check
    control-openhands browser count 'testid=provider-connection-modal'
  7. Note
    Expected: the modal stays open (1) and the toast reads like String should have at most 128 characters.
  8. Note
    Today the toast is the raw response, HTTP request failed (422 Unprocessable Entity): {"detail":[{"type":"string_too_long",... (fail, screenshot --feature F11.create-error --name toast).
  9. Note
    Close with role=dialog >> role=button[name="Cancel"].

Second connection #

  1. Note
    Create QA_spare the same way with dummy-key-2 as key and
  2. Check
    control-openhands browser fill 'testid=provider-connection-base-url-input' 'not a url'

    (accepted: base URLs are not validated).

  3. Note
    Toast Connection "QA_spare" created.

Row menu #

  1. Do
    control-openhands browser click 'testid=provider-connection-row >> has-text=QA_spare >> testid=provider-connection-menu-trigger'
  2. Check
    control-openhands browser snapshot 'testid=provider-connection-actions-menu'
  3. Note
    : menuitems Bulk add, Edit, Delete (screenshot --feature F11.row-menu --name open).
  4. Do
    control-openhands browser eval "document.activeElement.getAttribute('data-testid')"
  5. Note
    should be provider-connection-add-models; today it stays provider-connection-menu-trigger (fail) and ArrowDown does nothing until
  6. Do
    control-openhands browser press Tab
  7. Note
    moves focus to Bulk add.
  8. Note
    From there browser press ArrowDown focuses provider-connection-edit, two ArrowUp wrap to provider-connection-delete, and browser press Tab closes the menu (browser count 'testid=provider-connection-actions-menu' 0).
  9. Note
    Reopen and browser press Escape (0); reopen and click the trigger again (0); reopen and
  10. Check
    control-openhands browser click 'text=Share one API key across multiple models.'

    (0).

Edit #

  1. Note
    Run the trigger click above, then
  2. Do
    control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-edit'
  3. Check
    control-openhands browser snapshot 'role=dialog'
  4. Note
    : Edit provider connection, Name QA_spare, Provider deepseek, API Key Leave blank to keep the current key. with placeholder <hidden>, Base URL not a url; browser value 'testid=provider-connection-api-key-input' is empty and Save is enabled.
  5. Do
    control-openhands browser fill 'testid=provider-connection-name-input' QA_edited
  6. Check
    control-openhands browser fill 'testid=provider-connection-base-url-input' 'https://api.deepseek.com'
  7. Do
    control-openhands browser click 'testid=provider-connection-submit'
  8. Wait
    control-openhands browser wait-text 'Connection "QA_edited" updated'
  9. Expect
    After browser reload the row reads QA_edited\ndeepseek\n0 model(s), the QA_spare count is 0, and api GET /api/llm/provider-connections shows base_url https://api.deepseek.com and api_key_set true.
  10. Note
    Edit again: fill the name with ' ' (browser enabled 'testid=provider-connection-submit' false), restore QA_edited,
  11. Check
    control-openhands browser fill 'testid=provider-connection-base-url-input' ''
  12. Note
    Save, browser wait 'testid=provider-connection-modal' --state detached; the API shows base_url null.

Bulk add #

  1. Do
    control-openhands browser click 'testid=provider-connection-row >> has-text=QA_conn >> testid=provider-connection-menu-trigger'
  2. Do
    control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-add-models'
  3. Expect
    The dialog Add models as profiles has provider deepseek selected,
  4. Do
    control-openhands browser eval "[...document.querySelectorAll('[data-testid=add-models-connection] option')].map(o=>o.textContent+'='+o.selected).join(' | ')"

    is No connection (keyless)=false | QA_conn=true | QA_edited=false, and browser text 'testid=add-models-submit' is Add 12 profiles (every model checked).

  5. Do
    control-openhands browser uncheck 'testid=add-models-select-all'

    (Add 0 profiles),

  6. Do
    control-openhands browser check 'testid=add-models-check-deepseek/deepseek-flash'

    (Add 1 profiles),

  7. Do
    control-openhands browser click 'testid=add-models-submit'
  8. Wait
    control-openhands browser wait-text 'Added 1'
  9. Expect
    After browser reload the QA_conn row reads QA_conn\ndeepseek\n1 model(s) (QA_edited stays 0 model(s)), and api GET /api/profiles shows deepseek-flash with provider_connection_id and "active_profile": "deepseek-flash".

The agent uses the connection's key #

  1. Note
    Open Edit on QA_conn (as above), leave the key blank, click testid=provider-connection-submit and browser wait-text 'Connection "QA_conn" updated'.
  2. Wait
    control-openhands conversation start --prompt "Reply with exactly the word: pong" --wait --timeout 180

    (prints <id>, "model": "deepseek/deepseek-flash", status finished), then

  3. Check
    control-openhands conversation events <id> --kinds ConversationErrorEvent,MessageEvent
  4. Note
    : the agent message is pong and there is no ConversationErrorEvent.

Rotate the key #

  1. Do
    control-openhands browser goto /settings/llm
  2. Note
    open Edit on QA_conn,
  3. Do
    control-openhands browser fill 'testid=provider-connection-api-key-input' sk-qa-invalid-0000
  4. Note
    Save and browser wait-text 'Connection "QA_conn" updated'.
  5. Wait
    control-openhands conversation start --prompt "Reply with exactly the word: ping" --wait --timeout 180
  6. Check
    control-openhands conversation events <id> --kinds ConversationErrorEvent,MessageEvent
  7. Note
    Expected: a ConversationErrorEvent and no agent reply.
  8. Note
    Today the conversation shows the banner litellm.BadRequestError: DeepseekException - ... Authentication Fails, Your api key: ****0000 is invalid (so the new key was used), yet the agent still answers ping in the same turn (fail, screenshot --feature F11.rotate --name bad-key).
  9. Note
    Rotate back: browser goto /settings/llm, Edit QA_conn,
  10. Check
    control-openhands browser fill 'testid=provider-connection-api-key-input' --value-file <key file>
  11. Note
    Save; a new conversation start --prompt "Reply with exactly the word: pong" --wait --timeout 180 has pong and no ConversationErrorEvent.

Rename a linked connection #

  1. Do
    control-openhands browser goto /settings/llm
  2. Note
    Edit QA_conn, fill the name with QA_shared, Save and browser wait-text 'Connection "QA_shared" updated'.
  3. Expect
    After browser reload,
  4. Check
    control-openhands browser text 'testid=profile-group-header'

    is QA_SHARED (CSS upper-case).

Delete is refused while referenced #

  1. Do
    control-openhands browser click 'testid=provider-connection-row >> has-text=QA_shared >> testid=provider-connection-menu-trigger'
  2. Do
    control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-delete'
  3. Do
    control-openhands browser click 'testid=delete-provider-connection-confirm'
  4. Check
    control-openhands browser toasts
  5. Note
    : Provider connection cannot be deleted while it is referenced by LLM profile(s): deepseek-flash and referenced by the active agent settings. Update those references before deleting it. The dialog stays open (browser count 'role=dialog' > 0); close it with role=dialog >> role=button[name="Cancel"]; after browser reload the row is still there.

Delete #

  1. Do
    control-openhands browser click 'testid=provider-connection-row >> has-text=QA_edited >> testid=provider-connection-menu-trigger'
  2. Do
    control-openhands browser click 'testid=provider-connection-actions-menu >> testid=provider-connection-delete'
  3. Check
    control-openhands browser snapshot 'role=dialog'
  4. Note
    : Delete provider connection, Are you sure you want to delete the connection "QA_edited"?, buttons Cancel and Delete; browser eval "document.activeElement.textContent" is Cancel (screenshot --feature F11.delete --name confirm).
  5. Do
    control-openhands browser click 'role=dialog >> role=button[name="Cancel"]'
  6. Note
    keeps the row; reopen and browser press Escape keeps it too.
  7. Note
    Reopen,
  8. Do
    control-openhands browser click 'testid=delete-provider-connection-confirm'
  9. Wait
    control-openhands browser wait-text 'Connection "QA_edited" deleted'
  10. Note
    browser reload: browser count 'testid=provider-connection-row >> has-text=QA_edited' is 0.

Phone layout #

  1. Do
    control-openhands browser viewport phone
  2. Do
    control-openhands browser scroll 'testid=add-provider-connection'
  3. Check
    control-openhands browser bbox 'testid=provider-connection-row >> nth=0'

    (insideViewport true, pageHorizontalOverflow false) and browser screenshot --feature F11.phone --name list.

  4. Note
    Open the QA_shared row menu: browser bbox 'testid=provider-connection-actions-menu' is inside the viewport.
  5. Note
    Click testid=provider-connection-actions-menu >> testid=provider-connection-edit: browser bbox 'testid=provider-connection-modal' and browser bbox 'testid=provider-connection-submit' are inside the viewport (screenshot --name edit-modal).
  6. Note
    Cancel, then
  7. Do
    control-openhands browser viewport desktop

Long name and provider change #

  1. Note
    Create a connection named "QA_long$(printf 'x%.0s' $(seq 1 121))" (128 characters) with deepseek and dummy-key-5 as above: toast Connection "QA_long…" created, modal closed.
  2. Do
    control-openhands browser tooltip 'testid=provider-connection-row >> has-text=QA_long >> span[title]'

    returns the full 128-character name (source title attribute), browser eval on that span shows scrollWidth > clientWidth with text-overflow: ellipsis, and browser bbox 'testid=provider-connection-row >> has-text=QA_long' is insideViewport true, pageHorizontalOverflow false (screenshot --feature F11.long-name --name row).

  3. Note
    At browser viewport phone after browser scroll 'testid=add-provider-connection', browser bbox 'testid=provider-connection-row >> has-text=QA_long >> testid=provider-connection-menu-trigger' is inside the viewport with no horizontal overflow (screenshot --name phone); back to browser viewport desktop.
  4. Note
    Open Edit on QA_long: browser count 'testid=provider-connection-modal >> testid=set-indicator' is 1 (green key-set check beside API Key).
  5. Do
    control-openhands browser choose 'testid=provider-connection-provider-input' Anthropic
  6. Note
    Save, browser wait 'testid=provider-connection-modal' --state detached, browser reload: the row reads QA_long…\nanthropic\n0 model(s) and api GET /api/llm/provider-connections shows "provider": "anthropic".

Persistence across a backend restart #

  1. Do
    control-openhands restart
  2. Do
    control-openhands browser reload
  3. Note
    browser eval "[...document.querySelectorAll('[data-testid=provider-connection-row]')].map(r=>r.innerText.replace(/\n/g,'|')).join(' ; ')" lists the same rows and counts as before (for example QA_shared|deepseek|1 model(s) ; QA_long…|anthropic|0 model(s)).

Unlisted provider #

  1. Note
    Arrange (not proof)
  2. Arrange
    control-openhands api POST /api/llm/provider-connections --data '{"display_name":"QA_custom","provider":"qa_unlisted","api_key":"dummy-key-6","base_url":null}' --write

    (201), then browser reload: the row reads QA_custom\nqa_unlisted\n0 model(s).

  3. Note
    Open Edit on QA_custom: browser value 'testid=provider-connection-provider-input' is qa_unlisted; browser click that input, browser fill ... qa_ and browser snapshot 'role=listbox' shows only group Other Models with option qa_unlisted [selected].
  4. Note
    Click 'role=option[name="qa_unlisted"]', fill the name with QA_custom2, Save: toast Connection "QA_custom2" updated and the API still shows "provider": "qa_unlisted".
  5. Note
    Delete QA_custom2 and QA_long through their row menus and testid=delete-provider-connection-confirm (wait for the confirm button --state detached between them); api GET /api/llm/provider-connections lists only QA_shared.

Stale reference and cleanup #

  1. Note
    Delete the linked profile (F10):
  2. Do
    control-openhands browser click '[data-testid=profile-row]:has([title="deepseek-flash"]) >> testid=profile-menu-trigger'
  3. Do
    control-openhands browser click 'testid=profile-actions-menu >> testid=profile-delete'
  4. Do
    control-openhands browser click 'testid=delete-profile-confirm'

    (toast Profile "deepseek-flash" deleted).

  5. Expect
    After browser reload the QA_shared row reads 0 model(s).
  6. Note
    Delete QA_shared through its row menu and testid=delete-provider-connection-confirm.
  7. Note
    Expected: it is deleted.
  8. Note
    Today the toast is Provider connection cannot be deleted while it is referenced by the active agent settings. Update those references before deleting it. and nothing on the page clears that reference (fail, screenshot --feature F11.delete-stale-reference --name refused).
  9. Note
    To finish cleanup, close the dialog, arrange an unlinked active profile with
  10. Arrange
    control-openhands llm preset deepseek --api-key-file <key file>

    (this is the arrange step, not proof), browser reload, delete QA_shared through the row menu (toast Connection "QA_shared" deleted), browser reload: testid=provider-connections-empty is back and api GET /api/llm/provider-connections is [].

Load error #

  1. Note
    With the baseline restored (connections []), run the shell arrange step (it writes only this run's state) F=$OH_VERIFY_RUN/private/provider-connections/provider_connections.json; cp $F $F.bak; printf '{not json' > $F.
  2. Check
    control-openhands api GET /api/llm/provider-connections

    is now 400 (Provider connections file is unreadable: ...) while api GET /api/profiles is 200.

  3. Do
    control-openhands browser reload
  4. Check
    control-openhands browser text 'testid=provider-connections-load-error'

    (Failed to load provider connections.), browser count 'testid=add-provider-connection' (1) and browser count 'testid=profile-row' (> 0; screenshot --feature F11.load-error --name error).

  5. Note
    Restore with cp $F.bak $F && rm $F.bak, browser reload: testid=provider-connections-empty is back and provider-connections-load-error count is 0.

Errors sweep #

  1. Check
    control-openhands browser errors --app-only

    shows pageErrors 0; the HTTP errors are the deliberate POST 422 (long name), two DELETE 409 (referenced, then the stale reference) and the GET 400 from the load-error arrange, all on /api/llm/provider-connections.

  2. Note
    Run browser errors --clear afterwards.

Gotchas and known limits

  • Select the Provider by clicking an option (testid=provider-item-<name> exists only for verified providers; use 'role=option[name="<label>"]' for the others), or with browser choose 'testid=provider-connection-provider-input' <label>. browser fill alone types a filter that is cleared on blur and leaves Save disabled.
  • browser fill ... --value-file <key file> needs no positional value and prints only the length; keep keys out of commands and screenshots (the field is a password input).
  • Duplicate display names are accepted (two rows called QA_conn), and so is any base URL text; rows then differ only by order ('testid=provider-connection-row >> nth=1'). Keep fixture names unique.
  • Edit's Save is enabled even with no changes, and a blank key on Edit is omitted from the request (the stored key is kept); there is no way to clear a key.
  • The delete spinner (aria-busy) lasts under 100 ms on a local stack; --observe sees only Delete then <absent>.
  • role=dialog matches two nested elements in these modals; count > 0, do not expect 1.
  • The first profile added on a run with no profiles becomes the active one. Activating a linked profile copies the resolved key into the active agent settings, which keep the connection id: that is why the server still counts a reference after every linked profile is gone (see F11.delete-stale-reference) (OpenHands/software-agent-sdk#5498).
  • Delete a connection's linked profiles before the connection; then also make an unlinked profile active, or the delete is refused.
  • Rotation results are read from the conversation (conversation events ... --kinds ConversationErrorEvent,MessageEvent and the banner in the chat), not from the settings page, which never shows the key.
  • Model Router's Add connection reuses this modal (F12); its toasts and fields are the same.
  • F11.load-error needs a real server failure: the recipe corrupts this run's own provider_connections.json and restores it. Always restore it (keep the .bak), or every later family on the run sees the error; never touch another run's state.
  • At phone width a long name squeezes the provider column to one letter (d…); the full values stay in the API and the name's title.
  • Known issue OpenHands/software-agent-sdk#5497: after a key rotation, new conversations still run agent steps on the old key; only title generation uses the new one (F11.rotate).

Source paths: src/components/features/settings/llm-profiles/provider-connections-manager.tsx, src/components/features/settings/llm-profiles/provider-connection-row.tsx, src/components/features/settings/llm-profiles/provider-connection-actions-menu.tsx, src/components/features/settings/llm-profiles/provider-connection-modal.tsx, src/components/features/settings/llm-profiles/delete-provider-connection-modal.tsx, src/components/features/settings/llm-profiles/llm-profiles-manager.tsx, src/api/provider-connections-service/.