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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user