From d97667008109cacd667d2876b64b7f439b8fd4f7 Mon Sep 17 00:00:00 2001 From: Slobaka <130451520+Slobaka@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:39:18 +0800 Subject: [PATCH] fix(memory): authenticate OpenViking cloud /health when anonymous probe fails Hosted OpenViking (Volcengine) rejects anonymous GET /health with AuthenticationError, which made the provider look unhealthy and silently disabled automatic memory mirroring. Keep the anonymous probe first for identity safety, then retry once with the configured API key only when the server demands credentials. Fixes #78410 --- plugins/memory/openviking/__init__.py | 54 ++++++++++- .../memory/test_openviking_provider.py | 90 +++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 06dd9237f5fd2..06da1d7c2396d 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -451,8 +451,60 @@ class _VikingClient: ) return self._parse_response(resp) + def _authenticated_json(self, path: str) -> dict: + """JSON GET with the configured API key (no tenant headers). + + Used only after an anonymous probe is rejected for missing auth, so we + still avoid disclosing credentials to a server that answers health + anonymously. + """ + headers = {"Accept": "application/json"} + # Reuse the same key headers as authenticated API calls, but omit + # tenant identity — health is not a tenant-scoped resource. + if self._api_key: + headers["X-API-Key"] = self._api_key + headers["Authorization"] = "Bearer " + self._api_key + if self._agent: + headers["X-OpenViking-Actor-Peer"] = self._agent + resp = self._httpx.get( + self._url(path), headers=headers, timeout=3.0 + ) + return self._parse_response(resp) + + @staticmethod + def _health_requires_credentials(exc: Exception) -> bool: + """True when /health rejected the anonymous probe for auth reasons.""" + status = getattr(exc, "status_code", None) + if status in {401, 403}: + return True + message = str(exc).lower() + return any( + token in message + for token in ( + "authenticationerror", + "unauthorized", + "api key", + "apikey", + "invalid authentication", + "missing or invalid", + ) + ) + def health_payload(self) -> dict: - return self._anonymous_json("/health") + """Fetch ``GET /health``. + + Prefer an anonymous probe so credentials are never sent to an unknown + host during identity checks. Hosted OpenViking (e.g. Volcengine cloud) + requires authentication on ``/health``; when an API key is configured + and the anonymous call is rejected for auth, retry once with that key + so automatic memory mirroring is not silently disabled (#78410). + """ + try: + return self._anonymous_json("/health") + except _OpenVikingHTTPError as exc: + if not self._api_key or not self._health_requires_credentials(exc): + raise + return self._authenticated_json("/health") def openapi_payload(self) -> dict: return self._anonymous_json("/openapi.json") diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index e28c87f1a5e95..157bd1e8f4663 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -814,6 +814,96 @@ def test_repeated_openviking_health_probes_never_send_identity_headers(monkeypat ] +def test_cloud_health_retries_with_api_key_after_anonymous_auth_error(monkeypatch): + """Hosted OpenViking may require auth on GET /health (#78410).""" + calls = [] + client = _VikingClient( + "https://api.vikingdb.cn-beijing.volces.com/openviking", + api_key="account.user.0123456789abcdef0123456789abcdef", + agent="hermes", + ) + modern = {"status": "ok", "healthy": True, "version": "0.3.0"} + + def fake_get(url, **kwargs): + headers = kwargs["headers"] + calls.append(dict(headers)) + if "Authorization" not in headers: + return SimpleNamespace( + status_code=401, + text='{"error":{"code":"AuthenticationError","message":"The API key in the request is missing or invalid."}}', + json=lambda: { + "error": { + "code": "AuthenticationError", + "message": "The API key in the request is missing or invalid.", + } + }, + ) + return SimpleNamespace(status_code=200, text="", json=lambda: modern) + + monkeypatch.setattr(client._httpx, "get", fake_get) + + payload = client.health_payload() + assert payload == modern + assert client.health() is True + assert calls[0] == {"Accept": "application/json"} + assert calls[1]["Authorization"].startswith("Bearer account.user.") + assert calls[1]["X-API-Key"].startswith("account.user.") + # No tenant headers on health. + assert "X-OpenViking-Account" not in calls[1] + assert "X-OpenViking-User" not in calls[1] + + +def test_cloud_health_does_not_send_key_without_api_key(monkeypatch): + client = _VikingClient( + "https://api.vikingdb.cn-beijing.volces.com/openviking", + api_key="", + agent="hermes", + ) + calls = [] + + def fake_get(url, **kwargs): + calls.append(kwargs["headers"]) + return SimpleNamespace( + status_code=401, + text="AuthenticationError", + json=lambda: { + "error": { + "code": "AuthenticationError", + "message": "The API key in the request is missing or invalid.", + } + }, + ) + + monkeypatch.setattr(client._httpx, "get", fake_get) + + with pytest.raises(openviking_module._OpenVikingHTTPError): + client.health_payload() + assert calls == [{"Accept": "application/json"}] + + +def test_health_non_auth_errors_do_not_retry_with_credentials(monkeypatch): + client = _VikingClient( + "https://openviking.example", + api_key="secret-key", + agent="hermes", + ) + calls = [] + + def fake_get(url, **kwargs): + calls.append(kwargs["headers"]) + return SimpleNamespace( + status_code=503, + text="unavailable", + json=lambda: {"error": {"code": "UNAVAILABLE", "message": "down"}}, + ) + + monkeypatch.setattr(client._httpx, "get", fake_get) + + with pytest.raises(openviking_module._OpenVikingHTTPError): + client.health_payload() + assert calls == [{"Accept": "application/json"}] + + def test_modern_openviking_identity_does_not_probe_openapi(): client = MagicMock() client.health_payload.return_value = {