From 0376c0251d412aa4d9429e8a4eda814d8a5c0635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 9 Jun 2026 15:38:13 +0200 Subject: [PATCH] feat(admin): add /admin config surface (API + control plane) Server-level admin surface, gated by HINDSIGHT_API_ENABLE_ADMIN_API with an optional, independent HINDSIGHT_API_ADMIN_TOKEN: - API: GET /admin/config returns the resolved HindsightConfig with credentials redacted (set -> "***", unset -> null) via the credential denylist plus a name-suffix heuristic; features.admin_api exposed on /version. - Control plane: top-level /admin page reusing the shared (now data-driven) Sidebar with a single "Configuration" item + a dedicated AdminHeader; server proxy forwards an independent HINDSIGHT_CP_ADMIN_TOKEN to /admin/*. - Regenerated OpenAPI spec + Python/TS/Go clients; docs + .env.example; admin i18n across all locales. Groundwork for the visibility leg of #2034. The connectivity test + health surfacing land separately as a per-bank health endpoint. --- .env.example | 17 + hindsight-api-slim/hindsight_api/api/http.py | 85 ++++- hindsight-api-slim/hindsight_api/config.py | 12 + hindsight-api-slim/tests/test_admin_api.py | 128 ++++++++ hindsight-clients/go/api/openapi.yaml | 55 ++++ hindsight-clients/go/api_admin.go | 141 ++++++++ hindsight-clients/go/client.go | 3 + .../go/model_admin_config_response.go | 159 +++++++++ hindsight-clients/go/model_features_info.go | 31 +- .../python/.openapi-generator/FILES | 2 + .../python/hindsight_client_api/__init__.py | 2 + .../hindsight_client_api/api/__init__.py | 1 + .../hindsight_client_api/api/admin_api.py | 301 ++++++++++++++++++ .../hindsight_client_api/models/__init__.py | 1 + .../models/admin_config_response.py | 87 +++++ .../models/features_info.py | 4 +- .../typescript/generated/sdk.gen.ts | 16 + .../typescript/generated/types.gen.ts | 58 ++++ .../src/app/[locale]/admin/page.tsx | 48 +++ .../src/app/[locale]/banks/[bankId]/page.tsx | 67 +++- .../src/app/api/admin/config/route.ts | 41 +++ .../src/components/admin-config-view.tsx | 132 ++++++++ .../src/components/admin-header.tsx | 99 ++++++ .../src/components/sidebar.tsx | 70 ++-- hindsight-control-plane/src/lib/api.ts | 10 + .../src/lib/features-context.tsx | 2 + .../src/lib/hindsight-client.ts | 16 + hindsight-control-plane/src/messages/de.json | 18 ++ hindsight-control-plane/src/messages/en.json | 18 ++ hindsight-control-plane/src/messages/es.json | 18 ++ hindsight-control-plane/src/messages/fr.json | 18 ++ hindsight-control-plane/src/messages/ja.json | 18 ++ hindsight-control-plane/src/messages/ko.json | 18 ++ hindsight-control-plane/src/messages/pt.json | 18 ++ .../src/messages/yue-Hant.json | 18 ++ .../src/messages/zh-CN.json | 18 ++ .../src/messages/zh-TW.json | 18 ++ .../docs/developer/configuration.md | 2 + hindsight-docs/static/openapi.json | 72 +++++ .../references/developer/configuration.md | 2 + skills/hindsight-docs/references/openapi.json | 72 +++++ 41 files changed, 1873 insertions(+), 43 deletions(-) create mode 100644 hindsight-api-slim/tests/test_admin_api.py create mode 100644 hindsight-clients/go/api_admin.go create mode 100644 hindsight-clients/go/model_admin_config_response.go create mode 100644 hindsight-clients/python/hindsight_client_api/api/admin_api.py create mode 100644 hindsight-clients/python/hindsight_client_api/models/admin_config_response.py create mode 100644 hindsight-control-plane/src/app/[locale]/admin/page.tsx create mode 100644 hindsight-control-plane/src/app/api/admin/config/route.ts create mode 100644 hindsight-control-plane/src/components/admin-config-view.tsx create mode 100644 hindsight-control-plane/src/components/admin-header.tsx diff --git a/.env.example b/.env.example index 27e208f9f..6020baf8d 100644 --- a/.env.example +++ b/.env.example @@ -159,3 +159,20 @@ HINDSIGHT_API_LOG_LEVEL=info # When set, visitors see a login page and must enter the key before # accessing the dashboard or any /api/* routes (except /api/health). # HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key + +# Optional: Token the CP forwards to the dataplane admin API (/admin/*). +# Must match HINDSIGHT_API_ADMIN_TOKEN below. Leave unset for an open admin API. +# HINDSIGHT_CP_ADMIN_TOKEN=your-admin-token + +# ----------------------------------------------------------------------------- +# Admin surface (Optional, server-level) +# ----------------------------------------------------------------------------- + +# Enable the admin API (GET /admin/config) and the Control Plane /admin page. +# Off by default — the admin surface is invisible (404) until enabled. +# HINDSIGHT_API_ENABLE_ADMIN_API=true + +# Optional: Require this bearer token for the admin API. When unset, the admin +# API is open (once enabled). When set, callers must send +# `Authorization: Bearer `. Independent of the tenant API key. +# HINDSIGHT_API_ADMIN_TOKEN=your-admin-token diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 68e7d3ad4..fdbe4af92 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -6,6 +6,8 @@ """ import asyncio +import dataclasses +import hmac import json import logging import re @@ -79,7 +81,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any: return Field(default_factory=default_factory, json_schema_extra=json_extra, **kwargs) -from hindsight_api.config import get_config +from hindsight_api.config import _get_raw_config, get_config from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table from hindsight_api.engine.providers.none_llm import LLMNotAvailableError from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage @@ -1206,6 +1208,33 @@ class BankConfigResponse(BaseModel): overrides: dict[str, Any] = Field(description="Bank-specific configuration overrides only (Python field names)") +class AdminConfigResponse(BaseModel): + """Response model for the server-level (admin) configuration view. + + Returns the resolved ``HindsightConfig`` as a flat dict keyed by Python field + name. Credential fields (API keys, tokens, service-account keys, base URLs) are + masked: present as ``"***"`` when set and ``None`` when unset, so an operator can + see which credentials are configured without ever seeing their values. + """ + + config: dict[str, Any] = Field( + description="Resolved server-level configuration (Python field names); credentials are redacted" + ) + + +# Name suffixes that mark a config field as secret-bearing. Used in addition to +# HindsightConfig._CREDENTIAL_FIELDS so the admin config view never leaks a provider +# credential even if the (denylist) credential set misses a field. Suffixes are +# singular on purpose — "_token" must not also match value-bearing "_tokens" fields +# like recall_max_tokens. +_SENSITIVE_FIELD_SUFFIXES = ("_api_key", "_token", "_secret", "_access_key", "_account_key", "_password") + + +def _is_sensitive_config_field(field_name: str, credential_fields: set[str]) -> bool: + """Whether a config field must be redacted in the admin view.""" + return field_name in credential_fields or field_name.endswith(_SENSITIVE_FIELD_SUFFIXES) + + class GraphDataResponse(BaseModel): """Response model for graph data endpoint.""" @@ -2413,6 +2442,7 @@ class FeaturesInfo(BaseModel): mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled") worker: bool = Field(description="Whether the background worker is enabled") bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled") + admin_api: bool = Field(description="Whether the admin API (/admin) is enabled") file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled") document_export_api: bool = Field(description="Whether the document export endpoint is enabled") document_import_api: bool = Field(description="Whether the document import endpoint is enabled") @@ -2971,6 +3001,31 @@ def get_request_context(authorization: str | None = Header(default=None)) -> Req api_key = authorization.strip() return RequestContext(api_key=api_key) + def require_admin(authorization: str | None = Header(default=None)) -> None: + """Guard for the admin surface. + + - 404 when the admin API is disabled (so the surface is invisible by default). + - When ``HINDSIGHT_API_ADMIN_TOKEN`` is set, require it as a bearer token + (or a bare token) and reject with 401 otherwise. When unset, the admin API + is open (auth is optional) — consistent with the rest of the deployment. + """ + config = _get_raw_config() + if not config.enable_admin_api: + raise HTTPException( + status_code=404, + detail="Admin API is disabled. Set HINDSIGHT_API_ENABLE_ADMIN_API=true to enable.", + ) + expected = config.admin_api_token + if expected: + token = None + if authorization: + if authorization.lower().startswith("bearer "): + token = authorization[7:].strip() + else: + token = authorization.strip() + if not token or not hmac.compare_digest(token, expected): + raise HTTPException(status_code=401, detail="Invalid or missing admin token") + def precheck_for(operation: str): """ Build a FastAPI dependency that runs ``OperationValidator.precheck``. @@ -3080,6 +3135,7 @@ async def version_endpoint() -> VersionResponse: mcp=config.mcp_enabled, worker=config.worker_enabled, bank_config_api=config.enable_bank_config_api, + admin_api=config.enable_admin_api, file_upload_api=config.enable_file_upload_api, document_export_api=config.enable_document_export_api, document_import_api=config.enable_document_import_api, @@ -3088,6 +3144,33 @@ async def version_endpoint() -> VersionResponse: ), ) + @app.get( + "/admin/config", + response_model=AdminConfigResponse, + summary="Get resolved server-level configuration", + description="Returns the resolved server-level configuration with credentials redacted. " + "Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN.", + tags=["Admin"], + operation_id="get_admin_config", + dependencies=[Depends(require_admin)], + ) + async def admin_config_endpoint() -> AdminConfigResponse: + """Expose the resolved ``HindsightConfig`` for operator inspection. + + Sensitive fields (the credential denylist plus any field whose name ends in a + secret-bearing suffix — see ``_is_sensitive_config_field``) are masked so values + never leave the server: ``"***"`` when set, ``None`` when unset. All other fields + are returned as-is. + """ + config = _get_raw_config() + credential_fields = type(config).get_credential_fields() + raw = dataclasses.asdict(config) + redacted = { + key: ("***" if value is not None else None) if _is_sensitive_config_field(key, credential_fields) else value + for key, value in raw.items() + } + return AdminConfigResponse(config=redacted) + @app.get( "/metrics", summary="Prometheus metrics endpoint", diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index 0d8a99ede..6ba142779 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -351,6 +351,8 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS" ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS" ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API" +ENV_ENABLE_ADMIN_API = "HINDSIGHT_API_ENABLE_ADMIN_API" +ENV_ADMIN_API_TOKEN = "HINDSIGHT_API_ADMIN_TOKEN" ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE" ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER" ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT" @@ -792,6 +794,8 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only) DEFAULT_ENABLE_BANK_CONFIG_API = True +DEFAULT_ENABLE_ADMIN_API = False # Admin surface (server config view) is off unless explicitly enabled +DEFAULT_ADMIN_API_TOKEN: str | None = None # None = admin API open (when enabled); set = required bearer token DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks DEFAULT_GRAPH_RETRIEVER = "link_expansion" DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker @@ -1387,6 +1391,10 @@ class HindsightConfig: mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE) enable_bank_config_api: bool + # Admin surface (static, server-level only). enable_admin_api gates the /admin API + + # control-plane page; admin_api_token (when set) is the required bearer token. + enable_admin_api: bool + admin_api_token: str | None # Default bank template (static, server-level only). When set, the manifest is applied # to every newly-created bank, overriding the env/config defaults for any fields it sets. default_bank_template: dict | None @@ -1612,6 +1620,8 @@ class HindsightConfig: # File parser credentials "file_parser_iris_token", "file_parser_llama_parse_api_key", + # Admin surface token (never exposed via the admin config view itself) + "admin_api_token", } # CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank @@ -2218,6 +2228,8 @@ def from_env(cls) -> "HindsightConfig": mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true", enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower() == "true", + enable_admin_api=os.getenv(ENV_ENABLE_ADMIN_API, str(DEFAULT_ENABLE_ADMIN_API)).lower() == "true", + admin_api_token=os.getenv(ENV_ADMIN_API_TOKEN) or DEFAULT_ADMIN_API_TOKEN, default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)), # Recall graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER), diff --git a/hindsight-api-slim/tests/test_admin_api.py b/hindsight-api-slim/tests/test_admin_api.py new file mode 100644 index 000000000..3d4a64375 --- /dev/null +++ b/hindsight-api-slim/tests/test_admin_api.py @@ -0,0 +1,128 @@ +"""Tests for the admin surface: GET /admin/config + the admin_api feature flag. + +These are deterministic (no LLM): the endpoint only reads server-level config. We +toggle env vars + clear the config cache to exercise the enable flag, the optional +admin token, and credential redaction. +""" + +import httpx +import pytest +import pytest_asyncio + +from hindsight_api.api import create_app +from hindsight_api.config import clear_config_cache + + +@pytest_asyncio.fixture +async def admin_client(memory): + """Async test client for the FastAPI app (mock LLM).""" + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +def _set_env(monkeypatch, **values: str | None) -> None: + """Set/unset env vars and reset the cached config so the next read reflects them.""" + for key, value in values.items(): + if value is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, value) + clear_config_cache() + + +@pytest.fixture(autouse=True) +def _restore_config_cache(): + """Ensure the global config cache is reset after each test.""" + yield + clear_config_cache() + + +@pytest.mark.asyncio +async def test_admin_config_disabled_by_default(admin_client, monkeypatch): + """When the admin API is disabled (default), the endpoint is invisible (404).""" + _set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API=None, HINDSIGHT_API_ADMIN_TOKEN=None) + + response = await admin_client.get("/admin/config") + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_config_enabled_no_token(admin_client, monkeypatch): + """When enabled without a token, the endpoint is open and returns config.""" + _set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="true", HINDSIGHT_API_ADMIN_TOKEN=None) + + response = await admin_client.get("/admin/config") + + assert response.status_code == 200 + config = response.json()["config"] + # A representative spread of non-credential fields should be present. + assert "llm_provider" in config + assert "enable_admin_api" in config + assert config["enable_admin_api"] is True + + +@pytest.mark.asyncio +async def test_admin_config_redacts_credentials(admin_client, monkeypatch): + """Credential fields are masked, never returned in cleartext.""" + _set_env( + monkeypatch, + HINDSIGHT_API_ENABLE_ADMIN_API="true", + HINDSIGHT_API_ADMIN_TOKEN="s3cret-token", + HINDSIGHT_API_LLM_API_KEY="super-secret-key", + ) + + response = await admin_client.get("/admin/config", headers={"Authorization": "Bearer s3cret-token"}) + + assert response.status_code == 200 + config = response.json()["config"] + # The configured LLM key is present but masked. + assert config["llm_api_key"] == "***" + assert "super-secret-key" not in response.text + # Provider keys that fall back to the LLM key (and aren't in the credential + # denylist) must also be masked — the view redacts by name, not just the set. + assert config["embeddings_openrouter_api_key"] == "***" + assert config["reranker_openrouter_api_key"] == "***" + # The admin token must never leak through its own config view. + assert config["admin_api_token"] == "***" + assert "s3cret-token" not in response.text + # Value-bearing fields that merely contain "token" in their name (plural) are + # NOT redacted — they carry useful config, not secrets. + assert config["recall_max_tokens"] != "***" + + +@pytest.mark.asyncio +async def test_admin_config_requires_token_when_set(admin_client, monkeypatch): + """With a token configured, missing/wrong tokens are rejected; the right one passes.""" + _set_env( + monkeypatch, + HINDSIGHT_API_ENABLE_ADMIN_API="true", + HINDSIGHT_API_ADMIN_TOKEN="right-token", + ) + + missing = await admin_client.get("/admin/config") + assert missing.status_code == 401 + + wrong = await admin_client.get("/admin/config", headers={"Authorization": "Bearer wrong-token"}) + assert wrong.status_code == 401 + + bearer = await admin_client.get("/admin/config", headers={"Authorization": "Bearer right-token"}) + assert bearer.status_code == 200 + + # A bare token (no "Bearer " prefix) is also accepted. + bare = await admin_client.get("/admin/config", headers={"Authorization": "right-token"}) + assert bare.status_code == 200 + + +@pytest.mark.asyncio +async def test_version_reports_admin_api_flag(admin_client, monkeypatch): + """The /version feature flags track the admin enable flag.""" + _set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="true") + enabled = await admin_client.get("/version") + assert enabled.json()["features"]["admin_api"] is True + + _set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="false") + disabled = await admin_client.get("/version") + assert disabled.json()["features"]["admin_api"] is False diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index b72c71092..926710869 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -39,6 +39,36 @@ paths: summary: Get API version and feature flags tags: - Monitoring + /admin/config: + get: + description: "Returns the resolved server-level configuration with credentials\ + \ redacted. Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN." + operationId: get_admin_config + parameters: + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/AdminConfigResponse' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Get resolved server-level configuration + tags: + - Admin /metrics: get: description: Exports metrics in Prometheus format for scraping @@ -3796,6 +3826,26 @@ components: required: - content title: AddBackgroundRequest + AdminConfigResponse: + description: |- + Response model for the server-level (admin) configuration view. + + Returns the resolved ``HindsightConfig`` as a flat dict keyed by Python field + name. Credential fields (API keys, tokens, service-account keys, base URLs) are + masked: present as ``"***"`` when set and ``None`` when unset, so an operator can + see which credentials are configured without ever seeing their values. + example: + config: + key: "" + properties: + config: + additionalProperties: {} + description: Resolved server-level configuration (Python field names); credentials + are redacted + title: Config + required: + - config + title: AdminConfigResponse AsyncOperationSubmitResponse: description: Response model for submitting an async operation. example: @@ -5406,6 +5456,10 @@ components: description: Whether per-bank configuration API is enabled title: Bank Config Api type: boolean + admin_api: + description: Whether the admin API (/admin) is enabled + title: Admin Api + type: boolean file_upload_api: description: Whether file upload/conversion API is enabled title: File Upload Api @@ -5427,6 +5481,7 @@ components: title: Llm Trace type: boolean required: + - admin_api - audit_log - bank_config_api - document_export_api diff --git a/hindsight-clients/go/api_admin.go b/hindsight-clients/go/api_admin.go new file mode 100644 index 000000000..c81b720c3 --- /dev/null +++ b/hindsight-clients/go/api_admin.go @@ -0,0 +1,141 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" +) + + +// AdminAPIService AdminAPI service +type AdminAPIService service + +type ApiGetAdminConfigRequest struct { + ctx context.Context + ApiService *AdminAPIService + authorization *string +} + +func (r ApiGetAdminConfigRequest) Authorization(authorization string) ApiGetAdminConfigRequest { + r.authorization = &authorization + return r +} + +func (r ApiGetAdminConfigRequest) Execute() (*AdminConfigResponse, *http.Response, error) { + return r.ApiService.GetAdminConfigExecute(r) +} + +/* +GetAdminConfig Get resolved server-level configuration + +Returns the resolved server-level configuration with credentials redacted. Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiGetAdminConfigRequest +*/ +func (a *AdminAPIService) GetAdminConfig(ctx context.Context) ApiGetAdminConfigRequest { + return ApiGetAdminConfigRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// @return AdminConfigResponse +func (a *AdminAPIService) GetAdminConfigExecute(r ApiGetAdminConfigRequest) (*AdminConfigResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *AdminConfigResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AdminAPIService.GetAdminConfig") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/admin/config" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/hindsight-clients/go/client.go b/hindsight-clients/go/client.go index b6e1bc214..f42da5044 100644 --- a/hindsight-clients/go/client.go +++ b/hindsight-clients/go/client.go @@ -49,6 +49,8 @@ type APIClient struct { // API Services + AdminAPI *AdminAPIService + AuditAPI *AuditAPIService BankTemplatesAPI *BankTemplatesAPIService @@ -94,6 +96,7 @@ func NewAPIClient(cfg *Configuration) *APIClient { c.common.client = c // API Services + c.AdminAPI = (*AdminAPIService)(&c.common) c.AuditAPI = (*AuditAPIService)(&c.common) c.BankTemplatesAPI = (*BankTemplatesAPIService)(&c.common) c.BanksAPI = (*BanksAPIService)(&c.common) diff --git a/hindsight-clients/go/model_admin_config_response.go b/hindsight-clients/go/model_admin_config_response.go new file mode 100644 index 000000000..5f8c28d8b --- /dev/null +++ b/hindsight-clients/go/model_admin_config_response.go @@ -0,0 +1,159 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the AdminConfigResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &AdminConfigResponse{} + +// AdminConfigResponse Response model for the server-level (admin) configuration view. Returns the resolved ``HindsightConfig`` as a flat dict keyed by Python field name. Credential fields (API keys, tokens, service-account keys, base URLs) are masked: present as ``\"***\"`` when set and ``None`` when unset, so an operator can see which credentials are configured without ever seeing their values. +type AdminConfigResponse struct { + // Resolved server-level configuration (Python field names); credentials are redacted + Config map[string]interface{} `json:"config"` +} + +type _AdminConfigResponse AdminConfigResponse + +// NewAdminConfigResponse instantiates a new AdminConfigResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewAdminConfigResponse(config map[string]interface{}) *AdminConfigResponse { + this := AdminConfigResponse{} + this.Config = config + return &this +} + +// NewAdminConfigResponseWithDefaults instantiates a new AdminConfigResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewAdminConfigResponseWithDefaults() *AdminConfigResponse { + this := AdminConfigResponse{} + return &this +} + +// GetConfig returns the Config field value +func (o *AdminConfigResponse) GetConfig() map[string]interface{} { + if o == nil { + var ret map[string]interface{} + return ret + } + + return o.Config +} + +// GetConfigOk returns a tuple with the Config field value +// and a boolean to check if the value has been set. +func (o *AdminConfigResponse) GetConfigOk() (map[string]interface{}, bool) { + if o == nil { + return map[string]interface{}{}, false + } + return o.Config, true +} + +// SetConfig sets field value +func (o *AdminConfigResponse) SetConfig(v map[string]interface{}) { + o.Config = v +} + +func (o AdminConfigResponse) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o AdminConfigResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["config"] = o.Config + return toSerialize, nil +} + +func (o *AdminConfigResponse) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "config", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varAdminConfigResponse := _AdminConfigResponse{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varAdminConfigResponse) + + if err != nil { + return err + } + + *o = AdminConfigResponse(varAdminConfigResponse) + + return err +} + +type NullableAdminConfigResponse struct { + value *AdminConfigResponse + isSet bool +} + +func (v NullableAdminConfigResponse) Get() *AdminConfigResponse { + return v.value +} + +func (v *NullableAdminConfigResponse) Set(val *AdminConfigResponse) { + v.value = val + v.isSet = true +} + +func (v NullableAdminConfigResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableAdminConfigResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableAdminConfigResponse(val *AdminConfigResponse) *NullableAdminConfigResponse { + return &NullableAdminConfigResponse{value: val, isSet: true} +} + +func (v NullableAdminConfigResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableAdminConfigResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_features_info.go b/hindsight-clients/go/model_features_info.go index a6a116038..9eaaca4e7 100644 --- a/hindsight-clients/go/model_features_info.go +++ b/hindsight-clients/go/model_features_info.go @@ -29,6 +29,8 @@ type FeaturesInfo struct { Worker bool `json:"worker"` // Whether per-bank configuration API is enabled BankConfigApi bool `json:"bank_config_api"` + // Whether the admin API (/admin) is enabled + AdminApi bool `json:"admin_api"` // Whether file upload/conversion API is enabled FileUploadApi bool `json:"file_upload_api"` // Whether the document export endpoint is enabled @@ -47,12 +49,13 @@ type _FeaturesInfo FeaturesInfo // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewFeaturesInfo(observations bool, mcp bool, worker bool, bankConfigApi bool, fileUploadApi bool, documentExportApi bool, documentImportApi bool, auditLog bool, llmTrace bool) *FeaturesInfo { +func NewFeaturesInfo(observations bool, mcp bool, worker bool, bankConfigApi bool, adminApi bool, fileUploadApi bool, documentExportApi bool, documentImportApi bool, auditLog bool, llmTrace bool) *FeaturesInfo { this := FeaturesInfo{} this.Observations = observations this.Mcp = mcp this.Worker = worker this.BankConfigApi = bankConfigApi + this.AdminApi = adminApi this.FileUploadApi = fileUploadApi this.DocumentExportApi = documentExportApi this.DocumentImportApi = documentImportApi @@ -165,6 +168,30 @@ func (o *FeaturesInfo) SetBankConfigApi(v bool) { o.BankConfigApi = v } +// GetAdminApi returns the AdminApi field value +func (o *FeaturesInfo) GetAdminApi() bool { + if o == nil { + var ret bool + return ret + } + + return o.AdminApi +} + +// GetAdminApiOk returns a tuple with the AdminApi field value +// and a boolean to check if the value has been set. +func (o *FeaturesInfo) GetAdminApiOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.AdminApi, true +} + +// SetAdminApi sets field value +func (o *FeaturesInfo) SetAdminApi(v bool) { + o.AdminApi = v +} + // GetFileUploadApi returns the FileUploadApi field value func (o *FeaturesInfo) GetFileUploadApi() bool { if o == nil { @@ -299,6 +326,7 @@ func (o FeaturesInfo) ToMap() (map[string]interface{}, error) { toSerialize["mcp"] = o.Mcp toSerialize["worker"] = o.Worker toSerialize["bank_config_api"] = o.BankConfigApi + toSerialize["admin_api"] = o.AdminApi toSerialize["file_upload_api"] = o.FileUploadApi toSerialize["document_export_api"] = o.DocumentExportApi toSerialize["document_import_api"] = o.DocumentImportApi @@ -316,6 +344,7 @@ func (o *FeaturesInfo) UnmarshalJSON(data []byte) (err error) { "mcp", "worker", "bank_config_api", + "admin_api", "file_upload_api", "document_export_api", "document_import_api", diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 4c999742e..8ec3198b7 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -1,6 +1,7 @@ .openapi-generator-ignore hindsight_client_api/__init__.py hindsight_client_api/api/__init__.py +hindsight_client_api/api/admin_api.py hindsight_client_api/api/audit_api.py hindsight_client_api/api/bank_templates_api.py hindsight_client_api/api/banks_api.py @@ -21,6 +22,7 @@ hindsight_client_api/configuration.py hindsight_client_api/exceptions.py hindsight_client_api/models/__init__.py hindsight_client_api/models/add_background_request.py +hindsight_client_api/models/admin_config_response.py hindsight_client_api/models/async_operation_submit_response.py hindsight_client_api/models/audit_log_entry.py hindsight_client_api/models/audit_log_list_response.py diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 3c5fb3804..41ccf1a01 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -17,6 +17,7 @@ __version__ = "0.0.7" # import apis into sdk package +from hindsight_client_api.api.admin_api import AdminApi from hindsight_client_api.api.audit_api import AuditApi from hindsight_client_api.api.bank_templates_api import BankTemplatesApi from hindsight_client_api.api.banks_api import BanksApi @@ -45,6 +46,7 @@ # import models into sdk package from hindsight_client_api.models.add_background_request import AddBackgroundRequest +from hindsight_client_api.models.admin_config_response import AdminConfigResponse from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse from hindsight_client_api.models.audit_log_entry import AuditLogEntry from hindsight_client_api.models.audit_log_list_response import AuditLogListResponse diff --git a/hindsight-clients/python/hindsight_client_api/api/__init__.py b/hindsight-clients/python/hindsight_client_api/api/__init__.py index 5c8b5cea3..791c14b90 100644 --- a/hindsight-clients/python/hindsight_client_api/api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/api/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa # import apis into api package +from hindsight_client_api.api.admin_api import AdminApi from hindsight_client_api.api.audit_api import AuditApi from hindsight_client_api.api.bank_templates_api import BankTemplatesApi from hindsight_client_api.api.banks_api import BanksApi diff --git a/hindsight-clients/python/hindsight_client_api/api/admin_api.py b/hindsight-clients/python/hindsight_client_api/api/admin_api.py new file mode 100644 index 000000000..c49326bfa --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/api/admin_api.py @@ -0,0 +1,301 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + +import warnings +from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt +from typing import Any, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + +from pydantic import StrictStr +from typing import Optional +from hindsight_client_api.models.admin_config_response import AdminConfigResponse + +from hindsight_client_api.api_client import ApiClient, RequestSerialized +from hindsight_client_api.api_response import ApiResponse +from hindsight_client_api.rest import RESTResponseType + + +class AdminApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + + @validate_call + async def get_admin_config( + self, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> AdminConfigResponse: + """Get resolved server-level configuration + + Returns the resolved server-level configuration with credentials redacted. Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN. + + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_admin_config_serialize( + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "AdminConfigResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_admin_config_with_http_info( + self, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[AdminConfigResponse]: + """Get resolved server-level configuration + + Returns the resolved server-level configuration with credentials redacted. Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN. + + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_admin_config_serialize( + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "AdminConfigResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_admin_config_without_preload_content( + self, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Get resolved server-level configuration + + Returns the resolved server-level configuration with credentials redacted. Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN. + + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_admin_config_serialize( + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "AdminConfigResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_admin_config_serialize( + self, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/admin/config', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index fd7b80252..303f09607 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -15,6 +15,7 @@ # import models into model package from hindsight_client_api.models.add_background_request import AddBackgroundRequest +from hindsight_client_api.models.admin_config_response import AdminConfigResponse from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse from hindsight_client_api.models.audit_log_entry import AuditLogEntry from hindsight_client_api.models.audit_log_list_response import AuditLogListResponse diff --git a/hindsight-clients/python/hindsight_client_api/models/admin_config_response.py b/hindsight-clients/python/hindsight_client_api/models/admin_config_response.py new file mode 100644 index 000000000..beb7d465d --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/admin_config_response.py @@ -0,0 +1,87 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class AdminConfigResponse(BaseModel): + """ + Response model for the server-level (admin) configuration view. Returns the resolved ``HindsightConfig`` as a flat dict keyed by Python field name. Credential fields (API keys, tokens, service-account keys, base URLs) are masked: present as ``\"***\"`` when set and ``None`` when unset, so an operator can see which credentials are configured without ever seeing their values. + """ # noqa: E501 + config: Dict[str, Any] = Field(description="Resolved server-level configuration (Python field names); credentials are redacted") + __properties: ClassVar[List[str]] = ["config"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of AdminConfigResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of AdminConfigResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "config": obj.get("config") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/features_info.py b/hindsight-clients/python/hindsight_client_api/models/features_info.py index ba2dd4817..5a554772e 100644 --- a/hindsight-clients/python/hindsight_client_api/models/features_info.py +++ b/hindsight-clients/python/hindsight_client_api/models/features_info.py @@ -30,12 +30,13 @@ class FeaturesInfo(BaseModel): mcp: StrictBool = Field(description="Whether MCP (Model Context Protocol) server is enabled") worker: StrictBool = Field(description="Whether the background worker is enabled") bank_config_api: StrictBool = Field(description="Whether per-bank configuration API is enabled") + admin_api: StrictBool = Field(description="Whether the admin API (/admin) is enabled") file_upload_api: StrictBool = Field(description="Whether file upload/conversion API is enabled") document_export_api: StrictBool = Field(description="Whether the document export endpoint is enabled") document_import_api: StrictBool = Field(description="Whether the document import endpoint is enabled") audit_log: StrictBool = Field(description="Whether audit logging is enabled") llm_trace: StrictBool = Field(description="Whether per-bank LLM request tracing is enabled") - __properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api", "file_upload_api", "document_export_api", "document_import_api", "audit_log", "llm_trace"] + __properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api", "admin_api", "file_upload_api", "document_export_api", "document_import_api", "audit_log", "llm_trace"] model_config = ConfigDict( populate_by_name=True, @@ -92,6 +93,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "mcp": obj.get("mcp"), "worker": obj.get("worker"), "bank_config_api": obj.get("bank_config_api"), + "admin_api": obj.get("admin_api"), "file_upload_api": obj.get("file_upload_api"), "document_export_api": obj.get("document_export_api"), "document_import_api": obj.get("document_import_api"), diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 7f112836c..1cad51ccd 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -65,6 +65,9 @@ import type { FileRetainData, FileRetainErrors, FileRetainResponses, + GetAdminConfigData, + GetAdminConfigErrors, + GetAdminConfigResponses, GetAgentStatsData, GetAgentStatsErrors, GetAgentStatsResponses, @@ -262,6 +265,19 @@ export const getVersion = ( ...options, }); +/** + * Get resolved server-level configuration + * + * Returns the resolved server-level configuration with credentials redacted. Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN. + */ +export const getAdminConfig = ( + options?: Options +) => + (options?.client ?? client).get({ + url: "/admin/config", + ...options, + }); + /** * Prometheus metrics endpoint * diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index bf085e62c..85bcdaa72 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -24,6 +24,27 @@ export type AddBackgroundRequest = { update_disposition?: boolean; }; +/** + * AdminConfigResponse + * + * Response model for the server-level (admin) configuration view. + * + * Returns the resolved ``HindsightConfig`` as a flat dict keyed by Python field + * name. Credential fields (API keys, tokens, service-account keys, base URLs) are + * masked: present as ``"***"`` when set and ``None`` when unset, so an operator can + * see which credentials are configured without ever seeing their values. + */ +export type AdminConfigResponse = { + /** + * Config + * + * Resolved server-level configuration (Python field names); credentials are redacted + */ + config: { + [key: string]: unknown; + }; +}; + /** * AsyncOperationSubmitResponse * @@ -1658,6 +1679,12 @@ export type FeaturesInfo = { * Whether per-bank configuration API is enabled */ bank_config_api: boolean; + /** + * Admin Api + * + * Whether the admin API (/admin) is enabled + */ + admin_api: boolean; /** * File Upload Api * @@ -3783,6 +3810,37 @@ export type GetVersionResponses = { export type GetVersionResponse = GetVersionResponses[keyof GetVersionResponses]; +export type GetAdminConfigData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path?: never; + query?: never; + url: "/admin/config"; +}; + +export type GetAdminConfigErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetAdminConfigError = GetAdminConfigErrors[keyof GetAdminConfigErrors]; + +export type GetAdminConfigResponses = { + /** + * Successful Response + */ + 200: AdminConfigResponse; +}; + +export type GetAdminConfigResponse = GetAdminConfigResponses[keyof GetAdminConfigResponses]; + export type MetricsEndpointMetricsGetData = { body?: never; path?: never; diff --git a/hindsight-control-plane/src/app/[locale]/admin/page.tsx b/hindsight-control-plane/src/app/[locale]/admin/page.tsx new file mode 100644 index 000000000..308d080da --- /dev/null +++ b/hindsight-control-plane/src/app/[locale]/admin/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Settings } from "lucide-react"; +import { AdminHeader } from "@/components/admin-header"; +import { Sidebar } from "@/components/sidebar"; +import { FeatureNotEnabled } from "@/components/feature-not-enabled"; +import { AdminConfigView } from "@/components/admin-config-view"; +import { useFeatures } from "@/lib/features-context"; + +export default function AdminPage() { + const t = useTranslations("admin"); + const { features, loading } = useFeatures(); + + return ( +
+ + +
+ + +
+
+ {loading ? null : features?.admin_api ? ( + + ) : ( + ( + + HINDSIGHT_API_ENABLE_ADMIN_API=true + + ), + })} + /> + )} +
+
+
+
+ ); +} diff --git a/hindsight-control-plane/src/app/[locale]/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/[locale]/banks/[bankId]/page.tsx index 19d72ef00..7c3e09a59 100644 --- a/hindsight-control-plane/src/app/[locale]/banks/[bankId]/page.tsx +++ b/hindsight-control-plane/src/app/[locale]/banks/[bankId]/page.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { BankSelector } from "@/components/bank-selector"; import { Sidebar } from "@/components/sidebar"; +import type { SidebarItem } from "@/components/sidebar"; import { DataView } from "@/components/data-view"; import { DocumentsView } from "@/components/documents-view"; import { EntitiesView } from "@/components/entities-view"; @@ -42,7 +43,21 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Brain, Download, Trash2, Loader2, MoreVertical, Pencil, RotateCcw } from "lucide-react"; +import { + Brain, + Download, + Trash2, + Loader2, + MoreVertical, + Pencil, + RotateCcw, + Database, + Search, + Sparkles, + FileText, + Users, + Settings, +} from "lucide-react"; type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile"; type DataSubTab = "world" | "experience" | "observations" | "mental-models"; @@ -53,6 +68,7 @@ export default function BankPage() { const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations("bank"); + const tSidebar = useTranslations("bank.sidebar"); const tCommon = useTranslations("common"); const { features } = useFeatures(); const { currentBank: bankId, setCurrentBank, loadBanks } = useBank(); @@ -80,6 +96,47 @@ export default function BankPage() { router.push(bankRoute(bankId, `?view=${tab}`)); }; + const sidebarItems: SidebarItem[] = bankId + ? [ + { + id: "data", + label: tSidebar("memories"), + icon: Database, + href: bankRoute(bankId, "?view=data"), + }, + { + id: "recall", + label: tSidebar("recall"), + icon: Search, + href: bankRoute(bankId, "?view=recall"), + }, + { + id: "reflect", + label: tSidebar("reflect"), + icon: Sparkles, + href: bankRoute(bankId, "?view=reflect"), + }, + { + id: "documents", + label: tSidebar("documents"), + icon: FileText, + href: bankRoute(bankId, "?view=documents"), + }, + { + id: "entities", + label: tSidebar("entities"), + icon: Users, + href: bankRoute(bankId, "?view=entities"), + }, + { + id: "profile", + label: t("bankConfiguration"), + icon: Settings, + href: bankRoute(bankId, "?view=profile"), + }, + ] + : []; + const handleDataSubTabChange = (newSubTab: DataSubTab) => { if (!bankId) return; router.push(bankRoute(bankId, `?view=data&subTab=${newSubTab}`)); @@ -169,7 +226,13 @@ export default function BankPage() {
- + {bankId && ( + handleTabChange(id as NavItem)} + /> + )}
diff --git a/hindsight-control-plane/src/app/api/admin/config/route.ts b/hindsight-control-plane/src/app/api/admin/config/route.ts new file mode 100644 index 000000000..dedca183b --- /dev/null +++ b/hindsight-control-plane/src/app/api/admin/config/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; +import { DATAPLANE_URL, getAdminHeaders } from "@/lib/hindsight-client"; + +/** + * Proxy for the dataplane admin config endpoint (`GET /admin/config`). + * + * Forwards the independent admin token (HINDSIGHT_CP_ADMIN_TOKEN) rather than the + * tenant API key. The dataplane returns 404 when the admin API is disabled and 401 + * when a token is required but missing/wrong — both are surfaced to the caller. + */ +export async function GET(request: Request) { + try { + const response = await fetch(`${DATAPLANE_URL}/admin/config`, { + headers: getAdminHeaders({ Accept: "application/json" }), + cache: "no-store", + }); + + if (!response.ok) { + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Failed to get admin config", + errorKey: "api.errors.admin.config.fetch", + }), + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error getting admin config:", error); + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Failed to get admin config", + errorKey: "api.errors.admin.config.fetch", + }), + { status: 500 } + ); + } +} diff --git a/hindsight-control-plane/src/components/admin-config-view.tsx b/hindsight-control-plane/src/components/admin-config-view.tsx new file mode 100644 index 000000000..3a60aeaf6 --- /dev/null +++ b/hindsight-control-plane/src/components/admin-config-view.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Loader2, Search } from "lucide-react"; +import { client } from "@/lib/api"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +/** Render a config value compactly: primitives inline, objects/arrays as JSON. */ +function formatValue(value: unknown): string { + if (value === null || value === undefined) return "—"; + if (typeof value === "string") return value === "" ? '""' : value; + if (typeof value === "boolean" || typeof value === "number") return String(value); + return JSON.stringify(value); +} + +/** Group key, derived from the first underscore-delimited token (e.g. "retain_chunk_size" -> "retain"). */ +function groupOf(key: string): string { + const head = key.split("_")[0]; + return head || "general"; +} + +export function AdminConfigView() { + const t = useTranslations("admin"); + const [config, setConfig] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [filter, setFilter] = useState(""); + + useEffect(() => { + let cancelled = false; + client + .getAdminConfig() + .then((resp) => { + if (!cancelled) { + setConfig(resp.config); + setError(false); + } + }) + .catch(() => { + if (!cancelled) setError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const groups = useMemo(() => { + if (!config) return []; + const needle = filter.trim().toLowerCase(); + const byGroup = new Map(); + for (const [key, value] of Object.entries(config).sort(([a], [b]) => a.localeCompare(b))) { + if ( + needle && + !key.toLowerCase().includes(needle) && + !formatValue(value).toLowerCase().includes(needle) + ) { + continue; + } + const group = groupOf(key); + const rows = byGroup.get(group) ?? []; + rows.push([key, value]); + byGroup.set(group, rows); + } + return [...byGroup.entries()].sort(([a], [b]) => a.localeCompare(b)); + }, [config, filter]); + + if (loading) { + return ( +
+ + {t("loading")} +
+ ); + } + + if (error) { + return

{t("loadError")}

; + } + + return ( +
+
+

{t("configHeading")}

+

{t("configDescription")}

+
+ +
+ + setFilter(e.target.value)} + placeholder={t("searchPlaceholder")} + className="pl-8" + /> +
+

{t("redactedHint")}

+ + {groups.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ {groups.map(([group, rows]) => ( + + + + {group} + + + +
+ {rows.map(([key, value]) => ( +
+
{key}
+
+ {formatValue(value)} +
+
+ ))} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/hindsight-control-plane/src/components/admin-header.tsx b/hindsight-control-plane/src/components/admin-header.tsx new file mode 100644 index 000000000..ce15cfe43 --- /dev/null +++ b/hindsight-control-plane/src/components/admin-header.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import Link from "next/link"; +import { ArrowLeft, Moon, ShieldCheck, Sun, LogOut } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { LanguageSwitcher } from "@/components/language-switcher"; +import { useTheme } from "@/lib/theme-context"; +import { useFeatures } from "@/lib/features-context"; +import { withBasePath } from "@/lib/base-path"; + +/** + * Header for the global admin surface. + * + * Deliberately NOT the bank-scoped BankSelector: the admin area is not tied to a + * memory bank, so it shows a back-to-app link, the admin title, and global controls + * (theme, language, logout) instead of the bank dropdown / add-document actions. + */ +export function AdminHeader() { + const tNav = useTranslations("nav"); + const t = useTranslations("admin"); + const { theme, toggleTheme } = useTheme(); + const { features } = useFeatures(); + + return ( +
+
+ {/* Logo → back to app */} + + Hindsight + + +
+ + {/* Admin title */} +
+ + {t("title")} +
+ + {/* Spacer */} +
+ + {/* Back to app */} + + + {t("backToApp")} + + +
+ + {/* Dark Mode Toggle */} + + + + + {features?.access_key_auth && ( + <> +
+ + + )} +
+
+ ); +} diff --git a/hindsight-control-plane/src/components/sidebar.tsx b/hindsight-control-plane/src/components/sidebar.tsx index b14455671..af99b9d1e 100644 --- a/hindsight-control-plane/src/components/sidebar.tsx +++ b/hindsight-control-plane/src/components/sidebar.tsx @@ -2,47 +2,42 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; -import { useBank } from "@/lib/bank-context"; -import { bankRoute } from "@/lib/bank-url"; -import { - Search, - Sparkles, - Database, - FileText, - Users, - ChevronLeft, - ChevronRight, - Settings, -} from "lucide-react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import Link from "next/link"; -type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile"; +export interface SidebarItem { + /** Stable id compared against `currentTab` to mark the active entry. */ + id: string; + label: string; + icon: LucideIcon; + /** Destination href (Next.js applies basePath automatically). */ + href: string; +} interface SidebarProps { - currentTab: NavItem; - onTabChange: (tab: NavItem) => void; + /** Navigation entries to render, top to bottom. */ + items: SidebarItem[]; + /** Id of the active entry. */ + currentTab: string; + /** + * Optional in-app navigation handler. When provided, a plain left-click is + * intercepted and routed via the callback (middle-click / Cmd+click still open + * the href in a new tab). When omitted, the entry behaves as a normal link. + */ + onTabChange?: (id: string) => void; } -export function Sidebar({ currentTab, onTabChange }: SidebarProps) { +/** + * Collapsible left navigation, shared across the app (bank workspace, admin, …). + * It is purely presentational: callers provide the items, the active id, and an + * optional click handler. + */ +export function Sidebar({ items, currentTab, onTabChange }: SidebarProps) { const t = useTranslations("bank.sidebar"); - const tBank = useTranslations("bank"); - const { currentBank } = useBank(); const [isCollapsed, setIsCollapsed] = useState(true); - if (!currentBank) { - return null; - } - - const navItems = [ - { id: "data" as NavItem, label: t("memories"), icon: Database }, - { id: "recall" as NavItem, label: t("recall"), icon: Search }, - { id: "reflect" as NavItem, label: t("reflect"), icon: Sparkles }, - { id: "documents" as NavItem, label: t("documents"), icon: FileText }, - { id: "entities" as NavItem, label: t("entities"), icon: Users }, - { id: "profile" as NavItem, label: tBank("bankConfiguration"), icon: Settings }, - ]; - return (