Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,11 @@ HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
# two Codex members fails over between two ChatGPT accounts instead of retrying one.
# HINDSIGHT_API_LLM_1_PROVIDER=openai-codex
# HINDSIGHT_API_LLM_1_CODEX_HOME=/var/lib/hindsight/codex-b
# Strategy JSON: {"mode": "failover"} or {"mode": "round-robin"}.
# Strategy JSON: {"mode": "failover"}, {"mode": "round-robin"}, or
# {"mode": "metadata", "routes": [{"key": "tags", "value": "sensitive", "member": 1}]}.
# Round-robin accepts optional positive-int "weights" (one per member, primary first).
# Metadata routes are strict: all matches must select one member. An unmatched
# request uses member 0 (the primary); a match never falls across lanes.
# HINDSIGHT_API_LLM_STRATEGY={"mode": "failover"}

# API Configuration (Optional)
Expand Down
4 changes: 2 additions & 2 deletions hindsight-api-slim/hindsight_api/api/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -8310,14 +8310,14 @@ async def api_retain(
content_dict["event_date"] = item.timestamp
if item.context:
content_dict["context"] = item.context
if item.metadata:
if item.metadata is not None:
content_dict["metadata"] = item.metadata
if item.document_id:
content_dict["document_id"] = item.document_id
if item.entities:
content_dict["entities"] = [{"text": e.text, "type": e.type or "CONCEPT"} for e in item.entities]
content_dict["resolve_entities"] = item.resolve_entities
if item.tags:
if item.tags is not None:
content_dict["tags"] = item.tags
if item.observation_scopes is not None:
content_dict["observation_scopes"] = item.observation_scopes
Expand Down
53 changes: 45 additions & 8 deletions hindsight-api-slim/hindsight_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2072,30 +2072,45 @@ class LLMMemberConfig:
# Valid multi-LLM strategy modes.
LLM_STRATEGY_FAILOVER = "failover"
LLM_STRATEGY_ROUND_ROBIN = "round-robin"
_VALID_LLM_STRATEGY_MODES = (LLM_STRATEGY_FAILOVER, LLM_STRATEGY_ROUND_ROBIN)
LLM_STRATEGY_METADATA = "metadata"
_VALID_LLM_STRATEGY_MODES = (LLM_STRATEGY_FAILOVER, LLM_STRATEGY_ROUND_ROBIN, LLM_STRATEGY_METADATA)


@dataclass(frozen=True)
class LLMMetadataRoute:
"""One metadata match that pins an LLM request to a member."""

key: str
value: str
member: int


@dataclass
class LLMStrategyConfig:
"""How to route a request across the members of a multi-LLM chain.

``mode`` is "failover" (try members in order) or "round-robin" (rotate the
starting member per request, then fall through the rest on error). ``weights``
is round-robin only: positive integers, one per member (primary first), giving
an unbalanced rotation; ``None`` means uniform.
starting member per request, then fall through the rest on error), or
"metadata" (pin when all matching routes agree, defaulting to the primary).
``weights`` is round-robin only: positive integers, one per member (primary
first), giving an unbalanced rotation; ``routes`` is metadata only and every
matching route must select the same member.
"""

mode: str
weights: list[int] | None = None
routes: list[LLMMetadataRoute] | None = None


def _parse_llm_strategy(raw: str | None) -> LLMStrategyConfig | None:
"""Parse a multi-LLM strategy from a JSON env var.

Returns ``None`` when unset. The value must be a JSON object with a ``mode``
of "failover" or "round-robin"; ``weights`` (round-robin only) must be a list
of positive ints. Raises ``ValueError`` on any malformed input so
misconfiguration fails fast at startup rather than silently degrading.
of "failover", "round-robin", or "metadata". ``weights`` (round-robin only)
must be a list of positive ints. ``routes`` (metadata only) is a list
of ``{"key": str, "value": str, "member": non-negative int}`` objects.
Raises ``ValueError`` on malformed input so misconfiguration fails fast at
startup rather than silently degrading.
"""
text = (raw or "").strip()
if not text:
Expand All @@ -2118,7 +2133,29 @@ def _parse_llm_strategy(raw: str | None) -> LLMStrategyConfig | None:
if not isinstance(weights, list) or not weights or not all(isinstance(w, int) and w > 0 for w in weights):
raise ValueError("LLM strategy 'weights' must be a non-empty list of positive integers.")

return LLMStrategyConfig(mode=mode, weights=weights)
raw_routes = parsed.get("routes")
routes: list[LLMMetadataRoute] | None = None
if mode == LLM_STRATEGY_METADATA:
if not isinstance(raw_routes, list) or not raw_routes:
raise ValueError("LLM strategy 'routes' must be a non-empty list in metadata mode.")
routes = []
for index, route in enumerate(raw_routes):
if not isinstance(route, dict):
raise ValueError(f"LLM metadata route {index} must be a JSON object.")
key = route.get("key")
value = route.get("value")
member = route.get("member")
if not isinstance(key, str) or not key:
raise ValueError(f"LLM metadata route {index} 'key' must be a non-empty string.")
if not isinstance(value, str):
raise ValueError(f"LLM metadata route {index} 'value' must be a string.")
if not isinstance(member, int) or isinstance(member, bool) or member < 0:
raise ValueError(f"LLM metadata route {index} 'member' must be a non-negative integer.")
routes.append(LLMMetadataRoute(key=key, value=value, member=member))
elif raw_routes is not None:
raise ValueError(f"LLM strategy 'routes' is only valid with mode '{LLM_STRATEGY_METADATA}'.")

return LLMStrategyConfig(mode=mode, weights=weights, routes=routes)


def _parse_llm_members(prefix: str) -> list[LLMMemberConfig]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1342,7 +1342,13 @@ async def run_consolidation_job(

# Build a configured LLM wrapper that applies per-bank settings (e.g. safety settings)
# to every call without leaking across operations.
llm_config = memory_engine._consolidation_llm_config.with_config(config, bank_id=bank_id, operation="consolidation")
routing_metadata = memory_engine._pending_consolidation_llm_routing_metadata()
llm_config = memory_engine._consolidation_llm_config.with_config(
config,
bank_id=bank_id,
operation="consolidation",
routing_metadata=routing_metadata,
)

# Bind the operation trace context for the whole run so the create/update DB
# sites (deep inside _process_memory_batch) can accumulate the observations
Expand Down Expand Up @@ -2266,8 +2272,14 @@ async def _process_memory_batch(
# "consolidation_dedup" (routes through the consolidation concurrency bucket via llm_wrapper's
# "consolidation" prefix; recorded distinctly in llm_requests).
dedup_enabled = _dedup_active(config)
routing_metadata = memory_engine._pending_consolidation_llm_routing_metadata() if dedup_enabled else None
dedup_llm_config = (
memory_engine._consolidation_llm_config.with_config(config, bank_id=bank_id, operation="consolidation_dedup")
memory_engine._consolidation_llm_config.with_config(
config,
bank_id=bank_id,
operation="consolidation_dedup",
routing_metadata=routing_metadata,
)
if dedup_enabled
else None
)
Expand Down
3 changes: 3 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/llm_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,7 @@ def with_config(
bank_id: str | None = None,
operation: str | None = None,
metadata: dict[str, Any] | None = None,
routing_metadata: dict[str, Any] | None = None,
) -> "ConfiguredLLMProvider":
"""
Return a configured wrapper for a specific bank operation.
Expand All @@ -1609,6 +1610,8 @@ def with_config(
operation: Logical operation label ("retain", "reflect", ...) for
LLM trace rows.
metadata: Optional extra caller metadata stored on trace rows.
routing_metadata: Optional ephemeral values used by a multi-LLM
router. Ignored by a single provider and never persisted.

Returns:
A ``ConfiguredLLMProvider`` that delegates to this provider with
Expand Down
Loading