mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
7729396e12
The Codex provider never read the configured LLM timeout. The factory did not pass one, and CodexLLM extends LLMInterface (not the LLMProvider base that assigns self.timeout), so the class had no timeout attribute at all -- the three call sites hardcoded httpx timeout=120.0. That literal is a per-socket-read timeout, and the body was fetched with a buffering client.post(), so a backend wedged into runaway generation reset it forever: one consolidation call was read for ~830 s (~12 MB of SSE deltas for a ~340-character answer) until the backend closed the connection, holding the reserved consolidation slot for the whole time. Three such stalls cost ~1.7 h on one bank. - LLMInterface now takes and stores `timeout`, so no provider can silently drop it, and the factory threads the resolved value to the five that were missing it: codex, gemini, anthropic, fireworks and llamacpp. - Codex reads the SSE body with `client.stream()` inside an `asyncio.timeout` that covers the request *and* the parse, so the configured timeout is a total deadline rather than an idle one, plus a body-size ceiling that abandons a fast runaway stream in seconds instead of buffering it. Both surface as CodexRunawayStreamError, an httpx.RequestError, so the existing retry/backoff path handles them unchanged. - Gemini's hardcoded 90 s and Anthropic's own 300 s default become the unconfigured fallbacks rather than the only values. Codex tests move onto a shared streaming stub since the provider no longer calls client.post(). The consolidation wall-clock ceiling the issue also asks for already landed in #3746 (unreleased); it is an idle ceiling defaulting to 7200 s, so it would not have ended an 830 s stall on its own. Claude-Session: https://claude.ai/code/session_018HDqrzHgqZqsGc7EDqoTEu
1791 lines
77 KiB
Python
1791 lines
77 KiB
Python
"""
|
|
LLM wrapper for unified configuration across providers.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
import uuid
|
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from json_repair import repair_json
|
|
from pydantic import BaseModel
|
|
|
|
# Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM)
|
|
try:
|
|
from google.oauth2 import service_account
|
|
|
|
VERTEXAI_AVAILABLE = True
|
|
except ImportError:
|
|
VERTEXAI_AVAILABLE = False
|
|
|
|
from ..config import (
|
|
DEFAULT_LLM_MAX_CONCURRENT,
|
|
ENV_CONSOLIDATION_LLM_MAX_CONCURRENT,
|
|
ENV_LLM_MAX_CONCURRENT,
|
|
ENV_REFLECT_LLM_MAX_CONCURRENT,
|
|
ENV_RETAIN_LLM_MAX_CONCURRENT,
|
|
)
|
|
from .cache_affinity import parse_cache_affinity
|
|
from .llm_interface import (
|
|
LLM_TOOL_CHOICE_AUTO,
|
|
LLMInterface,
|
|
LLMToolChoice,
|
|
LLMToolChoiceMode,
|
|
)
|
|
from .llm_interface import (
|
|
OutputTooLongError as OutputTooLongError,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from .response_models import LLMToolCallResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Disable httpx logging
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
|
|
# Global semaphore to limit concurrent LLM requests across all instances.
|
|
# Set HINDSIGHT_API_LLM_MAX_CONCURRENT=1 for local LLMs (LM Studio, Ollama).
|
|
_llm_max_concurrent = int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT)))
|
|
_global_llm_semaphore = asyncio.Semaphore(_llm_max_concurrent)
|
|
|
|
|
|
def _build_per_op_semaphores() -> dict[str, asyncio.Semaphore]:
|
|
"""Build the per-operation semaphore registry from env vars.
|
|
|
|
Each per-op cap is composed with — not a substitute for — the global cap:
|
|
a call that matches a configured operation must acquire both its per-op
|
|
semaphore and the global semaphore. This lets operators reserve headroom
|
|
in the global pool by capping individual operations (e.g. cap retain at 2
|
|
of 4 global slots so the live chat path always has 2 slots available).
|
|
|
|
Operations without a configured env var are absent from the registry and
|
|
therefore only constrained by the global cap.
|
|
"""
|
|
semaphores: dict[str, asyncio.Semaphore] = {}
|
|
for op, env_var in (
|
|
("retain", ENV_RETAIN_LLM_MAX_CONCURRENT),
|
|
("reflect", ENV_REFLECT_LLM_MAX_CONCURRENT),
|
|
("consolidation", ENV_CONSOLIDATION_LLM_MAX_CONCURRENT),
|
|
):
|
|
raw = os.getenv(env_var)
|
|
if raw is None or raw == "":
|
|
continue
|
|
value = int(raw)
|
|
if value <= 0:
|
|
raise ValueError(f"{env_var} must be a positive integer, got {raw!r}")
|
|
semaphores[op] = asyncio.Semaphore(value)
|
|
return semaphores
|
|
|
|
|
|
_per_op_llm_semaphores: dict[str, asyncio.Semaphore] = _build_per_op_semaphores()
|
|
|
|
|
|
def _scope_to_operation(scope: str) -> str | None:
|
|
"""Map a call scope to its per-operation concurrency bucket.
|
|
|
|
Returns None for scopes that don't belong to a tracked operation
|
|
(verification probes, bank_mission, memory_think, mental_model_delta_ops),
|
|
which then run under the global cap only.
|
|
"""
|
|
if scope.startswith("retain"):
|
|
return "retain"
|
|
if scope.startswith("reflect"):
|
|
return "reflect"
|
|
if scope.startswith("consolidation"):
|
|
return "consolidation"
|
|
return None
|
|
|
|
|
|
def _semaphores_for_scope(scope: str) -> list[asyncio.Semaphore]:
|
|
"""Return the semaphores a call with the given scope must acquire.
|
|
|
|
Always includes the global semaphore; includes the per-op semaphore when
|
|
one is configured for the scope's operation bucket.
|
|
"""
|
|
op = _scope_to_operation(scope)
|
|
per_op = _per_op_llm_semaphores.get(op) if op is not None else None
|
|
if per_op is None:
|
|
return [_global_llm_semaphore]
|
|
# Per-op acquired first so contention queues on the narrower cap before
|
|
# holding a global slot.
|
|
return [per_op, _global_llm_semaphore]
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _attempt_permits(scope: str):
|
|
"""Hold configured LLM concurrency permits for one upstream attempt."""
|
|
from ..worker.stage import get_stage, set_stage
|
|
|
|
async with AsyncExitStack() as stack:
|
|
for sem in _semaphores_for_scope(scope):
|
|
await stack.enter_async_context(sem)
|
|
try:
|
|
yield
|
|
except BaseException:
|
|
# A failed attempt exits here with its permits released while the
|
|
# provider classifies the error and sleeps out its backoff. Suffix
|
|
# the stage so `attempt=N` always means "permits held, request in
|
|
# flight" (#3002); the next attempt re-stamps after re-acquiring.
|
|
stage = get_stage()
|
|
if stage is not None and not stage.endswith(".backoff"):
|
|
set_stage(f"{stage}.backoff")
|
|
raise
|
|
|
|
|
|
def _request_params(
|
|
*,
|
|
max_completion_tokens: int | None = None,
|
|
temperature: float | None = None,
|
|
scope: str | None = None,
|
|
response_format: Any | None = None,
|
|
tool_choice: LLMToolChoice | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Build the requested-params bag for tracing — only values the caller set.
|
|
|
|
Omitting unset values avoids the misleading nulls we used to record (e.g.
|
|
consolidation, which passes no token cap), while surfacing the real cap for
|
|
callers that do set one (e.g. retain's ``retain_max_completion_tokens``).
|
|
"""
|
|
params: dict[str, Any] = {}
|
|
if max_completion_tokens is not None:
|
|
params["max_completion_tokens"] = max_completion_tokens
|
|
if temperature is not None:
|
|
params["temperature"] = temperature
|
|
if response_format is not None:
|
|
params["response_schema"] = getattr(response_format, "__name__", None) or "structured"
|
|
if tool_choice is not None and tool_choice.mode is not LLMToolChoiceMode.AUTO:
|
|
params["tool_choice"] = tool_choice.function_name or tool_choice.mode.value
|
|
return params or None
|
|
|
|
|
|
_UNSAFE_TEXT_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\ud800-\udfff]")
|
|
|
|
|
|
def sanitize_text(text: str | None) -> str | None:
|
|
"""
|
|
Sanitize text by removing characters that break downstream systems.
|
|
|
|
Removes:
|
|
- ASCII control characters (0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F): break
|
|
json.loads and PostgreSQL UTF-8 encoding; tab (0x09), newline (0x0A), and
|
|
carriage return (0x0D) are preserved as they are valid in text and JSON.
|
|
- Unicode surrogates (U+D800-U+DFFF): Invalid in UTF-8, break LLM APIs
|
|
|
|
Surrogate characters are used in UTF-16 encoding but cannot be encoded
|
|
in UTF-8. They can appear in Python strings from improperly decoded data
|
|
(e.g., from JavaScript or broken files): a client may serialize a half-emoji
|
|
split at a boundary as a lone ``\\udXXX`` escape. Such input crashes the
|
|
SentenceTransformers/cross-encoder Rust tokenizers and stdout logging, so
|
|
user content is sanitized at the retain/recall/reflect ingress (see issue
|
|
#1875). Control characters commonly appear in LLM output embedded inside
|
|
JSON string values.
|
|
"""
|
|
if text is None:
|
|
return None
|
|
if not text:
|
|
return text
|
|
return _UNSAFE_TEXT_RE.sub("", text)
|
|
|
|
|
|
# Back-compat alias: this helper was originally introduced to scrub LLM *output*;
|
|
# it now also scrubs user *input* at ingress, hence the broader name.
|
|
sanitize_llm_output = sanitize_text
|
|
|
|
|
|
def sanitize_llm_value(value: Any) -> Any:
|
|
"""
|
|
Recursively strip UTF-8-hostile characters from every string an LLM produced.
|
|
|
|
``sanitize_text`` guards a single field. This guards a whole response — the
|
|
text a provider returned, the dict a structured call parsed, the pydantic
|
|
model it validated into, the ``(result, usage)`` tuple ``return_usage=True``
|
|
hands back — so that *every* LLM call is covered at one boundary instead of
|
|
each consumer remembering to scrub its own fields (see issue #3729).
|
|
|
|
It matters beyond embeddings. A lone surrogate is legal in a Python ``str``
|
|
but cannot be UTF-8 encoded, so it also breaks the cross-encoder's Rust
|
|
tokenizer at rerank time, asyncpg on the way into a ``text`` column, and
|
|
stdout logging. Sanitizing where the text enters the process means the
|
|
downstream stages never have to care which field it landed in.
|
|
|
|
Only strings are touched; ints, floats, datetimes and the like pass through
|
|
as-is. Every container returns the *same object* when nothing inside it
|
|
changed, so the overwhelmingly common clean response is not copied and
|
|
object identity (a validated response model, an enum member) survives
|
|
untouched.
|
|
"""
|
|
if isinstance(value, str):
|
|
# A str subclass (a StrEnum member, say) comes back as itself unless it
|
|
# actually carries a hostile character — at which point a plain str is the
|
|
# only safe answer, and the alternative was a crash.
|
|
cleaned = _UNSAFE_TEXT_RE.sub("", value)
|
|
return cleaned if cleaned != value else value
|
|
|
|
if isinstance(value, BaseModel):
|
|
updates = {}
|
|
for name, field_value in value.__dict__.items():
|
|
cleaned = sanitize_llm_value(field_value)
|
|
if cleaned is not field_value:
|
|
updates[name] = cleaned
|
|
# ``model_copy`` skips validation, which is what we want: the values are
|
|
# already sanitized, and re-validating could reject a model the provider
|
|
# built with ``model_construct``.
|
|
return value.model_copy(update=updates) if updates else value
|
|
|
|
if isinstance(value, dict):
|
|
cleaned_dict = {}
|
|
changed = False
|
|
for key, item in value.items():
|
|
# Keys are sanitized too: a surrogate in a key is just as fatal once
|
|
# the dict is rendered to text or bound to a query parameter.
|
|
cleaned_key = sanitize_llm_value(key)
|
|
cleaned_item = sanitize_llm_value(item)
|
|
changed = changed or cleaned_key is not key or cleaned_item is not item
|
|
cleaned_dict[cleaned_key] = cleaned_item
|
|
return cleaned_dict if changed else value
|
|
|
|
# ``tuple`` is here for the ``return_usage=True`` shape, ``(result, TokenUsage)``.
|
|
if isinstance(value, (list, tuple)):
|
|
cleaned_items = [sanitize_llm_value(item) for item in value]
|
|
if all(cleaned is original for cleaned, original in zip(cleaned_items, value)):
|
|
return value
|
|
return cleaned_items if isinstance(value, list) else tuple(cleaned_items)
|
|
|
|
return value
|
|
|
|
|
|
# ``OutputTooLongError`` is re-exported from ``llm_interface`` (the canonical
|
|
# definition the providers raise) so that ``fact_extraction`` and ``multi_llm``,
|
|
# which import it from here, catch/inspect the very same class. Do NOT redefine
|
|
# it locally: a shadow class silently breaks ``except OutputTooLongError`` on the
|
|
# real provider path (see issue #3172).
|
|
|
|
|
|
_JSON_CONTROL_ESCAPES = {"\n": "\\n", "\r": "\\r", "\t": "\\t", "\b": "\\b", "\f": "\\f"}
|
|
|
|
|
|
def _escape_control_chars_in_json(text: str) -> str:
|
|
"""Make raw control characters inside JSON string values parseable.
|
|
|
|
``json.loads`` rejects an unescaped control character in a string. Models
|
|
hit this whenever they write a multi-line value (a markdown table, a list,
|
|
a code fence) without escaping the line breaks.
|
|
|
|
A line break, tab or form feed inside a string is *content*: it is escaped
|
|
so it survives. Blanking it out instead welds a markdown table onto one
|
|
line, and the damage is invisible downstream (#3361). Every other control
|
|
character carries no meaning in text, so it keeps the historical treatment
|
|
of becoming a space.
|
|
|
|
The scan tracks string state because only characters inside a string need
|
|
escaping; the same byte between tokens is junk either way.
|
|
"""
|
|
out: list[str] = []
|
|
in_string = False
|
|
escape = False
|
|
for ch in text:
|
|
if in_string and escape:
|
|
escape = False
|
|
out.append(ch)
|
|
continue
|
|
if in_string and ch == "\\":
|
|
escape = True
|
|
out.append(ch)
|
|
continue
|
|
if ch == '"':
|
|
in_string = not in_string
|
|
out.append(ch)
|
|
continue
|
|
if in_string and ch in _JSON_CONTROL_ESCAPES:
|
|
out.append(_JSON_CONTROL_ESCAPES[ch])
|
|
continue
|
|
if ch <= "\x1f" or ch == "\x7f":
|
|
out.append(" ")
|
|
continue
|
|
out.append(ch)
|
|
return "".join(out)
|
|
|
|
|
|
def parse_llm_json(raw: str) -> Any:
|
|
"""
|
|
Robustly parse JSON returned by an LLM.
|
|
|
|
Handles common LLM output quirks:
|
|
1. Markdown code fences (```json ... ```) — strip them before parsing.
|
|
2. Embedded control characters (\\x00-\\x1f, \\x7f) — escape the ones inside
|
|
string values (so a raw newline stays a line break), drop the ones
|
|
between tokens, and retry if the initial parse fails.
|
|
3. Structural malformation (trailing commas, unterminated strings, single
|
|
quotes, invalid ``\\escape`` sequences) — repaired as a last resort via
|
|
``json_repair`` (#2547/#2544).
|
|
|
|
The repair pass is purely *structural*: it fixes JSON that ``json.loads``
|
|
cannot parse at all. It deliberately does NOT touch content semantics —
|
|
degenerate-but-valid JSON (repetition loops or leaked scaffolding inside
|
|
string values) parses fine here and is out of scope for this helper.
|
|
|
|
Every successful parse is passed through ``sanitize_llm_value``. Decoding is
|
|
where an un-encodable surrogate is *born*: a model that writes ``"\\ud83d"``
|
|
emits six harmless ASCII characters, and only ``json.loads`` turns them into a
|
|
lone surrogate no downstream stage can UTF-8 encode (#3729). Scrubbing the raw
|
|
text beforehand cannot see it; scrubbing the parsed object can.
|
|
|
|
Args:
|
|
raw: Raw text returned by the LLM.
|
|
|
|
Returns:
|
|
Parsed Python object (dict, list, etc.), with model-authored strings
|
|
scrubbed of surrogates and control characters.
|
|
|
|
Raises:
|
|
json.JSONDecodeError: If the text cannot be parsed even after cleanup
|
|
and structural repair (e.g. repair yields an empty result).
|
|
"""
|
|
text = raw.strip()
|
|
|
|
# Strip markdown code fences (some models wrap JSON in ```json ... ```)
|
|
if text.startswith("```"):
|
|
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
|
if text.endswith("```"):
|
|
text = text[:-3]
|
|
text = text.strip()
|
|
|
|
try:
|
|
return sanitize_llm_value(json.loads(text))
|
|
except json.JSONDecodeError:
|
|
# Some models (e.g. Gemini) embed raw control characters inside JSON
|
|
# string values. Escape them rather than blank them out: a raw newline
|
|
# in a string is the model writing a real line break, and replacing it
|
|
# with a space silently welds a markdown table or list onto one line
|
|
# (#3361). Control characters *outside* a string are noise and are
|
|
# dropped, since nothing meaningful can sit between JSON tokens.
|
|
cleaned = _escape_control_chars_in_json(text)
|
|
|
|
try:
|
|
return sanitize_llm_value(json.loads(cleaned))
|
|
except json.JSONDecodeError:
|
|
# Last resort: structural repair of malformed JSON. ``repair_json`` never
|
|
# raises — unrecoverable input yields an empty result ("" / {} / []). Keep
|
|
# failing loudly in that case rather than let an empty object masquerade
|
|
# as a successful parse: callers (retry ladders, the #1833 fail-loud path)
|
|
# rely on JSONDecodeError to retry or surface the failure.
|
|
repaired = repair_json(cleaned, return_objects=True)
|
|
if not repaired:
|
|
raise
|
|
return sanitize_llm_value(repaired)
|
|
|
|
|
|
_PROVIDERS_WITHOUT_API_KEY = frozenset(
|
|
{
|
|
"ollama",
|
|
"lmstudio",
|
|
"llamacpp",
|
|
"openai-codex",
|
|
"claude-code",
|
|
"github-copilot",
|
|
"mock",
|
|
"none",
|
|
"vertexai",
|
|
"litellm",
|
|
"litellmrouter",
|
|
"bedrock",
|
|
"nous",
|
|
"xai-oauth",
|
|
}
|
|
)
|
|
|
|
|
|
def requires_api_key(provider: str) -> bool:
|
|
"""Return True if the given provider requires an API key to operate."""
|
|
return provider.lower() not in _PROVIDERS_WITHOUT_API_KEY
|
|
|
|
|
|
def _validate_ollama_num_ctx(value: Any) -> int | None:
|
|
"""Validate a native Ollama context-window override."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ValueError(f"ollama_num_ctx must be a positive integer, got {value!r}")
|
|
if value < 1:
|
|
raise ValueError(f"ollama_num_ctx must be >= 1, got {value}")
|
|
return value
|
|
|
|
|
|
def create_llm_provider(
|
|
provider: str,
|
|
api_key: str,
|
|
base_url: str,
|
|
model: str,
|
|
reasoning_effort: str | None,
|
|
groq_service_tier: str | None = None,
|
|
openai_service_tier: str | None = None,
|
|
bedrock_service_tier: str | None = None,
|
|
extra_body: dict[str, Any] | None = None,
|
|
default_headers: dict[str, str] | None = None,
|
|
vertexai_project_id: str | None = None,
|
|
vertexai_region: str | None = None,
|
|
vertexai_credentials: Any = None,
|
|
gemini_safety_settings: list | None = None,
|
|
prompt_cache_enabled: bool = False,
|
|
litellmrouter_config: dict[str, Any] | None = None,
|
|
gemini_service_tier: str | None = None,
|
|
timeout: float | None = None,
|
|
ollama_num_ctx: int | None = None,
|
|
cache_affinity: str | None = None,
|
|
structured_output_forced_tool: bool = False,
|
|
) -> Any: # Returns LLMInterface
|
|
"""
|
|
Factory function to create the appropriate LLM provider implementation.
|
|
|
|
Args:
|
|
provider: Provider name ("openai", "groq", "ollama", "gemini", "anthropic", etc.).
|
|
api_key: API key (may be None for local providers or OAuth providers).
|
|
base_url: Base URL for the API.
|
|
model: Model name.
|
|
reasoning_effort: Reasoning effort level for supported providers, or None when
|
|
the operator configured none (providers then fall back to the default level
|
|
and may skip the parameter entirely).
|
|
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
|
|
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
|
|
bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved".
|
|
gemini_service_tier: Gemini service tier (for Gemini provider) - None (default) or "flex" (50% cheaper).
|
|
ollama_num_ctx: Native Ollama context window override. None lets Ollama use the
|
|
model/server default.
|
|
extra_body: Extra request-body params merged into the provider's native
|
|
call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/
|
|
VertexAI and LiteLLM providers (each merges them in its own parameter
|
|
space). Keys must use each provider's native names (e.g. ``max_tokens``
|
|
for OpenAI/Anthropic vs ``max_output_tokens`` for Gemini).
|
|
default_headers: Custom headers passed to provider SDK clients (used by operators
|
|
routing through proxies / request-tracing middleware). Wired into the Anthropic
|
|
provider, the ``OpenAICompatibleLLM`` branch, ``fireworks``, ``nous`` and the
|
|
Responses API (SDK ``default_headers``), and into the LiteLLM-backed providers —
|
|
``litellm``, ``litellmrouter`` and ``bedrock`` — as the LiteLLM ``extra_headers``
|
|
completion kwarg; other providers may opt in as needed.
|
|
cache_affinity: Backend prompt-cache pinning mode, forwarded to the
|
|
``OpenAICompatibleLLM`` branch, ``fireworks`` and ``nous`` (all three share the
|
|
OpenAI-compatible wire format): "none" (default), "xai_conv_id",
|
|
"openai_prompt_cache_key", or "auto". Providers on other branches do their own
|
|
cache work or none at all. See ``engine/cache_affinity.py``.
|
|
structured_output_forced_tool: Ask the LiteLLM-backed providers (``litellm``,
|
|
``litellmrouter``, ``bedrock``) for structured output via a forced tool call
|
|
instead of ``response_format``. For backends that reject the response_format
|
|
route — see ``HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL``. Other
|
|
providers ignore it.
|
|
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
|
|
vertexai_region: Vertex AI region (for VertexAI provider).
|
|
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
|
|
timeout: Per-request LLM timeout in seconds (resolved by the caller from the
|
|
per-operation/global config). Threaded into the providers that honour a
|
|
configurable request timeout (LiteLLM, LiteLLM Router, OpenAI-compatible,
|
|
Nous). ``None`` lets each provider fall back to its own default
|
|
(``HINDSIGHT_API_LLM_TIMEOUT`` / ``DEFAULT_LLM_TIMEOUT`` for those four;
|
|
Anthropic and Gemini keep their provider-specific defaults).
|
|
|
|
Returns:
|
|
LLMInterface implementation for the specified provider.
|
|
"""
|
|
ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
|
|
|
|
from .providers import (
|
|
AnthropicLLM,
|
|
ClaudeCodeLLM,
|
|
CodexLLM,
|
|
FireworksLLM,
|
|
GeminiLLM,
|
|
GitHubCopilotLLM,
|
|
LiteLLMLLM,
|
|
LiteLLMRouterLLM,
|
|
LlamaCppLLM,
|
|
MockLLM,
|
|
NoneLLM,
|
|
OpenAICompatibleLLM,
|
|
OpenAIResponsesLLM,
|
|
)
|
|
|
|
provider_lower = provider.lower()
|
|
if provider_lower == "gemini":
|
|
from ..config import parse_gemini_service_tier
|
|
|
|
gemini_service_tier = parse_gemini_service_tier(gemini_service_tier)
|
|
else:
|
|
gemini_service_tier = None
|
|
|
|
if provider_lower == "openai-codex":
|
|
return CodexLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
extra_body=extra_body,
|
|
timeout=timeout,
|
|
)
|
|
|
|
elif provider_lower == "claude-code":
|
|
return ClaudeCodeLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
)
|
|
|
|
elif provider_lower == "github-copilot":
|
|
return GitHubCopilotLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
timeout=timeout,
|
|
)
|
|
|
|
elif provider_lower == "mock":
|
|
return MockLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
)
|
|
|
|
elif provider_lower == "none":
|
|
return NoneLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
)
|
|
|
|
elif provider_lower in ("gemini", "vertexai"):
|
|
return GeminiLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
timeout=timeout,
|
|
vertexai_project_id=vertexai_project_id,
|
|
vertexai_region=vertexai_region,
|
|
vertexai_credentials=vertexai_credentials,
|
|
gemini_safety_settings=gemini_safety_settings,
|
|
gemini_service_tier=gemini_service_tier,
|
|
prompt_cache_enabled=prompt_cache_enabled,
|
|
extra_body=extra_body,
|
|
)
|
|
|
|
elif provider_lower == "anthropic":
|
|
return AnthropicLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
default_headers=default_headers,
|
|
extra_body=extra_body,
|
|
timeout=timeout,
|
|
)
|
|
|
|
elif provider_lower == "litellm":
|
|
return LiteLLMLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
extra_body=extra_body,
|
|
default_headers=default_headers,
|
|
timeout=timeout,
|
|
structured_output_forced_tool=structured_output_forced_tool,
|
|
)
|
|
|
|
elif provider_lower == "litellmrouter":
|
|
if not litellmrouter_config:
|
|
raise ValueError(
|
|
"Provider 'litellmrouter' requires a config object. "
|
|
"Set HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG (or the per-op variant) "
|
|
"to a JSON object accepted by litellm.Router. "
|
|
"See https://docs.litellm.ai/docs/routing."
|
|
)
|
|
return LiteLLMRouterLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
config=litellmrouter_config,
|
|
reasoning_effort=reasoning_effort,
|
|
extra_body=extra_body,
|
|
default_headers=default_headers,
|
|
timeout=timeout,
|
|
structured_output_forced_tool=structured_output_forced_tool,
|
|
)
|
|
|
|
elif provider_lower == "bedrock":
|
|
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
|
|
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
|
|
return LiteLLMLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=bedrock_model,
|
|
reasoning_effort=reasoning_effort,
|
|
extra_body=extra_body,
|
|
default_headers=default_headers,
|
|
bedrock_service_tier=bedrock_service_tier,
|
|
timeout=timeout,
|
|
structured_output_forced_tool=structured_output_forced_tool,
|
|
)
|
|
|
|
elif provider_lower == "llamacpp":
|
|
from ..config import get_config
|
|
|
|
config = get_config()
|
|
return LlamaCppLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
extra_body=extra_body,
|
|
timeout=timeout,
|
|
model_path=config.llamacpp_model_path,
|
|
gpu_layers=config.llamacpp_gpu_layers,
|
|
context_size=config.llamacpp_context_size,
|
|
chat_format=config.llamacpp_chat_format,
|
|
no_grammar=config.llamacpp_no_grammar,
|
|
extra_args=config.llamacpp_extra_args,
|
|
)
|
|
|
|
elif provider_lower == "fireworks":
|
|
# Fireworks online inference is OpenAI-compatible; FireworksLLM adds the
|
|
# native (non-OpenAI) batch API on top. The existing LiteLLM
|
|
# ``fireworks_ai/...`` online path (provider="litellm") is untouched.
|
|
return FireworksLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
extra_body=extra_body,
|
|
default_headers=default_headers,
|
|
cache_affinity=cache_affinity,
|
|
timeout=timeout,
|
|
)
|
|
|
|
elif provider_lower == "nous":
|
|
# Nous Portal is OpenAI-compatible on the wire; NousLLM adds rotating
|
|
# inference:invoke JWT auth read natively from ~/.hermes/auth.json
|
|
# (no static api_key, no hermes_cli dependency — same shape as Codex).
|
|
# default_headers/cache_affinity ride NousLLM's **kwargs passthrough to
|
|
# OpenAICompatibleLLM.__init__ unchanged (see NousLLM.__init__).
|
|
from hindsight_api.engine.providers.nous_llm import NousLLM
|
|
|
|
return NousLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
extra_body=extra_body,
|
|
default_headers=default_headers,
|
|
cache_affinity=cache_affinity,
|
|
timeout=timeout,
|
|
)
|
|
|
|
elif provider_lower == "xai-oauth":
|
|
# SuperGrok subscription lane: api.x.ai spoken plainly, but the
|
|
# credential is a device-code OAuth grant with proactive/reactive
|
|
# refresh over a shared on-disk store, and xAI's 403 shapes need their
|
|
# own classification — neither fits the OpenAI SDK client, hence its
|
|
# own provider.
|
|
from hindsight_api.engine.providers.xai_oauth_llm import XaiOAuthLLM
|
|
|
|
return XaiOAuthLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
timeout=timeout,
|
|
)
|
|
|
|
elif provider_lower == "openai-responses":
|
|
# OpenAI Responses API (/v1/responses). Unlike chat/completions, it
|
|
# supports reasoning + function tools together, so reflect's tool loop
|
|
# can run with a real reasoning_effort. See OpenAIResponsesLLM.
|
|
return OpenAIResponsesLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
openai_service_tier=openai_service_tier,
|
|
extra_body=extra_body,
|
|
default_headers=default_headers,
|
|
timeout=timeout,
|
|
)
|
|
|
|
elif provider_lower in (
|
|
"openai",
|
|
"groq",
|
|
"ollama",
|
|
"ollama-cloud",
|
|
"lmstudio",
|
|
"minimax",
|
|
"deepseek",
|
|
"volcano",
|
|
"openrouter",
|
|
"requesty",
|
|
"zai",
|
|
"opencode-go",
|
|
"atlas",
|
|
):
|
|
return OpenAICompatibleLLM(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
groq_service_tier=groq_service_tier,
|
|
openai_service_tier=openai_service_tier,
|
|
extra_body=extra_body,
|
|
default_headers=default_headers,
|
|
cache_affinity=cache_affinity,
|
|
ollama_num_ctx=ollama_num_ctx,
|
|
timeout=timeout,
|
|
)
|
|
|
|
else:
|
|
raise ValueError(f"Unknown provider: {provider}")
|
|
|
|
|
|
class LLMProvider:
|
|
"""
|
|
Unified LLM provider.
|
|
|
|
Supports OpenAI, Groq, Ollama (OpenAI-compatible), and Gemini.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
provider: str,
|
|
api_key: str,
|
|
base_url: str,
|
|
model: str,
|
|
reasoning_effort: str | None = None,
|
|
groq_service_tier: str | None = None,
|
|
openai_service_tier: str | None = None,
|
|
bedrock_service_tier: str | None = None,
|
|
gemini_safety_settings: list | None = None,
|
|
prompt_cache_enabled: bool = False,
|
|
extra_body: dict[str, Any] | None = None,
|
|
default_headers: dict[str, str] | None = None,
|
|
litellmrouter_config: dict[str, Any] | None = None,
|
|
gemini_service_tier: str | None = None,
|
|
vertexai_project_id: str | None = None,
|
|
vertexai_region: str | None = None,
|
|
vertexai_service_account_key: str | None = None,
|
|
timeout: float | None = None,
|
|
max_retries: int | None = None,
|
|
initial_backoff: float | None = None,
|
|
max_backoff: float | None = None,
|
|
ollama_num_ctx: int | None = None,
|
|
cache_affinity: str | None = None,
|
|
structured_output_forced_tool: bool = False,
|
|
):
|
|
"""
|
|
Initialize LLM provider.
|
|
|
|
Args:
|
|
provider: Provider name ("openai", "groq", "ollama", "gemini", "anthropic", "lmstudio").
|
|
api_key: API key.
|
|
base_url: Base URL for the API.
|
|
model: Model name.
|
|
reasoning_effort: Reasoning effort level for supported providers, or None
|
|
when the operator configured none.
|
|
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
|
|
openai_service_tier: OpenAI service tier (None or "flex") - from config.
|
|
bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config.
|
|
gemini_service_tier: Gemini service tier (None or "flex") - from config.
|
|
ollama_num_ctx: Native Ollama context window override. ``None`` lets Ollama
|
|
use the model/server default.
|
|
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
|
|
extra_body: Extra request-body params merged into the provider's native call
|
|
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
|
|
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients.
|
|
Used by operators routing through proxies / request-tracing middleware.
|
|
cache_affinity: Backend prompt-cache pinning mode for the OpenAI-compatible and
|
|
Fireworks providers ("none", "xai_conv_id", "openai_prompt_cache_key",
|
|
"auto"). Validated here for every provider so a typo never fails silently;
|
|
providers on other factory branches ignore it. Used verbatim — callers
|
|
resolve the per-operation/global fallback.
|
|
litellmrouter_config: Provider-specific config for ``provider="litellmrouter"``.
|
|
JSON object passed verbatim to ``litellm.Router(**config)`` — see
|
|
https://docs.litellm.ai/docs/routing. Ignored unless ``provider == "litellmrouter"``.
|
|
vertexai_project_id: Vertex AI project ID for ``provider="vertexai"`` (required for
|
|
that provider).
|
|
vertexai_region: Vertex AI region for ``provider="vertexai"`` (defaults to
|
|
``"us-central1"`` when ``None``).
|
|
vertexai_service_account_key: Path to a Vertex AI service-account key file for
|
|
``provider="vertexai"`` (uses ADC when ``None``).
|
|
timeout: Per-request LLM timeout in seconds. Resolved by the caller from the
|
|
per-operation/global config (``retain_llm_timeout`` falling back to
|
|
``llm_timeout``, etc.). ``None`` lets each provider apply its own default.
|
|
max_retries: Default retry-attempt budget for ``call`` / ``call_with_tools``
|
|
when the per-call argument is omitted. Resolved by the caller from the
|
|
per-operation/global config (``reflect_llm_max_retries`` falling back to
|
|
``llm_max_retries``, etc.). ``None`` keeps each method's own fallback.
|
|
initial_backoff: Default initial retry backoff (seconds), same resolution as
|
|
``max_retries``. ``None`` keeps each method's own fallback.
|
|
max_backoff: Default maximum retry backoff (seconds), same resolution as
|
|
``max_retries``. ``None`` keeps each method's own fallback.
|
|
structured_output_forced_tool: Structured output via a forced tool call
|
|
instead of ``response_format``, for the LiteLLM-backed providers - from
|
|
config (``HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL``).
|
|
|
|
This constructor uses every argument as passed and does not read global
|
|
``HindsightConfig``: resolving the server-level default for a ``None`` argument is the
|
|
caller's responsibility (see ``MemoryEngine``'s per-op builds, ``_member_to_llm``, and
|
|
``LLMProvider.from_env``). Keeping it config-free makes a provider's effective settings a
|
|
pure function of its arguments — which is what lets each member of a multi-LLM chain be
|
|
configured independently.
|
|
"""
|
|
self.provider = provider.lower()
|
|
self.api_key = api_key
|
|
self.base_url = base_url
|
|
self.model = model
|
|
self.reasoning_effort = reasoning_effort
|
|
# Per-request timeout (seconds). Used verbatim — the caller resolves the
|
|
# per-operation/global fallback. ``None`` defers to the provider default.
|
|
self.timeout = timeout
|
|
# Default retry policy for call()/call_with_tools(). The caller resolves the
|
|
# per-operation/global fallback; ``None`` keeps each method's own fallback so
|
|
# providers built without a resolved config (from_env, tests) are unchanged.
|
|
self.max_retries = max_retries
|
|
self.initial_backoff = initial_backoff
|
|
self.max_backoff = max_backoff
|
|
self.litellmrouter_config = litellmrouter_config
|
|
# Service tiers from hierarchical config (not env vars)
|
|
self.groq_service_tier = groq_service_tier
|
|
self.openai_service_tier = openai_service_tier
|
|
self.bedrock_service_tier = bedrock_service_tier
|
|
self.gemini_service_tier = gemini_service_tier
|
|
# Structured-output transport for the LiteLLM-backed providers. Used verbatim —
|
|
# the caller resolves the server-level default, like the fields above.
|
|
self.structured_output_forced_tool = structured_output_forced_tool
|
|
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
|
|
# Gemini safety settings (instance default; can be overridden per-request via context var)
|
|
self.gemini_safety_settings = gemini_safety_settings
|
|
# Gemini prompt caching: when True, retain extraction (and any future
|
|
# caller that opts in) will reuse a CachedContent prefix to cut
|
|
# input-token cost. Off by default so the change is observable behind
|
|
# a flip rather than a silent behaviour change on upgrade.
|
|
self.prompt_cache_enabled = prompt_cache_enabled
|
|
# Extra body params for OpenAI-compatible providers (e.g. chat_template_kwargs)
|
|
self.extra_body = extra_body
|
|
# Default headers passed to provider SDK clients (e.g. proxy auth, request tracing).
|
|
# Used verbatim — callers resolve the global fallback (see _member_to_llm /
|
|
# the per-op builds in MemoryEngine, and LLMProvider.from_env).
|
|
self.default_headers = default_headers
|
|
# Backend prompt-cache pinning mode. Validated here rather than only at the
|
|
# provider so a typo fails for every provider, not just the ones that act on
|
|
# it — the setting has no visible effect in the response, so a silent
|
|
# fallback to "none" would be indistinguishable from it working.
|
|
self.cache_affinity = parse_cache_affinity(cache_affinity).value
|
|
|
|
# Validate provider
|
|
valid_providers = [
|
|
"openai",
|
|
"openai-responses",
|
|
"groq",
|
|
"ollama",
|
|
"ollama-cloud",
|
|
"gemini",
|
|
"anthropic",
|
|
"lmstudio",
|
|
"llamacpp",
|
|
"vertexai",
|
|
"openai-codex",
|
|
"claude-code",
|
|
"github-copilot",
|
|
"mock",
|
|
"none",
|
|
"minimax",
|
|
"deepseek",
|
|
"litellm",
|
|
"litellmrouter",
|
|
"bedrock",
|
|
"volcano",
|
|
"openrouter",
|
|
"requesty",
|
|
"zai",
|
|
"opencode-go",
|
|
"atlas",
|
|
"fireworks",
|
|
"nous",
|
|
"xai-oauth",
|
|
]
|
|
if self.provider not in valid_providers:
|
|
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
|
|
|
# Set default base URLs
|
|
if not self.base_url:
|
|
if self.provider == "groq":
|
|
self.base_url = "https://api.groq.com/openai/v1"
|
|
elif self.provider == "ollama":
|
|
self.base_url = "http://localhost:11434/v1"
|
|
elif self.provider == "ollama-cloud":
|
|
self.base_url = "https://ollama.com/v1"
|
|
elif self.provider == "lmstudio":
|
|
self.base_url = "http://localhost:1234/v1"
|
|
elif self.provider == "minimax":
|
|
self.base_url = "https://api.minimax.io/v1"
|
|
elif self.provider == "deepseek":
|
|
self.base_url = "https://api.deepseek.com"
|
|
elif self.provider == "openrouter":
|
|
self.base_url = "https://openrouter.ai/api/v1"
|
|
elif self.provider == "requesty":
|
|
self.base_url = "https://router.requesty.ai/v1"
|
|
elif self.provider == "zai":
|
|
self.base_url = "https://api.z.ai/api/coding/paas/v4"
|
|
elif self.provider == "opencode-go":
|
|
self.base_url = "https://opencode.ai/zen/go/v1"
|
|
elif self.provider == "atlas":
|
|
self.base_url = "https://api.atlascloud.ai/v1"
|
|
elif self.provider == "nous":
|
|
self.base_url = "https://inference-api.nousresearch.com/v1"
|
|
|
|
# Prepare Vertex AI config (if applicable). Values are used as passed; the
|
|
# caller resolves the global-config fallback (MemoryEngine builds /
|
|
# _member_to_llm / from_env). The region keeps a constant default here.
|
|
vertexai_credentials = None
|
|
|
|
if self.provider == "vertexai":
|
|
if not vertexai_project_id:
|
|
raise ValueError(
|
|
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required for Vertex AI provider. "
|
|
"Set it to your GCP project ID."
|
|
)
|
|
|
|
vertexai_region = vertexai_region or "us-central1"
|
|
service_account_key = vertexai_service_account_key
|
|
|
|
# Load explicit service account credentials if provided
|
|
if service_account_key:
|
|
if not VERTEXAI_AVAILABLE:
|
|
raise ValueError(
|
|
"Vertex AI service account auth requires 'google-auth' package. "
|
|
"Install with: pip install google-auth"
|
|
)
|
|
vertexai_credentials = service_account.Credentials.from_service_account_file(
|
|
service_account_key,
|
|
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
)
|
|
logger.info(f"Vertex AI: Using service account key: {service_account_key}")
|
|
|
|
# Strip google/ prefix from model name — native SDK uses bare names
|
|
if self.model.startswith("google/"):
|
|
self.model = self.model[len("google/") :]
|
|
|
|
logger.info(
|
|
f"Vertex AI: project={vertexai_project_id}, region={vertexai_region}, "
|
|
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}"
|
|
)
|
|
|
|
# Normalize the Gemini service tier (pure: maps/validates the passed value,
|
|
# no global config read). Non-Gemini providers never carry a tier. The
|
|
# server-level default is resolved by the caller, like the other fields.
|
|
if self.provider == "gemini":
|
|
from ..config import parse_gemini_service_tier
|
|
|
|
self.gemini_service_tier = parse_gemini_service_tier(self.gemini_service_tier)
|
|
else:
|
|
self.gemini_service_tier = None
|
|
|
|
# gemini_safety_settings / prompt_cache_enabled / litellmrouter_config are
|
|
# used as passed — the caller resolves the global-config fallback. Providers
|
|
# that don't support prompt caching ignore the flag.
|
|
router_config: dict[str, Any] | None = self.litellmrouter_config
|
|
|
|
# Create provider implementation using factory
|
|
self._provider_impl = create_llm_provider(
|
|
provider=self.provider,
|
|
api_key=self.api_key,
|
|
base_url=self.base_url,
|
|
model=self.model,
|
|
reasoning_effort=self.reasoning_effort,
|
|
groq_service_tier=self.groq_service_tier,
|
|
openai_service_tier=self.openai_service_tier,
|
|
bedrock_service_tier=self.bedrock_service_tier,
|
|
gemini_service_tier=self.gemini_service_tier,
|
|
extra_body=self.extra_body,
|
|
default_headers=self.default_headers,
|
|
vertexai_project_id=vertexai_project_id,
|
|
vertexai_region=vertexai_region,
|
|
vertexai_credentials=vertexai_credentials,
|
|
gemini_safety_settings=self.gemini_safety_settings,
|
|
prompt_cache_enabled=self.prompt_cache_enabled,
|
|
litellmrouter_config=router_config,
|
|
ollama_num_ctx=self.ollama_num_ctx,
|
|
timeout=self.timeout,
|
|
cache_affinity=self.cache_affinity,
|
|
structured_output_forced_tool=self.structured_output_forced_tool,
|
|
)
|
|
|
|
# Backward compatibility: Keep mock provider properties
|
|
self._mock_calls: list[dict] = []
|
|
self._mock_response: Any = None
|
|
|
|
@property
|
|
def _client(self) -> Any:
|
|
"""
|
|
Get the OpenAI client for OpenAI-compatible providers.
|
|
|
|
This property provides backward compatibility for code that directly accesses
|
|
the _client attribute (e.g., benchmarks, memory_engine).
|
|
|
|
Returns:
|
|
AsyncOpenAI client instance for OpenAI-compatible providers, or None for other providers.
|
|
"""
|
|
from .providers.openai_compatible_llm import OpenAICompatibleLLM
|
|
|
|
if isinstance(self._provider_impl, OpenAICompatibleLLM):
|
|
return self._provider_impl._client
|
|
return None
|
|
|
|
@property
|
|
def _gemini_client(self) -> Any:
|
|
"""
|
|
Get the Gemini client for Gemini/VertexAI providers.
|
|
|
|
This property provides backward compatibility for code that directly accesses
|
|
the _gemini_client attribute.
|
|
|
|
Returns:
|
|
genai.Client instance for Gemini/VertexAI providers, or None for other providers.
|
|
"""
|
|
from .providers.gemini_llm import GeminiLLM
|
|
|
|
if isinstance(self._provider_impl, GeminiLLM):
|
|
return self._provider_impl._client
|
|
return None
|
|
|
|
async def verify_connection(self) -> None:
|
|
"""
|
|
Verify that the LLM provider is configured correctly by making a simple test call.
|
|
|
|
Raises:
|
|
RuntimeError: If the connection test fails.
|
|
"""
|
|
await self._provider_impl.verify_connection()
|
|
|
|
async def supports_batch_api(self) -> bool:
|
|
"""Whether the underlying provider supports the OpenAI/Groq Batch API."""
|
|
return await self._provider_impl.supports_batch_api()
|
|
|
|
async def batch_provider_impl(self, account_key: str | None = None) -> LLMInterface | None:
|
|
"""The implementation serving batch, or ``None`` when it cannot serve one.
|
|
|
|
Exists so the batch path (``extract_facts_from_contents_batch_api``) can
|
|
target the implementation through the same interface as a multi-LLM chain:
|
|
``MultiLLMProvider`` returns the first batch-capable member's impl here,
|
|
while a single provider returns its own. Returning ``None`` rather than a
|
|
provider that would reject every batch call keeps the "can it serve one?"
|
|
answer in one place — the caller raises on ``None``.
|
|
|
|
``account_key`` is a :attr:`LLMInterface.batch_account_key` persisted when
|
|
an in-flight batch was submitted. Passing it restricts the answer to the
|
|
account that actually owns that batch, so a same-provider lookalike is
|
|
rejected (``None``) instead of being handed the wrong credentials.
|
|
"""
|
|
if not await self._provider_impl.supports_batch_api():
|
|
return None
|
|
if account_key is not None and self._provider_impl.batch_account_key != account_key:
|
|
return None
|
|
return self._provider_impl
|
|
|
|
async def call(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
response_format: Any | None = None,
|
|
max_completion_tokens: int | None = None,
|
|
temperature: float | None = None,
|
|
scope: str = "memory",
|
|
max_retries: int | None = None,
|
|
initial_backoff: float | None = None,
|
|
max_backoff: float | None = None,
|
|
skip_validation: bool = False,
|
|
strict_schema: bool | None = None,
|
|
return_usage: bool = False,
|
|
cached_prefix: str | None = None,
|
|
) -> Any:
|
|
"""
|
|
Make an LLM API call with retry logic.
|
|
|
|
Args:
|
|
messages: List of message dicts with 'role' and 'content'.
|
|
response_format: Optional Pydantic model for structured output.
|
|
max_completion_tokens: Maximum tokens in response.
|
|
temperature: Sampling temperature (0.0-2.0).
|
|
scope: Scope identifier for tracking.
|
|
max_retries: Maximum retry attempts. ``None`` uses the provider's configured
|
|
default (per-operation/global ``llm_max_retries``), else 10.
|
|
initial_backoff: Initial backoff time in seconds. ``None`` uses the provider's
|
|
configured default (``llm_initial_backoff``), else 1.0.
|
|
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
|
|
configured default (``llm_max_backoff``), else 60.0.
|
|
skip_validation: Return raw JSON without Pydantic validation.
|
|
strict_schema: Per-call override requesting grammar-enforced (json_schema strict)
|
|
structured output instead of the soft json_object path. None (the default)
|
|
inherits the server-level HINDSIGHT_API_LLM_STRICT_SCHEMA flag; an explicit
|
|
True or False wins over it, so a caller can force strict output on -- or off --
|
|
for its own scope. Providers without a strict mode ignore it.
|
|
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
|
|
|
|
Returns:
|
|
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
|
|
If return_usage=True: Tuple of (result, TokenUsage) with token counts from the LLM call.
|
|
|
|
Raises:
|
|
OutputTooLongError: If output exceeds token limits.
|
|
Exception: Re-raises API errors after retries exhausted.
|
|
"""
|
|
# Stage breadcrumb so the worker log shows which LLM call a task is
|
|
# currently inside; the stage_age field then reveals long JSON-schema
|
|
# retry loops (e.g. a small model that can't satisfy strict_schema).
|
|
# No-op outside a worker context.
|
|
from ..worker.stage import set_stage
|
|
|
|
structured = "+structured" if response_format is not None else ""
|
|
# `.queued` until the concurrency permits are in hand — see the acquire
|
|
# below. Without it, a call waiting on a saturated semaphore is
|
|
# indistinguishable from one the provider is actively running, and the
|
|
# label points at the provider (#3002: an operator lost an hour to
|
|
# "llm.bedrock.*" for tasks that had never reached Bedrock).
|
|
base_stage = f"llm.{self.provider}.{scope}{structured}"
|
|
set_stage(f"{base_stage}.queued")
|
|
|
|
# Resolve the retry policy: explicit per-call arg wins, else the provider's
|
|
# configured per-operation/global default, else this method's own fallback.
|
|
max_retries = (
|
|
max_retries if max_retries is not None else (self.max_retries if self.max_retries is not None else 10)
|
|
)
|
|
initial_backoff = (
|
|
initial_backoff
|
|
if initial_backoff is not None
|
|
else (self.initial_backoff if self.initial_backoff is not None else 1.0)
|
|
)
|
|
max_backoff = (
|
|
max_backoff if max_backoff is not None else (self.max_backoff if self.max_backoff is not None else 60.0)
|
|
)
|
|
|
|
# Resolve strict-schema once, here, rather than in each provider: the
|
|
# per-call argument, falling back to the server-level
|
|
# HINDSIGHT_API_LLM_STRICT_SCHEMA flag when the caller expressed no
|
|
# preference. Providers with a json_schema response_format (OpenAI-compatible,
|
|
# LiteLLM) then grammar-enforce structured output instead of the fragile
|
|
# soft json_object path; Gemini already enforces its native response_schema,
|
|
# and providers without a strict mode simply ignore the flag.
|
|
from ..config import get_config
|
|
|
|
# An explicit per-call value wins in BOTH directions -- `or` would have made a
|
|
# per-call False indistinguishable from "unset", silently ignoring any caller
|
|
# that opts out while the global flag is on.
|
|
strict_schema = strict_schema if strict_schema is not None else get_config().llm_strict_schema
|
|
|
|
# LLM call observability flows through the OTel GenAI recorder
|
|
# (tracing.get_span_recorder().record_llm_call). Provider implementations
|
|
# record successful calls; we forward failures here since they don't.
|
|
# The requested params are stashed in a contextvar (only what the caller
|
|
# actually set) so the recorder can attach them to either path.
|
|
from ..tracing import get_span_recorder
|
|
from .llm_trace import (
|
|
current_response_usage,
|
|
reset_request_context,
|
|
reset_response_usage,
|
|
set_request_context,
|
|
set_response_usage,
|
|
)
|
|
|
|
call_start = time.monotonic()
|
|
request_token = set_request_context(
|
|
_request_params(
|
|
max_completion_tokens=max_completion_tokens,
|
|
temperature=temperature,
|
|
scope=scope,
|
|
response_format=response_format,
|
|
)
|
|
)
|
|
# Cleared per call; the provider stashes real usage once a response is in
|
|
# hand so the error path below can attach it if parsing/validation fails.
|
|
usage_token = set_response_usage(None)
|
|
try:
|
|
# Providers that own retry loops acquire the shared permits for each
|
|
# upstream attempt so backoff never occupies request capacity.
|
|
attempt_gated = self._provider_impl.supports_attempt_scoped_concurrency()
|
|
async with AsyncExitStack() as stack:
|
|
if not attempt_gated:
|
|
for sem in _semaphores_for_scope(scope):
|
|
await stack.enter_async_context(sem)
|
|
# Permits in hand — only now leave `.queued`. Attempt-gated
|
|
# providers acquire permits per attempt instead, so they keep
|
|
# `.queued` until their first `attempt=N` stamp lands after
|
|
# the permit acquire inside attempt_context (#3002).
|
|
set_stage(base_stage)
|
|
|
|
# cached_prefix is only set for providers that returned a handle
|
|
# from get_or_create_cached_prefix() (e.g. Gemini); it's None for
|
|
# the rest. Forward it only when present so providers that don't
|
|
# implement caching keep their call() signature untouched.
|
|
cache_kwarg = {"cached_prefix": cached_prefix} if cached_prefix is not None else {}
|
|
try:
|
|
# Delegate to provider implementation
|
|
attempt_kwarg = {"attempt_context": lambda: _attempt_permits(scope)} if attempt_gated else {}
|
|
result = await self._provider_impl.call(
|
|
messages=messages,
|
|
response_format=response_format,
|
|
max_completion_tokens=max_completion_tokens,
|
|
temperature=temperature,
|
|
scope=scope,
|
|
max_retries=max_retries,
|
|
initial_backoff=initial_backoff,
|
|
max_backoff=max_backoff,
|
|
skip_validation=skip_validation,
|
|
strict_schema=strict_schema,
|
|
return_usage=return_usage,
|
|
**cache_kwarg,
|
|
**attempt_kwarg,
|
|
)
|
|
except Exception as e:
|
|
# The provider call may have succeeded (and incurred token
|
|
# cost) before local parsing/validation raised; attach the
|
|
# provider-reported usage to the error trace when available.
|
|
usage = current_response_usage()
|
|
get_span_recorder().record_llm_call(
|
|
provider=self.provider,
|
|
model=self.model,
|
|
scope=scope,
|
|
messages=messages,
|
|
response_content=None,
|
|
input_tokens=usage.input_tokens if usage else 0,
|
|
output_tokens=usage.output_tokens if usage else 0,
|
|
cached_tokens=usage.cached_tokens if usage else 0,
|
|
duration=time.monotonic() - call_start,
|
|
error=e,
|
|
)
|
|
raise
|
|
|
|
# Backward compatibility: Update mock call tracking for mock provider
|
|
# This allows existing tests using LLMProvider._mock_calls to continue working
|
|
if self.provider == "mock":
|
|
from .providers.mock_llm import MockLLM
|
|
|
|
if isinstance(self._provider_impl, MockLLM):
|
|
# Sync the mock calls from provider implementation to wrapper
|
|
self._mock_calls = self._provider_impl.get_mock_calls()
|
|
finally:
|
|
reset_request_context(request_token)
|
|
reset_response_usage(usage_token)
|
|
|
|
# Single scrub point for every structured/text LLM response in the engine:
|
|
# a model can emit a lone `\udXXX` escape that JSON decoding turns into an
|
|
# un-encodable surrogate, and the field it lands in is not knowable here
|
|
# (#3729). Clean output is returned unchanged, object identity included.
|
|
return sanitize_llm_value(result)
|
|
|
|
async def call_with_tools(
|
|
self,
|
|
messages: list[dict[str, Any]],
|
|
tools: list[dict[str, Any]],
|
|
max_completion_tokens: int | None = None,
|
|
temperature: float | None = None,
|
|
scope: str = "tools",
|
|
max_retries: int | None = None,
|
|
initial_backoff: float | None = None,
|
|
max_backoff: float | None = None,
|
|
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
|
|
cached_prefix: str | None = None,
|
|
cached_prefix_message_count: int = 0,
|
|
) -> "LLMToolCallResult":
|
|
"""
|
|
Make an LLM API call with tool/function calling support.
|
|
|
|
Args:
|
|
messages: List of message dicts. Can include tool results with role='tool'.
|
|
tools: List of tool definitions in OpenAI format.
|
|
max_completion_tokens: Maximum tokens in response.
|
|
temperature: Sampling temperature (0.0-2.0).
|
|
scope: Scope identifier for tracking.
|
|
max_retries: Maximum retry attempts. ``None`` uses the provider's configured
|
|
default (per-operation/global ``llm_max_retries``), else 5.
|
|
initial_backoff: Initial backoff time in seconds. ``None`` uses the provider's
|
|
configured default (``llm_initial_backoff``), else 1.0.
|
|
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
|
|
configured default (``llm_max_backoff``), else 30.0.
|
|
tool_choice: Canonical tool-selection policy.
|
|
|
|
Returns:
|
|
LLMToolCallResult with content and/or tool_calls.
|
|
"""
|
|
from ..worker.stage import set_stage
|
|
|
|
# `.queued` until the permits are held — see the structured path above.
|
|
base_stage = f"llm.{self.provider}.{scope}+tools"
|
|
set_stage(f"{base_stage}.queued")
|
|
|
|
# Resolve the retry policy: explicit per-call arg wins, else the provider's
|
|
# configured per-operation/global default, else this method's own fallback.
|
|
max_retries = (
|
|
max_retries if max_retries is not None else (self.max_retries if self.max_retries is not None else 5)
|
|
)
|
|
initial_backoff = (
|
|
initial_backoff
|
|
if initial_backoff is not None
|
|
else (self.initial_backoff if self.initial_backoff is not None else 1.0)
|
|
)
|
|
max_backoff = (
|
|
max_backoff if max_backoff is not None else (self.max_backoff if self.max_backoff is not None else 30.0)
|
|
)
|
|
|
|
# Failures forwarded to the GenAI recorder; successes recorded by providers.
|
|
from ..tracing import get_span_recorder
|
|
from .llm_trace import (
|
|
current_response_usage,
|
|
reset_request_context,
|
|
reset_response_usage,
|
|
set_request_context,
|
|
set_response_usage,
|
|
)
|
|
|
|
call_start = time.monotonic()
|
|
request_token = set_request_context(
|
|
_request_params(
|
|
max_completion_tokens=max_completion_tokens,
|
|
temperature=temperature,
|
|
scope=scope,
|
|
tool_choice=tool_choice,
|
|
)
|
|
)
|
|
# Cleared per call; the provider stashes real usage once a response is in
|
|
# hand so the error path below can attach it if parsing/validation fails.
|
|
usage_token = set_response_usage(None)
|
|
try:
|
|
attempt_gated = self._provider_impl.supports_attempt_scoped_concurrency()
|
|
async with AsyncExitStack() as stack:
|
|
if not attempt_gated:
|
|
for sem in _semaphores_for_scope(scope):
|
|
await stack.enter_async_context(sem)
|
|
# Permits in hand — only now leave `.queued`; attempt-gated
|
|
# providers stay `.queued` until their first post-acquire
|
|
# `attempt=N` stamp (see call() above, #3002).
|
|
set_stage(base_stage)
|
|
|
|
# cached_prefix is only set for providers that returned a handle
|
|
# from get_or_create_cached_prefix() / create_incremental_cache();
|
|
# forward it (plus how many leading messages it covers) only when
|
|
# present so non-caching providers keep their signature.
|
|
cache_kwarg = (
|
|
{"cached_prefix": cached_prefix, "cached_prefix_message_count": cached_prefix_message_count}
|
|
if cached_prefix is not None
|
|
else {}
|
|
)
|
|
try:
|
|
# Delegate to provider implementation
|
|
attempt_kwarg = {"attempt_context": lambda: _attempt_permits(scope)} if attempt_gated else {}
|
|
result = await self._provider_impl.call_with_tools(
|
|
messages=messages,
|
|
tools=tools,
|
|
max_completion_tokens=max_completion_tokens,
|
|
temperature=temperature,
|
|
scope=scope,
|
|
max_retries=max_retries,
|
|
initial_backoff=initial_backoff,
|
|
max_backoff=max_backoff,
|
|
tool_choice=tool_choice,
|
|
**cache_kwarg,
|
|
**attempt_kwarg,
|
|
)
|
|
except Exception as e:
|
|
# The provider call may have succeeded (and incurred token
|
|
# cost) before local parsing/validation raised; attach the
|
|
# provider-reported usage to the error trace when available.
|
|
usage = current_response_usage()
|
|
get_span_recorder().record_llm_call(
|
|
provider=self.provider,
|
|
model=self.model,
|
|
scope=scope,
|
|
messages=messages,
|
|
response_content=None,
|
|
input_tokens=usage.input_tokens if usage else 0,
|
|
output_tokens=usage.output_tokens if usage else 0,
|
|
cached_tokens=usage.cached_tokens if usage else 0,
|
|
duration=time.monotonic() - call_start,
|
|
error=e,
|
|
)
|
|
raise
|
|
|
|
# Backward compatibility: Update mock call tracking for mock provider
|
|
# This allows existing tests using LLMProvider._mock_calls to continue working
|
|
if self.provider == "mock":
|
|
from .providers.mock_llm import MockLLM
|
|
|
|
if isinstance(self._provider_impl, MockLLM):
|
|
# Sync the mock calls from provider implementation to wrapper
|
|
self._mock_calls = self._provider_impl.get_mock_calls()
|
|
finally:
|
|
reset_request_context(request_token)
|
|
reset_response_usage(usage_token)
|
|
|
|
# Same scrub for the tool-calling path: the agent's text content and every
|
|
# tool-call argument are model-authored and flow on to storage and reranking.
|
|
return sanitize_llm_value(result)
|
|
|
|
def set_response_callback(self, fn: Any) -> None:
|
|
"""Set a callback invoked on each call() instead of the fixed mock response."""
|
|
if self.provider == "mock":
|
|
from .providers.mock_llm import MockLLM
|
|
|
|
if isinstance(self._provider_impl, MockLLM):
|
|
self._provider_impl.set_response_callback(fn)
|
|
|
|
def set_mock_response(self, response: Any) -> None:
|
|
"""Set the response to return from mock calls."""
|
|
# Backward compatibility: Store in both wrapper and provider implementation
|
|
self._mock_response = response
|
|
if self.provider == "mock":
|
|
from .providers.mock_llm import MockLLM
|
|
|
|
if isinstance(self._provider_impl, MockLLM):
|
|
self._provider_impl.set_mock_response(response)
|
|
|
|
def get_mock_calls(self) -> list[dict]:
|
|
"""Get the list of recorded mock calls."""
|
|
# Backward compatibility: Read from provider implementation if mock provider
|
|
if self.provider == "mock":
|
|
from .providers.mock_llm import MockLLM
|
|
|
|
if isinstance(self._provider_impl, MockLLM):
|
|
return self._provider_impl.get_mock_calls()
|
|
return self._mock_calls
|
|
|
|
def clear_mock_calls(self) -> None:
|
|
"""Clear the recorded mock calls."""
|
|
# Backward compatibility: Clear in both wrapper and provider implementation
|
|
self._mock_calls = []
|
|
if self.provider == "mock":
|
|
from .providers.mock_llm import MockLLM
|
|
|
|
if isinstance(self._provider_impl, MockLLM):
|
|
self._provider_impl.clear_mock_calls()
|
|
|
|
def _load_codex_auth(self) -> tuple[str, str]:
|
|
"""
|
|
Load OAuth credentials from the Codex ``auth.json``.
|
|
|
|
Honors ``CODEX_HOME`` (falling back to ``~/.codex``).
|
|
|
|
Returns:
|
|
Tuple of (access_token, account_id).
|
|
|
|
Raises:
|
|
FileNotFoundError: If auth file doesn't exist.
|
|
ValueError: If auth file is invalid.
|
|
"""
|
|
from .providers.codex_auth import default_codex_auth_file
|
|
|
|
auth_file = default_codex_auth_file()
|
|
|
|
if not auth_file.exists():
|
|
raise FileNotFoundError(
|
|
f"Codex auth file not found: {auth_file}\nRun 'codex auth login' to authenticate with ChatGPT Plus/Pro."
|
|
)
|
|
|
|
with open(auth_file) as f:
|
|
data = json.load(f)
|
|
|
|
# Validate auth structure
|
|
auth_mode = data.get("auth_mode")
|
|
if auth_mode != "chatgpt":
|
|
raise ValueError(f"Expected auth_mode='chatgpt', got: {auth_mode}")
|
|
|
|
tokens = data.get("tokens", {})
|
|
access_token = tokens.get("access_token")
|
|
account_id = tokens.get("account_id")
|
|
|
|
if not access_token:
|
|
raise ValueError("No access_token found in Codex auth file. Run 'codex auth login' again.")
|
|
|
|
return access_token, account_id
|
|
|
|
def _verify_claude_code_available(self) -> None:
|
|
"""
|
|
Verify that Claude Agent SDK can be imported and is properly configured.
|
|
|
|
Raises:
|
|
ImportError: If Claude Agent SDK is not installed.
|
|
RuntimeError: If Claude Code is not authenticated.
|
|
"""
|
|
try:
|
|
# Import Claude Agent SDK
|
|
# Reduce Claude Agent SDK logging verbosity
|
|
import logging as sdk_logging
|
|
|
|
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
|
|
|
|
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
|
|
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
|
|
|
|
logger.debug("Claude Agent SDK imported successfully")
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"Claude Agent SDK not installed. Run: uv add claude-agent-sdk or pip install claude-agent-sdk"
|
|
) from e
|
|
|
|
# SDK will automatically check for authentication when first used
|
|
# No need to verify here - let it fail gracefully on first call with helpful error
|
|
|
|
def with_config(
|
|
self,
|
|
config: Any,
|
|
*,
|
|
bank_id: str | None = None,
|
|
operation: str | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> "ConfiguredLLMProvider":
|
|
"""
|
|
Return a configured wrapper for a specific bank operation.
|
|
|
|
The wrapper applies per-bank overrides (e.g. Gemini safety settings)
|
|
to every ``call()`` / ``call_with_tools()`` invocation without
|
|
changing the underlying provider or its long-lived client connection.
|
|
|
|
Args:
|
|
config: Resolved ``HindsightConfig`` for the current bank/request.
|
|
bank_id: Bank the operation runs for; attributed to LLM trace rows.
|
|
operation: Logical operation label ("retain", "reflect", ...) for
|
|
LLM trace rows.
|
|
metadata: Optional extra caller metadata stored on trace rows.
|
|
|
|
Returns:
|
|
A ``ConfiguredLLMProvider`` that delegates to this provider with
|
|
the supplied config applied.
|
|
"""
|
|
trace_ctx = None
|
|
if bank_id is not None or operation is not None or metadata:
|
|
from .llm_trace import LLMTraceContext
|
|
|
|
# One trace + operation span per with_config() call — i.e. per
|
|
# operation invocation. Every LLM call made through this wrapper
|
|
# shares them, so a reflect/retain/consolidation run groups its
|
|
# calls as parent (operation) → children (LLM calls).
|
|
trace_ctx = LLMTraceContext(
|
|
bank_id=bank_id,
|
|
operation=operation,
|
|
metadata=dict(metadata or {}),
|
|
trace_id=str(uuid.uuid4()),
|
|
operation_span_id=str(uuid.uuid4()),
|
|
)
|
|
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings, trace_ctx)
|
|
|
|
async def cleanup(self) -> None:
|
|
"""Clean up resources (e.g. stop llamacpp subprocess)."""
|
|
if self._provider_impl:
|
|
await self._provider_impl.cleanup()
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "LLMProvider":
|
|
"""Create provider from environment variables using config.py constants."""
|
|
# Read every field straight from the environment. The constructor no longer
|
|
# resolves global-config fallbacks, so this factory must supply them — and it
|
|
# does so without building the full HindsightConfig, keeping from_env() a
|
|
# lightweight env-only loader (see test_llm_provider_from_env_keeps_lightweight_loader).
|
|
from ..config import (
|
|
DEFAULT_LLM_CACHE_AFFINITY,
|
|
DEFAULT_LLM_GROQ_SERVICE_TIER,
|
|
DEFAULT_LLM_OPENAI_SERVICE_TIER,
|
|
DEFAULT_LLM_PROMPT_CACHE_ENABLED,
|
|
DEFAULT_LLM_PROVIDER,
|
|
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
|
DEFAULT_LLM_TIMEOUT,
|
|
ENV_LLM_API_KEY,
|
|
ENV_LLM_BASE_URL,
|
|
ENV_LLM_BEDROCK_SERVICE_TIER,
|
|
ENV_LLM_CACHE_AFFINITY,
|
|
ENV_LLM_DEFAULT_HEADERS,
|
|
ENV_LLM_EXTRA_BODY,
|
|
ENV_LLM_GEMINI_SAFETY_SETTINGS,
|
|
ENV_LLM_GEMINI_SERVICE_TIER,
|
|
ENV_LLM_GROQ_SERVICE_TIER,
|
|
ENV_LLM_LITELLMROUTER_CONFIG,
|
|
ENV_LLM_MODEL,
|
|
ENV_LLM_OLLAMA_NUM_CTX,
|
|
ENV_LLM_OPENAI_SERVICE_TIER,
|
|
ENV_LLM_PROMPT_CACHE_ENABLED,
|
|
ENV_LLM_PROVIDER,
|
|
ENV_LLM_REASONING_EFFORT,
|
|
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
|
ENV_LLM_TIMEOUT,
|
|
ENV_LLM_VERTEXAI_PROJECT_ID,
|
|
ENV_LLM_VERTEXAI_REGION,
|
|
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
|
|
_get_default_model_for_provider,
|
|
_parse_boolean_env,
|
|
_parse_llm_router_config,
|
|
_parse_optional_positive_int,
|
|
parse_gemini_service_tier,
|
|
)
|
|
|
|
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
|
|
api_key = os.getenv(ENV_LLM_API_KEY, "")
|
|
|
|
if not api_key and not requires_api_key(provider):
|
|
pass # Provider handles its own auth
|
|
elif not api_key:
|
|
raise ValueError(f"{ENV_LLM_API_KEY} environment variable is required for provider '{provider}'")
|
|
|
|
base_url = os.getenv(ENV_LLM_BASE_URL, "")
|
|
model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(provider)
|
|
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
|
|
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
|
|
# Same default as HindsightConfig.from_env: this entry point must not
|
|
# resolve to a different mode than the engine's own config path.
|
|
cache_affinity = os.getenv(ENV_LLM_CACHE_AFFINITY, DEFAULT_LLM_CACHE_AFFINITY) or None
|
|
prompt_cache_enabled = os.getenv(
|
|
ENV_LLM_PROMPT_CACHE_ENABLED, str(DEFAULT_LLM_PROMPT_CACHE_ENABLED)
|
|
).lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
"on",
|
|
)
|
|
|
|
return cls(
|
|
provider=provider,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
model=model,
|
|
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT) or None,
|
|
extra_body=extra_body,
|
|
default_headers=default_headers,
|
|
cache_affinity=cache_affinity,
|
|
groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
|
|
openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
|
|
bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
|
|
gemini_service_tier=(
|
|
parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER))
|
|
if provider.lower() == "gemini"
|
|
else None
|
|
),
|
|
gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
|
|
prompt_cache_enabled=prompt_cache_enabled,
|
|
ollama_num_ctx=_parse_optional_positive_int(ENV_LLM_OLLAMA_NUM_CTX, os.getenv(ENV_LLM_OLLAMA_NUM_CTX)),
|
|
litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
|
|
vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or None,
|
|
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,
|
|
vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY) or None,
|
|
timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
|
|
structured_output_forced_tool=_parse_boolean_env(
|
|
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
|
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
|
),
|
|
)
|
|
|
|
|
|
class ConfiguredLLMProvider:
|
|
"""
|
|
Thin wrapper around LLMProvider that applies bank-specific config to every call.
|
|
|
|
Obtained via ``LLMProvider.with_config(resolved_config)``. The wrapper
|
|
sets any provider-specific overrides (currently Gemini safety settings)
|
|
immediately before each call using a ContextVar token, then resets it
|
|
afterwards — so nesting is safe and the configuration cannot leak across
|
|
operations.
|
|
|
|
All attribute access falls through to the underlying provider so callers
|
|
that read ``llm.provider``, ``llm.model``, etc. continue to work without
|
|
any changes.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
provider: "LLMProvider",
|
|
gemini_safety_settings: list | None,
|
|
trace_ctx: Any | None = None,
|
|
) -> None:
|
|
# Use object.__setattr__ to avoid triggering __getattr__
|
|
object.__setattr__(self, "_provider", provider)
|
|
object.__setattr__(self, "_gemini_safety_settings", gemini_safety_settings)
|
|
object.__setattr__(self, "_trace_ctx", trace_ctx)
|
|
|
|
# ── attribute passthrough ──────────────────────────────────────────────────
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
return getattr(object.__getattribute__(self, "_provider"), name)
|
|
|
|
# ── overridden call methods ────────────────────────────────────────────────
|
|
|
|
async def call(self, messages: list[dict[str, Any]], **kwargs: Any) -> Any:
|
|
from .providers.gemini_llm import _safety_settings_ctx
|
|
|
|
token = _safety_settings_ctx.set(object.__getattribute__(self, "_gemini_safety_settings"))
|
|
trace_token = self._bind_trace_context()
|
|
try:
|
|
return await object.__getattribute__(self, "_provider").call(messages=messages, **kwargs)
|
|
finally:
|
|
_safety_settings_ctx.reset(token)
|
|
self._reset_trace_context(trace_token)
|
|
|
|
async def call_with_tools(
|
|
self,
|
|
messages: list[dict[str, Any]],
|
|
tools: list[dict[str, Any]],
|
|
**kwargs: Any,
|
|
) -> "LLMToolCallResult":
|
|
from .providers.gemini_llm import _safety_settings_ctx
|
|
|
|
token = _safety_settings_ctx.set(object.__getattribute__(self, "_gemini_safety_settings"))
|
|
trace_token = self._bind_trace_context()
|
|
try:
|
|
return await object.__getattribute__(self, "_provider").call_with_tools(
|
|
messages=messages, tools=tools, **kwargs
|
|
)
|
|
finally:
|
|
_safety_settings_ctx.reset(token)
|
|
self._reset_trace_context(trace_token)
|
|
|
|
def trace_context(self) -> Any | None:
|
|
"""The operation-level LLM trace context (or None when untraced).
|
|
|
|
Lets the engine attach the operation's produced/consumed memory_ids to
|
|
this run's trace rows once they're known (after the LLM calls).
|
|
"""
|
|
return object.__getattribute__(self, "_trace_ctx")
|
|
|
|
def _bind_trace_context(self) -> Any | None:
|
|
"""Bind bank/operation attribution for the duration of one call."""
|
|
trace_ctx = object.__getattribute__(self, "_trace_ctx")
|
|
if trace_ctx is None:
|
|
return None
|
|
from .llm_trace import set_trace_context
|
|
|
|
return set_trace_context(trace_ctx)
|
|
|
|
def _reset_trace_context(self, trace_token: Any | None) -> None:
|
|
if trace_token is None:
|
|
return
|
|
from .llm_trace import reset_trace_context
|
|
|
|
reset_trace_context(trace_token)
|
|
|
|
|
|
# Backwards compatibility alias
|
|
LLMConfig = LLMProvider
|