Files
srbhr--resume-matcher/apps/backend/app/llm.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

1228 lines
46 KiB
Python

"""LiteLLM wrapper for multi-provider AI support."""
import json
import logging
import re
import threading
from typing import Any, Literal
import litellm
from litellm import Router
from litellm.router import RetryPolicy
from pydantic import BaseModel
from app.config import load_config_file, save_config_file, settings
LITELLM_LOGGER_NAMES = ("LiteLLM", "LiteLLM Router", "LiteLLM Proxy")
def _configure_litellm_logging() -> None:
"""Align LiteLLM logger levels with application settings."""
numeric_level = getattr(logging, settings.log_llm, logging.WARNING)
for logger_name in LITELLM_LOGGER_NAMES:
logging.getLogger(logger_name).setLevel(numeric_level)
_configure_litellm_logging()
# Let LiteLLM drop provider-unsupported params (reasoning_effort, non-default
# temperature, etc.) instead of raising UnsupportedParamsError. This replaces
# the hardcoded per-model compatibility branches this module used to carry.
litellm.drop_params = True
# Let LiteLLM auto-drop `thinking_blocks` from assistant messages when required
# for a given turn (e.g., tool-call turns missing the blocks). Defensive; no
# current code path sends thinking, but future-proofs the Router.
litellm.modify_params = True
# LLM timeout configuration (seconds) - base values
LLM_TIMEOUT_HEALTH_CHECK = 30
LLM_TIMEOUT_COMPLETION = 120
LLM_TIMEOUT_JSON = 180 # JSON completions may take longer
# JSON-010: JSON extraction safety limits
MAX_JSON_EXTRACTION_RECURSION = 10
MAX_JSON_CONTENT_SIZE = 1024 * 1024 # 1MB
# Default token budget for structured JSON completions (e.g. resume parsing).
# Chosen to accommodate large resumes while staying within most providers'
# output limits. Callers should use get_safe_max_tokens() so this is
# automatically clamped to the model's actual capacity.
DEFAULT_JSON_MAX_TOKENS = 8192
class LLMConfig(BaseModel):
"""LLM configuration model."""
provider: str
model: str
api_key: str
api_base: str | None = None
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
def _normalize_api_base(provider: str, api_base: str | None) -> str | None:
"""Normalize api_base for LiteLLM provider-specific expectations.
When using proxies/aggregators, users often paste a base URL that already
includes a version segment (e.g., `/v1`). Some LiteLLM provider handlers
append those segments internally, which can lead to duplicated paths like
`/v1/v1/...` and cause 404s.
For the `openai` provider, LiteLLM uses the upstream OpenAI client which
handles `/v1` correctly — we MUST preserve whatever the user pasted so
that OpenAI-compatible endpoints like llama.cpp (http://localhost:8080/v1)
round-trip intact. See issue #751.
"""
if not api_base:
return None
base = api_base.strip()
if not base:
return None
base = base.rstrip("/")
# OpenAI / OpenAI-compatible: preserve the URL as-is. The OpenAI client
# resolves paths correctly whether the base includes /v1 or not.
if provider in ("openai", "openai_compatible"):
return base or None
# Anthropic handler appends '/v1/messages'. If base already ends with '/v1',
# strip it to avoid '/v1/v1/messages'.
if provider == "anthropic" and base.endswith("/v1"):
base = base[: -len("/v1")].rstrip("/")
# Gemini handler appends '/v1/models/...'. If base already ends with '/v1',
# strip it to avoid '/v1/v1/models/...'.
if provider == "gemini" and base.endswith("/v1"):
base = base[: -len("/v1")].rstrip("/")
# OpenRouter base is https://openrouter.ai/api/v1. LiteLLM appends /v1
# internally, so strip it to avoid /v1/v1.
if provider == "openrouter" and base.endswith("/v1"):
base = base[: -len("/v1")].rstrip("/")
# Ollama doesn't use /v1 paths. Strip common suffixes users might paste:
# /v1, /api/chat, /api/generate
if provider == "ollama":
for suffix in ("/v1", "/api/chat", "/api/generate", "/api"):
if base.endswith(suffix):
base = base[: -len(suffix)].rstrip("/")
break
return base or None
# Sentinel passed to the OpenAI client when the user leaves api_key blank for
# openai_compatible. The client validates non-empty strings but not the value
# format; local servers that don't check auth ignore it.
_OPENAI_COMPATIBLE_SENTINEL = "sk-no-key"
def _effective_api_key(provider: str, api_key: str) -> str:
"""Return the api_key to pass to LiteLLM.
For openai_compatible with a blank key, substitute a sentinel so the
OpenAI client accepts the call. Other providers pass through unchanged.
"""
if provider == "openai_compatible" and not api_key:
return _OPENAI_COMPATIBLE_SENTINEL
return api_key
def _extract_text_parts(value: Any, depth: int = 0, max_depth: int = 10) -> list[str]:
"""Recursively extract text segments from nested response structures.
Handles strings, lists, dicts with 'text'/'content'/'value' keys, and objects
with text/content attributes. Limits recursion depth to avoid cycles.
Args:
value: Input value that may contain text in strings, lists, dicts, or objects.
depth: Current recursion depth.
max_depth: Maximum recursion depth before returning no content.
Returns:
A list of extracted text segments.
"""
if depth >= max_depth:
return []
if value is None:
return []
if isinstance(value, str):
return [value]
if isinstance(value, list):
parts: list[str] = []
next_depth = depth + 1
for item in value:
parts.extend(_extract_text_parts(item, next_depth, max_depth))
return parts
if isinstance(value, dict):
next_depth = depth + 1
if "text" in value:
return _extract_text_parts(value.get("text"), next_depth, max_depth)
if "content" in value:
return _extract_text_parts(value.get("content"), next_depth, max_depth)
if "value" in value:
return _extract_text_parts(value.get("value"), next_depth, max_depth)
return []
next_depth = depth + 1
if hasattr(value, "text"):
return _extract_text_parts(getattr(value, "text"), next_depth, max_depth)
if hasattr(value, "content"):
return _extract_text_parts(getattr(value, "content"), next_depth, max_depth)
return []
def _join_text_parts(parts: list[str]) -> str | None:
"""Join text parts with newlines, filtering empty strings.
Args:
parts: Candidate text segments.
Returns:
Joined string or None if the result is empty.
"""
joined = "\n".join(part for part in parts if part).strip()
return joined or None
def _extract_message_text(message: Any) -> str | None:
"""Extract plain text from a LiteLLM message object across providers.
Fallback order:
1. message.content (standard OpenAI-compatible path)
2. message.reasoning_content (DeepSeek R1, OpenAI o1/o3 via LiteLLM
standardized field)
3. message.thinking (Anthropic extended thinking)
Reasoning-only responses are treated as valid content so thinking models
can be used without special-casing them in every call site.
"""
content: Any = None
if hasattr(message, "content"):
content = message.content
elif isinstance(message, dict):
content = message.get("content")
text = _join_text_parts(_extract_text_parts(content))
if text:
return text
# Fallback: reasoning_content (DeepSeek R1, OpenAI o1/o3).
reasoning = _safe_get(message, "reasoning_content")
text = _join_text_parts(_extract_text_parts(reasoning))
if text:
return text
# Fallback: thinking (Anthropic extended thinking).
thinking = _safe_get(message, "thinking")
return _join_text_parts(_extract_text_parts(thinking))
def _safe_get(obj: Any, key: str) -> Any:
"""Get attribute or dict key from an object."""
if hasattr(obj, key):
return getattr(obj, key)
if isinstance(obj, dict):
return obj.get(key)
return None
def _extract_choice_text(choice: Any) -> str | None:
"""Extract plain text from a LiteLLM choice object.
Tries message.content first, then choice.text, then choice.delta. Handles both
object attributes and dict keys.
"""
content = _extract_message_text(_safe_get(choice, "message"))
if content:
return content
for field in ("text", "delta"):
value = _safe_get(choice, field)
if value is not None:
extracted = _join_text_parts(_extract_text_parts(value))
if extracted:
return extracted
return None
def _to_code_block(content: str | None, language: str = "text") -> str:
"""Wrap content in a markdown code block for client display."""
text = (content or "").strip()
if not text:
text = "<empty>"
return f"```{language}\n{text}\n```"
# Regex for provider-style API-key tokens that may appear in upstream error
# messages (OpenAI / Anthropic / OpenRouter / DeepSeek all use ``sk-...``;
# Google AI Studio uses ``AIza...``). The OpenAI client already partially
# masks keys in its error text but leaves the first ~8 and last ~4 chars
# visible, which is enough to identify the provider and correlate with the
# user's stored key. We redact any remaining key-like run before we surface
# the message to the client via ``error_detail``.
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
# sk-<anything-non-whitespace>, covering both plain and already-masked
# tokens (e.g., ``sk-ant-a****...7QAA``). Minimum length of 12 avoids
# matching harmless substrings like ``sk-foo``.
re.compile(r"sk-[A-Za-z0-9_\-*.]{12,}"),
# Google AI Studio.
re.compile(r"AIza[0-9A-Za-z_\-]{10,}"),
# Generic Bearer tokens in an Authorization header line.
re.compile(r"(?i)(Bearer\s+)[^\s\"']+"),
)
def _scrub_secrets(text: str) -> str:
"""Redact API-key-like substrings before the text leaves the server.
Applied to ``error_detail`` on the failing-health-check path so that
upstream exception messages (which may include partially-masked keys)
can't be used by a Settings-page viewer to identify which provider /
key variant is configured.
"""
if not text:
return text
redacted = text
for pattern in _SECRET_PATTERNS:
redacted = pattern.sub("<redacted>", redacted)
return redacted
_PROVIDER_KEY_MAP: dict[str, str] = {
"openai": "openai",
"openai_compatible": "openai_compatible",
"anthropic": "anthropic",
"gemini": "google",
"openrouter": "openrouter",
"deepseek": "deepseek",
"groq": "groq",
"ollama": "ollama",
}
# Providers where the user commonly runs a local server without auth. For
# these, we MUST NOT fall back to ``settings.llm_api_key`` (the env-level
# default), because the env var may hold a real paid-API key that would then
# leak to a local/compatible endpoint the user set up expecting no auth.
_PROVIDERS_WITHOUT_ENV_KEY_FALLBACK: frozenset[str] = frozenset(
{"openai_compatible", "ollama"}
)
def resolve_api_key(stored: dict, provider: str) -> str:
"""Resolve the effective API key from stored config.
Priority: top-level ``api_key`` > ``api_keys[provider]`` > env/settings
default — EXCEPT for providers in ``_PROVIDERS_WITHOUT_ENV_KEY_FALLBACK``
(``openai_compatible`` / ``ollama``), where the env-level default is
skipped so a paid OpenAI key in ``LLM_API_KEY`` cannot leak to a local
self-hosted server when the user leaves the provider key blank.
This is the single source of truth for key resolution. Every code path
that needs an API key (runtime, config display, health check, test
endpoint) must call this function instead of reading ``stored["api_key"]``
directly.
"""
api_key = stored.get("api_key", "")
if not api_key:
api_keys = stored.get("api_keys", {})
if not isinstance(api_keys, dict):
api_keys = {}
config_provider = _PROVIDER_KEY_MAP.get(provider, provider)
env_default = (
""
if provider in _PROVIDERS_WITHOUT_ENV_KEY_FALLBACK
else settings.llm_api_key
)
api_key = api_keys.get(config_provider, env_default)
return api_key
def get_llm_config() -> LLMConfig:
"""Get current LLM configuration.
Priority for api_key: top-level api_key > api_keys[provider] > env/settings
Priority for reasoning_effort: config.json > env/settings
Runs a one-shot migration for existing gpt-5 users: if provider is openai,
model contains 'gpt-5', and reasoning_effort is ABSENT from config.json
(not merely empty), persist reasoning_effort='minimal' to preserve the
behavior the removed hardcoded branch provided. Users who clear the
field explicitly (empty string persisted by the PUT handler) will not
have it restored.
"""
stored = load_config_file()
provider = stored.get("provider", settings.llm_provider)
model = stored.get("model", settings.llm_model)
# One-shot migration: preserve old gpt-5 reasoning_effort behavior for
# existing configs. Gated on ABSENT key so users can opt out by clearing
# the field (PUT handler persists an empty string on clear).
if (
provider == "openai"
and "gpt-5" in model.lower()
and "reasoning_effort" not in stored
):
stored["reasoning_effort"] = "minimal"
try:
save_config_file(stored)
logging.info(
"Migrated gpt-5 config to preserve reasoning_effort=minimal "
"(set REASONING_EFFORT= or clear in Settings to disable)"
)
except Exception as e:
# Non-fatal — retry on next call.
logging.warning("Failed to persist gpt-5 migration: %s", e)
api_key = resolve_api_key(stored, provider)
raw_re = stored.get("reasoning_effort", settings.reasoning_effort)
# Normalize empty string to None — user explicitly cleared.
reasoning_effort = raw_re if raw_re else None
return LLMConfig(
provider=provider,
model=model,
api_key=api_key,
api_base=stored.get("api_base", settings.llm_api_base),
reasoning_effort=reasoning_effort,
)
def get_model_name(config: LLMConfig) -> str:
"""Convert provider/model to LiteLLM format.
For most providers, adds the provider prefix if not already present.
For OpenRouter, always adds 'openrouter/' prefix since OpenRouter models
use nested prefixes like 'openrouter/anthropic/claude-3.5-sonnet'.
"""
provider_prefixes = {
"openai": "", # OpenAI models don't need prefix
# openai_compatible: route via LiteLLM's openai/ prefix so the OpenAI
# client handles the request; works for llama.cpp, vLLM, LM Studio,
# and any server exposing the OpenAI Chat Completions API shape.
"openai_compatible": "openai/",
"anthropic": "anthropic/",
"openrouter": "openrouter/",
"gemini": "gemini/",
"deepseek": "deepseek/",
"groq": "groq/",
"ollama": "ollama_chat/", # ollama_chat/ routes to /api/chat (supports messages array)
}
prefix = provider_prefixes.get(config.provider, "")
# OpenRouter is special: always add openrouter/ prefix unless already present
# OpenRouter models use nested format: openrouter/anthropic/claude-3.5-sonnet
if config.provider == "openrouter":
if config.model.startswith("openrouter/"):
return config.model
return f"openrouter/{config.model}"
# For other providers, don't add prefix if model already has a known prefix
known_prefixes = [
"openrouter/",
"anthropic/",
"gemini/",
"deepseek/",
"groq/",
"ollama/",
"ollama_chat/",
"openai/",
]
if any(config.model.startswith(p) for p in known_prefixes):
return config.model
# Add provider prefix for models that need it
return f"{prefix}{config.model}" if prefix else config.model
# ---------------------------------------------------------------------------
# Router — centralises transport retries, cooldowns, and error-type policies
# ---------------------------------------------------------------------------
_router: Router | None = None
_router_config_key: str = ""
_router_lock = threading.Lock()
def _config_fingerprint(config: LLMConfig) -> str:
"""Generate a fingerprint to detect config changes.
Uses Python's built-in ``hash()`` on the API key — stable within a
single process (which is the cache lifetime), collision-resistant,
and not a cryptographic function so it won't trigger CodeQL alerts.
The raw key is never stored in the fingerprint string.
"""
key_hash = hash(config.api_key) if config.api_key else 0
return f"{config.provider}|{config.model}|{key_hash}|{config.api_base}"
def _build_router(config: LLMConfig) -> Router:
"""Build a LiteLLM Router with error-type retry policies."""
model_name = get_model_name(config)
litellm_params: dict[str, Any] = {"model": model_name}
effective_key = _effective_api_key(config.provider, config.api_key)
if effective_key:
litellm_params["api_key"] = effective_key
api_base = _normalize_api_base(config.provider, config.api_base)
if api_base:
litellm_params["api_base"] = api_base
return Router(
model_list=[
{
"model_name": "primary",
"litellm_params": litellm_params,
}
],
num_retries=3,
retry_policy=RetryPolicy(
AuthenticationErrorRetries=0,
BadRequestErrorRetries=0,
TimeoutErrorRetries=2,
RateLimitErrorRetries=3,
ContentPolicyViolationErrorRetries=0,
InternalServerErrorRetries=2,
),
# Cooldowns disabled: with a single deployment and no fallback,
# cooldowns would blackout the backend on transient failures.
# Re-enable when a fallback deployment is added.
disable_cooldowns=True,
)
def get_router(config: LLMConfig | None = None) -> tuple[Router, LLMConfig]:
"""Get or rebuild the LiteLLM Router.
The Router is cached and only rebuilt when the underlying config changes.
Returns the Router and the config it was built from.
"""
global _router, _router_config_key
if config is None:
config = get_llm_config()
key = _config_fingerprint(config)
with _router_lock:
if _router is None or _router_config_key != key:
_router = _build_router(config)
_router_config_key = key
logging.info("LiteLLM Router rebuilt for %s/%s", config.provider, config.model)
router = _router
return router, config
async def check_llm_health(
config: LLMConfig | None = None,
*,
include_details: bool = False,
test_prompt: str | None = None,
) -> dict[str, Any]:
"""Check if the LLM provider is accessible and working."""
if config is None:
config = get_llm_config()
# Check if API key is configured. Ollama and openai_compatible local
# servers often run without auth, so a blank key is acceptable for those
# providers — a sentinel is passed downstream (see _effective_api_key)
# to satisfy the OpenAI client's non-empty-string validation.
if config.provider not in ("ollama", "openai_compatible") and not config.api_key:
return {
"healthy": False,
"provider": config.provider,
"model": config.model,
"error_code": "api_key_missing",
}
model_name = get_model_name(config)
prompt = test_prompt or "Hi"
try:
# Make a minimal test call with timeout
# Pass API key directly to avoid race conditions with global os.environ
kwargs: dict[str, Any] = {
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 64,
"api_key": _effective_api_key(config.provider, config.api_key),
"api_base": _normalize_api_base(config.provider, config.api_base),
"timeout": LLM_TIMEOUT_HEALTH_CHECK,
}
if config.reasoning_effort:
kwargs["reasoning_effort"] = config.reasoning_effort
response = await litellm.acompletion(**kwargs)
content = _extract_choice_text(response.choices[0])
if not content:
# LLM-003: Empty response (even after reasoning_content / thinking
# fallbacks in _extract_choice_text) marks health as unhealthy.
logging.warning(
"LLM health check returned empty content",
extra={"provider": config.provider, "model": config.model},
)
result: dict[str, Any] = {
"healthy": False,
"provider": config.provider,
"model": config.model,
"response_model": response.model if response else None,
"error_code": "empty_content",
"message": "LLM returned empty response",
}
if include_details:
result["test_prompt"] = _to_code_block(prompt)
result["model_output"] = _to_code_block(None)
return result
result = {
"healthy": True,
"provider": config.provider,
"model": config.model,
"response_model": response.model if response else None,
}
if include_details:
result["test_prompt"] = _to_code_block(prompt)
result["model_output"] = _to_code_block(content)
# Surface reasoning/thinking text separately ONLY when the model
# also returned distinct primary content. If message.content was
# empty, _extract_choice_text already folded the reasoning text
# into `content` above — surfacing it here too would duplicate
# identical text in "Model output" and "Model thinking".
msg = response.choices[0].message
primary_content = _join_text_parts(
_extract_text_parts(_safe_get(msg, "content"))
)
reasoning_text = None
if primary_content:
reasoning_text = (
_join_text_parts(_extract_text_parts(_safe_get(msg, "reasoning_content")))
or _join_text_parts(_extract_text_parts(_safe_get(msg, "thinking")))
)
result["reasoning_content"] = (
_to_code_block(reasoning_text) if reasoning_text else None
)
return result
except Exception as e:
# Log full exception details server-side, but do not expose them to clients
logging.exception(
"LLM health check failed",
extra={"provider": config.provider, "model": config.model},
)
# Provide a minimal, actionable client-facing hint without leaking secrets.
error_code = "health_check_failed"
message = str(e)
if "404" in message and "/v1/v1/" in message:
error_code = "duplicate_v1_path"
elif "404" in message:
error_code = "not_found_404"
elif "<!doctype html" in message.lower() or "<html" in message.lower():
error_code = "html_response"
result = {
"healthy": False,
"provider": config.provider,
"model": config.model,
"error_code": error_code,
}
if include_details:
result["test_prompt"] = _to_code_block(prompt)
result["model_output"] = _to_code_block(None)
# Scrub api-key-like tokens before surfacing the upstream error
# text so the Settings UI can't be used to read back even a
# partially-masked copy of the configured key.
result["error_detail"] = _to_code_block(_scrub_secrets(message))
return result
async def complete(
prompt: str,
system_prompt: str | None = None,
config: LLMConfig | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> str:
"""Make a completion request to the LLM.
Transport retries (429, 500, timeout) are handled by the Router.
"""
router, config = get_router(config)
model_name = get_model_name(config)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
try:
kwargs: dict[str, Any] = {
"model": "primary",
"messages": messages,
"max_tokens": max_tokens,
"timeout": _calculate_timeout("completion", max_tokens, config.provider),
}
if _supports_temperature(model_name, temperature):
kwargs["temperature"] = temperature
if config.reasoning_effort:
kwargs["reasoning_effort"] = config.reasoning_effort
response = await router.acompletion(**kwargs)
content = _extract_choice_text(response.choices[0])
if not content:
raise ValueError("Empty response from LLM")
# Strip thinking tags from reasoning models (deepseek-r1, qwq, etc.)
if "<think>" in content:
content = _strip_thinking_tags(content)
if not content:
raise ValueError("Response contained only thinking content, no output")
return content
except Exception as e:
# Log the actual error server-side for debugging
logging.error(f"LLM completion failed: {e}", extra={
"model": model_name})
raise ValueError(
"LLM completion failed. Please check your API configuration and try again."
) from e
def _supports_json_mode(model_name: str) -> bool:
"""Check if the model supports JSON mode via LiteLLM's model registry.
Queries LiteLLM's model info for every provider (including openai,
anthropic, etc.) so that capability is always determined from the
registry rather than a hardcoded provider list.
Ollama models support JSON mode natively (format="json") but are
often not in LiteLLM's registry (custom/local models), so we
always return True for ollama.
Args:
model_name: LiteLLM-formatted model name (from get_model_name).
"""
# Ollama supports JSON mode natively via format="json" even when
# models aren't in LiteLLM's registry (custom, quantized, etc.)
if model_name.startswith(("ollama/", "ollama_chat/")):
return True
try:
info = litellm.get_model_info(model=model_name)
supported_params = info.get("supported_openai_params", [])
return "response_format" in supported_params
except Exception:
# Model not in LiteLLM's registry — fall back to prompt-only JSON
# mode (the system prompt already instructs "respond with valid JSON
# only"). This avoids sending response_format to models that may
# reject it.
logging.debug("Model %s not in LiteLLM registry, skipping JSON mode", model_name)
return False
def _is_response_format_unsupported(error: Exception) -> bool:
"""Return True if a 400 indicates the server rejected ``response_format``.
Some OpenAI-compatible servers (e.g. LM Studio, older llama.cpp builds) are
reported as supporting ``response_format`` by LiteLLM's registry but reject
the ``{"type": "json_object"}`` we send for JSON mode, returning a 400 such
as ``'response_format.type' must be 'json_schema' or 'text'`` (issue #857).
Detecting this lets ``complete_json`` fall back to prompt-only JSON mode
instead of failing the whole request, while genuine bad requests (e.g.
context-length errors) still propagate.
Requires both a mention of ``response_format`` *and* a rejection/validation
cue, so that an unrelated 400 which merely names the parameter (e.g. a
context-length error) does not trigger a pointless fallback retry. The cue
list stays broad enough to catch varied provider wording ("must be ...",
"not supported", "unsupported", "not allowed", "invalid") rather than any
single provider's exact message.
"""
msg = str(error).lower()
if "response_format" not in msg:
return False
rejection_cues = ("must be", "not support", "unsupported", "not allowed", "invalid")
return any(cue in msg for cue in rejection_cues)
FALLBACK_MAX_TOKENS = 4096
def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKENS) -> int:
"""Return a token count safe for the given model, clamped to its output limit.
Queries LiteLLM's model registry for ``max_output_tokens`` and returns
``min(requested, model_limit)`` so callers never send a value that exceeds
what the backend actually supports.
If the model is not in the registry (e.g. custom Ollama models), it falls
back to a safe conservative limit (FALLBACK_MAX_TOKENS).
Args:
model_name: LiteLLM-formatted model name (from get_model_name).
requested: Desired token budget; defaults to DEFAULT_JSON_MAX_TOKENS.
Returns:
Safe token count, clamped correctly and always >= 1.
"""
safe_requested = max(1, requested)
try:
info = litellm.get_model_info(model=model_name)
model_limit = info.get("max_output_tokens") or info.get("max_tokens")
if model_limit and isinstance(model_limit, int) and model_limit > 0:
safe = min(safe_requested, model_limit)
if safe < safe_requested:
logging.debug(
"max_tokens clamped %d%d for model %s (model limit)",
safe_requested,
safe,
model_name,
)
return safe
except Exception:
pass # Model not in registry, drop down to fallback logic
safe = min(safe_requested, FALLBACK_MAX_TOKENS)
logging.debug(
"Model %s not in LiteLLM registry, clamping requested max_tokens %d%d constraint",
model_name,
safe_requested,
safe,
)
return safe
def _appears_truncated(data: dict, schema_type: str = "resume") -> bool:
"""LLM-001: Check if JSON data appears to be truncated.
Detects suspicious patterns indicating incomplete responses.
The checks are schema-aware so that enrichment/diff/keyword outputs
are not evaluated against resume-structure heuristics.
Args:
data: Parsed JSON dict.
schema_type: Expected schema — "resume" (full resume), "enrichment"
(analyze output), "diff" (diff changes), "keywords", or
"interview_prep".
Determines which fields are checked for truncation.
"""
if not isinstance(data, dict):
return False
if schema_type == "resume":
# Full resume structure: check for empty required arrays
suspicious_empty_arrays = ["workExperience", "education", "skills"]
for key in suspicious_empty_arrays:
if key in data and data[key] == []:
# Log warning - these are rarely empty in real resumes
logging.warning(
"Possible truncation detected: '%s' is empty",
key,
)
return True
return False
if schema_type == "enrichment":
# Enrichment analyze returns items_to_enrich + questions.
# Empty arrays are valid (resume is already strong).
# Only flag if keys are entirely missing (LLM ignored structure).
if "items_to_enrich" not in data or "questions" not in data:
logging.warning(
"Possible truncation detected: enrichment missing required keys"
)
return True
return False
if schema_type == "interview_prep":
required = {
"role_fit_analysis",
"resume_questions",
"project_follow_ups",
"skill_gaps",
"talking_points",
}
missing = required - set(data)
if missing:
logging.warning(
"Possible truncation detected: interview_prep missing required keys: %s",
", ".join(sorted(missing)),
)
return True
return False
# For "diff", "keywords", and unknown schemas: no truncation heuristics.
# Diff may legitimately return empty changes; keywords may return empty
# lists when the job description has no actionable terms.
return False
def _supports_temperature(model_name: str, temperature: float | None = None) -> bool:
"""Check if the model supports the given temperature value.
Uses LiteLLM model registry for capability detection, with
provider-specific fallbacks for known restrictions:
- Anthropic claude-opus-4.*: temperature is deprecated
- Moonshot kimi-k2.6: only temperature=1 allowed
Queries LiteLLM's model info for every provider so that capability is
always determined from the registry rather than a hardcoded list.
Args:
model_name: LiteLLM-formatted model name (from get_model_name).
temperature: The temperature value to check. If None, returns True
(caller isn't setting a specific value).
Returns:
True if the model supports the given temperature, False otherwise.
"""
if temperature is None:
return True
# Ollama models are often not in LiteLLM's registry (custom/local),
# but they universally support temperature.
if model_name.startswith(("ollama/", "ollama_chat/")):
return True
try:
info = litellm.get_model_info(model=model_name)
supported_params = info.get("supported_openai_params", [])
if "temperature" not in supported_params:
return False
except Exception:
# Model not in LiteLLM's registry — be conservative and skip
# temperature to avoid BadRequestError from unsupported params.
logging.debug(
"Model %s not in LiteLLM registry, skipping temperature", model_name
)
return False
# Provider-specific restrictions not captured by the registry.
# Anthropic Opus 4.x deprecated temperature entirely.
if "claude-opus-4" in model_name.lower():
return False
# Moonshot kimi-k2.6 only allows temperature=1.
if "kimi-k2.6" in model_name.lower() and temperature != 1.0:
return False
return True
def _get_retry_temperature(model_name: str, attempt: int, base_temp: float = 0.1) -> float | None:
"""LLM-002: Get temperature for retry attempt.
Returns None if the model does not support temperature at all.
Returns 1.0 for models that only support temperature=1.
Otherwise returns increasing temperatures for retry variation.
"""
# Moonshot kimi-k2.6 only allows temperature=1.
if "kimi-k2.6" in model_name.lower():
return 1.0
if not _supports_temperature(model_name, base_temp):
return None
temperatures = [base_temp, 0.3, 0.5, 0.7]
return temperatures[min(attempt, len(temperatures) - 1)]
def _calculate_timeout(
operation: str,
max_tokens: int = 4096,
provider: str = "openai",
) -> int:
"""LLM-005: Calculate adaptive timeout based on operation and parameters."""
base_timeouts = {
"health_check": LLM_TIMEOUT_HEALTH_CHECK,
"completion": LLM_TIMEOUT_COMPLETION,
"json": LLM_TIMEOUT_JSON,
}
base = base_timeouts.get(operation, LLM_TIMEOUT_COMPLETION)
# Scale by token count (relative to 4096 baseline)
token_factor = max(1.0, max_tokens / 4096)
# Provider-specific latency adjustments
provider_factors = {
"openai": 1.0,
"anthropic": 1.2,
"openrouter": 1.5, # More variable latency
"groq": 1.0,
"ollama": 2.0, # Local models can be slower
}
provider_factor = provider_factors.get(provider, 1.0)
return int(base * token_factor * provider_factor)
def _strip_thinking_tags(content: str) -> str:
"""Strip thinking/reasoning tags from model output.
Ollama thinking models (deepseek-r1, qwq, etc.) wrap their reasoning
in <think>...</think> tags. The actual answer follows after the closing
tag. Strip these so JSON extraction finds the real output.
"""
# Remove <think>...</think> blocks (including multiline)
stripped = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
# Also handle unclosed <think> tag (model may still be "thinking" at end)
stripped = re.sub(r"<think>.*", "", stripped, flags=re.DOTALL)
return stripped.strip()
def _extract_json(content: str, _depth: int = 0) -> str:
"""Extract JSON from LLM response, handling various formats.
LLM-001: Improved to detect and reject likely truncated JSON.
LLM-007: Improved error messages for debugging.
JSON-010: Added recursion depth and size limits.
"""
# JSON-010: Safety limits
if _depth > MAX_JSON_EXTRACTION_RECURSION:
raise ValueError(
f"JSON extraction exceeded max recursion depth: {_depth}")
if len(content) > MAX_JSON_CONTENT_SIZE:
raise ValueError(
f"Content too large for JSON extraction: {len(content)} bytes")
original = content
# Strip thinking model tags (deepseek-r1, qwq, etc.)
if "<think>" in content:
content = _strip_thinking_tags(content)
# Remove markdown code blocks
if "```json" in content:
content = content.split("```json")[1].split("```")[0]
elif "```" in content:
parts = content.split("```")
if len(parts) >= 2:
content = parts[1]
# Remove language identifier if present (e.g., "json\n{...")
if content.startswith(("json", "JSON")):
content = content[4:]
content = content.strip()
# If content starts with {, find the matching }
if content.startswith("{"):
depth = 0
end_idx = -1
in_string = False
escape_next = False
for i, char in enumerate(content):
if escape_next:
escape_next = False
continue
if char == "\\":
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if in_string:
continue
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
end_idx = i
break
# LLM-001: Check for unbalanced braces - loop ended without depth reaching 0
if end_idx == -1 and depth != 0:
logging.warning(
"JSON extraction found unbalanced braces (depth=%d), possible truncation",
depth,
)
if end_idx != -1:
return content[: end_idx + 1]
# Try to find JSON object in the content (only if not already at start)
start_idx = content.find("{")
if start_idx > 0:
# Only recurse if { is found after position 0 to avoid infinite recursion
return _extract_json(content[start_idx:], _depth + 1)
# LLM-007: Log unrecognized format for debugging
logging.error(
"Could not extract JSON from response format. Content preview: %s",
content[:200] if content else "<empty>",
)
raise ValueError(f"No JSON found in response: {original[:200]}")
async def complete_json(
prompt: str,
system_prompt: str | None = None,
config: LLMConfig | None = None,
max_tokens: int = 4096,
retries: int = 2,
schema_type: str = "resume",
) -> dict[str, Any]:
"""Make a completion request expecting JSON response.
Uses JSON mode when available, with app-level retries for content-quality
issues (malformed JSON, truncation). Transport retries (429, 500, timeout)
are handled by the Router and are NOT retried again here.
Args:
schema_type: Expected schema — "resume", "enrichment", "diff",
"keywords", or "interview_prep". Passed to _appears_truncated for
context-aware truncation detection and used to tailor retry hints.
"""
router, config = get_router(config)
model_name = get_model_name(config)
# Build messages
json_system = (
system_prompt or ""
) + "\n\nYou must respond with valid JSON only. No explanations, no markdown."
messages = [
{"role": "system", "content": json_system},
{"role": "user", "content": prompt},
]
# Check if we can use JSON mode
use_json_mode = _supports_json_mode(model_name)
json_mode_failed = False
for attempt in range(retries + 1):
try:
kwargs: dict[str, Any] = {
"model": "primary",
"messages": messages,
"max_tokens": max_tokens,
"timeout": _calculate_timeout("json", max_tokens, config.provider),
}
# LLM-002: Increase temperature on retry for variation
retry_temp = _get_retry_temperature(model_name, attempt)
if retry_temp is not None:
kwargs["temperature"] = retry_temp
if config.reasoning_effort:
kwargs["reasoning_effort"] = config.reasoning_effort
# JSON-012: Fallback to prompt-only JSON mode after JSON-mode failure.
# LiteLLM registry may report support for models that the upstream
# aggregator (OpenRouter) cannot actually serve with response_format.
if use_json_mode and not json_mode_failed:
kwargs["response_format"] = {"type": "json_object"}
response = await router.acompletion(**kwargs)
content = _extract_choice_text(response.choices[0])
if not content:
raise ValueError("Empty response from LLM")
logging.debug(
f"LLM response (attempt {attempt + 1}): {content[:300]}")
# Extract and parse JSON
json_str = _extract_json(content)
result = json.loads(json_str)
# LLM-001: Check if parsed result appears truncated
if isinstance(result, dict) and _appears_truncated(result, schema_type):
if attempt < retries:
logging.warning(
"Parsed JSON appears truncated (attempt %d/%d), retrying",
attempt + 1,
retries + 1,
)
if schema_type == "resume":
hint = (
"\n\nIMPORTANT: Output the COMPLETE JSON object with ALL sections. Do not truncate."
)
elif schema_type == "enrichment":
hint = (
"\n\nIMPORTANT: Output the COMPLETE JSON object with ALL keys: items_to_enrich, questions, analysis_summary. Do not truncate."
)
elif schema_type == "interview_prep":
hint = (
"\n\nIMPORTANT: Output the COMPLETE JSON object with ALL keys: role_fit_analysis, resume_questions, project_follow_ups, skill_gaps, talking_points. Do not truncate."
)
else:
hint = (
"\n\nIMPORTANT: Output ONLY a valid JSON object. Start with { and end with }."
)
messages[-1]["content"] = prompt + hint
continue
logging.warning(
"Parsed JSON appears truncated on final attempt, proceeding with result"
)
return result
except json.JSONDecodeError as e:
# Content quality — malformed JSON, retry with prompt hint
logging.warning(f"JSON parse failed (attempt {attempt + 1}): {e}")
if use_json_mode and not json_mode_failed:
# JSON-012: Registry claimed JSON mode support but the upstream
# failed to return valid JSON. Disable JSON mode for retries.
json_mode_failed = True
logging.warning(
"JSON mode failed for %s, falling back to prompt-only (attempt %d)",
model_name, attempt + 1,
)
if attempt < retries:
messages[-1]["content"] = (
prompt
+ "\n\nIMPORTANT: Output ONLY a valid JSON object. Start with { and end with }."
)
continue
raise ValueError(
f"Failed to parse JSON after {retries + 1} attempts: {e}")
except ValueError as e:
# Content quality — empty response, JSON extraction failure
logging.warning(f"Content extraction failed (attempt {attempt + 1}): {e}")
if attempt < retries:
continue
raise
except litellm.BadRequestError as e:
# JSON-012b: some OpenAI-compatible servers (e.g. LM Studio) report
# response_format support via the registry but reject
# {"type": "json_object"} with a 400 (issue #857). The Router does
# not retry bad requests, so recover here by disabling JSON mode and
# retrying prompt-only. Unrelated 400s (e.g. context length) still
# propagate.
if (
use_json_mode
and not json_mode_failed
and _is_response_format_unsupported(e)
):
json_mode_failed = True
logging.warning(
"Provider rejected response_format for %s; falling back to "
"prompt-only JSON mode (attempt %d)",
model_name,
attempt + 1,
)
if attempt < retries:
continue
raise
except Exception:
# Transport errors — Router already retried with backoff.
# Cooldowns are disabled (see _build_router); no additional
# retry is attempted here.
raise
raise ValueError(f"Failed after {retries + 1} attempts")