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 = {