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:
parent
7942f181c2
commit
73ef99e7b1
12 changed files with 637 additions and 19 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
325
hindsight-api/tests/test_gemini_safety_settings.py
Normal file
325
hindsight-api/tests/test_gemini_safety_settings.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue