fix(desktop): collect + persist API key for custom OpenAI endpoints (#43896)

The desktop "Local / custom endpoint" onboarding never collected an API
key and /api/model/set silently dropped one, so an auth-gated endpoint
(e.g. a hosted vLLM behind a key) could never enumerate models — and
Settings' "Set up custom endpoint" routed `custom` into a non-existent
OAuth flow, booting the user back to the first screen (the reported loop).

Backend (web_server.py):
- /api/providers/validate accepts an optional api_key and sends it as a
  Bearer header when probing a custom endpoint's /v1/models.
- /api/model/set accepts api_key, persists it to model.api_key (same
  switch/preserve lifecycle as base_url), and registers a named
  custom_providers entry via _save_custom_provider — matching the
  `hermes model` CLI flow so the endpoint shows up as a ready picker row.

Desktop:
- ApiKeyForm shows an optional API key field for the local/custom option;
  the key is threaded through saveOnboardingLocalEndpoint → validate +
  setModelAssignment.
- New onboarding `localEndpoint` intent + startManualLocalEndpoint(); the
  Settings "Set up custom endpoint" button now opens the local-endpoint
  form (URL + key) instead of the OAuth dead-end.
- Added localApiKeyPlaceholder i18n key (en + types + zh).

Tests: api_key lifecycle on _apply_main_model_assignment, key persistence
+ custom_providers registration on /api/model/set, Bearer-header probe;
onboarding store forwards + persists the key.
This commit is contained in:
brooklyn!
2026-06-12 00:03:55 +00:00
committed by GitHub
parent c6007e5c1a
commit 4ddb03390a
13 changed files with 363 additions and 37 deletions
+138
View File
@@ -1880,6 +1880,28 @@ class TestWebServerEndpoints:
out = _apply_main_model_assignment("not-a-dict", "custom", "m", "http://x/v1")
assert out == {"provider": "custom", "default": "m", "base_url": "http://x/v1"}
# api_key follows the same lifecycle as base_url:
# supplied → persisted.
out = _apply_main_model_assignment(
{}, "custom", "m", "http://x/v1", "sk-secret"
)
assert out["api_key"] == "sk-secret"
# same provider, no new key → existing key preserved (re-picking a model
# on the same custom endpoint must not wipe the saved key).
out = _apply_main_model_assignment(
{"provider": "custom", "base_url": "http://x/v1", "api_key": "sk-keep"},
"custom",
"m2",
)
assert out["api_key"] == "sk-keep"
# switching providers without a new key → stale key cleared.
out = _apply_main_model_assignment(
{"provider": "custom", "api_key": "sk-old"}, "openrouter", "m"
)
assert out["api_key"] == ""
def test_parse_model_ids_handles_openai_and_bare_shapes(self):
"""Model discovery must tolerate the common /v1/models shapes and
never raise (so a slightly non-standard local endpoint still works)."""
@@ -1936,6 +1958,45 @@ class TestWebServerEndpoints:
assert model_cfg["default"] == "llama-3.1-8b"
assert model_cfg["base_url"] == "http://127.0.0.1:8000/v1"
def test_set_model_main_custom_persists_api_key_and_registers_provider(self):
"""A custom endpoint that requires auth must persist model.api_key (where
the runtime reads it) AND register a named custom_providers entry so the
endpoint reappears as a ready row in the picker — matching the
``hermes model`` custom flow. Regression for the desktop loop where a
keyed custom endpoint could never be configured from the GUI."""
from hermes_cli.config import load_config
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "custom",
"model": "gpt-oss-120b",
"base_url": "https://text.example.com/v1",
"api_key": "sk-secret",
},
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
cfg = load_config()
model_cfg = cfg.get("model")
assert isinstance(model_cfg, dict)
assert model_cfg["provider"] == "custom"
assert model_cfg["base_url"] == "https://text.example.com/v1"
assert model_cfg["api_key"] == "sk-secret"
# Registered in custom_providers (dedup by base_url) so the picker shows
# a proper ready row instead of the "needs setup" dead-end.
custom = cfg.get("custom_providers") or []
assert any(
isinstance(e, dict)
and e.get("base_url") == "https://text.example.com/v1"
and e.get("api_key") == "sk-secret"
and e.get("model") == "gpt-oss-120b"
for e in custom
)
def test_set_model_main_non_custom_clears_stale_base_url(self):
"""Switching to a hosted provider must clear a stale base_url so the
resolver picks that provider's own default endpoint."""
@@ -5018,6 +5079,83 @@ class TestValidateProviderCredential:
data = self._post("OPENAI_API_KEY", " ").json()
assert data["ok"] is False
def test_local_endpoint_forwards_api_key_as_bearer(self, monkeypatch):
"""A custom endpoint that gates /v1/models behind auth must still
enumerate models: the optional api_key is sent as a Bearer header so the
probe doesn't come back empty (the desktop loop's root cause)."""
captured = {}
class _Resp:
status_code = 200
is_success = True
def json(self):
return {"data": [{"id": "gpt-oss-120b"}]}
class _Client:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, url, *a, headers=None, **k):
captured["url"] = url
captured["headers"] = headers
return _Resp()
monkeypatch.setattr("httpx.Client", _Client)
resp = self.client.post(
"/api/providers/validate",
json={
"key": "OPENAI_BASE_URL",
"value": "https://text.example.com/v1",
"api_key": "sk-secret",
},
)
data = resp.json()
assert data["ok"] is True and data["reachable"] is True
assert data["models"] == ["gpt-oss-120b"]
assert captured["url"] == "https://text.example.com/v1/models"
assert captured["headers"] == {"Authorization": "Bearer sk-secret"}
def test_local_endpoint_without_key_sends_no_auth_header(self, monkeypatch):
"""No key → no Authorization header (keyless local servers unaffected)."""
captured = {}
class _Resp:
status_code = 200
is_success = True
def json(self):
return {"data": []}
class _Client:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, url, *a, headers=None, **k):
captured["headers"] = headers
return _Resp()
monkeypatch.setattr("httpx.Client", _Client)
self.client.post(
"/api/providers/validate",
json={"key": "OPENAI_BASE_URL", "value": "http://127.0.0.1:8000/v1"},
)
assert captured["headers"] is None
class TestDesktopCronTicker:
"""The dashboard backend fires cron jobs itself only when desktop-spawned."""