diff --git a/openrag/api/main.py b/openrag/api/main.py index cec7a8395..812475106 100644 --- a/openrag/api/main.py +++ b/openrag/api/main.py @@ -327,9 +327,10 @@ def get_config(): instead of assuming every admin has partition access. """ from api.dependencies.auth import SUPER_ADMIN_MODE + from core.utils.redaction import redact_secrets from fastapi.encoders import jsonable_encoder - return {**jsonable_encoder(settings), "super_admin_mode": SUPER_ADMIN_MODE} + return {**redact_secrets(jsonable_encoder(settings)), "super_admin_mode": SUPER_ADMIN_MODE} # Router mounts. Phase 10F finished moving these into diff --git a/openrag/api/routers/admin/model_endpoints.py b/openrag/api/routers/admin/model_endpoints.py index f0fdd0414..7b9cbfa57 100644 --- a/openrag/api/routers/admin/model_endpoints.py +++ b/openrag/api/routers/admin/model_endpoints.py @@ -12,15 +12,23 @@ CreateModelEndpointRequest, ModelEndpointResponse, ModelEndpointType, + RevealApiKeyResponse, UpdateModelEndpointRequest, ValidateEndpointRequest, ValidateEndpointResponse, ) from core.config.model_endpoints import ModelEndpointRow +from core.utils.logging import get_logger from di.providers import get_model_endpoint_service -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, status router = APIRouter(dependencies=[Depends(require_admin)]) +logger = get_logger() + + +def _same_endpoint_url(left: str, right: str) -> bool: + """Compare endpoint URLs after the schema-level normalization rules.""" + return left.strip().rstrip("/") == right.strip().rstrip("/") @router.post( @@ -97,6 +105,23 @@ async def set_default_model_endpoint( return await service.get_model_endpoint(name=name, model_type=model_type) +@router.post("/{model_type}/{name}/reveal-api-key", response_model=RevealApiKeyResponse) +async def reveal_model_endpoint_api_key( + model_type: ModelEndpointType, + name: str, + service=Depends(get_model_endpoint_service), +): + """Return the stored API key only after an explicit admin reveal action.""" + endpoint = await service.get_model_endpoint(name=name, model_type=model_type) + api_key = endpoint.extra.get("api_key") + logger.bind( + model_type=model_type, + name=name, + has_api_key=isinstance(api_key, str), + ).info("Model endpoint API key revealed.") + return {"api_key": api_key if isinstance(api_key, str) else None} + + @router.post("/validate", response_model=ValidateEndpointResponse) async def validate_endpoint_draft( body: ValidateEndpointRequest, @@ -104,10 +129,22 @@ async def validate_endpoint_draft( ): """Probe arbitrary endpoint values (before they are saved) for reachability and model availability.""" + api_key = body.api_key + if api_key is None and body.stored_api_key_model_type and body.stored_api_key_name: + endpoint = await service.get_model_endpoint( + name=body.stored_api_key_name, + model_type=body.stored_api_key_model_type, + ) + if not _same_endpoint_url(body.endpoint, endpoint.endpoint): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Stored API key can only be reused with its saved endpoint URL.", + ) + api_key = endpoint.extra.get("api_key") return await service.validate_endpoint( url=body.endpoint, model_name=body.model_name, - api_key=body.api_key, + api_key=api_key, ) diff --git a/openrag/api/schemas/admin/model_endpoint_schemas.py b/openrag/api/schemas/admin/model_endpoint_schemas.py index 334087aa2..46e483de1 100644 --- a/openrag/api/schemas/admin/model_endpoint_schemas.py +++ b/openrag/api/schemas/admin/model_endpoint_schemas.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import Any, Literal +from core.utils.redaction import redact_secret_mapping from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator ModelEndpointType = Literal["embedder", "reranker", "llm", "vlm"] @@ -98,10 +99,25 @@ class ModelEndpointResponse(BaseModel): batch_size: int timeout: float extra: dict[str, Any] + has_api_key: bool = False is_default: bool created_at: datetime updated_at: datetime + @model_validator(mode="before") + @classmethod + def redact_secret_extra(cls, value: Any) -> Any: + if hasattr(value, "model_dump"): + data = value.model_dump() + elif isinstance(value, dict): + data = dict(value) + else: + data = dict(value) + extra = dict(data.get("extra") or {}) + data["has_api_key"] = bool(extra.get("api_key")) + data["extra"] = redact_secret_mapping(extra) + return data + class ValidateEndpointRequest(BaseModel): """Request body to validate endpoint values before they are saved (draft).""" @@ -109,6 +125,29 @@ class ValidateEndpointRequest(BaseModel): endpoint: str model_name: str | None = None api_key: str | None = None + stored_api_key_model_type: ModelEndpointType | None = None + stored_api_key_name: str | None = None + + @field_validator("endpoint") + @classmethod + def validate_endpoint(cls, value: str) -> str: + """Normalize the draft endpoint URL before probing it.""" + return _normalize_endpoint(value) + + @field_validator("stored_api_key_name") + @classmethod + def validate_stored_api_key_name(cls, value: str | None) -> str | None: + """Normalize the optional saved endpoint name used as credential source.""" + return _normalize_name(value) if value is not None else None + + @model_validator(mode="after") + def require_complete_stored_api_key_source(self) -> ValidateEndpointRequest: + """Require both fields when draft validation reuses a stored key.""" + has_type = self.stored_api_key_model_type is not None + has_name = self.stored_api_key_name is not None + if has_type != has_name: + raise ValueError("stored_api_key_model_type and stored_api_key_name must be provided together") + return self class ValidateEndpointResponse(BaseModel): @@ -120,10 +159,17 @@ class ValidateEndpointResponse(BaseModel): detail: str | None = None +class RevealApiKeyResponse(BaseModel): + """Response body for explicitly revealing a stored endpoint API key.""" + + api_key: str | None = None + + __all__ = [ "CreateModelEndpointRequest", "ModelEndpointResponse", "ModelEndpointType", + "RevealApiKeyResponse", "UpdateModelEndpointRequest", "ValidateEndpointRequest", "ValidateEndpointResponse", diff --git a/openrag/core/utils/redaction.py b/openrag/core/utils/redaction.py new file mode 100644 index 000000000..d55944cf9 --- /dev/null +++ b/openrag/core/utils/redaction.py @@ -0,0 +1,216 @@ +"""Helpers for shaping public data without exposing secrets.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +REDACTED_SECRET = "" +MASK_SUFFIX = "********" +MASK_PREFIX_LENGTH = 3 +MIN_PREFIX_MASK_LENGTH = 8 + +SECRET_FIELD_NAMES = frozenset( + { + "api_key", + "api_token", + "access_key", + "auth_token", + "chainlit_auth_secret", + "client_secret", + "hf_token", + "oidc_client_secret", + "oidc_token_encryption_key", + "password", + "private_key", + "refresh_token", + "secret", + "secret_key", + "signing_key", + "token", + "token_encryption_key", + } +) +SECRET_FIELD_SUFFIXES = frozenset( + { + "_access_key", + "_api_key", + "_auth_token", + "_password", + "_private_key", + "_refresh_token", + "_secret", + "_signing_key", + "_token", + "_token_encryption_key", + } +) + + +def is_secret_field(key: str) -> bool: + """Return true only for known secret field names, not fuzzy token matches.""" + normalized = key.lower() + return normalized in SECRET_FIELD_NAMES or any(normalized.endswith(suffix) for suffix in SECRET_FIELD_SUFFIXES) + + +def mask_secret_value(value: Any) -> str: + """Return a public masked value with a short prefix when that is useful.""" + if not isinstance(value, str) or len(value) < MIN_PREFIX_MASK_LENGTH: + return REDACTED_SECRET + return f"{value[:MASK_PREFIX_LENGTH]}{MASK_SUFFIX}" + + +def is_masked_secret_value(value: Any) -> bool: + """Return true for placeholders produced by the public redaction layer.""" + return ( + isinstance(value, str) and len(value) == MASK_PREFIX_LENGTH + len(MASK_SUFFIX) and value.endswith(MASK_SUFFIX) + ) + + +def is_clear_secret_value(value: Any) -> bool: + """Return true when an update explicitly clears a stored secret.""" + return value is None or value == "" + + +def is_secret_placeholder_value(value: Any) -> bool: + """Return true for redacted values that should never be stored as secrets.""" + return value == REDACTED_SECRET or is_masked_secret_value(value) + + +def redact_secrets(value: Any) -> Any: + """Recursively redact values for known secret fields without mutating input.""" + if isinstance(value, Mapping): + return { + key: REDACTED_SECRET if is_secret_field(str(key)) else redact_secrets(item) for key, item in value.items() + } + if isinstance(value, list): + return [redact_secrets(item) for item in value] + if isinstance(value, tuple): + return tuple(redact_secrets(item) for item in value) + return value + + +def redact_secret_mapping(extra: Mapping[str, Any] | None) -> dict[str, Any]: + """Return the public model-endpoint extra shape shown in Admin UI.""" + return _redact_endpoint_extra(dict(extra or {})) + + +def _redact_endpoint_extra(value: Any, key: str | None = None) -> Any: + if key is not None and is_secret_field(key): + return mask_secret_value(value) + if isinstance(value, Mapping): + return {entry_key: _redact_endpoint_extra(item, str(entry_key)) for entry_key, item in value.items()} + if isinstance(value, list): + return [_redact_endpoint_extra(item) for item in value] + if isinstance(value, tuple): + return tuple(_redact_endpoint_extra(item) for item in value) + return value + + +def preserve_existing_secrets(existing: Mapping[str, Any] | None, incoming: Mapping[str, Any]) -> dict[str, Any]: + """Keep stored secrets when an update payload omits or echoes a redacted value.""" + return _preserve_existing_secrets(existing or {}, incoming) + + +def _preserve_existing_secrets(existing: Mapping[str, Any], incoming: Mapping[str, Any]) -> dict[str, Any]: + merged = dict(incoming) + for key, value in existing.items(): + incoming_value = merged.get(key) + if is_secret_field(str(key)): + if key in merged and is_clear_secret_value(incoming_value): + merged.pop(key, None) + continue + if key not in merged or is_secret_placeholder_value(incoming_value): + merged[key] = value + continue + if isinstance(value, Mapping) and isinstance(incoming_value, Mapping): + merged[key] = _preserve_existing_secrets(value, incoming_value) + elif isinstance(value, list) and isinstance(incoming_value, list): + merged[key] = _preserve_existing_secret_lists(value, incoming_value) + for key, value in list(merged.items()): + if is_secret_field(str(key)) and (is_clear_secret_value(value) or is_secret_placeholder_value(value)): + merged.pop(key, None) + return merged + + +def _preserve_existing_secret_lists(existing: list[Any], incoming: list[Any]) -> list[Any]: + if len(existing) == 1 and len(incoming) == 1: + return [_preserve_existing_list_item(existing[0], incoming[0])] + + remaining_existing = list(existing) + merged: list[Any] = [] + for incoming_item in incoming: + match_index = _find_matching_secret_list_item(remaining_existing, incoming_item) + if match_index is None: + merged.append(_drop_unbacked_secret_placeholders(incoming_item)) + continue + existing_item = remaining_existing.pop(match_index) + merged.append(_preserve_existing_list_item(existing_item, incoming_item)) + return merged + + +def _preserve_existing_list_item(existing_item: Any, incoming_item: Any) -> Any: + if isinstance(existing_item, Mapping) and isinstance(incoming_item, Mapping): + return _preserve_existing_secrets(existing_item, incoming_item) + if isinstance(existing_item, list) and isinstance(incoming_item, list): + return _preserve_existing_secret_lists(existing_item, incoming_item) + return incoming_item + + +def _find_matching_secret_list_item(existing: list[Any], incoming_item: Any) -> int | None: + incoming_identity = _non_secret_identity(incoming_item) + if not _has_non_secret_identity(incoming_identity): + return None + matches = [ + index + for index, existing_item in enumerate(existing) + if _non_secret_identity(existing_item) == incoming_identity + ] + return matches[0] if len(matches) == 1 else None + + +def _non_secret_identity(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: _non_secret_identity(item) for key, item in value.items() if not is_secret_field(str(key))} + if isinstance(value, list): + return [_non_secret_identity(item) for item in value] + if isinstance(value, tuple): + return tuple(_non_secret_identity(item) for item in value) + return value + + +def _has_non_secret_identity(value: Any) -> bool: + if isinstance(value, Mapping): + return any(_has_non_secret_identity(item) for item in value.values()) + if isinstance(value, (list, tuple)): + return any(_has_non_secret_identity(item) for item in value) + return True + + +def _drop_unbacked_secret_placeholders(value: Any) -> Any: + if isinstance(value, Mapping): + cleaned: dict[Any, Any] = {} + for key, item in value.items(): + if is_secret_field(str(key)) and (is_clear_secret_value(item) or is_secret_placeholder_value(item)): + continue + cleaned[key] = _drop_unbacked_secret_placeholders(item) + return cleaned + if isinstance(value, list): + return [_drop_unbacked_secret_placeholders(item) for item in value] + if isinstance(value, tuple): + return tuple(_drop_unbacked_secret_placeholders(item) for item in value) + return value + + +__all__ = [ + "REDACTED_SECRET", + "SECRET_FIELD_NAMES", + "is_masked_secret_value", + "is_secret_field", + "is_clear_secret_value", + "is_secret_placeholder_value", + "mask_secret_value", + "preserve_existing_secrets", + "redact_secret_mapping", + "redact_secrets", +] diff --git a/openrag/services/orchestrators/model_endpoint_service.py b/openrag/services/orchestrators/model_endpoint_service.py index ded8d5483..02dfb04bb 100644 --- a/openrag/services/orchestrators/model_endpoint_service.py +++ b/openrag/services/orchestrators/model_endpoint_service.py @@ -11,10 +11,12 @@ import os from datetime import UTC, datetime from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit from core.config.model_endpoints import ModelEndpointConfig, ModelEndpointRow from core.utils.exceptions import NotFoundError, ValidationError from core.utils.logging import get_logger +from core.utils.redaction import preserve_existing_secrets if TYPE_CHECKING: from core.config.root import Settings @@ -245,6 +247,9 @@ async def update_model_endpoint(self, name: str, model_type: str, **fields: obje # default by promoting another endpoint, never by leaving the type with none. promote_to_default = bool(fields.pop("is_default", None)) + if isinstance(fields.get("extra"), dict): + fields["extra"] = preserve_existing_secrets(existing.extra, fields["extra"]) # type: ignore[arg-type] + if fields: updated = await self._repo.update(name, model_type, **fields) else: @@ -336,10 +341,21 @@ async def validate_endpoint( "models_served": None, "detail": None, } + try: + parsed = urlsplit(url) + except ValueError: + result["detail"] = "Endpoint URL must be an absolute HTTP(S) URL." + return result + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + result["detail"] = "Endpoint URL must be an absolute HTTP(S) URL." + return result + if parsed.username or parsed.password: + result["detail"] = "Endpoint URL must not include credentials." + return result models_url = url.rstrip("/") + "/models" headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} try: - async with httpx.AsyncClient(timeout=5.0, headers=headers) as client: + async with httpx.AsyncClient(timeout=5.0, headers=headers, follow_redirects=False) as client: resp = await client.get(models_url) result["reachable"] = True if resp.status_code == 200: diff --git a/tests/integration/api/test_model_endpoints.py b/tests/integration/api/test_model_endpoints.py index bdff261ee..9de322b91 100644 --- a/tests/integration/api/test_model_endpoints.py +++ b/tests/integration/api/test_model_endpoints.py @@ -45,7 +45,9 @@ def test_model_endpoint_crud_validate_and_default_selection(api_client): }, ) assert create_endpoint.status_code == 201, create_endpoint.text - assert create_endpoint.json()["extra"]["api_key"] == "ci-test-key" + assert "ci-test-key" not in create_endpoint.text + assert create_endpoint.json()["has_api_key"] is True + assert create_endpoint.json()["extra"]["api_key"] == "ci-********" validate_missing = api_client.post(f"/model-endpoints/llm/{endpoint_name}/validate") _assert_success(validate_missing, context="validate missing model") diff --git a/tests/unit/api/routers/admin/test_phase14_admin_routers.py b/tests/unit/api/routers/admin/test_phase14_admin_routers.py index e50fd1e5d..5e79564ed 100644 --- a/tests/unit/api/routers/admin/test_phase14_admin_routers.py +++ b/tests/unit/api/routers/admin/test_phase14_admin_routers.py @@ -227,6 +227,58 @@ async def test_update_model_endpoint_maps_name_to_new_name(async_client_factory) ] +@pytest.mark.asyncio +async def test_model_endpoint_read_responses_hide_stored_api_key(async_client_factory): + model_service = FakeModelEndpointService() + model_service.endpoint_extra = { + "implementation": "vllm", + "api_key": "secret-token", + "temperature": 0.2, + } + app = _build_app(model_service=model_service) + + async with async_client_factory(app) as client: + response = await client.get("/model-endpoints/llm/default") + + assert response.status_code == 200 + payload = response.json() + assert "secret-token" not in response.text + assert payload["has_api_key"] is True + assert payload["extra"] == {"api_key": "sec********", "implementation": "vllm", "temperature": 0.2} + + +@pytest.mark.asyncio +async def test_model_endpoint_reveal_api_key_returns_stored_secret(async_client_factory, monkeypatch): + model_service = FakeModelEndpointService() + model_service.endpoint_extra = {"api_key": "secret-token", "implementation": "vllm"} + app = _build_app(model_service=model_service) + logs: list[tuple[dict[str, Any], str]] = [] + + class FakeLogger: + def __init__(self, context: dict[str, Any] | None = None) -> None: + self.context = context or {} + + def bind(self, **kwargs: Any) -> FakeLogger: + return FakeLogger({**self.context, **kwargs}) + + def info(self, message: str) -> None: + logs.append((self.context, message)) + + monkeypatch.setattr(model_endpoints, "logger", FakeLogger()) + + async with async_client_factory(app) as client: + response = await client.post("/model-endpoints/llm/default/reveal-api-key") + + assert response.status_code == 200 + assert response.json() == {"api_key": "secret-token"} + assert logs == [ + ( + {"model_type": "llm", "name": "default", "has_api_key": True}, + "Model endpoint API key revealed.", + ) + ] + + @pytest.mark.asyncio async def test_validate_model_endpoint_uses_route_identity(async_client_factory): """Endpoint validation should resolve route identity before probing.""" @@ -285,6 +337,54 @@ async def test_validate_endpoint_draft_forwards_body_without_lookup(async_client ] +@pytest.mark.asyncio +async def test_validate_endpoint_draft_can_reuse_stored_api_key(async_client_factory): + model_service = FakeModelEndpointService() + model_service.endpoint_extra = {"api_key": "secret-token"} + app = _build_app(model_service=model_service) + + async with async_client_factory(app) as client: + response = await client.post( + "/model-endpoints/validate", + json={ + "endpoint": "http://llm:8000/v1/", + "model_name": "mistral-small", + "stored_api_key_model_type": "llm", + "stored_api_key_name": "default", + }, + ) + + assert response.status_code == 200 + assert model_service.calls == [ + ("get", {"name": "default", "model_type": "llm"}), + ("validate", {"url": "http://llm:8000/v1", "model_name": "mistral-small", "api_key": "secret-token"}), + ] + + +@pytest.mark.asyncio +async def test_validate_endpoint_draft_rejects_stored_key_for_different_endpoint(async_client_factory): + model_service = FakeModelEndpointService() + model_service.endpoint_extra = {"api_key": "secret-token"} + app = _build_app(model_service=model_service) + + async with async_client_factory(app) as client: + response = await client.post( + "/model-endpoints/validate", + json={ + "endpoint": "http://candidate:8000/v1", + "model_name": "mistral-small", + "stored_api_key_model_type": "llm", + "stored_api_key_name": "default", + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Stored API key can only be reused with its saved endpoint URL." + assert model_service.calls == [ + ("get", {"name": "default", "model_type": "llm"}), + ] + + @pytest.mark.asyncio async def test_validate_endpoint_draft_defaults_optional_fields(async_client_factory): """``model_name`` and ``api_key`` are optional in the draft body.""" diff --git a/tests/unit/api/test_secret_redaction.py b/tests/unit/api/test_secret_redaction.py new file mode 100644 index 000000000..e6e70f954 --- /dev/null +++ b/tests/unit/api/test_secret_redaction.py @@ -0,0 +1,151 @@ +from __future__ import annotations + + +def test_redact_secrets_fully_hides_known_secret_keys_without_false_token_matches(): + from core.utils.redaction import redact_secrets + + payload = { + "llm": {"api_key": "sk-llm-secret", "model": "mistral"}, + "object_storage": {"access_key": "object-store-secret"}, + "rdb": {"password": "db-secret-value", "host": "rdb"}, + "websearch": {"api_token": "search-secret", "max_tokens": 2048}, + "oidc_client_secret": "oidc-secret", + "chainlit_auth_secret": "chainlit-secret", + "future": { + "backend_secret": "backend-secret-value", + "session_token": "session-token-value", + "storage_access_key": "storage-access-key-value", + }, + "nested": [ + { + "private_key": "private-key-secret", + "refresh_token": "refresh-token-secret", + "signing_key": "signing-key-secret", + "token_encryption_key": "fernet-secret", + } + ], + } + + redacted = redact_secrets(payload) + + assert redacted["llm"]["api_key"] == "" + assert redacted["object_storage"]["access_key"] == "" + assert redacted["rdb"]["password"] == "" + assert redacted["websearch"]["api_token"] == "" + assert redacted["websearch"]["max_tokens"] == 2048 + assert redacted["oidc_client_secret"] == "" + assert redacted["chainlit_auth_secret"] == "" + assert redacted["future"]["backend_secret"] == "" + assert redacted["future"]["session_token"] == "" + assert redacted["future"]["storage_access_key"] == "" + assert redacted["nested"][0]["private_key"] == "" + assert redacted["nested"][0]["refresh_token"] == "" + assert redacted["nested"][0]["signing_key"] == "" + assert redacted["nested"][0]["token_encryption_key"] == "" + assert payload["llm"]["api_key"] == "sk-llm-secret" + + +def test_redact_secret_mapping_keeps_non_secret_endpoint_extra_shape(): + from core.utils.redaction import redact_secret_mapping + + redacted = redact_secret_mapping( + { + "api_key": "sk-top-level-secret", + "implementation": "vllm", + "auth": {"token": "nested-token"}, + "headers": [{"api_key": "hf-nested-secret"}], + "temperature": 0.2, + "enable_thinking": True, + } + ) + + assert redacted == { + "api_key": "sk-********", + "implementation": "vllm", + "auth": {"token": "nes********"}, + "headers": [{"api_key": "hf-********"}], + "temperature": 0.2, + "enable_thinking": True, + } + + +def test_preserve_existing_secrets_accepts_prefix_masked_values(): + from core.utils.redaction import preserve_existing_secrets + + merged = preserve_existing_secrets( + { + "api_key": "sk-top-level-secret", + "auth": {"token": "nested-token-secret"}, + "headers": [{"api_key": "hf-nested-secret"}], + }, + { + "api_key": "sk-********", + "auth": {"token": "nes********"}, + "headers": [{"api_key": "hf-********"}], + }, + ) + + assert merged == { + "api_key": "sk-top-level-secret", + "auth": {"token": "nested-token-secret"}, + "headers": [{"api_key": "hf-nested-secret"}], + } + + +def test_preserve_existing_secrets_clears_explicit_empty_secret_values(): + from core.utils.redaction import preserve_existing_secrets + + merged = preserve_existing_secrets( + { + "api_key": "stored-key", + "auth": {"token": "nested-token"}, + "headers": [{"api_key": "nested-key"}], + }, + { + "implementation": "vllm", + "api_key": "", + "auth": {"token": None}, + "headers": [{"api_key": ""}], + }, + ) + + assert merged == { + "implementation": "vllm", + "auth": {}, + "headers": [{}], + } + + +def test_preserve_existing_secrets_matches_list_items_by_non_secret_identity(): + from core.utils.redaction import preserve_existing_secrets + + merged = preserve_existing_secrets( + { + "providers": [ + {"name": "a", "api_key": "key-a"}, + {"name": "b", "api_key": "key-b"}, + ] + }, + { + "providers": [ + {"name": "b", "api_key": ""}, + ] + }, + ) + + assert merged == { + "providers": [ + {"name": "b", "api_key": "key-b"}, + ] + } + + +def test_preserve_existing_secrets_drops_unmatched_list_placeholders_without_identity(): + from core.utils.redaction import preserve_existing_secrets + + merged = preserve_existing_secrets( + {"headers": [{"api_key": "key-a"}, {"api_key": "key-b"}]}, + {"headers": [{"api_key": ""}]}, + ) + + assert merged == {"headers": [{}]} diff --git a/tests/unit/services/orchestrators/test_model_endpoint_service.py b/tests/unit/services/orchestrators/test_model_endpoint_service.py index d77df7ad3..9fcadd305 100644 --- a/tests/unit/services/orchestrators/test_model_endpoint_service.py +++ b/tests/unit/services/orchestrators/test_model_endpoint_service.py @@ -476,6 +476,84 @@ async def test_update_is_default_false_does_not_demote_or_touch_default_cache(): assert cache.get("default") is sentinel +@pytest.mark.asyncio +async def test_update_extra_without_api_key_preserves_existing_secret(): + existing = _make_row( + name="jina", + extra={"implementation": "vllm", "api_key": "stored-key", "temperature": 0.1}, + ) + repo = _FakeEndpointRepo(rows=[existing]) + svc = _make_service(repo) + + await svc.update_model_endpoint("jina", "embedder", extra={"implementation": "vllm", "temperature": 0.2}) + + updated = repo._store[("jina", "embedder")] + assert updated.extra == {"implementation": "vllm", "temperature": 0.2, "api_key": "stored-key"} + + +@pytest.mark.asyncio +async def test_update_extra_preserves_nested_redacted_secrets(): + existing = _make_row( + name="jina", + extra={ + "implementation": "vllm", + "auth": { + "token": "stored-token", + "headers": [{"api_key": "nested-key"}], + }, + }, + ) + repo = _FakeEndpointRepo(rows=[existing]) + svc = _make_service(repo) + + await svc.update_model_endpoint( + "jina", + "embedder", + extra={ + "implementation": "vllm", + "auth": { + "token": "", + "headers": [{"api_key": ""}], + }, + "temperature": 0.2, + }, + ) + + updated = repo._store[("jina", "embedder")] + assert updated.extra == { + "implementation": "vllm", + "auth": { + "token": "stored-token", + "headers": [{"api_key": "nested-key"}], + }, + "temperature": 0.2, + } + + +@pytest.mark.asyncio +async def test_update_extra_with_new_api_key_rotates_existing_secret(): + existing = _make_row(name="jina", extra={"implementation": "vllm", "api_key": "old-key"}) + repo = _FakeEndpointRepo(rows=[existing]) + svc = _make_service(repo) + + await svc.update_model_endpoint("jina", "embedder", extra={"implementation": "vllm", "api_key": "new-key"}) + + updated = repo._store[("jina", "embedder")] + assert updated.extra == {"implementation": "vllm", "api_key": "new-key"} + + +@pytest.mark.asyncio +async def test_update_extra_with_empty_api_key_clears_existing_secret(): + existing = _make_row(name="jina", extra={"implementation": "vllm", "api_key": "old-key"}) + repo = _FakeEndpointRepo(rows=[existing]) + svc = _make_service(repo) + + await svc.update_model_endpoint("jina", "embedder", extra={"implementation": "vllm", "api_key": ""}) + + updated = repo._store[("jina", "embedder")] + assert updated.extra == {"implementation": "vllm"} + + # ------------------------------------------------------------------ # delete_model_endpoint # ------------------------------------------------------------------ @@ -604,9 +682,10 @@ def json(self): return {"data": [{"id": "mistral-small"}]} class FakeClient: - def __init__(self, *, timeout, headers): + def __init__(self, *, timeout, headers, follow_redirects): assert timeout == 5.0 assert headers == {} + assert follow_redirects is False async def __aenter__(self): return self @@ -645,8 +724,9 @@ def json(self): return {"data": [{"id": "mistral-small"}]} class FakeClient: - def __init__(self, *, timeout, headers): + def __init__(self, *, timeout, headers, follow_redirects): captured_headers.append(headers) + assert follow_redirects is False async def __aenter__(self): return self @@ -662,3 +742,66 @@ async def get(self, url): await svc.validate_endpoint("http://llm:8000/v1", "mistral-small", api_key="secret-token") assert captured_headers == [{"Authorization": "Bearer secret-token"}] + + +@pytest.mark.asyncio +async def test_validate_endpoint_rejects_non_http_urls_without_request(monkeypatch): + import httpx + + svc = _make_service() + + def fail_client(**_kwargs): + raise AssertionError("HTTP client should not be created for invalid URLs") + + monkeypatch.setattr(httpx, "AsyncClient", fail_client) + + result = await svc.validate_endpoint("file:///etc/passwd", "mistral-small") + + assert result == { + "reachable": False, + "model_found": None, + "models_served": None, + "detail": "Endpoint URL must be an absolute HTTP(S) URL.", + } + + +@pytest.mark.asyncio +async def test_validate_endpoint_rejects_malformed_urls_without_request(monkeypatch): + import httpx + + svc = _make_service() + + def fail_client(**_kwargs): + raise AssertionError("HTTP client should not be created for malformed URLs") + + monkeypatch.setattr(httpx, "AsyncClient", fail_client) + + result = await svc.validate_endpoint("http://[::1", "mistral-small") + + assert result == { + "reachable": False, + "model_found": None, + "models_served": None, + "detail": "Endpoint URL must be an absolute HTTP(S) URL.", + } + + +@pytest.mark.asyncio +async def test_validate_endpoint_rejects_url_credentials_without_request(monkeypatch): + import httpx + + svc = _make_service() + + def fail_client(**_kwargs): + raise AssertionError("HTTP client should not be created for URLs with credentials") + + monkeypatch.setattr(httpx, "AsyncClient", fail_client) + + result = await svc.validate_endpoint("https://user:pass@example.test/v1", "mistral-small") + + assert result == { + "reachable": False, + "model_found": None, + "models_served": None, + "detail": "Endpoint URL must not include credentials.", + } diff --git a/ui/src/lib/api/models.test.ts b/ui/src/lib/api/models.test.ts index eff163abe..3c2448bb7 100644 --- a/ui/src/lib/api/models.test.ts +++ b/ui/src/lib/api/models.test.ts @@ -1,7 +1,40 @@ -import { describe, it, expect } from "vitest"; -import { pickDefaultEndpoint, resolveEmbedderName } from "./models"; +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { + displayModelEndpointExtra, + mergeModelEndpointApiKeyExtra, + pickDefaultEndpoint, + prepareModelEndpointExtraForSubmit, + revealModelEndpointApiKey, + resolveEmbedderName, + splitModelEndpointApiKeyExtra, + validateModelEndpoint, +} from "./models"; import type { ModelEndpointResponse } from "./models"; +function fakeResponse({ + status = 200, + body = "{}", +}: { status?: number; body?: string } = {}): Response { + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (key: string) => (key.toLowerCase() === "content-type" ? "application/json" : null) }, + json: async () => JSON.parse(body), + text: async () => body, + } as unknown as Response; +} + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + function ep(name: string, is_default = false): ModelEndpointResponse { return { name, @@ -64,3 +97,162 @@ describe("resolveEmbedderName", () => { expect(resolveEmbedderName("default", [ep("default", true)])).toBe("default"); }); }); + +describe("validateModelEndpoint", () => { + it("can request draft validation with a stored server-side API key", async () => { + fetchMock.mockResolvedValue(fakeResponse({ body: JSON.stringify({ reachable: true }) })); + + await validateModelEndpoint({ + endpoint: "http://candidate:8000/v1", + model_name: "mistral-small", + stored_api_key_model_type: "llm", + stored_api_key_name: "private-llm", + }); + + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + endpoint: "http://candidate:8000/v1", + model_name: "mistral-small", + stored_api_key_model_type: "llm", + stored_api_key_name: "private-llm", + }); + }); +}); + +describe("revealModelEndpointApiKey", () => { + it("requests the admin-only reveal endpoint for the selected model endpoint", async () => { + fetchMock.mockResolvedValue(fakeResponse({ body: JSON.stringify({ api_key: "secret-token" }) })); + + const result = await revealModelEndpointApiKey("llm", "private-llm"); + + expect(result).toEqual({ api_key: "secret-token" }); + expect(fetchMock).toHaveBeenCalledWith( + "/model-endpoints/llm/private-llm/reveal-api-key", + expect.objectContaining({ method: "POST" }), + ); + }); +}); + +describe("model endpoint secret placeholders", () => { + it("shows backend redacted secret sentinels as password-style bullets", () => { + expect( + displayModelEndpointExtra({ + auth: { token: "" }, + backend_secret: "", + headers: [{ api_key: "" }], + note: "", + }), + ).toEqual({ + auth: { token: "••••••••" }, + backend_secret: "••••••••", + headers: [{ api_key: "sk-********" }], + note: "", + }); + }); + + it("keeps backend prefix-masked secret values visible without revealing the full secret", () => { + expect( + displayModelEndpointExtra({ + auth: { token: "nes********" }, + headers: [{ api_key: "hf-********" }], + note: "abc********", + }), + ).toEqual({ + auth: { token: "nes********" }, + headers: [{ api_key: "hf-********" }], + note: "abc********", + }); + }); + + it("converts unchanged bullet placeholders back to the backend redacted sentinel", () => { + expect( + prepareModelEndpointExtraForSubmit({ + auth: { token: "••••••••" }, + headers: [{ api_key: "hf-********" }], + note: "••••••••", + }), + ).toEqual({ + auth: { token: "" }, + headers: [{ api_key: "" }], + note: "••••••••", + }); + }); + + it("still accepts the previous API key placeholder when an edit form is already open", () => { + expect(prepareModelEndpointExtraForSubmit({ api_key: "********" })).toEqual({ + api_key: "", + }); + }); + + it("splits the API key out of endpoint extra for the dedicated form field", () => { + expect( + splitModelEndpointApiKeyExtra({ + api_key: "sk-real-secret", + implementation: "vllm", + temperature: 0.2, + }), + ).toEqual({ + apiKey: "sk-********", + extra: { + implementation: "vllm", + temperature: 0.2, + }, + }); + }); + + it("merges the dedicated API key field back into the endpoint extra payload", () => { + expect( + mergeModelEndpointApiKeyExtra( + { + implementation: "vllm", + }, + "sk-new-secret", + ), + ).toEqual({ + implementation: "vllm", + api_key: "sk-new-secret", + }); + }); + + it("preserves a stored API key when the dedicated field is unchanged", () => { + expect( + mergeModelEndpointApiKeyExtra( + { + implementation: "vllm", + }, + "sk-********", + ), + ).toEqual({ + implementation: "vllm", + api_key: "", + }); + }); + + it("submits an explicit empty API key when clearing an existing stored key", () => { + expect( + mergeModelEndpointApiKeyExtra( + { + implementation: "vllm", + }, + "", + { clearApiKey: true }, + ), + ).toEqual({ + implementation: "vllm", + api_key: "", + }); + }); + + it("omits an empty API key when creating an endpoint without a stored key", () => { + expect( + mergeModelEndpointApiKeyExtra( + { + implementation: "vllm", + }, + "", + ), + ).toEqual({ + implementation: "vllm", + }); + }); +}); diff --git a/ui/src/lib/api/models.ts b/ui/src/lib/api/models.ts index 938d60a86..c9f04e2c4 100644 --- a/ui/src/lib/api/models.ts +++ b/ui/src/lib/api/models.ts @@ -9,6 +9,7 @@ import { request } from "./client"; // PUT /model-endpoints/{type}/{name} update // DELETE /model-endpoints/{type}/{name} delete → 204 // POST /model-endpoints/{type}/{name}/set-default +// POST /model-endpoints/{type}/{name}/reveal-api-key // POST /model-endpoints/{type}/{name}/validate → ValidateEndpointResponse (no body) export type ModelType = "embedder" | "reranker" | "llm" | "vlm"; @@ -21,6 +22,7 @@ export interface ModelEndpointResponse { batch_size: number; timeout: number; extra: Record; + has_api_key?: boolean; is_default: boolean; created_at: string; updated_at: string; @@ -55,8 +57,132 @@ export interface ValidateModelEndpointResponse { detail?: string | null; } +export interface RevealApiKeyResponse { + api_key: string | null; +} + const BASE = "/model-endpoints"; const enc = encodeURIComponent; +export const REDACTED_SECRET = ""; +export const API_KEY_DISPLAY_PLACEHOLDER = "sk-********"; +export const SECRET_DISPLAY_PLACEHOLDER = "••••••••"; +const MASK_SUFFIX = "********"; +const MASK_PREFIX_LENGTH = 3; +const LEGACY_API_KEY_DISPLAY_PLACEHOLDER = "********"; + +const SECRET_FIELD_NAMES = new Set([ + "api_key", + "api_token", + "access_key", + "auth_token", + "chainlit_auth_secret", + "client_secret", + "hf_token", + "oidc_client_secret", + "oidc_token_encryption_key", + "password", + "private_key", + "refresh_token", + "secret", + "secret_key", + "signing_key", + "token", + "token_encryption_key", +]); +const SECRET_FIELD_SUFFIXES = [ + "_access_key", + "_api_key", + "_auth_token", + "_password", + "_private_key", + "_refresh_token", + "_secret", + "_signing_key", + "_token", + "_token_encryption_key", +]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSecretField(key: string | undefined): boolean { + if (!key) return false; + const normalized = key.toLowerCase(); + return SECRET_FIELD_NAMES.has(normalized) || SECRET_FIELD_SUFFIXES.some((suffix) => normalized.endsWith(suffix)); +} + +function displayPlaceholderFor(key: string | undefined): string { + return key?.toLowerCase() === "api_key" ? API_KEY_DISPLAY_PLACEHOLDER : SECRET_DISPLAY_PLACEHOLDER; +} + +function isPrefixMaskedSecret(value: string): boolean { + return value.length === MASK_PREFIX_LENGTH + MASK_SUFFIX.length && value.endsWith(MASK_SUFFIX); +} + +function isUnchangedPlaceholder(value: string, key: string | undefined): boolean { + if (value === displayPlaceholderFor(key)) return true; + if (isPrefixMaskedSecret(value)) return true; + return key?.toLowerCase() === "api_key" && value === LEGACY_API_KEY_DISPLAY_PLACEHOLDER; +} + +function transformSecretPlaceholders(value: unknown, direction: "display" | "submit", key?: string): unknown { + if (isSecretField(key) && typeof value === "string") { + if (direction === "display") { + if (isPrefixMaskedSecret(value)) return value; + return displayPlaceholderFor(key); + } + if (isUnchangedPlaceholder(value, key)) return REDACTED_SECRET; + } + if (Array.isArray(value)) { + return value.map((item) => transformSecretPlaceholders(item, direction)); + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([entryKey, item]) => [ + entryKey, + transformSecretPlaceholders(item, direction, entryKey), + ]), + ); + } + return value; +} + +export function displayModelEndpointExtra(extra: Record): Record { + return transformSecretPlaceholders(extra, "display") as Record; +} + +export function prepareModelEndpointExtraForSubmit(extra: Record): Record { + return transformSecretPlaceholders(extra, "submit") as Record; +} + +export function splitModelEndpointApiKeyExtra(extra: Record): { + apiKey: string; + extra: Record; +} { + const displayExtra = displayModelEndpointExtra(extra); + const { api_key: apiKey, ...rest } = displayExtra; + return { + apiKey: typeof apiKey === "string" ? apiKey : "", + extra: rest, + }; +} + +export function mergeModelEndpointApiKeyExtra( + extra: Record, + apiKey: string, + options: { clearApiKey?: boolean } = {}, +): Record { + const prepared = prepareModelEndpointExtraForSubmit(extra); + const normalizedApiKey = prepareModelEndpointExtraForSubmit({ api_key: apiKey.trim() }).api_key; + if (typeof normalizedApiKey === "string" && normalizedApiKey) { + return { ...prepared, api_key: normalizedApiKey }; + } + if (options.clearApiKey) { + return { ...prepared, api_key: "" }; + } + return prepared; +} /** List endpoints (bare array). Optionally filter by model type. */ export function listModelEndpoints(modelType?: ModelType) { @@ -93,6 +219,13 @@ export function setDefaultModelEndpoint(modelType: ModelType, name: string) { ); } +export function revealModelEndpointApiKey(modelType: ModelType, name: string) { + return request( + `${BASE}/${enc(modelType)}/${enc(name)}/reveal-api-key`, + { method: "POST" }, + ); +} + export function deleteModelEndpoint(modelType: ModelType, name: string) { return request(`${BASE}/${enc(modelType)}/${enc(name)}`, { method: "DELETE", @@ -103,6 +236,8 @@ export interface ValidateModelEndpointRequest { endpoint: string; model_name?: string; api_key?: string; + stored_api_key_model_type?: ModelType; + stored_api_key_name?: string; } /** diff --git a/ui/src/pages/admin/models.tsx b/ui/src/pages/admin/models.tsx index a34fc2aef..b242ba984 100644 --- a/ui/src/pages/admin/models.tsx +++ b/ui/src/pages/admin/models.tsx @@ -1,13 +1,29 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Plus, Trash2, Pencil, Star, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { + Plus, + Trash2, + Pencil, + Star, + Loader2, + CheckCircle, + XCircle, + Eye, + EyeOff, + Copy, +} from "lucide-react"; import { listModelEndpoints, createModelEndpoint, updateModelEndpoint, deleteModelEndpoint, + mergeModelEndpointApiKeyExtra, + prepareModelEndpointExtraForSubmit, + revealModelEndpointApiKey, + REDACTED_SECRET, setDefaultModelEndpoint, + splitModelEndpointApiKeyExtra, validateModelEndpoint, } from "@/lib/api/models"; import type { @@ -24,6 +40,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogFooter, @@ -37,6 +54,14 @@ import { formatDate, intOr, numOr } from "@/lib/utils"; const MODEL_TYPES = ["embedder", "reranker", "llm", "vlm"] as const; +type RevealedApiKey = { + modelType: ModelType; + name: string; + value: string; +}; + +const normalizeEndpointUrl = (value: string) => value.trim().replace(/\/+$/, ""); + export default function ModelsPage() { const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState("embedder"); @@ -246,39 +271,145 @@ function EndpointDialog({ const [modelName, setModelName] = useState(""); const [batchSize, setBatchSize] = useState("32"); const [timeout, setTimeout] = useState("30"); + const [apiKey, setApiKey] = useState(""); const [extraJson, setExtraJson] = useState("{}"); const [validated, setValidated] = useState(null); const [validating, setValidating] = useState(false); const [validationMsg, setValidationMsg] = useState(null); + const [revealedApiKey, setRevealedApiKey] = useState(null); + const [apiKeyVisible, setApiKeyVisible] = useState(false); + const [revealingApiKey, setRevealingApiKey] = useState(false); useEffect(() => { + if (!open) { + setRevealedApiKey(null); + setApiKeyVisible(false); + setRevealingApiKey(false); + return; + } if (open) { setValidated(null); setValidating(false); - setValidationMsg(null); + setRevealedApiKey(null); + setApiKeyVisible(false); + setRevealingApiKey(false); if (editing) { + const { apiKey: displayApiKey, extra: displayExtra } = splitModelEndpointApiKeyExtra(editing.extra); setName(editing.name); setEndpoint(editing.endpoint); setModelName(editing.model_name || ""); setBatchSize(String(editing.batch_size)); setTimeout(String(editing.timeout)); - setExtraJson(JSON.stringify(editing.extra, null, 2)); + setApiKey(displayApiKey); + setExtraJson(JSON.stringify(displayExtra, null, 2)); + setValidated(true); + setValidationMsg( + editing.has_api_key + ? "API key is stored server-side. Leave it unchanged to keep it, or type a new key to rotate it." + : null, + ); } else { setName(""); setEndpoint(""); setModelName(""); setBatchSize("32"); setTimeout("30"); + setApiKey(""); setExtraJson("{}"); + setValidated(null); + setValidationMsg(null); } } }, [open, editing]); // Reset validation when relevant fields change useEffect(() => { + const editingExtra = editing ? splitModelEndpointApiKeyExtra(editing.extra) : null; + if ( + editing && + endpoint === editing.endpoint && + (modelName || "") === (editing.model_name || "") && + apiKey === editingExtra?.apiKey && + extraJson === JSON.stringify(editingExtra.extra, null, 2) + ) { + setValidated(true); + setValidationMsg( + editing.has_api_key + ? "API key is stored server-side. Leave it unchanged to keep it, or type a new key to rotate it." + : null, + ); + return; + } setValidated(null); setValidationMsg(null); - }, [endpoint, modelName, extraJson]); + }, [endpoint, modelName, apiKey, extraJson, editing]); + + const apiKeySubmitValue = () => + prepareModelEndpointExtraForSubmit({ api_key: apiKey.trim() }).api_key; + + const shouldReuseStoredApiKey = () => { + const preparedApiKey = apiKeySubmitValue(); + return editing?.has_api_key === true && preparedApiKey === REDACTED_SECRET; + }; + + const revealedApiKeyForEditing = + editing && + revealedApiKey?.modelType === editing.model_type && + revealedApiKey.name === editing.name + ? revealedApiKey.value + : null; + + const fetchStoredApiKey = async ({ cache = true }: { cache?: boolean } = {}): Promise => { + const target = editing?.has_api_key + ? { modelType: editing.model_type, name: editing.name } + : null; + if (!target) return null; + if (revealedApiKeyForEditing) return revealedApiKeyForEditing; + setRevealingApiKey(true); + try { + const result = await revealModelEndpointApiKey(target.modelType, target.name); + if (!result.api_key) { + toast.error("No API key is stored for this endpoint"); + return null; + } + if (cache) { + setRevealedApiKey({ ...target, value: result.api_key }); + } + return result.api_key; + } catch (e) { + const msg = e instanceof Error ? e.message : "Failed to reveal API key"; + toast.error(msg); + return null; + } finally { + setRevealingApiKey(false); + } + }; + + const handleToggleApiKeyVisibility = async () => { + if (apiKeyVisible) { + setApiKeyVisible(false); + setRevealedApiKey(null); + return; + } + if (shouldReuseStoredApiKey()) { + const storedApiKey = await fetchStoredApiKey(); + if (!storedApiKey) return; + setApiKeyVisible(true); + return; + } + setApiKeyVisible(true); + }; + + const handleCopyApiKey = async () => { + const value = shouldReuseStoredApiKey() ? await fetchStoredApiKey({ cache: false }) : apiKey.trim(); + if (!value) return; + try { + await navigator.clipboard.writeText(value); + toast.success("API key copied"); + } catch { + toast.error("Could not copy API key"); + } + }; const handleValidate = async () => { // Draft validation: probe the values currently in the form (before saving), @@ -288,19 +419,52 @@ function EndpointDialog({ return; } let apiKey: string | undefined; + let submittedApiKey: string | undefined; try { - apiKey = (JSON.parse(extraJson || "{}").api_key as string) || undefined; + const preparedApiKey = apiKeySubmitValue(); + submittedApiKey = typeof preparedApiKey === "string" ? preparedApiKey : undefined; + if (submittedApiKey && submittedApiKey !== REDACTED_SECRET) { + apiKey = submittedApiKey; + } else if (revealedApiKeyForEditing) { + apiKey = revealedApiKeyForEditing; + } } catch { // invalid extra JSON is reported on save; ignore here } setValidating(true); setValidationMsg(null); try { - const res = await validateModelEndpoint({ - endpoint, - model_name: modelName || undefined, - api_key: apiKey, - }); + const isClearingStoredApiKey = editing?.has_api_key === true && submittedApiKey === ""; + if ( + editing?.has_api_key === true && + !isClearingStoredApiKey && + normalizeEndpointUrl(endpoint) !== normalizeEndpointUrl(editing.endpoint) && + !apiKey && + (!submittedApiKey || submittedApiKey === REDACTED_SECRET) + ) { + setValidated(false); + const msg = "Reveal or enter the API key before validating a changed endpoint URL."; + setValidationMsg(msg); + toast.error(msg); + return; + } + const canUseStoredSecret = + editing?.has_api_key === true && + !isClearingStoredApiKey && + !apiKey && + (!submittedApiKey || submittedApiKey === REDACTED_SECRET); + const res = canUseStoredSecret + ? await validateModelEndpoint({ + endpoint, + model_name: modelName || undefined, + stored_api_key_model_type: editing.model_type, + stored_api_key_name: editing.name, + }) + : await validateModelEndpoint({ + endpoint, + model_name: modelName || undefined, + api_key: apiKey, + }); if (!res.reachable) { setValidated(false); const msg = res.detail || "Endpoint is unreachable."; @@ -337,7 +501,9 @@ function EndpointDialog({ e.preventDefault(); let extra: Record = {}; try { - extra = JSON.parse(extraJson); + extra = mergeModelEndpointApiKeyExtra(JSON.parse(extraJson), apiKey, { + clearApiKey: editing?.has_api_key === true, + }); } catch { toast.error("Invalid JSON in extra field"); return; @@ -375,6 +541,9 @@ function EndpointDialog({ {editing ? `Edit ${editing.name}` : `Add ${activeTab} endpoint`} + + Configure the endpoint connection and stored credentials. +
@@ -403,6 +572,57 @@ function EndpointDialog({ setTimeout(e.target.value)} />
+
+
+ +
+ + +
+
+ { + setApiKey(e.target.value); + setRevealedApiKey(null); + }} + placeholder={editing?.has_api_key ? "Stored API key" : "Optional API key"} + autoComplete="off" + /> +

+ {editing?.has_api_key + ? "Leave unchanged to keep the stored key, or type a new key to rotate it." + : "Stored in the endpoint extra payload when provided."} +

+