feat: add configurable Gemini/Vertex AI safety settings (#473)

Adds per-bank configurable safety settings for Gemini/Vertex AI:
- New `HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS` env var (JSON array)
- Hierarchical config field so banks can override via Config API
- ContextVar pattern for zero-signature-change per-request override
- All 6 thresholds supported: UNSPECIFIED, OFF, BLOCK_NONE, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH
- UI: Models > Gemini/Vertex AI section with per-category threshold selectors and link to Google docs
- Graceful handling when bank_config_api feature is disabled
- 12 new tests covering config parsing, GeminiLLM behaviour, and context var override
This commit is contained in:
Nicolò Boschi 2026-03-03 13:48:29 +01:00 committed by GitHub
parent 7942f181c2
commit 73ef99e7b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 637 additions and 19 deletions

View file

@ -252,6 +252,9 @@ ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY"
# Gemini safety settings
ENV_LLM_GEMINI_SAFETY_SETTINGS = "HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
@ -353,6 +356,9 @@ DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
DEFAULT_LLM_VERTEXAI_REGION = "us-central1"
DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
# Gemini safety settings defaults
DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety settings
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
@ -567,6 +573,9 @@ class HindsightConfig:
llm_vertexai_region: str
llm_vertexai_service_account_key: str | None
# Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold)
llm_gemini_safety_settings: list | None
# Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None
retain_llm_api_key: str | None
@ -792,6 +801,8 @@ class HindsightConfig:
"disposition_skepticism",
"disposition_literalism",
"disposition_empathy",
# Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
"llm_gemini_safety_settings",
}
@property
@ -912,6 +923,8 @@ class HindsightConfig:
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY)
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
# Gemini safety settings (JSON-encoded list of {category, threshold} dicts)
llm_gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
# Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,

View file

@ -126,6 +126,12 @@ async def run_consolidation_job(
"""
# Resolve bank-specific config with hierarchical overrides
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
# Apply bank-specific Gemini safety settings for this request context
from ..providers.gemini_llm import set_gemini_safety_settings
set_gemini_safety_settings(config.llm_gemini_safety_settings)
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size
llm_batch_size = max(1, config.consolidation_llm_batch_size)

View file

@ -124,6 +124,7 @@ def create_llm_provider(
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
gemini_safety_settings: list | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@ -192,6 +193,7 @@ def create_llm_provider(
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=gemini_safety_settings,
)
elif provider_lower == "anthropic":
@ -234,6 +236,7 @@ class LLMProvider:
reasoning_effort: str = "low",
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
):
"""
Initialize LLM provider.
@ -246,6 +249,7 @@ class LLMProvider:
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
"""
self.provider = provider.lower()
self.api_key = api_key
@ -255,6 +259,8 @@ class LLMProvider:
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
# Gemini safety settings (instance default; can be overridden per-request via context var)
self.gemini_safety_settings = gemini_safety_settings
# Validate provider
valid_providers = [
@ -323,6 +329,18 @@ class LLMProvider:
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}"
)
# For Gemini/VertexAI providers: read safety settings from global config if not explicitly provided
# Use _get_raw_config() to bypass StaticConfigProxy (which blocks configurable fields),
# since LLMProvider initialization legitimately needs the server-level default.
if self.provider in ("gemini", "vertexai") and self.gemini_safety_settings is None:
from ..config import _get_raw_config
try:
raw_config = _get_raw_config()
self.gemini_safety_settings = raw_config.llm_gemini_safety_settings
except Exception:
pass # Config may not be initialized in test environments
# Create provider implementation using factory
self._provider_impl = create_llm_provider(
provider=self.provider,
@ -335,6 +353,7 @@ class LLMProvider:
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=self.gemini_safety_settings,
)
# Backward compatibility: Keep mock provider properties

View file

@ -1831,6 +1831,11 @@ class MemoryEngine(MemoryEngineInterface):
# Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
# Apply bank-specific Gemini safety settings for this request context
from .providers.gemini_llm import set_gemini_safety_settings
set_gemini_safety_settings(resolved_config.llm_gemini_safety_settings)
# Create parent span for retain operation
with create_operation_span("retain", bank_id):
return await orchestrator.retain_batch(
@ -4482,6 +4487,12 @@ class MemoryEngine(MemoryEngineInterface):
# The agent can call lookup() to list available models if needed.
# This is critical for banks with many mental models to avoid huge prompts.
# Apply bank-specific Gemini safety settings for this request context
resolved_reflect_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
from .providers.gemini_llm import set_gemini_safety_settings
set_gemini_safety_settings(resolved_reflect_config.llm_gemini_safety_settings)
# Compute max iterations based on budget
config = get_config()
base_max_iterations = config.reflect_max_iterations

View file

@ -11,6 +11,7 @@ import json
import logging
import os
import time
from contextvars import ContextVar
from typing import Any
from google import genai
@ -24,6 +25,25 @@ from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# Context variable for per-request Gemini safety settings override (supports per-bank configuration)
_safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settings", default=None)
def set_gemini_safety_settings(settings: list | None) -> None:
"""
Set Gemini safety settings for the current async context.
This allows per-bank safety settings to be applied without changing
the LLM provider interface. Call this before making LLM calls within
an operation that has resolved bank-specific configuration.
Args:
settings: List of safety setting dicts with 'category' and 'threshold' keys,
or None to use the instance default (from env var).
"""
_safety_settings_ctx.set(settings)
# Vertex AI imports (optional)
try:
import google.auth
@ -58,6 +78,9 @@ class GeminiLLM(LLMInterface):
self._client = None
self._is_vertexai = self.provider == "vertexai"
# Safety settings: None means use Gemini's defaults
self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
if self._is_vertexai:
self._init_vertexai(**kwargs)
else:
@ -216,6 +239,16 @@ class GeminiLLM(LLMInterface):
if temperature is not None:
config_kwargs["temperature"] = temperature
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
last_exception = None
@ -489,6 +522,16 @@ class GeminiLLM(LLMInterface):
)
# "auto" is the default (no tool_config needed)
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
config = genai_types.GenerateContentConfig(**config_kwargs)
last_exception = None

View file

@ -171,6 +171,7 @@ def main():
llm_vertexai_project_id=config.llm_vertexai_project_id,
llm_vertexai_region=config.llm_vertexai_region,
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
llm_gemini_safety_settings=config.llm_gemini_safety_settings,
retain_llm_provider=config.retain_llm_provider,
retain_llm_api_key=config.retain_llm_api_key,
retain_llm_model=config.retain_llm_model,

View file

@ -0,0 +1,325 @@
"""
Tests for Gemini safety settings feature.
Verifies that:
- Safety settings are read from env var and stored on GeminiLLM instances
- Settings are applied to GenerateContentConfig in call() and call_with_tools()
- The context variable override allows per-bank settings at request time
- None (unset) means Gemini's default safety settings are used (no override)
"""
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
pytest.importorskip("google.genai")
SAMPLE_SAFETY_SETTINGS = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
]
# ─── Config / env var parsing ─────────────────────────────────────────────────
def test_gemini_safety_settings_parsed_from_env():
"""Safety settings JSON from env var is parsed into HindsightConfig."""
import json
from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, HindsightConfig, clear_config_cache
settings_json = json.dumps(SAMPLE_SAFETY_SETTINGS)
with patch.dict(os.environ, {ENV_LLM_GEMINI_SAFETY_SETTINGS: settings_json}, clear=False):
clear_config_cache()
config = HindsightConfig.from_env()
assert config.llm_gemini_safety_settings == SAMPLE_SAFETY_SETTINGS
clear_config_cache()
def test_gemini_safety_settings_default_is_none():
"""When env var is not set, llm_gemini_safety_settings defaults to None."""
from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, HindsightConfig, clear_config_cache
env = {k: v for k, v in os.environ.items() if k != ENV_LLM_GEMINI_SAFETY_SETTINGS}
with patch.dict(os.environ, env, clear=True):
clear_config_cache()
config = HindsightConfig.from_env()
assert config.llm_gemini_safety_settings is None
clear_config_cache()
def test_gemini_safety_settings_is_configurable_field():
"""llm_gemini_safety_settings appears in configurable (per-bank) fields."""
from hindsight_api.config import HindsightConfig
assert "llm_gemini_safety_settings" in HindsightConfig.get_configurable_fields()
def test_gemini_safety_settings_not_in_credential_fields():
"""llm_gemini_safety_settings is NOT a credential — it is safe to expose via API."""
from hindsight_api.config import HindsightConfig
assert "llm_gemini_safety_settings" not in HindsightConfig.get_credential_fields()
# ─── GeminiLLM instance ───────────────────────────────────────────────────────
def _make_gemini_provider(safety_settings=None):
"""Return a GeminiLLM instance with a mocked genai.Client."""
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.providers.gemini_llm import GeminiLLM
provider = GeminiLLM(
provider="gemini",
api_key="fake-api-key",
base_url="",
model="gemini-2.5-flash",
gemini_safety_settings=safety_settings,
)
# Replace client with a fresh mock so we can inspect calls
provider._client = MagicMock()
return provider
def test_gemini_llm_stores_safety_settings():
"""GeminiLLM stores safety settings passed at construction."""
provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
assert provider._safety_settings == SAMPLE_SAFETY_SETTINGS
def test_gemini_llm_no_safety_settings_is_none():
"""GeminiLLM._safety_settings is None when not provided."""
provider = _make_gemini_provider(safety_settings=None)
assert provider._safety_settings is None
# ─── call() applies safety settings ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_call_applies_safety_settings():
"""call() includes safety_settings in GenerateContentConfig when configured."""
from google.genai import types as genai_types
provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
# Build a fake successful response
fake_response = MagicMock()
fake_response.text = "hello"
fake_response.candidates = [MagicMock(finish_reason="STOP")]
fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="test",
)
# Inspect the config passed to generate_content
call_args = provider._client.aio.models.generate_content.call_args
config_arg = call_args.kwargs.get("config") or call_args.args[0] if call_args.args else None
# config may be in kwargs or positional; grab from kwargs
config_arg = call_args.kwargs.get("config")
assert config_arg is not None, "GenerateContentConfig should have been passed"
assert hasattr(config_arg, "safety_settings"), "Config should have safety_settings"
assert config_arg.safety_settings is not None
categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
assert "HARM_CATEGORY_HARASSMENT" in categories
assert "HARM_CATEGORY_HATE_SPEECH" in categories
assert "HARM_CATEGORY_SEXUALLY_EXPLICIT" in categories
assert "HARM_CATEGORY_DANGEROUS_CONTENT" in categories
thresholds = [s.threshold.value if hasattr(s.threshold, "value") else str(s.threshold) for s in config_arg.safety_settings]
assert all(t == "BLOCK_NONE" for t in thresholds)
@pytest.mark.asyncio
async def test_call_no_safety_settings_omits_key():
"""call() does NOT add safety_settings to GenerateContentConfig when none configured."""
provider = _make_gemini_provider(safety_settings=None)
fake_response = MagicMock()
fake_response.text = "hello"
fake_response.candidates = [MagicMock(finish_reason="STOP")]
fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="test",
)
call_args = provider._client.aio.models.generate_content.call_args
config_arg = call_args.kwargs.get("config")
# When no safety settings, config is either None or lacks safety_settings
if config_arg is not None:
assert not hasattr(config_arg, "safety_settings") or config_arg.safety_settings is None
# ─── call_with_tools() applies safety settings ────────────────────────────────
@pytest.mark.asyncio
async def test_call_with_tools_applies_safety_settings():
"""call_with_tools() includes safety_settings in GenerateContentConfig."""
provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
# Build a fake tool-use response (no tool calls, just text)
fake_part = MagicMock()
fake_part.text = "answer"
fake_part.function_call = None
fake_candidate = MagicMock()
fake_candidate.content = MagicMock(parts=[fake_part])
fake_response = MagicMock()
fake_response.candidates = [fake_candidate]
fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=3)
provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
tools = [
{
"type": "function",
"function": {
"name": "test_tool",
"description": "A test tool",
"parameters": {"type": "object", "properties": {}, "required": []},
},
}
]
await provider.call_with_tools(
messages=[{"role": "user", "content": "hi"}],
tools=tools,
scope="test",
)
call_args = provider._client.aio.models.generate_content.call_args
config_arg = call_args.kwargs.get("config")
assert config_arg is not None
assert config_arg.safety_settings is not None
categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
assert "HARM_CATEGORY_HARASSMENT" in categories
# ─── Context variable override ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_context_var_overrides_instance_settings():
"""The context var safety settings take precedence over instance defaults."""
from hindsight_api.engine.providers.gemini_llm import set_gemini_safety_settings
# Instance has settings, but we'll override via context var with different settings
instance_settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_ONLY_HIGH"}]
ctx_settings = [{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}]
provider = _make_gemini_provider(safety_settings=instance_settings)
fake_response = MagicMock()
fake_response.text = "hello"
fake_response.candidates = [MagicMock(finish_reason="STOP")]
fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
# Set context var override
set_gemini_safety_settings(ctx_settings)
try:
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="test",
)
finally:
set_gemini_safety_settings(None) # Reset context
call_args = provider._client.aio.models.generate_content.call_args
config_arg = call_args.kwargs.get("config")
assert config_arg is not None
assert config_arg.safety_settings is not None
# Should use ctx_settings (HATE_SPEECH/BLOCK_NONE), not instance_settings (HARASSMENT/BLOCK_ONLY_HIGH)
categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
assert "HARM_CATEGORY_HATE_SPEECH" in categories
assert "HARM_CATEGORY_HARASSMENT" not in categories
@pytest.mark.asyncio
async def test_context_var_none_falls_back_to_instance():
"""When context var is None (not set), instance settings are used."""
from hindsight_api.engine.providers.gemini_llm import set_gemini_safety_settings
instance_settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}]
provider = _make_gemini_provider(safety_settings=instance_settings)
fake_response = MagicMock()
fake_response.text = "hello"
fake_response.candidates = [MagicMock(finish_reason="STOP")]
fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
# Explicitly set context var to None (fallback)
set_gemini_safety_settings(None)
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="test",
)
call_args = provider._client.aio.models.generate_content.call_args
config_arg = call_args.kwargs.get("config")
assert config_arg is not None
assert config_arg.safety_settings is not None
categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
assert "HARM_CATEGORY_HARASSMENT" in categories
# ─── LLMProvider reads safety settings from config ────────────────────────────
def test_llm_provider_reads_safety_settings_from_config():
"""LLMProvider reads llm_gemini_safety_settings from global config for Gemini provider."""
import json
from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, clear_config_cache
settings_json = json.dumps(SAMPLE_SAFETY_SETTINGS)
env_overrides = {
"HINDSIGHT_API_LLM_PROVIDER": "gemini",
"HINDSIGHT_API_LLM_API_KEY": "fake-key",
ENV_LLM_GEMINI_SAFETY_SETTINGS: settings_json,
}
with patch.dict(os.environ, env_overrides, clear=False):
clear_config_cache()
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
provider="gemini",
api_key="fake-key",
base_url="",
model="gemini-2.5-flash",
)
assert provider.gemini_safety_settings == SAMPLE_SAFETY_SETTINGS
clear_config_cache()

View file

@ -86,7 +86,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable
# Verify count is correct
assert len(configurable) == 13
assert len(configurable) == 14
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials

View file

@ -197,10 +197,15 @@ export default function BankPage() {
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowResetConfigDialog(true)}
disabled={!bankConfigEnabled}
className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300"
title={!bankConfigEnabled ? "Bank Config API is disabled" : undefined}
>
<RotateCcw className="w-4 h-4 mr-2" />
Reset Configuration
{!bankConfigEnabled && (
<span className="ml-auto text-xs text-muted-foreground">Off</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@ -230,19 +235,21 @@ export default function BankPage() {
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => handleBankConfigTabChange("configuration")}
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
bankConfigTab === "configuration"
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Configuration
{bankConfigTab === "configuration" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{bankConfigEnabled && (
<button
onClick={() => handleBankConfigTabChange("configuration")}
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
bankConfigTab === "configuration"
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Configuration
{bankConfigTab === "configuration" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
</div>
</div>
@ -260,7 +267,7 @@ export default function BankPage() {
</div>
</div>
)}
{bankConfigTab === "configuration" && (
{bankConfigTab === "configuration" && bankConfigEnabled && (
<div className="space-y-6">
<BankConfigView />
</div>

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useMemo, type ReactNode } from "react";
import { useBank } from "@/lib/bank-context";
import { useFeatures } from "@/lib/features-context";
import { client } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -60,6 +61,38 @@ type MCPEdits = {
mcp_enabled_tools: string[] | null;
};
type GeminiSafetySetting = {
category: string;
threshold: string;
};
type GeminiEdits = {
llm_gemini_safety_settings: GeminiSafetySetting[] | null;
};
// ─── Gemini safety settings catalogue ────────────────────────────────────────
const GEMINI_HARM_CATEGORIES = [
{ value: "HARM_CATEGORY_HARASSMENT", label: "Harassment" },
{ value: "HARM_CATEGORY_HATE_SPEECH", label: "Hate Speech" },
{ value: "HARM_CATEGORY_SEXUALLY_EXPLICIT", label: "Sexually Explicit" },
{ value: "HARM_CATEGORY_DANGEROUS_CONTENT", label: "Dangerous Content" },
] as const;
const GEMINI_THRESHOLDS = [
{ value: "HARM_BLOCK_THRESHOLD_UNSPECIFIED", label: "Unspecified (use Gemini default)" },
{ value: "OFF", label: "Off (filter disabled)" },
{ value: "BLOCK_NONE", label: "Block none" },
{ value: "BLOCK_LOW_AND_ABOVE", label: "Block low & above" },
{ value: "BLOCK_MEDIUM_AND_ABOVE", label: "Block medium & above" },
{ value: "BLOCK_ONLY_HIGH", label: "Block only high" },
] as const;
const DEFAULT_GEMINI_SAFETY_SETTINGS: GeminiSafetySetting[] = GEMINI_HARM_CATEGORIES.map((c) => ({
category: c.value,
threshold: "BLOCK_NONE",
}));
// ─── MCP tool catalogue ───────────────────────────────────────────────────────
const MCP_TOOL_GROUPS: { label: string; tools: string[] }[] = [
@ -134,6 +167,12 @@ function mcpSlice(config: Record<string, any>): MCPEdits {
};
}
function geminiSlice(config: Record<string, any>): GeminiEdits {
return {
llm_gemini_safety_settings: config.llm_gemini_safety_settings ?? null,
};
}
const DEFAULT_PROFILE: ProfileData = {
reflect_mission: "",
disposition_skepticism: 3,
@ -145,6 +184,8 @@ const DEFAULT_PROFILE: ProfileData = {
export function BankConfigView() {
const { currentBank: bankId } = useBank();
const { features } = useFeatures();
const bankConfigEnabled = features?.bank_config_api ?? true; // optimistic default while loading
const [loading, setLoading] = useState(true);
// Source of truth
@ -161,6 +202,7 @@ export function BankConfigView() {
);
const [reflectEdits, setReflectEdits] = useState<ProfileData>(DEFAULT_PROFILE);
const [mcpEdits, setMcpEdits] = useState<MCPEdits>(mcpSlice({}));
const [geminiEdits, setGeminiEdits] = useState<GeminiEdits>(geminiSlice({}));
// Per-section saving/error state
const [retainSaving, setRetainSaving] = useState(false);
@ -168,11 +210,13 @@ export function BankConfigView() {
const [entityLabelsSaving, setEntityLabelsSaving] = useState(false);
const [reflectSaving, setReflectSaving] = useState(false);
const [mcpSaving, setMcpSaving] = useState(false);
const [geminiSaving, setGeminiSaving] = useState(false);
const [retainError, setRetainError] = useState<string | null>(null);
const [observationsError, setObservationsError] = useState<string | null>(null);
const [entityLabelsError, setEntityLabelsError] = useState<string | null>(null);
const [reflectError, setReflectError] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null);
const [geminiError, setGeminiError] = useState<string | null>(null);
// Reset dialog
@ -197,6 +241,10 @@ export function BankConfigView() {
() => JSON.stringify(mcpEdits) !== JSON.stringify(mcpSlice(baseConfig)),
[mcpEdits, baseConfig]
);
const geminiDirty = useMemo(
() => JSON.stringify(geminiEdits) !== JSON.stringify(geminiSlice(baseConfig)),
[geminiEdits, baseConfig]
);
useEffect(() => {
if (bankId) loadAll();
@ -226,6 +274,7 @@ export function BankConfigView() {
setEntityLabelsEdits(entityLabelsSlice(cfg));
setReflectEdits(prof);
setMcpEdits(mcpSlice(cfg));
setGeminiEdits(geminiSlice(cfg));
} catch (err) {
console.error("Failed to load bank data:", err);
} finally {
@ -312,6 +361,20 @@ export function BankConfigView() {
}
};
const saveGemini = async () => {
if (!bankId) return;
setGeminiSaving(true);
setGeminiError(null);
try {
await client.updateBankConfig(bankId, geminiEdits);
setBaseConfig((prev) => ({ ...prev, ...geminiEdits }));
} catch (err: any) {
setGeminiError(err.message || "Failed to save Gemini settings");
} finally {
setGeminiSaving(false);
}
};
if (!bankId) {
return (
<div className="flex items-center justify-center py-12">
@ -320,6 +383,21 @@ export function BankConfigView() {
);
}
if (!bankConfigEnabled) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<p className="text-base font-medium text-foreground">Bank configuration is disabled</p>
<p className="text-sm text-muted-foreground max-w-sm">
Set{" "}
<code className="font-mono text-xs bg-muted px-1 py-0.5 rounded">
HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true
</code>{" "}
to enable per-bank configuration.
</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@ -551,6 +629,62 @@ export function BankConfigView() {
/>
)}
</ConfigSection>
{/* Models Section */}
<ConfigSection
title="Models"
description="Provider-specific model settings"
error={geminiError}
dirty={geminiDirty}
saving={geminiSaving}
onSave={saveGemini}
>
{/* Gemini subsection */}
<div className="px-6 py-4 space-y-4">
<p className="text-sm font-semibold">Gemini / Vertex AI</p>
<div className="pl-4 border-l-2 border-border/40 space-y-4">
<FieldRow
label="Safety settings"
description={
<>
When off, Gemini&apos;s default safety thresholds are used. When on, configure
thresholds per harm category.{" "}
<a
href="https://ai.google.dev/gemini-api/docs/safety-settings"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
Learn more
</a>
</>
}
>
<div className="flex items-center gap-2 justify-end">
<Switch
checked={geminiEdits.llm_gemini_safety_settings !== null}
onCheckedChange={(enabled) =>
setGeminiEdits({
llm_gemini_safety_settings: enabled
? [...DEFAULT_GEMINI_SAFETY_SETTINGS]
: null,
})
}
/>
<Label className="text-xs text-muted-foreground">
{geminiEdits.llm_gemini_safety_settings !== null ? "Custom" : "Default"}
</Label>
</div>
</FieldRow>
{geminiEdits.llm_gemini_safety_settings !== null && (
<GeminiSafetyEditor
value={geminiEdits.llm_gemini_safety_settings}
onChange={(settings) => setGeminiEdits({ llm_gemini_safety_settings: settings })}
/>
)}
</div>
</div>
</ConfigSection>
</div>
</>
);
@ -712,7 +846,7 @@ function FieldRow({
children,
}: {
label: string;
description?: string;
description?: ReactNode;
children: ReactNode;
}) {
return (
@ -1029,3 +1163,64 @@ function EntityLabelsEditor({
</div>
);
}
// ─── GeminiSafetyEditor ───────────────────────────────────────────────────────
function GeminiSafetyEditor({
value,
onChange,
}: {
value: GeminiSafetySetting[];
onChange: (settings: GeminiSafetySetting[]) => void;
}) {
const getThreshold = (category: string): string => {
return value.find((s) => s.category === category)?.threshold ?? "BLOCK_MEDIUM_AND_ABOVE";
};
const setThreshold = (category: string, threshold: string) => {
const next = GEMINI_HARM_CATEGORIES.map((c) => ({
category: c.value,
threshold: c.value === category ? threshold : getThreshold(c.value),
}));
onChange(next);
};
return (
<div className="px-6 py-4 space-y-3">
<p className="text-xs text-muted-foreground">
Set the blocking threshold for each harm category. "Off" disables the filter entirely
(default for Gemini 2.5+). Lower thresholds block more content.{" "}
<a
href="https://ai.google.dev/gemini-api/docs/safety-settings"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
Learn more
</a>
</p>
<div className="space-y-2">
{GEMINI_HARM_CATEGORIES.map((cat) => (
<div key={cat.value} className="flex items-center justify-between gap-4">
<span className="text-sm">{cat.label}</span>
<Select
value={getThreshold(cat.value)}
onValueChange={(v) => setThreshold(cat.value, v)}
>
<SelectTrigger className="w-48 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GEMINI_THRESHOLDS.map((t) => (
<SelectItem key={t.value} value={t.value} className="text-xs">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
);
}

View file

@ -86,8 +86,6 @@ console.log(`Page items: ${page.items.length}`);
// [docs:document-get]
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// Get document to expand context from recall results
const { data: doc, error } = await sdk.getDocument({
client: apiClient,

View file

@ -91,12 +91,12 @@ async def list_documents_example():
result = await api.list_documents(bank_id="my-bank", limit=20, offset=40)
print(f"Page items: {len(result.items)}")
import asyncio
asyncio.run(list_documents_example())
# [/docs:document-list]
# [docs:document-get]
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi