import logging import math import os from pathlib import Path from typing import Annotated, Any, ClassVar, Literal, cast from urllib.parse import urlparse import tomllib from dotenv import load_dotenv from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator from pydantic.fields import FieldInfo from pydantic_settings import ( BaseSettings, DotEnvSettingsSource, EnvSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict, ) # Load .env file for local development. # Make sure this is called before AppSettings is instantiated if you rely on .env for AppSettings construction. if not os.getenv("PYTHON_DOTENV_DISABLED"): load_dotenv(override=True) logger = logging.getLogger(__name__) ModelTransport = Literal["anthropic", "openai", "gemini"] EmbeddingTransport = Literal["openai", "gemini"] EmbeddingDimensionsMode = Literal["auto", "always", "never"] EmbeddingEncodingFormat = Literal["float", "base64"] EmbeddingEncodingFormatMode = Literal["auto", "float", "base64"] # OpenAI-compatible models that reject the `dimensions=` request parameter. _EMBEDDING_KNOWN_REJECTING_MODELS: frozenset[str] = frozenset( {"text-embedding-ada-002"} ) # Hosts known to serve base64 embeddings, which are ~3.6x smaller on the wire. _EMBEDDING_BASE64_CAPABLE_HOSTS: frozenset[str] = frozenset({"api.openai.com"}) def _default_embedding_model_for_transport(transport: EmbeddingTransport) -> str: if transport == "gemini": return "gemini-embedding-001" return "text-embedding-3-small" def load_toml_config(config_path: str = "config.toml") -> dict[str, Any]: """Load configuration from TOML file if it exists.""" if config_path == "config.toml" and os.getenv("HONCHO_CONFIG_TOML_DISABLED"): return {} config_file = Path(config_path) if config_file.exists(): try: with open(config_file, "rb") as f: return tomllib.load(f) except (tomllib.TOMLDecodeError, OSError) as exc: logger.warning("Failed to load %s: %s", config_path, exc) return {} return {} # Load TOML config once TOML_CONFIG = load_toml_config() ThinkingEffortLevel = Literal[ "none", "minimal", "low", "medium", "high", "xhigh", "max" ] # "json_object" injects the schema into the prompt for OpenAI-compatible # providers that don't support json_schema (Structured Outputs). StructuredOutputMode = Literal["json_schema", "json_object"] PROVIDER_TIMEOUT_ERROR_TEXT = ( "provider_params.timeout must be a positive number of seconds" ) def coerce_provider_timeout(value: Any) -> float: """Coerce a `provider_params.timeout` value to positive, finite seconds. Canonical implementation shared by config-load validation (here) and per-request validation (`src.llm.request_builder.request_timeout_from_extra_params`, which translates the ValueError into a ValidationException). Lives in config.py because src.exceptions imports src.config, so config validators cannot raise Honcho exception types. """ if isinstance(value, bool): raise ValueError(PROVIDER_TIMEOUT_ERROR_TEXT) if isinstance(value, int | float): timeout = float(value) elif isinstance(value, str): try: timeout = float(value.strip()) except ValueError as exc: raise ValueError(PROVIDER_TIMEOUT_ERROR_TEXT) from exc else: raise ValueError(PROVIDER_TIMEOUT_ERROR_TEXT) if not math.isfinite(timeout) or timeout <= 0: raise ValueError(PROVIDER_TIMEOUT_ERROR_TEXT) return timeout class ModelOverrideSettings(BaseModel): """Advanced module-level transport overrides.""" api_key: str | None = None api_key_env: str | None = None base_url: str | None = None provider_params: dict[str, Any] = Field( default_factory=dict, description=( "Operator escape hatch for provider-specific request fields. " "Three recognized keys: `extra_body` (merged into the request body), " "`extra_headers` (HTTP headers), `extra_query` (URL query params). " "OpenAI and Anthropic transports forward these as identically-named " "SDK kwargs. The Gemini transport merges `extra_body` into the " "GenerateContentConfig dict and folds `extra_headers` into " "`http_options.headers`; `extra_query` is unsupported. Shallow merge " "with operator-wins — if Honcho and the operator both set the same " "key inside `extra_body`, the operator's value replaces Honcho's. " "Operators are responsible for picking a coherent combination of " "this and other config (e.g. unset `thinking_budget_tokens` when " "supplying an `extra_body.thinking` for Anthropic-via-proxy)." ), ) @field_validator("provider_params") @classmethod def _validate_provider_timeout(cls, v: dict[str, Any]) -> dict[str, Any]: """Reject bad `timeout` values at config load; normalize good ones to float.""" if "timeout" not in v: return v return {**v, "timeout": coerce_provider_timeout(v["timeout"])} class PromptCachePolicy(BaseModel): """Per-call prompt-caching configuration. Lives in config.py (not src/llm/caching.py) so ModelConfig can reference it as a field without a circular import. src/llm/caching.py re-exports this class for existing import paths. """ mode: Literal["none", "prefix", "gemini_cached_content"] = "none" ttl_seconds: int | None = None key_version: str = "v1" def _normalize_model_transport(data: Any) -> Any: """Normalize 'provider/model' shorthand into separate transport + model fields.""" if not isinstance(data, dict): return data raw_data = cast(dict[Any, Any], data) update: dict[str, Any] = {str(key): value for key, value in raw_data.items()} model_value = update.get("model") transport_value = update.get("transport") if isinstance(model_value, str) and "/" in model_value and transport_value is None: prefix, bare_model = model_value.split("/", 1) if prefix in {"anthropic", "openai", "gemini"}: update["transport"] = prefix update["model"] = bare_model return update def _validate_thinking_constraints( transport: ModelTransport, thinking_budget_tokens: int | None ) -> None: """Enforce transport-specific thinking_budget_tokens rules. Anthropic requires a minimum of 1024 tokens when thinking is enabled. Gemini/OpenAI accept any non-negative value (including 0 to disable). """ if ( transport == "anthropic" and thinking_budget_tokens is not None and 0 < thinking_budget_tokens < 1024 ): raise ValueError("thinking_budget_tokens must be >= 1024 for Anthropic models") def _validate_structured_output_mode( transport: ModelTransport, structured_output_mode: StructuredOutputMode | None ) -> None: """Reject ``structured_output_mode`` on transports that ignore it. Only the OpenAI backend honors this setting (it controls the json_schema vs json_object structured-output path). On the anthropic/gemini transports it is a silent no-op, so a value set there is a misconfiguration — fail fast at startup rather than letting the operator wonder why it has no effect. """ if structured_output_mode is not None and transport != "openai": raise ValueError( "structured_output_mode is only supported on the 'openai' transport; " + f"remove it from the '{transport}' model config" ) class FallbackModelSettings(BaseModel): """Independent fallback model configuration. No inheritance from primary.""" model: str transport: ModelTransport temperature: float | None = None top_p: float | None = None top_k: int | None = None frequency_penalty: float | None = None presence_penalty: float | None = None seed: int | None = None thinking_effort: ThinkingEffortLevel | None = Field( default=None, validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), ) thinking_budget_tokens: int | None = None structured_output_mode: StructuredOutputMode | None = None max_output_tokens: int | None = None stop_sequences: list[str] | None = None cache_policy: PromptCachePolicy | None = None overrides: ModelOverrideSettings = Field(default_factory=ModelOverrideSettings) @model_validator(mode="before") @classmethod def _normalize_legacy_model_format(cls, data: Any) -> Any: return _normalize_model_transport(data) @property def reasoning_effort(self) -> ThinkingEffortLevel | None: return self.thinking_effort @model_validator(mode="after") def _validate_runtime_shape(self) -> "FallbackModelSettings": _validate_thinking_constraints(self.transport, self.thinking_budget_tokens) _validate_structured_output_mode(self.transport, self.structured_output_mode) return self class ConfiguredModelSettings(BaseModel): """Operator-configurable persisted model settings.""" model: str transport: ModelTransport fallback: FallbackModelSettings | None = None temperature: float | None = None top_p: float | None = None top_k: int | None = None frequency_penalty: float | None = None presence_penalty: float | None = None seed: int | None = None thinking_effort: ThinkingEffortLevel | None = Field( default=None, validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), ) thinking_budget_tokens: int | None = None structured_output_mode: StructuredOutputMode | None = None max_output_tokens: int | None = None stop_sequences: list[str] | None = None cache_policy: PromptCachePolicy | None = None overrides: ModelOverrideSettings = Field(default_factory=ModelOverrideSettings) @model_validator(mode="before") @classmethod def _normalize_legacy_model_format(cls, data: Any) -> Any: return _normalize_model_transport(data) @property def reasoning_effort(self) -> ThinkingEffortLevel | None: """Backward-compatible alias for the generic thinking effort field.""" return self.thinking_effort @model_validator(mode="after") def _validate_runtime_shape(self) -> "ConfiguredModelSettings": _validate_thinking_constraints(self.transport, self.thinking_budget_tokens) _validate_structured_output_mode(self.transport, self.structured_output_mode) return self class ResolvedFallbackConfig(BaseModel): """Runtime-resolved fallback config with credentials already resolved.""" model: str transport: ModelTransport api_key: str | None = None base_url: str | None = None temperature: float | None = None top_p: float | None = None top_k: int | None = None frequency_penalty: float | None = None presence_penalty: float | None = None seed: int | None = None thinking_effort: ThinkingEffortLevel | None = Field( default=None, validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), ) thinking_budget_tokens: int | None = None structured_output_mode: StructuredOutputMode | None = None provider_params: dict[str, Any] = Field(default_factory=dict) max_output_tokens: int | None = None stop_sequences: list[str] | None = None cache_policy: PromptCachePolicy | None = None @property def reasoning_effort(self) -> ThinkingEffortLevel | None: return self.thinking_effort class ModelConfig(BaseModel): """Reusable model configuration for any non-embedding LLM caller.""" model: str transport: ModelTransport fallback: ResolvedFallbackConfig | None = None api_key: str | None = None base_url: str | None = None temperature: float | None = None top_p: float | None = None top_k: int | None = None frequency_penalty: float | None = None presence_penalty: float | None = None seed: int | None = None thinking_effort: ThinkingEffortLevel | None = Field( default=None, validation_alias=AliasChoices("thinking_effort", "reasoning_effort"), ) thinking_budget_tokens: int | None = None structured_output_mode: StructuredOutputMode | None = None provider_params: dict[str, Any] = Field(default_factory=dict) max_output_tokens: int | None = None stop_sequences: list[str] | None = None cache_policy: PromptCachePolicy | None = None @model_validator(mode="before") @classmethod def _normalize_legacy_model_format(cls, data: Any) -> Any: return _normalize_model_transport(data) @property def reasoning_effort(self) -> ThinkingEffortLevel | None: """Backward-compatible alias for the generic thinking effort field.""" return self.thinking_effort @model_validator(mode="after") def _validate_thinking_constraints_on_self(self) -> "ModelConfig": _validate_thinking_constraints(self.transport, self.thinking_budget_tokens) return self def for_model( self, model_override: str, *, transport_override: ModelTransport | None = None, ) -> "ModelConfig": return self.model_copy( update={ "model": model_override, "transport": transport_override or self.transport, } ) class ConfiguredEmbeddingModelSettings(BaseModel): """Operator-configurable persisted embedding settings.""" model: str = "text-embedding-3-small" transport: EmbeddingTransport = "openai" overrides: ModelOverrideSettings = Field(default_factory=ModelOverrideSettings) dimensions_mode: EmbeddingDimensionsMode = "auto" encoding_format_mode: EmbeddingEncodingFormatMode = "auto" max_batch_size: Annotated[int, Field(gt=0)] | None = None # Client HTTP timeout in seconds. OpenAI receives seconds; Gemini converts to ms. timeout: float | None = None @field_validator("timeout", mode="before") @classmethod def _validate_timeout(cls, v: Any) -> float | None: if v is None: return None return coerce_provider_timeout(v) @model_validator(mode="before") @classmethod def _normalize_legacy_model_format(cls, data: Any) -> Any: if not isinstance(data, dict): return data raw_data = cast(dict[Any, Any], data) update: dict[str, Any] = {str(key): value for key, value in raw_data.items()} model_value = update.get("model") transport_value = update.get("transport") if ( isinstance(model_value, str) and "/" in model_value and transport_value is None ): prefix, bare_model = model_value.split("/", 1) if prefix in {"openai", "gemini"}: update["transport"] = prefix update["model"] = bare_model return update @model_validator(mode="after") def _default_model_for_transport(self) -> "ConfiguredEmbeddingModelSettings": if "model" not in self.model_fields_set: self.model = _default_embedding_model_for_transport(self.transport) return self class EmbeddingModelConfig(BaseModel): """Runtime embedding configuration with resolved credentials.""" model: str = "text-embedding-3-small" transport: EmbeddingTransport = "openai" api_key: str | None = None base_url: str | None = None max_batch_size: Annotated[int, Field(gt=0)] | None = None # Client HTTP timeout in seconds. OpenAI receives seconds; Gemini converts to ms. timeout: float | None = None @field_validator("timeout", mode="before") @classmethod def _validate_timeout(cls, v: Any) -> float | None: if v is None: return None return coerce_provider_timeout(v) @model_validator(mode="before") @classmethod def _normalize_legacy_model_format(cls, data: Any) -> Any: if not isinstance(data, dict): return data raw_data = cast(dict[Any, Any], data) update: dict[str, Any] = {str(key): value for key, value in raw_data.items()} model_value = update.get("model") transport_value = update.get("transport") if ( isinstance(model_value, str) and "/" in model_value and transport_value is None ): prefix, bare_model = model_value.split("/", 1) if prefix in {"openai", "gemini"}: update["transport"] = prefix update["model"] = bare_model return update @model_validator(mode="after") def _default_model_for_transport(self) -> "EmbeddingModelConfig": if "model" not in self.model_fields_set: self.model = _default_embedding_model_for_transport(self.transport) return self def _resolve_secret(value: str | None, env_name: str | None) -> str | None: if value is not None: return value if env_name is None: return None return os.getenv(env_name) def _resolve_fallback_config( fallback: FallbackModelSettings, ) -> ResolvedFallbackConfig: """Resolve a FallbackModelSettings into a runtime ResolvedFallbackConfig.""" return ResolvedFallbackConfig( model=fallback.model, transport=fallback.transport, api_key=_resolve_secret( fallback.overrides.api_key, fallback.overrides.api_key_env, ), base_url=fallback.overrides.base_url, temperature=fallback.temperature, top_p=fallback.top_p, top_k=fallback.top_k, frequency_penalty=fallback.frequency_penalty, presence_penalty=fallback.presence_penalty, seed=fallback.seed, thinking_effort=fallback.thinking_effort, thinking_budget_tokens=fallback.thinking_budget_tokens, structured_output_mode=fallback.structured_output_mode, provider_params=fallback.overrides.provider_params, max_output_tokens=fallback.max_output_tokens, stop_sequences=fallback.stop_sequences, cache_policy=fallback.cache_policy, ) def resolve_model_config(configured: ConfiguredModelSettings) -> ModelConfig: """Resolve persisted model settings into the runtime ModelConfig.""" resolved_fallback = ( _resolve_fallback_config(configured.fallback) if configured.fallback is not None else None ) return ModelConfig( model=configured.model, transport=configured.transport, fallback=resolved_fallback, api_key=_resolve_secret( configured.overrides.api_key, configured.overrides.api_key_env, ), base_url=configured.overrides.base_url, temperature=configured.temperature, top_p=configured.top_p, top_k=configured.top_k, frequency_penalty=configured.frequency_penalty, presence_penalty=configured.presence_penalty, seed=configured.seed, thinking_effort=configured.thinking_effort, thinking_budget_tokens=configured.thinking_budget_tokens, structured_output_mode=configured.structured_output_mode, provider_params=configured.overrides.provider_params, max_output_tokens=configured.max_output_tokens, stop_sequences=configured.stop_sequences, cache_policy=configured.cache_policy, ) def _default_embedding_api_key(transport: EmbeddingTransport) -> str | None: """Fall back to the global LLM API key for the matching transport.""" if transport == "openai": return settings.LLM.OPENAI_API_KEY if transport == "gemini": return settings.LLM.GEMINI_API_KEY def resolve_embedding_model_config( configured: ConfiguredEmbeddingModelSettings, ) -> EmbeddingModelConfig: """Resolve persisted embedding settings into the runtime config.""" api_key = _resolve_secret( configured.overrides.api_key, configured.overrides.api_key_env, ) if api_key is None: api_key = _default_embedding_api_key(configured.transport) return EmbeddingModelConfig( model=configured.model, transport=configured.transport, api_key=api_key, base_url=configured.overrides.base_url, max_batch_size=configured.max_batch_size, timeout=configured.timeout, ) _TRANSPORT_SPECIFIC_THINKING_KEYS: frozenset[str] = frozenset( {"thinking_budget_tokens", "thinking_effort"} ) def _fill_defaults_for_nested_field( data: dict[str, Any], field_name: str, default_factory: Any, ) -> dict[str, Any]: """Fill missing keys in a partial nested dict from the field's defaults. When Pydantic's env_nested_delimiter splits an env var like ``DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=2048`` it produces ``{"MODEL_CONFIG": {"THINKING_BUDGET_TOKENS": 2048}}``. Without merging that partial dict would fail validation because required keys like ``model`` and ``transport`` are missing. This helper fills them from the field's ``default_factory`` so partial overrides work. If the env override switches ``transport`` to a value that differs from the default's, transport-specific thinking params (``thinking_budget_tokens``, ``thinking_effort``) are dropped from the default before merging. This prevents e.g. a Gemini default's ``thinking_budget_tokens=1024`` from leaking into an OpenAI override, which would then be rejected by the OpenAI backend (OpenAI uses ``reasoning.effort``, not a token budget). Explicit thinking params in the env override are preserved. """ raw: Any = data.get(field_name) or data.get(field_name.lower()) if not isinstance(raw, dict): return data default_obj = default_factory() if isinstance(default_obj, BaseModel): default_dict: dict[str, Any] = default_obj.model_dump(by_alias=True) else: default_dict = dict(default_obj) raw_dict = cast(dict[str, Any], raw) raw_lower = {k.lower(): v for k, v in raw_dict.items()} default_lower = {k.lower(): v for k, v in default_dict.items()} override_transport = raw_lower.get("transport") default_transport = default_lower.get("transport") if override_transport is not None and override_transport != default_transport: for k in list(default_dict.keys()): if k.lower() in _TRANSPORT_SPECIFIC_THINKING_KEYS: del default_dict[k] merged: dict[str, Any] = {**default_dict, **raw_dict} # Preserve the key casing used in data key = field_name if field_name in data else field_name.lower() data[key] = merged return data class TomlConfigSettingsSource(PydanticBaseSettingsSource): """Custom settings source for loading from TOML file.""" def __init__(self, settings_cls: type[BaseSettings]) -> None: super().__init__(settings_cls) SECTION_MAP: ClassVar[dict[str, str]] = { "DB": "db", "AUTH": "auth", "SENTRY": "sentry", "CACHE": "cache", "LLM": "llm", "EMBEDDING": "embedding", "DERIVER": "deriver", "PEER_CARD": "peer_card", "DIALECTIC": "dialectic", "SUMMARY": "summary", "WEBHOOK": "webhook", "DREAM": "dream", "VECTOR_STORE": "vector_store", "METRICS": "metrics", "TELEMETRY": "telemetry", "": "app", # For AppSettings with no prefix } def get_field_value( self, field: FieldInfo, field_name: str ) -> tuple[Any, str, bool]: # Get the env_prefix from the model config prefix = self.settings_cls.model_config.get("env_prefix", "") if prefix.endswith("_"): prefix = prefix[:-1] # Map prefixes to TOML sections section = self.SECTION_MAP.get(prefix, prefix.lower()) toml_data = TOML_CONFIG.get(section, {}) # Try different case variations field_value = toml_data.get(field_name.lower()) if field_value is None: field_value = toml_data.get(field_name.upper()) if field_value is None: field_value = toml_data.get(field_name) return field_value, field_name, False def __call__(self) -> dict[str, Any]: # Get the env_prefix from the model config prefix = self.settings_cls.model_config.get("env_prefix", "") if prefix.endswith("_"): prefix = prefix[:-1] section = self.SECTION_MAP.get(prefix, prefix.lower()) toml_data = TOML_CONFIG.get(section, {}) # Convert keys to uppercase to match field names return {key.upper(): value for key, value in toml_data.items()} class HonchoSettings(BaseSettings): """Base class for all settings models in Honcho. Defines the source precedence for loading settings. """ @classmethod def settings_customise_sources( # pyright: ignore cls, settings_cls: type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: EnvSettingsSource, dotenv_settings: DotEnvSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> tuple[PydanticBaseSettingsSource, ...]: # Correct precedence: init > env > .env > toml > secrets > defaults return ( init_settings, env_settings, dotenv_settings, TomlConfigSettingsSource(settings_cls), file_secret_settings, ) class DBSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="DB_", extra="ignore") # pyright: ignore CONNECTION_URI: str = ( "postgresql+psycopg://postgres:postgres@localhost:5432/postgres" ) SCHEMA: str = "public" POOL_CLASS: str = "default" POOL_PRE_PING: bool = True POOL_SIZE: Annotated[int, Field(default=10, gt=0, le=1000)] = 10 MAX_OVERFLOW: Annotated[int, Field(default=20, ge=0, le=1000)] = 20 POOL_TIMEOUT: Annotated[int, Field(default=5, gt=0, le=300)] = ( 5 # seconds a pooled checkout may wait for a free connection (QueuePool # only; NullPool has no local queue wait) ) POOL_RECYCLE: Annotated[int, Field(default=300, gt=0, le=7200)] = ( 300 # seconds (max 2 hours) ) POOL_USE_LIFO: bool = True SQL_DEBUG: bool = False TRACING: bool = False # Per-connection establish timeout (seconds) passed to the driver, so a # single connection attempt fails fast instead of hanging when the server or # pooler is unreachable or stalled. Connection acquisition is a single # attempt with no retry; callers handle failure (the API surfaces it, the # deriver backs off and retries on a later poll). CONNECT_TIMEOUT_SECONDS: Annotated[int, Field(default=2, gt=0, le=60)] = 2 class AuthSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="AUTH_", extra="ignore") # pyright: ignore USE_AUTH: bool = False JWT_SECRET: str | None = None # Must be set if USE_AUTH is true @model_validator(mode="after") # type: ignore def _require_jwt_secret(self) -> "AuthSettings": if self.USE_AUTH and not self.JWT_SECRET: raise ValueError("JWT_SECRET must be set if USE_AUTH is true") return self class SentrySettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="SENTRY_", extra="ignore") # pyright: ignore ENABLED: bool = False DSN: str | None = None RELEASE: str | None = None # TODO maybe centralize this with release number ENVIRONMENT: str = "development" TRACES_SAMPLE_RATE: Annotated[float, Field(default=0.1, ge=0.0, le=1.0)] = 0.1 PROFILES_SAMPLE_RATE: Annotated[float, Field(default=0.1, ge=0.0, le=1.0)] = 0.1 class LLMSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="LLM_", extra="ignore") # pyright: ignore # API Keys for LLM providers ANTHROPIC_API_KEY: str | None = None OPENAI_API_KEY: str | None = None GEMINI_API_KEY: str | None = None # Base URLs for LLM providers (for OpenAI-compatible proxies like # OpenRouter, vLLM, Together, Anyscale, self-hosted, etc.) ANTHROPIC_BASE_URL: str | None = None OPENAI_BASE_URL: str | None = None GEMINI_BASE_URL: str | None = None # General LLM settings DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100_000)] = 2500 # Maximum characters for tool output to prevent token explosion. # Set to 10,000 chars (~2,500 tokens at 4 chars/token) to stay well under # typical context limits while providing substantial tool output. MAX_TOOL_OUTPUT_CHARS: Annotated[int, Field(default=10000, gt=0, le=100_000)] = ( 10000 ) # Maximum characters for individual message content in tool results. # Keeps each message preview concise while preserving key context. MAX_MESSAGE_CONTENT_CHARS: Annotated[int, Field(default=2000, gt=0, le=10_000)] = ( 2000 ) class EmbeddingSettings(HonchoSettings): model_config = SettingsConfigDict( # pyright: ignore env_prefix="EMBEDDING_", env_nested_delimiter="__", extra="ignore" ) @staticmethod def _MODEL_CONFIG_DEFAULT() -> ConfiguredEmbeddingModelSettings: return ConfiguredEmbeddingModelSettings( transport="openai", model="text-embedding-3-small", ) MODEL_CONFIG: ConfiguredEmbeddingModelSettings = Field( default_factory=_MODEL_CONFIG_DEFAULT ) VECTOR_DIMENSIONS: Annotated[int, Field(default=1536, gt=0)] = 1536 MAX_INPUT_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192 MAX_TOKENS_PER_REQUEST: Annotated[int, Field(default=300_000, gt=0)] = 300_000 # Caps concurrent message-embedding fan-out on the API request path (the # immediate-embed background task). The reconciler is unaffected. MAX_CONCURRENT_EMBEDDINGS: Annotated[int, Field(default=10, gt=0, le=100)] = 10 # Caps in-flight immediate-embed background tasks per API process. When # saturated, message creation skips the fast path entirely and the # reconciler embeds on its next cycle. 0 disables the fast path. MAX_PENDING_EMBED_TASKS: Annotated[int, Field(default=50, ge=0)] = 50 @model_validator(mode="before") @classmethod def _merge_model_config_defaults(cls, data: Any) -> Any: if isinstance(data, dict): _fill_defaults_for_nested_field( cast(dict[str, Any], data), "MODEL_CONFIG", cls._MODEL_CONFIG_DEFAULT, ) return data # pyright: ignore[reportUnknownVariableType] def resolve_send_dimensions(self) -> bool: """Decide whether OpenAI embedding calls should forward ``dimensions=``. Lives on the settings instance because ``auto`` mode needs access to ``self.model_fields_set`` to tell whether the operator explicitly set ``VECTOR_DIMENSIONS`` — a standalone resolver over ``ConfiguredEmbeddingModelSettings`` cannot see that. """ mode = self.MODEL_CONFIG.dimensions_mode if mode == "always": return True if mode == "never": return False if self.MODEL_CONFIG.model in _EMBEDDING_KNOWN_REJECTING_MODELS: return False return "VECTOR_DIMENSIONS" in self.model_fields_set def resolve_encoding_format(self) -> EmbeddingEncodingFormat: """Pick the ``encoding_format`` for OpenAI embedding calls. ``auto`` keeps the compact base64 wire format on hosts known to support it and falls back to float elsewhere, since OpenAI-compatible providers may answer a base64 request with an error or empty data. """ mode = self.MODEL_CONFIG.encoding_format_mode if mode != "auto": return mode base_url = self.MODEL_CONFIG.overrides.base_url if not base_url: return "base64" host = urlparse(base_url).hostname return "base64" if host in _EMBEDDING_BASE64_CAPABLE_HOSTS else "float" class DeriverSettings(HonchoSettings): model_config = SettingsConfigDict( # pyright: ignore env_prefix="DERIVER_", env_nested_delimiter="__", extra="ignore" ) ENABLED: bool = True WORKERS: Annotated[int, Field(default=1, gt=0, le=100)] = 1 POLLING_SLEEP_INTERVAL_SECONDS: Annotated[ float, Field(default=1.0, gt=0.0, le=60.0) ] = 1.0 # Adaptive polling: when the queue is idle (or the loop is erroring) the # sleep interval grows from POLLING_SLEEP_INTERVAL_SECONDS toward # POLLING_SLEEP_MAX_INTERVAL_SECONDS by POLLING_BACKOFF_MULTIPLIER each # cycle, then snaps back to the base interval as soon as work is found. # Reduces steady-state query load against the (shared) DB/pooler. POLLING_BACKOFF_ENABLED: bool = True POLLING_SLEEP_MAX_INTERVAL_SECONDS: Annotated[ float, Field(default=30.0, gt=0.0, le=300.0) ] = 30.0 POLLING_BACKOFF_MULTIPLIER: Annotated[ float, Field(default=2.0, ge=1.0, le=10.0) ] = 2.0 # Sleep a uniform-random delay in [0, POLLING_STARTUP_JITTER_SECONDS] before # the first poll so instances that start together don't poll in lockstep. # Set to 0.0 to disable. POLLING_STARTUP_JITTER_SECONDS: Annotated[ float, Field(default=30.0, ge=0.0, le=300.0) ] = 30.0 # Multiply every poll sleep by a random factor in [1 - ratio, 1 + ratio] # (0.5 -> [0.5x, 1.5x]) so poll loops don't re-converge over time. The # backoff schedule is unchanged; only the returned sleep is scattered. Set # to 0.0 to disable. POLLING_JITTER_RATIO: Annotated[float, Field(default=0.5, ge=0.0, le=1.0)] = 0.5 STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5 # Minimum (jittered) spacing between stale-work-unit cleanup runs STALE_WORK_UNIT_CLEANUP_INTERVAL_SECONDS: Annotated[ float, Field(default=60.0, ge=0.0, le=3600.0) ] = 60.0 # Retention window (seconds) for keeping errored items in the queue QUEUE_ERROR_RETENTION_SECONDS: Annotated[ int, Field(default=30 * 24 * 3600, gt=0) ] = 30 * 24 * 3600 # 30 days default @staticmethod def _MODEL_CONFIG_DEFAULT() -> ConfiguredModelSettings: # Minimal default: transport + model only. Any other knobs would merge # into operator-supplied env / config.toml overrides via # _fill_defaults_for_nested_field and clobber intent. return ConfiguredModelSettings( transport="openai", model="gpt-5.4-mini", ) MODEL_CONFIG: ConfiguredModelSettings = Field(default_factory=_MODEL_CONFIG_DEFAULT) # Whether to deduplicate documents when creating them DEDUPLICATE: bool = True LOG_OBSERVATIONS: bool = False MAX_INPUT_TOKENS: Annotated[int, Field(default=25000, gt=0, le=25000)] = 25000 MAX_CUSTOM_INSTRUCTIONS_TOKENS: Annotated[ int, Field(default=2000, ge=0, le=2000) ] = 2000 # Maximum number of observations to return in working representation # This is applied to both explicit and deductive observations WORKING_REPRESENTATION_MAX_OBSERVATIONS: Annotated[ int, Field(default=100, gt=0, le=1000) ] = 100 # Minimum tokens a representation work unit must accumulate (summed over # its own unprocessed messages) before it becomes claimable. Bypassed by # FLUSH_ENABLED and by REPRESENTATION_BATCH_MAX_AGE_SECONDS age-flushing. # 0 disables the accumulation gate entirely (equivalent to FLUSH_ENABLED # for claiming): work units are claimable as soon as anything is pending. REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS: Annotated[ int, Field(default=512, ge=0, le=16_384), ] = 512 # Cumulative-token cap on the conversation window (queued messages plus # interleaved context) fed to a single deriver LLM call when draining a # claimed work unit. The first unprocessed message is always included, # even if it alone exceeds the cap. REPRESENTATION_BATCH_TARGET_INPUT_TOKENS: Annotated[ int, Field(default=1024, ge=128, le=16_384), ] = 1024 # Sub-threshold work units become eligible once their oldest unprocessed # item exceeds this age. 0 disables age-based flushing. REPRESENTATION_BATCH_MAX_AGE_SECONDS: Annotated[int, Field(default=1800, ge=0)] = ( 1800 ) # When enabled, bypasses the batch token threshold and processes work immediately FLUSH_ENABLED: bool = False @model_validator(mode="before") @classmethod def _merge_model_config_defaults(cls, data: Any) -> Any: if isinstance(data, dict): _fill_defaults_for_nested_field( cast(dict[str, Any], data), "MODEL_CONFIG", cls._MODEL_CONFIG_DEFAULT, ) return data # pyright: ignore[reportUnknownVariableType] @model_validator(mode="after") def validate_batch_tokens_vs_context_limit(self): if self.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS > self.MAX_INPUT_TOKENS: raise ValueError( f"REPRESENTATION_BATCH_TARGET_INPUT_TOKENS ({self.REPRESENTATION_BATCH_TARGET_INPUT_TOKENS}) cannot exceed max deriver input tokens ({self.MAX_INPUT_TOKENS})" ) return self class PeerCardSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="PEER_CARD_", extra="ignore") # pyright: ignore ENABLED: bool = True # Reasoning levels for dialectic - defined here to avoid circular imports with schemas ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"] REASONING_LEVELS: list[ReasoningLevel] = [ "minimal", "low", "medium", "high", "max", ] class DialecticLevelSettings(BaseModel): """Settings for a specific reasoning level in the dialectic.""" model_config = SettingsConfigDict(populate_by_name=True) # pyright: ignore MODEL_CONFIG: Annotated[ ConfiguredModelSettings, Field(validation_alias="model_config"), ] MAX_TOOL_ITERATIONS: Annotated[ int, Field(ge=0, le=50, validation_alias="max_tool_iterations") ] MAX_OUTPUT_TOKENS: Annotated[ int | None, Field(ge=1, le=100_000, validation_alias="max_output_tokens") ] = None # None means use global DIALECTIC.MAX_OUTPUT_TOKENS TOOL_CHOICE: Annotated[str | None, Field(validation_alias="tool_choice")] = ( None # None/auto lets model decide, "any"/"required" forces tool use ) @model_validator(mode="after") def _validate_anthropic_thinking_budget(self) -> "DialecticLevelSettings": """Ensure Anthropic thinking budget is >= 1024 when enabled.""" if ( self.MODEL_CONFIG.transport == "anthropic" and self.MODEL_CONFIG.thinking_budget_tokens is not None and self.MODEL_CONFIG.thinking_budget_tokens > 0 and self.MODEL_CONFIG.thinking_budget_tokens < 1024 ): raise ValueError( "MODEL_CONFIG.thinking_budget_tokens must be >= 1024 for " + "Anthropic models when enabled " + f"(got {self.MODEL_CONFIG.thinking_budget_tokens})" ) return self def _default_dialectic_levels() -> dict[ReasoningLevel, DialecticLevelSettings]: # Minimal defaults per level: transport + model only. Non-MODEL_CONFIG # level tuning (MAX_TOOL_ITERATIONS, MAX_OUTPUT_TOKENS, TOOL_CHOICE) # stays here because it's the per-level behavior, not a model knob — # operators still override any of it via # DIALECTIC_LEVELS____MODEL_CONFIG__* without conflict. def _default_model_config() -> ConfiguredModelSettings: return ConfiguredModelSettings( transport="openai", model="gpt-5.4-mini", ) return { "minimal": DialecticLevelSettings( MODEL_CONFIG=_default_model_config(), MAX_TOOL_ITERATIONS=1, MAX_OUTPUT_TOKENS=250, TOOL_CHOICE="auto", ), "low": DialecticLevelSettings( MODEL_CONFIG=_default_model_config(), MAX_TOOL_ITERATIONS=5, TOOL_CHOICE="auto", ), "medium": DialecticLevelSettings( MODEL_CONFIG=_default_model_config(), MAX_TOOL_ITERATIONS=2, ), "high": DialecticLevelSettings( MODEL_CONFIG=_default_model_config(), MAX_TOOL_ITERATIONS=4, ), "max": DialecticLevelSettings( MODEL_CONFIG=_default_model_config(), MAX_TOOL_ITERATIONS=10, ), } class DialecticSettings(HonchoSettings): model_config = SettingsConfigDict( # pyright: ignore env_prefix="DIALECTIC_", env_nested_delimiter="__", extra="ignore" ) LEVELS: dict[ReasoningLevel, DialecticLevelSettings] = Field( default_factory=_default_dialectic_levels ) MAX_OUTPUT_TOKENS: Annotated[int, Field(default=8192, gt=0, le=100_000)] = 8192 MAX_INPUT_TOKENS: Annotated[int, Field(default=100_000, gt=0, le=200_000)] = 100_000 # Token limit for get_recent_history tool within the agent HISTORY_TOKEN_LIMIT: Annotated[int, Field(default=8192, gt=0, le=100_000)] = 8192 # Session history injection: max tokens of recent messages to include when session_id is specified. # Set to 0 to disable automatic session history injection. SESSION_HISTORY_MAX_TOKENS: Annotated[ int, Field(default=4_096, ge=0, le=16_384) ] = 4_096 @model_validator(mode="before") @classmethod def _merge_level_defaults(cls, data: Any) -> Any: """Merge partial level overrides with built-in defaults.""" if not isinstance(data, dict): return data typed_data = cast(dict[str, Any], data) levels_raw: dict[str, Any] | None = typed_data.get("LEVELS") or typed_data.get( "levels" ) if not isinstance(levels_raw, dict): return data # pyright: ignore[reportUnknownVariableType] defaults = _default_dialectic_levels() for level_name_key, level_override_val in levels_raw.items(): level_name = str(level_name_key) if not isinstance(level_override_val, dict): continue level_override = cast(dict[str, Any], level_override_val) if level_name in defaults: base: dict[str, Any] = defaults[level_name].model_dump(by_alias=True) # Recursively merge nested MODEL_CONFIG / model_config too. # model_dump() always produces the Python field name # ("MODEL_CONFIG"), but TOML overrides arrive as lowercase # ("model_config"). Check both casings in the override and # resolve the base value from whichever casing is present. for mc_key in ("MODEL_CONFIG", "model_config"): if mc_key in level_override and isinstance( level_override[mc_key], dict ): base_mc: dict[str, Any] = dict( base.get("MODEL_CONFIG") or base.get("model_config") or {} ) override_mc = cast(dict[str, Any], level_override[mc_key]) override_lower = {k.lower(): v for k, v in override_mc.items()} base_lower = {k.lower(): v for k, v in base_mc.items()} override_transport = override_lower.get("transport") base_transport = base_lower.get("transport") if ( override_transport is not None and override_transport != base_transport ): for k in list(base_mc.keys()): if k.lower() in _TRANSPORT_SPECIFIC_THINKING_KEYS: del base_mc[k] level_override[mc_key] = {**base_mc, **override_mc} levels_raw[level_name] = {**base, **level_override} # Backfill any reasoning levels the operator didn't explicitly set with the default values. for default_level_name, default_level in defaults.items(): if default_level_name not in levels_raw: levels_raw[default_level_name] = default_level.model_dump(by_alias=True) return data # pyright: ignore[reportUnknownVariableType] @model_validator(mode="after") def _validate_token_budgets(self) -> "DialecticSettings": """Ensure the output token limit exceeds all thinking budgets.""" for level, level_settings in self.LEVELS.items(): thinking_budget = level_settings.MODEL_CONFIG.thinking_budget_tokens or 0 effective_max = ( level_settings.MAX_OUTPUT_TOKENS if level_settings.MAX_OUTPUT_TOKENS is not None else self.MAX_OUTPUT_TOKENS ) if thinking_budget > 0 and thinking_budget >= effective_max: raise ValueError( "MAX_OUTPUT_TOKENS must be greater than MODEL_CONFIG." + f"thinking_budget_tokens for level '{level}'" ) return self @model_validator(mode="after") def _validate_all_levels_present(self) -> "DialecticSettings": """Ensure all reasoning levels are configured.""" missing = set(REASONING_LEVELS) - set(self.LEVELS.keys()) if missing: raise ValueError(f"Missing configuration for reasoning levels: {missing}") return self class SummarySettings(HonchoSettings): model_config = SettingsConfigDict( # pyright: ignore env_prefix="SUMMARY_", env_nested_delimiter="__", extra="ignore" ) ENABLED: bool = True MESSAGES_PER_SHORT_SUMMARY: Annotated[int, Field(default=20, gt=0, le=100)] = 20 MESSAGES_PER_LONG_SUMMARY: Annotated[int, Field(default=60, gt=0, le=500)] = 60 @staticmethod def _MODEL_CONFIG_DEFAULT() -> ConfiguredModelSettings: # Minimal default; extra knobs would merge into env/TOML overrides. return ConfiguredModelSettings( transport="openai", model="gpt-5.4-mini", ) MODEL_CONFIG: ConfiguredModelSettings = Field(default_factory=_MODEL_CONFIG_DEFAULT) @model_validator(mode="before") @classmethod def _merge_model_config_defaults(cls, data: Any) -> Any: if isinstance(data, dict): _fill_defaults_for_nested_field( cast(dict[str, Any], data), "MODEL_CONFIG", cls._MODEL_CONFIG_DEFAULT, ) return data # pyright: ignore[reportUnknownVariableType] MAX_TOKENS_SHORT: Annotated[int, Field(default=1000, gt=0, le=10_000)] = 1000 MAX_TOKENS_LONG: Annotated[int, Field(default=4000, gt=0, le=20_000)] = 4000 class WebhookSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="WEBHOOK_", extra="ignore") # pyright: ignore SECRET: str | None = None # Must be set if configuring webhooks MAX_WORKSPACE_LIMIT: int = 10 class MetricsSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="METRICS_", extra="ignore") # pyright: ignore ENABLED: bool = False NAMESPACE: str | None = None class TelemetrySettings(HonchoSettings): """CloudEvents telemetry settings for analytics. These settings configure the CloudEvents emitter for pushing structured events to an analytics backend. """ model_config = SettingsConfigDict(env_prefix="TELEMETRY_", extra="ignore") # pyright: ignore # Master toggle for CloudEvents emission ENABLED: bool = False # CloudEvents HTTP endpoint (e.g., "https://telemetry.honcho.dev/v1/events") ENDPOINT: str | None = None # Optional headers for authentication HEADERS: dict[str, str] | None = None # Batching configuration BATCH_SIZE: Annotated[int, Field(default=100, gt=0, le=1000)] = 100 FLUSH_INTERVAL_SECONDS: Annotated[float, Field(default=1.0, gt=0.0, le=60.0)] = 1.0 FLUSH_THRESHOLD: Annotated[int, Field(default=50, gt=0, le=1000)] = 50 # Retry configuration MAX_RETRIES: Annotated[int, Field(default=3, gt=0, le=10)] = 3 # Buffer configuration MAX_BUFFER_SIZE: Annotated[int, Field(default=10000, gt=0, le=100000)] = 10000 # Namespace for instance identification (propagated from top-level NAMESPACE if not set) NAMESPACE: str | None = None # Sample rate for high-volume events: llm.call.completed, embedding.call.completed, # agent.iteration, agent.tool.call.completed. Deterministic on run_id so traces # remain coherent end-to-end. Aggregate envelopes (RepresentationCompleted, # DialecticCompleted, DreamRun, etc.) are NEVER sampled — they're calibration # ground truth. # # Design trade-off: at rate < 1.0, aggregate events still emit but their # high-volume children get dropped. Downstream `JOIN ... ON run_id` queries # will see parents without complete children — this is intentional (the # aggregates carry totals; detail events are best-effort), but consumers # MUST NOT rebuild per-call analytics from the sampled children alone or # they'll undercount. If you tune this below 1.0, audit dashboards/queries # that join high-volume events to aggregate envelopes first. HIGH_VOLUME_SAMPLE_RATE: Annotated[float, Field(default=1.0, ge=0.0, le=1.0)] = 1.0 # --- Full-fidelity payload tracing (llm.call.traced / trace.content) --- # Master toggle for replay-grade content capture. Default-off. TRACE_PAYLOADS_ENABLED: bool = False # Per-message cap (bytes) for captured content; oversized string content is # clipped (with a marker) and the call is flagged was_truncated. TRACE_MAX_BYTES: Annotated[int, Field(default=262144, gt=0)] = 262144 # Allowlist of CallPurpose values to capture; empty = all. Typed as str to # keep the enum out of config (validated against CallPurpose at the producer, # same pattern as LLMTelemetryContext.call_purpose). TRACE_PURPOSES: list[str] = Field(default_factory=list) class CacheSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="CACHE_", extra="ignore") # pyright: ignore ENABLED: bool = False URL: str = "redis://localhost:6379/0?suppress=true" # URL points at a Redis Cluster (OSS cluster protocol, e.g. GCP Memorystore # for Redis Cluster). A standalone client cannot follow the MOVED redirects # such deployments return for keys hashed to another shard. CLUSTER: bool = False NAMESPACE: str | None = None DEFAULT_TTL_SECONDS: Annotated[int, Field(default=300, ge=1, le=86_400)] = ( 300 # how long to keep items in cache ) DEFAULT_LOCK_TTL_SECONDS: Annotated[int, Field(default=5, ge=1, le=86_400)] = ( 5 # how long to hold a lock on a resource when fetching DB after cache miss ) # Polling interval while waiting for another worker's fetch lock. cashews # defaults to 0, which busy-spins the event loop for the whole wait. LOCK_WAIT_CHECK_INTERVAL_SECONDS: Annotated[ float, Field(default=0.1, gt=0, le=5) ] = 0.1 class SurprisalSettings(BaseModel): """Settings for tree-based surprisal sampling during dreams.""" ENABLED: bool = False # Tree configuration TREE_TYPE: Literal[ "kdtree", "balltree", "rptree", "covertree", "lsh", "graph", "prototype" ] = "kdtree" TREE_K: Annotated[int, Field(default=5, gt=0, le=20)] = 5 # k for kNN-based trees # Sampling strategy SAMPLING_STRATEGY: Literal["recent", "random", "all"] = "recent" SAMPLE_SIZE: Annotated[int, Field(default=200, gt=0, le=2000)] = 200 # Surprisal filtering (normalized scores: 0.0 = lowest, 1.0 = highest) TOP_PERCENT_SURPRISAL: Annotated[float, Field(default=0.10, gt=0.0, le=1.0)] = ( 0.10 # Top 10% of observations ) # Hybrid mode: min high-surprisal observations to replace standard questions MIN_HIGH_SURPRISAL_FOR_REPLACE: Annotated[int, Field(default=10, gt=0)] = 10 # Observation level filtering INCLUDE_LEVELS: list[str] = ["explicit", "deductive"] class DreamSettings(HonchoSettings): model_config = SettingsConfigDict( # pyright: ignore env_prefix="DREAM_", env_nested_delimiter="__", extra="ignore" ) ENABLED: bool = True DOCUMENT_THRESHOLD: Annotated[int, Field(default=50, gt=0, le=1000)] = 50 IDLE_TIMEOUT_MINUTES: Annotated[int, Field(default=60, gt=0, le=1440)] = 60 MIN_HOURS_BETWEEN_DREAMS: Annotated[int, Field(default=8, gt=0, le=72)] = 8 ENABLED_TYPES: list[str] = ["omni"] # Agent iteration limit - increased for extended reasoning workflow MAX_TOOL_ITERATIONS: Annotated[int, Field(default=20, gt=0, le=50)] = 20 # Token limit for get_recent_history tool within the agent HISTORY_TOKEN_LIMIT: Annotated[int, Field(default=16_384, gt=0, le=200_000)] = ( 16_384 ) @staticmethod def _DEDUCTION_MODEL_CONFIG_DEFAULT() -> ConfiguredModelSettings: # Minimal default; extra knobs would merge into env/TOML overrides. return ConfiguredModelSettings( transport="openai", model="gpt-5.4-mini", ) DEDUCTION_MODEL_CONFIG: ConfiguredModelSettings = Field( default_factory=_DEDUCTION_MODEL_CONFIG_DEFAULT ) @staticmethod def _INDUCTION_MODEL_CONFIG_DEFAULT() -> ConfiguredModelSettings: # Minimal default; extra knobs would merge into env/TOML overrides. return ConfiguredModelSettings( transport="openai", model="gpt-5.4-mini", ) INDUCTION_MODEL_CONFIG: ConfiguredModelSettings = Field( default_factory=_INDUCTION_MODEL_CONFIG_DEFAULT ) # Surprisal-based sampling subsystem SURPRISAL: SurprisalSettings = Field(default_factory=SurprisalSettings) @model_validator(mode="before") @classmethod def _merge_model_config_defaults(cls, data: Any) -> Any: if isinstance(data, dict): typed_data = cast(dict[str, Any], data) _fill_defaults_for_nested_field( typed_data, "DEDUCTION_MODEL_CONFIG", cls._DEDUCTION_MODEL_CONFIG_DEFAULT, ) _fill_defaults_for_nested_field( typed_data, "INDUCTION_MODEL_CONFIG", cls._INDUCTION_MODEL_CONFIG_DEFAULT, ) return data # pyright: ignore[reportUnknownVariableType] @model_validator(mode="after") def _validate_specialist_token_budgets(self) -> "DreamSettings": """Ensure thinking_budget_tokens < max_output_tokens for each specialist.""" for name, cfg in ( ("DEDUCTION_MODEL_CONFIG", self.DEDUCTION_MODEL_CONFIG), ("INDUCTION_MODEL_CONFIG", self.INDUCTION_MODEL_CONFIG), ): if ( cfg.max_output_tokens is not None and cfg.thinking_budget_tokens is not None and cfg.max_output_tokens <= cfg.thinking_budget_tokens ): raise ValueError( f"dream.{name}.max_output_tokens must be greater than " + f"dream.{name}.thinking_budget_tokens" ) return self class VectorStoreSettings(HonchoSettings): """Settings for vector store (pgvector, Turbopuffer, or LanceDB).""" model_config = SettingsConfigDict(env_prefix="VECTOR_STORE_", extra="ignore") # pyright: ignore # Vector store type to use TYPE: Literal["pgvector", "turbopuffer", "lancedb"] = "pgvector" MIGRATED: bool = False # Global namespace prefix for all vector namespaces # Namespaces follow the pattern: {NAMESPACE}.{type}.{hash} # where hash is a base64url-encoded SHA-256 of the workspace/peer names # - Documents: {NAMESPACE}.doc.{hash(workspace, observer, observed)} # - Messages: {NAMESPACE}.msg.{hash(workspace)} NAMESPACE: str = "honcho" DIMENSIONS: Annotated[ int, Field( default=1536, gt=0, ), ] = 1536 # Turbopuffer-specific settings TURBOPUFFER_API_KEY: str | None = None TURBOPUFFER_REGION: str | None = None # LanceDB-specific settings (local embedded mode) LANCEDB_PATH: str = "./lancedb_data" RECONCILIATION_INTERVAL_SECONDS: Annotated[int, Field(default=300, gt=0)] = ( 300 # 5 minutes ) @model_validator(mode="after") def _require_api_key_for_turbopuffer(self) -> "VectorStoreSettings": if self.TYPE == "turbopuffer" and not self.TURBOPUFFER_API_KEY: raise ValueError( "VECTOR_STORE_TURBOPUFFER_API_KEY must be set when TYPE is 'turbopuffer'" ) return self class TraceViewerSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="TRACE_VIEWER_", extra="ignore") # pyright: ignore ENABLED: bool = False HOST: str = "127.0.0.1" PORT: int = 8002 STORAGE_DIR: str = "./traces" MAX_REQUEST_BYTES: int = 10 * 1024 * 1024 # 10 MB VENDOR_CDN_BASE: str = "https://cdn.jsdelivr.net/npm" class AppSettings(HonchoSettings): # No env_prefix for app-level settings model_config = SettingsConfigDict( # pyright: ignore env_prefix="", env_nested_delimiter="__", extra="ignore" ) # Application-wide settings LOG_LEVEL: str = "INFO" PERFORMANCE_LOG_FORMAT: str = "compact" SESSION_OBSERVERS_LIMIT: Annotated[int, Field(default=10, gt=0)] = 10 MAX_FILE_SIZE: Annotated[int, Field(default=5_242_880, gt=0)] = 5_242_880 # 5MB GET_CONTEXT_MAX_TOKENS: Annotated[int, Field(default=100_000, gt=0, le=250_000)] = ( 100_000 ) MAX_MESSAGE_SIZE: Annotated[int, Field(default=25_000, gt=0)] = 25_000 EMBED_MESSAGES: bool = True LANGFUSE_HOST: str | None = None LANGFUSE_PUBLIC_KEY: str | None = None # How Langfuse traces are produced: # "exporter" (default) — Langfuse is a projection over the captured # CapturedLLMCall stream (LangfuseExporter), the same source of truth as # the CloudEvents trace stream. # "inline" — legacy live instrumentation (@observe + propagate_attributes # spans during execution). Kept one release for side-by-side validation. LANGFUSE_EXPORTER_MODE: Literal["inline", "exporter"] = "exporter" @property def langfuse_inline_enabled(self) -> bool: """True when the legacy inline Langfuse instrumentation is active (keys configured + ``LANGFUSE_EXPORTER_MODE == "inline"``).""" return ( bool(self.LANGFUSE_PUBLIC_KEY) and self.LANGFUSE_EXPORTER_MODE == "inline" ) @property def langfuse_exporter_enabled(self) -> bool: """True when the Langfuse exporter (a projection over the captured call stream) is active (keys configured + ``LANGFUSE_EXPORTER_MODE == "exporter"``).""" return ( bool(self.LANGFUSE_PUBLIC_KEY) and self.LANGFUSE_EXPORTER_MODE == "exporter" ) # Origins allowed by the FastAPI CORSMiddleware CORS_ORIGINS: list[str] = [ "http://localhost", "http://127.0.0.1:8000", "https://api.honcho.dev", ] COLLECT_METRICS_LOCAL: bool = False LOCAL_METRICS_FILE: str = "metrics.jsonl" REASONING_TRACES_FILE: str | None = None # Path to JSONL file for reasoning traces NAMESPACE: str = "honcho" # Top-level namespace for all settings, can be overridden by nested-model settings # Nested settings models DB: DBSettings = Field(default_factory=DBSettings) AUTH: AuthSettings = Field(default_factory=AuthSettings) SENTRY: SentrySettings = Field(default_factory=SentrySettings) LLM: LLMSettings = Field(default_factory=LLMSettings) EMBEDDING: EmbeddingSettings = Field(default_factory=EmbeddingSettings) DERIVER: DeriverSettings = Field(default_factory=DeriverSettings) DIALECTIC: DialecticSettings = Field(default_factory=DialecticSettings) PEER_CARD: PeerCardSettings = Field(default_factory=PeerCardSettings) SUMMARY: SummarySettings = Field(default_factory=SummarySettings) WEBHOOK: WebhookSettings = Field(default_factory=WebhookSettings) METRICS: MetricsSettings = Field(default_factory=MetricsSettings) TELEMETRY: TelemetrySettings = Field(default_factory=TelemetrySettings) CACHE: CacheSettings = Field(default_factory=CacheSettings) DREAM: DreamSettings = Field(default_factory=DreamSettings) VECTOR_STORE: VectorStoreSettings = Field(default_factory=VectorStoreSettings) TRACE_VIEWER: TraceViewerSettings = Field(default_factory=TraceViewerSettings) @field_validator("LOG_LEVEL") def validate_log_level(cls, v: str) -> str: log_level = v.upper() if log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: raise ValueError(f"Invalid log level: {v}") return log_level @field_validator("PERFORMANCE_LOG_FORMAT") def validate_performance_log_format(cls, v: str) -> str: log_format = v.lower() if log_format not in ["compact", "rich"]: raise ValueError(f"Invalid performance log format: {v}") return log_format @model_validator(mode="after") def propagate_namespace(self) -> "AppSettings": """Propagate top-level NAMESPACE to nested settings if not explicitly set.""" if "NAMESPACE" not in self.CACHE.model_fields_set: self.CACHE.NAMESPACE = self.NAMESPACE if "NAMESPACE" not in self.VECTOR_STORE.model_fields_set: self.VECTOR_STORE.NAMESPACE = self.NAMESPACE if "DIMENSIONS" in self.VECTOR_STORE.model_fields_set: # VECTOR_STORE_DIMENSIONS is deprecated: EMBEDDING_VECTOR_DIMENSIONS # is the single source of truth. Log a runtime-visible warning # so operators see it (DeprecationWarning is filtered by Python's # default config outside __main__/tests) and also raise the stdlib # warning so tests can assert on it. import warnings message = ( "VECTOR_STORE_DIMENSIONS is deprecated; " "EMBEDDING_VECTOR_DIMENSIONS is authoritative. " "Drop VECTOR_STORE_DIMENSIONS from your .env." ) logger.warning(message) warnings.warn(message, DeprecationWarning, stacklevel=2) self.VECTOR_STORE.DIMENSIONS = self.EMBEDDING.VECTOR_DIMENSIONS if "NAMESPACE" not in self.TELEMETRY.model_fields_set: self.TELEMETRY.NAMESPACE = self.NAMESPACE if "NAMESPACE" not in self.METRICS.model_fields_set: self.METRICS.NAMESPACE = self.NAMESPACE return self # Create a single global instance of the settings settings: AppSettings = AppSettings()