Skip to content

Backend: Model providers — provider store + nested models + named-secret key (#15492) - #4455

Draft
juanmichelini wants to merge 7 commits into
mainfrom
feat/provider-connections-api
Draft

Backend: Model providers — provider store + nested models + named-secret key (#15492)#4455
juanmichelini wants to merge 7 commits into
mainfrom
feat/provider-connections-api

Conversation

@juanmichelini

@juanmichelini juanmichelini commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Backend: Model providers (provider-first)

Foundation for the provider-centric "connect a provider once, then manage its models under it" feature from the wireframes in OpenHands/OpenHands#15492. Blocks the other PRs.

Data model (single source of truth)

ModelProvider { id, display_name, kind, base_url, wire_api,
                secret_name -> SecretsStore (value never returned),
                custom_headers, models: ProviderModel[], created_at, updated_at }
ProviderModel { name, wire_api?  # optional per-model override }
  • One providers.json document via FileProvidersStore, reusing the existing atomic-write + file-lock primitives (same as workspaces/secrets).
  • The key is stored as a named secret (SecretsStore); the provider record holds only secret_name. Responses expose api_key_set and never the value or the secret name.
  • Models are a nested list the user curates — not a fan-out of standalone records.

Endpoints (mounted under /api/llm)

GET/POST                    /model-providers                 list / create
GET/PATCH/DELETE            /model-providers/{id}            get / update+rotate key / delete
POST/PATCH/DELETE           /model-providers/{id}/models[/{name}]  nested model CRUD
POST                        /model-providers/{id}/test       optional key probe (never mutates models)

Create writes the secret first and rolls it back if the record write fails; delete removes the named secret; update can rotate the key in one write. test probes the stored key and returns suggested_models (the provider's advertised catalog) purely as an "add model" convenience — it never overwrites the curated list.

Running a provider's models

The provider store is authoritative. Composing a runnable LLM from (provider fields + model + resolved key) at conversation-activation time in the agent-server is a documented follow-up; this PR is the store + API foundation.

Design: simpler than the first draft

This revision deliberately drops the heavier "auto-catalog connection" path in favor of a smaller, provider-centric store. Compared to the earlier iteration, it removes:

  • the process-global secret: resolver injected into core openhands.sdk.llm.LLM (core SDK is now untouched vs. main);
  • the backfill-on-GET that rewrote existing LLM profiles into secret:<name> references (a read must not mutate saved profiles);
  • the per-model LLM-profile fan-out as a source of truth (models are nested under the provider — one source of truth);
  • validate() overwriting the curated model list with the full provider catalog.

and keeps named-secret key storage (secret_name + api_key_set; key never returned).

Testing

  • tests/agent_server/test_llm_providers.py (11 tests): create, key-stored-as-named-secret, key never echoed, get/404, update + rotate key, update-requires-a-field, delete-removes-provider-and-secret, nested add/edit/remove model, test-probe-never-mutates-models, bad-key, and custom-endpoint-catalog-without-probe.
  • ruff, pyright, pycodestyle and import-dependency checks pass.

Related PRs


This PR was created by an AI agent (OpenHands) on behalf of the user.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

Add a first-class Provider Connection object: connect a vendor once with one
key, pick from its model catalog, and spawn LLM profiles that reference the
connection's key by name (secret:<name>) instead of inline-duplicating it.

Agent-server (openhands-agent-server):
- persistence: ProviderConnection + PersistedConnections models, FileConnectionsStore
  (single JSON, file-locked, atomic write), get_connections_store/reset_stores.
- llm_connections router (mounted at /api/llm/connections): create/list/get/patch/
  delete + /validate. The connection's key is stored as a named secret via the
  existing SecretsStore; responses never echo the key (api_key_set only).
  validate_provider_key is module-level so tests monkeypatch it (no network).
  Key is per-connection so a second key for a provider is additive later.
- api.create_app registers the LLM secret:<name> resolver against the SecretsStore.

SDK (openhands-sdk):
- LLM._get_api_key_value resolves a secret:<name> api_key via an injectable
  resolver (register_llm_secret_resolver); raw keys pass through unchanged, and
  an unset resolver degrades gracefully (None), so standalone SDK use is
  unaffected. This realizes secret-by-name at runtime with a minimal, testable
  change (rotation = one store write, every profile picks it up).

Tests: 20 new tests covering CRUD, masking, validate (incl. injected validator),
connection limit, persistence roundtrip/schema guard, and LLM secret-ref
resolution (with/without resolver, raw-key passthrough). Existing llm/settings/
profiles router tests still pass (no regressions).

Refs OpenHands/OpenHands#15492, Linear OSS-5295.

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

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   api.py3162791%134, 136–141, 143, 145, 147, 189, 201, 216, 222, 278, 283, 292–294, 324, 330, 334, 355–356, 603, 606, 612
   llm_connections.py2713189%205, 208–209, 276–277, 279–280, 285–290, 304, 306, 314–315, 383–385, 396–397, 454–456, 524–525, 584, 630, 660–661
openhands-agent-server/openhands/agent_server/persistence
   models.py2363486%294, 299, 337, 385, 420–426, 428, 430, 433, 437, 463, 480–481, 526, 530, 532, 561–566, 606, 610, 641–644, 647
   store.py4778682%44–45, 95, 99, 103, 107, 111, 117, 140–141, 163, 166–169, 171–172, 177, 214, 216–220, 222–226, 231–236, 340–341, 346, 348–349, 459, 485–486, 491, 493–494, 501–502, 506, 563–564, 641, 643, 655–656, 661, 665, 683–684, 689, 753–754, 758–760, 779, 837–845, 861, 891–892, 894, 1012–1013, 1015–1017, 1020, 1053
openhands-sdk/openhands/sdk/llm
   llm.py108510590%144, 147–148, 153, 179–186, 189, 735, 751, 790–791, 796, 902, 918, 1101, 1141–1143, 1177, 1184, 1334, 1462, 1665, 1669–1670, 1779, 1783–1784, 1847, 1854, 1865, 1929, 1933–1934, 2006, 2013, 2023, 2030, 2041, 2091, 2104, 2127, 2141, 2143, 2145, 2170, 2172, 2181–2182, 2255, 2476, 2632–2633, 2943–2944, 2953, 2971, 2998–2999, 3001, 3003, 3005, 3013, 3016, 3018, 3020, 3031–3032, 3040, 3043, 3046–3047, 3058–3060, 3064, 3068–3069, 3074, 3084, 3089, 3153, 3155, 3157–3160, 3162–3165, 3170–3173, 3188, 3199, 3259, 3261
TOTAL40720762781% 

…connect/rotate

- validate now returns a `verified` flag and only claims a key is authenticated
  when a live provider probe ran (opt-in via ?live=true / OH_CONNECTIONS_LIVE_VALIDATE);
  catalog-only responses are explicitly verified=false so the UI/docs stop
  overstating validation
- add POST /connections/{id}/profiles to create an LLM profile bound to a
  connection's key by reference (secret:<name>), wiring the 'pick from every
  model the provider offers in Agent Profile' half of #15492
- DELETE now returns the profiles that referenced the connection so clients can
  warn instead of silently breaking auth
- rotate the named secret inside the connections lock, after confirming the
  record still exists, to avoid orphaned rotated keys under concurrent delete
- validate persists the returned catalog onto the connection's models
- tidy: top-level imports for PersistedConnections/get_llm_profile_store,
  document the process-global secret resolver coupling

Co-authored-by: openhands <openhands@all-hands.dev>
@juanmichelini juanmichelini changed the title [draft] Provider Connection endpoints + named-secret key storage Backend: Provider Connection endpoints + named-secret key storage (#15492) Aug 11, 2026
@juanmichelini juanmichelini changed the title Backend: Provider Connection endpoints + named-secret key storage (#15492) Backend: Model providers — provider store + nested models + named-secret key (#15492) Aug 14, 2026
Replace the auto-catalog connection design with a provider-first model:
providers hold one key (named secret) and a nested, user-managed model list.

- persistence: ModelProvider/ProviderModel/PersistedProviders + FileProvidersStore
- llm_providers.py: /api/llm/model-providers CRUD + nested model CRUD + optional
  key-probe test (never mutates the curated model list)
- Remove the core-LLM global secret resolver, the backfill-on-GET that rewrote
  user LLM profiles, and validate() clobbering the model list
- Keep named-secret key storage (secret_name + api_key_set; key never returned)

Co-authored-by: openhands <openhands@all-hands.dev>
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