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_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
|
||||||
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY"
|
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
|
# Retain settings
|
||||||
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
|
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
|
||||||
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
|
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_REGION = "us-central1"
|
||||||
DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
|
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_PROVIDER = "local"
|
||||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
|
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)
|
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_region: str
|
||||||
llm_vertexai_service_account_key: str | None
|
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)
|
# Per-operation LLM configuration (None = use default LLM config)
|
||||||
retain_llm_provider: str | None
|
retain_llm_provider: str | None
|
||||||
retain_llm_api_key: str | None
|
retain_llm_api_key: str | None
|
||||||
|
|
@ -792,6 +801,8 @@ class HindsightConfig:
|
||||||
"disposition_skepticism",
|
"disposition_skepticism",
|
||||||
"disposition_literalism",
|
"disposition_literalism",
|
||||||
"disposition_empathy",
|
"disposition_empathy",
|
||||||
|
# Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
|
||||||
|
"llm_gemini_safety_settings",
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -912,6 +923,8 @@ class HindsightConfig:
|
||||||
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
|
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)
|
llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY)
|
||||||
or DEFAULT_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)
|
# Per-operation LLM config (None = use default)
|
||||||
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
|
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
|
||||||
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) 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
|
# Resolve bank-specific config with hierarchical overrides
|
||||||
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
|
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)
|
perf = ConsolidationPerfLog(bank_id)
|
||||||
max_memories_per_batch = config.consolidation_batch_size
|
max_memories_per_batch = config.consolidation_batch_size
|
||||||
llm_batch_size = max(1, config.consolidation_llm_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_project_id: str | None = None,
|
||||||
vertexai_region: str | None = None,
|
vertexai_region: str | None = None,
|
||||||
vertexai_credentials: Any = None,
|
vertexai_credentials: Any = None,
|
||||||
|
gemini_safety_settings: list | None = None,
|
||||||
) -> Any: # Returns LLMInterface
|
) -> Any: # Returns LLMInterface
|
||||||
"""
|
"""
|
||||||
Factory function to create the appropriate LLM provider implementation.
|
Factory function to create the appropriate LLM provider implementation.
|
||||||
|
|
@ -192,6 +193,7 @@ def create_llm_provider(
|
||||||
vertexai_project_id=vertexai_project_id,
|
vertexai_project_id=vertexai_project_id,
|
||||||
vertexai_region=vertexai_region,
|
vertexai_region=vertexai_region,
|
||||||
vertexai_credentials=vertexai_credentials,
|
vertexai_credentials=vertexai_credentials,
|
||||||
|
gemini_safety_settings=gemini_safety_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif provider_lower == "anthropic":
|
elif provider_lower == "anthropic":
|
||||||
|
|
@ -234,6 +236,7 @@ class LLMProvider:
|
||||||
reasoning_effort: str = "low",
|
reasoning_effort: str = "low",
|
||||||
groq_service_tier: str | None = None,
|
groq_service_tier: str | None = None,
|
||||||
openai_service_tier: str | None = None,
|
openai_service_tier: str | None = None,
|
||||||
|
gemini_safety_settings: list | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize LLM provider.
|
Initialize LLM provider.
|
||||||
|
|
@ -246,6 +249,7 @@ class LLMProvider:
|
||||||
reasoning_effort: Reasoning effort level for supported providers.
|
reasoning_effort: Reasoning effort level for supported providers.
|
||||||
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
|
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
|
||||||
openai_service_tier: OpenAI service tier (None or "flex") - 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.provider = provider.lower()
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
|
|
@ -255,6 +259,8 @@ class LLMProvider:
|
||||||
# Service tiers from hierarchical config (not env vars)
|
# Service tiers from hierarchical config (not env vars)
|
||||||
self.groq_service_tier = groq_service_tier
|
self.groq_service_tier = groq_service_tier
|
||||||
self.openai_service_tier = openai_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
|
# Validate provider
|
||||||
valid_providers = [
|
valid_providers = [
|
||||||
|
|
@ -323,6 +329,18 @@ class LLMProvider:
|
||||||
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}"
|
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
|
# Create provider implementation using factory
|
||||||
self._provider_impl = create_llm_provider(
|
self._provider_impl = create_llm_provider(
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
|
|
@ -335,6 +353,7 @@ class LLMProvider:
|
||||||
vertexai_project_id=vertexai_project_id,
|
vertexai_project_id=vertexai_project_id,
|
||||||
vertexai_region=vertexai_region,
|
vertexai_region=vertexai_region,
|
||||||
vertexai_credentials=vertexai_credentials,
|
vertexai_credentials=vertexai_credentials,
|
||||||
|
gemini_safety_settings=self.gemini_safety_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Backward compatibility: Keep mock provider properties
|
# Backward compatibility: Keep mock provider properties
|
||||||
|
|
|
||||||
|
|
@ -1831,6 +1831,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||||
# Resolve bank-specific config for this operation
|
# Resolve bank-specific config for this operation
|
||||||
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
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
|
# Create parent span for retain operation
|
||||||
with create_operation_span("retain", bank_id):
|
with create_operation_span("retain", bank_id):
|
||||||
return await orchestrator.retain_batch(
|
return await orchestrator.retain_batch(
|
||||||
|
|
@ -4482,6 +4487,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||||
# The agent can call lookup() to list available models if needed.
|
# The agent can call lookup() to list available models if needed.
|
||||||
# This is critical for banks with many mental models to avoid huge prompts.
|
# 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
|
# Compute max iterations based on budget
|
||||||
config = get_config()
|
config = get_config()
|
||||||
base_max_iterations = config.reflect_max_iterations
|
base_max_iterations = config.reflect_max_iterations
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from contextvars import ContextVar
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from google import genai
|
from google import genai
|
||||||
|
|
@ -24,6 +25,25 @@ from hindsight_api.metrics import get_metrics_collector
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
# Vertex AI imports (optional)
|
||||||
try:
|
try:
|
||||||
import google.auth
|
import google.auth
|
||||||
|
|
@ -58,6 +78,9 @@ class GeminiLLM(LLMInterface):
|
||||||
self._client = None
|
self._client = None
|
||||||
self._is_vertexai = self.provider == "vertexai"
|
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:
|
if self._is_vertexai:
|
||||||
self._init_vertexai(**kwargs)
|
self._init_vertexai(**kwargs)
|
||||||
else:
|
else:
|
||||||
|
|
@ -216,6 +239,16 @@ class GeminiLLM(LLMInterface):
|
||||||
if temperature is not None:
|
if temperature is not None:
|
||||||
config_kwargs["temperature"] = temperature
|
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
|
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
|
||||||
|
|
||||||
last_exception = None
|
last_exception = None
|
||||||
|
|
@ -489,6 +522,16 @@ class GeminiLLM(LLMInterface):
|
||||||
)
|
)
|
||||||
# "auto" is the default (no tool_config needed)
|
# "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)
|
config = genai_types.GenerateContentConfig(**config_kwargs)
|
||||||
|
|
||||||
last_exception = None
|
last_exception = None
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ def main():
|
||||||
llm_vertexai_project_id=config.llm_vertexai_project_id,
|
llm_vertexai_project_id=config.llm_vertexai_project_id,
|
||||||
llm_vertexai_region=config.llm_vertexai_region,
|
llm_vertexai_region=config.llm_vertexai_region,
|
||||||
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
|
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_provider=config.retain_llm_provider,
|
||||||
retain_llm_api_key=config.retain_llm_api_key,
|
retain_llm_api_key=config.retain_llm_api_key,
|
||||||
retain_llm_model=config.retain_llm_model,
|
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
|
assert "entity_labels" in configurable
|
||||||
|
|
||||||
# Verify count is correct
|
# Verify count is correct
|
||||||
assert len(configurable) == 13
|
assert len(configurable) == 14
|
||||||
|
|
||||||
# Verify credential fields (NEVER exposed)
|
# Verify credential fields (NEVER exposed)
|
||||||
assert "llm_api_key" in credentials
|
assert "llm_api_key" in credentials
|
||||||
|
|
|
||||||
|
|
@ -197,10 +197,15 @@ export default function BankPage() {
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setShowResetConfigDialog(true)}
|
onClick={() => setShowResetConfigDialog(true)}
|
||||||
|
disabled={!bankConfigEnabled}
|
||||||
className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300"
|
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" />
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
Reset Configuration
|
Reset Configuration
|
||||||
|
{!bankConfigEnabled && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">Off</span>
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
@ -230,19 +235,21 @@ export default function BankPage() {
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{bankConfigEnabled && (
|
||||||
onClick={() => handleBankConfigTabChange("configuration")}
|
<button
|
||||||
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
|
onClick={() => handleBankConfigTabChange("configuration")}
|
||||||
bankConfigTab === "configuration"
|
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
|
||||||
? "text-primary"
|
bankConfigTab === "configuration"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
? "text-primary"
|
||||||
}`}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
>
|
}`}
|
||||||
Configuration
|
>
|
||||||
{bankConfigTab === "configuration" && (
|
Configuration
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
{bankConfigTab === "configuration" && (
|
||||||
)}
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -260,7 +267,7 @@ export default function BankPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{bankConfigTab === "configuration" && (
|
{bankConfigTab === "configuration" && bankConfigEnabled && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<BankConfigView />
|
<BankConfigView />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, type ReactNode } from "react";
|
import { useState, useEffect, useMemo, type ReactNode } from "react";
|
||||||
import { useBank } from "@/lib/bank-context";
|
import { useBank } from "@/lib/bank-context";
|
||||||
|
import { useFeatures } from "@/lib/features-context";
|
||||||
import { client } from "@/lib/api";
|
import { client } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -60,6 +61,38 @@ type MCPEdits = {
|
||||||
mcp_enabled_tools: string[] | null;
|
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 ───────────────────────────────────────────────────────
|
// ─── MCP tool catalogue ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const MCP_TOOL_GROUPS: { label: string; tools: string[] }[] = [
|
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 = {
|
const DEFAULT_PROFILE: ProfileData = {
|
||||||
reflect_mission: "",
|
reflect_mission: "",
|
||||||
disposition_skepticism: 3,
|
disposition_skepticism: 3,
|
||||||
|
|
@ -145,6 +184,8 @@ const DEFAULT_PROFILE: ProfileData = {
|
||||||
|
|
||||||
export function BankConfigView() {
|
export function BankConfigView() {
|
||||||
const { currentBank: bankId } = useBank();
|
const { currentBank: bankId } = useBank();
|
||||||
|
const { features } = useFeatures();
|
||||||
|
const bankConfigEnabled = features?.bank_config_api ?? true; // optimistic default while loading
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Source of truth
|
// Source of truth
|
||||||
|
|
@ -161,6 +202,7 @@ export function BankConfigView() {
|
||||||
);
|
);
|
||||||
const [reflectEdits, setReflectEdits] = useState<ProfileData>(DEFAULT_PROFILE);
|
const [reflectEdits, setReflectEdits] = useState<ProfileData>(DEFAULT_PROFILE);
|
||||||
const [mcpEdits, setMcpEdits] = useState<MCPEdits>(mcpSlice({}));
|
const [mcpEdits, setMcpEdits] = useState<MCPEdits>(mcpSlice({}));
|
||||||
|
const [geminiEdits, setGeminiEdits] = useState<GeminiEdits>(geminiSlice({}));
|
||||||
|
|
||||||
// Per-section saving/error state
|
// Per-section saving/error state
|
||||||
const [retainSaving, setRetainSaving] = useState(false);
|
const [retainSaving, setRetainSaving] = useState(false);
|
||||||
|
|
@ -168,11 +210,13 @@ export function BankConfigView() {
|
||||||
const [entityLabelsSaving, setEntityLabelsSaving] = useState(false);
|
const [entityLabelsSaving, setEntityLabelsSaving] = useState(false);
|
||||||
const [reflectSaving, setReflectSaving] = useState(false);
|
const [reflectSaving, setReflectSaving] = useState(false);
|
||||||
const [mcpSaving, setMcpSaving] = useState(false);
|
const [mcpSaving, setMcpSaving] = useState(false);
|
||||||
|
const [geminiSaving, setGeminiSaving] = useState(false);
|
||||||
const [retainError, setRetainError] = useState<string | null>(null);
|
const [retainError, setRetainError] = useState<string | null>(null);
|
||||||
const [observationsError, setObservationsError] = useState<string | null>(null);
|
const [observationsError, setObservationsError] = useState<string | null>(null);
|
||||||
const [entityLabelsError, setEntityLabelsError] = useState<string | null>(null);
|
const [entityLabelsError, setEntityLabelsError] = useState<string | null>(null);
|
||||||
const [reflectError, setReflectError] = useState<string | null>(null);
|
const [reflectError, setReflectError] = useState<string | null>(null);
|
||||||
const [mcpError, setMcpError] = useState<string | null>(null);
|
const [mcpError, setMcpError] = useState<string | null>(null);
|
||||||
|
const [geminiError, setGeminiError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Reset dialog
|
// Reset dialog
|
||||||
|
|
||||||
|
|
@ -197,6 +241,10 @@ export function BankConfigView() {
|
||||||
() => JSON.stringify(mcpEdits) !== JSON.stringify(mcpSlice(baseConfig)),
|
() => JSON.stringify(mcpEdits) !== JSON.stringify(mcpSlice(baseConfig)),
|
||||||
[mcpEdits, baseConfig]
|
[mcpEdits, baseConfig]
|
||||||
);
|
);
|
||||||
|
const geminiDirty = useMemo(
|
||||||
|
() => JSON.stringify(geminiEdits) !== JSON.stringify(geminiSlice(baseConfig)),
|
||||||
|
[geminiEdits, baseConfig]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bankId) loadAll();
|
if (bankId) loadAll();
|
||||||
|
|
@ -226,6 +274,7 @@ export function BankConfigView() {
|
||||||
setEntityLabelsEdits(entityLabelsSlice(cfg));
|
setEntityLabelsEdits(entityLabelsSlice(cfg));
|
||||||
setReflectEdits(prof);
|
setReflectEdits(prof);
|
||||||
setMcpEdits(mcpSlice(cfg));
|
setMcpEdits(mcpSlice(cfg));
|
||||||
|
setGeminiEdits(geminiSlice(cfg));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load bank data:", err);
|
console.error("Failed to load bank data:", err);
|
||||||
} finally {
|
} 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) {
|
if (!bankId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
|
|
@ -551,6 +629,62 @@ export function BankConfigView() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ConfigSection>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -712,7 +846,7 @@ function FieldRow({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -1029,3 +1163,64 @@ function EntityLabelsEditor({
|
||||||
</div>
|
</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]
|
// [docs:document-get]
|
||||||
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
|
|
||||||
|
|
||||||
// Get document to expand context from recall results
|
// Get document to expand context from recall results
|
||||||
const { data: doc, error } = await sdk.getDocument({
|
const { data: doc, error } = await sdk.getDocument({
|
||||||
client: apiClient,
|
client: apiClient,
|
||||||
|
|
|
||||||
|
|
@ -91,12 +91,12 @@ async def list_documents_example():
|
||||||
result = await api.list_documents(bank_id="my-bank", limit=20, offset=40)
|
result = await api.list_documents(bank_id="my-bank", limit=20, offset=40)
|
||||||
print(f"Page items: {len(result.items)}")
|
print(f"Page items: {len(result.items)}")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
asyncio.run(list_documents_example())
|
asyncio.run(list_documents_example())
|
||||||
# [/docs:document-list]
|
# [/docs:document-list]
|
||||||
|
|
||||||
|
|
||||||
# [docs:document-get]
|
# [docs:document-get]
|
||||||
import asyncio
|
|
||||||
from hindsight_client_api import ApiClient, Configuration
|
from hindsight_client_api import ApiClient, Configuration
|
||||||
from hindsight_client_api.api import DocumentsApi
|
from hindsight_client_api.api import DocumentsApi
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue