Vital / llm /client.py
eddyejembi's picture
Upload folder using huggingface_hub
4e88df3 verified
Raw
History Blame Contribute Delete
27.8 kB
"""OpenAI-compatible LLM client for Modal vLLM with local tool execution."""
import json
import logging
import re
import time
from typing import cast
from openai import OpenAI
from openai.types.chat import ChatCompletionMessageParam
from vital_types.llm import ChatMessage, LlmResponse, ToolCallRequest
from llm.chat_helpers import (
extract_food_description,
infer_meal_type_from_text,
turn_had_successful_tool,
user_requests_food_log,
recent_user_text,
)
from llm.config import LlmConfig, get_llm_config
from llm.system_prompt import build_system_prompt, trim_prompt_to_budget
from llm.tool_runner import execute_tool, tool_result_to_json
from llm.tools import TOOL_SCHEMAS
logger = logging.getLogger(__name__)
_JSON_BLOCK_PATTERN = re.compile(r"\{[\s\S]*\}")
def _json_schema_response_format(schema: dict[str, object]) -> dict[str, object]:
"""Build a schema-constrained response_format for vLLM structured outputs."""
return {
"type": "json_schema",
"json_schema": {"name": "structured_output", "schema": schema},
}
_PROMPT_TOOL_PATTERN = re.compile(
r'\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{[\s\S]*?\})\s*\}'
)
# Schema for the constrained retry when a free-form coach reply fails
# validation. Tool calls and constrained decoding cannot be combined in one
# request, so the tool loop runs free and this schema guards the final answer.
COACH_REPLY_JSON_SCHEMA: dict[str, object] = {
"type": "object",
"properties": {
"reply": {
"type": "string",
"minLength": 1,
"maxLength": 2000,
"description": "The coach's conversational answer to the user, plain text.",
},
},
"required": ["reply"],
"additionalProperties": False,
}
_MAX_CHAT_REPLY_CHARS = 4000
def _validate_chat_reply(content: str) -> str | None:
"""Return a cleaned coach reply, or None when the text fails validation.
Rejects empty output and replies that are leaked JSON (tool-call spill or
a raw object instead of prose), which Nemotron occasionally emits.
"""
trimmed = content.strip()
if not trimmed:
return None
if trimmed.startswith("{") and trimmed.endswith("}"):
return None
if _PROMPT_TOOL_PATTERN.search(trimmed):
return None
if len(trimmed) > _MAX_CHAT_REPLY_CHARS:
trimmed = trimmed[:_MAX_CHAT_REPLY_CHARS].rstrip() + "..."
return trimmed
class LlmClient:
"""Calls the remote Nemotron endpoint and runs tools locally."""
def __init__(self, config: LlmConfig | None = None):
self.config = config or get_llm_config()
self._openai = OpenAI(
base_url=self.config.base_url,
api_key=self.config.api_key,
timeout=self.config.request_timeout_seconds,
)
def _to_api_messages(self, messages: list[ChatMessage]) -> list[ChatCompletionMessageParam]:
"""Convert internal messages to OpenAI API format."""
api_messages: list[ChatCompletionMessageParam] = []
for message in messages:
if message.role == "tool":
api_messages.append(
{
"role": "tool",
"content": message.content,
"tool_call_id": message.tool_call_id or "",
}
)
continue
api_messages.append(
{
"role": message.role,
"content": message.content,
}
)
return api_messages
def _parse_tool_arguments(self, raw_arguments: str) -> dict[str, object]:
"""Parse tool call arguments JSON from the model."""
if not raw_arguments.strip():
return {}
parsed = json.loads(raw_arguments)
if not isinstance(parsed, dict):
raise ValueError("Tool arguments must be a JSON object.")
return cast(dict[str, object], parsed)
def _extract_prompt_based_tool_call(self, content: str) -> ToolCallRequest | None:
"""Fallback: parse {"tool": "...", "args": {...}} from plain text."""
match = _PROMPT_TOOL_PATTERN.search(content)
if match is None:
return None
tool_name = match.group(1)
args_json = match.group(2)
arguments = self._parse_tool_arguments(args_json)
return ToolCallRequest(id="prompt_tool_0", name=tool_name, arguments=arguments)
def _completion_to_response(
self,
message_content: str | None,
message,
finish_reason: str | None = None,
) -> LlmResponse:
"""Map an OpenAI completion message to an internal LlmResponse."""
tool_calls: list[ToolCallRequest] = []
raw_tool_calls = getattr(message, "tool_calls", None)
if raw_tool_calls:
for index, tool_call in enumerate(raw_tool_calls):
function_block = tool_call.function
arguments = self._parse_tool_arguments(function_block.arguments or "{}")
tool_calls.append(
ToolCallRequest(
id=tool_call.id or f"tool_{index}",
name=function_block.name,
arguments=arguments,
)
)
content = message_content or ""
if not tool_calls:
fallback = self._extract_prompt_based_tool_call(content)
if fallback is not None:
tool_calls = [fallback]
content = ""
resolved_finish_reason = finish_reason or getattr(message, "finish_reason", None)
return LlmResponse(
content=content,
tool_calls=tool_calls,
raw_finish_reason=resolved_finish_reason,
)
def complete(
self,
messages: list[ChatMessage],
use_tools: bool = True,
json_mode: bool = False,
temperature: float | None = None,
json_schema: dict[str, object] | None = None,
) -> LlmResponse:
"""Send one chat completion request to the remote LLM."""
api_messages = self._to_api_messages(messages)
request_temperature = temperature
if request_temperature is None:
request_temperature = (
self.config.json_temperature if json_mode else self.config.tool_temperature
)
kwargs: dict[str, object] = {
"model": self.config.model_id,
"messages": api_messages,
"temperature": request_temperature,
"max_tokens": self.config.max_tokens,
}
if use_tools:
kwargs["tools"] = TOOL_SCHEMAS
kwargs["tool_choice"] = "auto"
if json_schema is not None:
kwargs["response_format"] = _json_schema_response_format(json_schema)
elif json_mode:
kwargs["response_format"] = {"type": "json_object"}
completion = self._openai.chat.completions.create(**kwargs)
choice = completion.choices[0]
message = choice.message
return self._completion_to_response(
message.content,
message,
finish_reason=choice.finish_reason,
)
def _run_completion_on_api_messages(
self,
api_messages: list[ChatCompletionMessageParam],
use_tools: bool,
json_mode: bool = False,
temperature: float | None = None,
tools_override: list[dict[str, object]] | None = None,
max_tokens: int | None = None,
json_schema: dict[str, object] | None = None,
) -> LlmResponse:
"""Send a completion using raw OpenAI API message dicts."""
request_temperature = temperature
if request_temperature is None:
request_temperature = (
self.config.json_temperature if json_mode else self.config.tool_temperature
)
kwargs: dict[str, object] = {
"model": self.config.model_id,
"messages": api_messages,
"temperature": request_temperature,
"max_tokens": max_tokens if max_tokens is not None else self.config.max_tokens,
}
if use_tools:
kwargs["tools"] = tools_override if tools_override is not None else TOOL_SCHEMAS
kwargs["tool_choice"] = "auto"
if json_schema is not None:
kwargs["response_format"] = _json_schema_response_format(json_schema)
elif json_mode:
kwargs["response_format"] = {"type": "json_object"}
completion = self._openai.chat.completions.create(**kwargs)
choice = completion.choices[0]
message = choice.message
return self._completion_to_response(
message.content,
message,
finish_reason=choice.finish_reason,
)
def chat(
self,
user_message: str,
extra_messages: list[ChatMessage] | None = None,
use_tools: bool = True,
) -> str:
"""Run a full coach conversation turn with optional tool loop."""
system_prompt = trim_prompt_to_budget(
build_system_prompt(include_tools_instruction=use_tools),
self.config.context_limit_tokens,
)
api_messages: list[ChatCompletionMessageParam] = [
{"role": "system", "content": system_prompt},
]
if extra_messages:
api_messages.extend(self._to_api_messages(extra_messages))
api_messages.append({"role": "user", "content": user_message})
food_log_requested = user_requests_food_log(user_message, extra_messages)
for _iteration in range(self.config.max_tool_iterations):
response = self._run_completion_on_api_messages(api_messages, use_tools=use_tools)
if not response.tool_calls:
if food_log_requested and not turn_had_successful_tool(api_messages, "log_food"):
logger.warning(
"[chat] User requested food log but model replied without log_food — "
"nudging for write tool.",
)
api_messages.append(
{
"role": "user",
"content": (
"Before answering, call log_food now with meal_type and "
"food_description from what I said I ate. Do not claim you "
"logged anything until log_food returns success:true."
),
}
)
continue
validated_reply = _validate_chat_reply(response.content)
if validated_reply is not None:
return validated_reply
logger.warning(
"[chat] Free-form reply failed validation (%s chars) — "
"retrying with strict reply schema.",
len(response.content),
)
break
assistant_tool_calls: list[dict[str, object]] = []
for tool_call in response.tool_calls:
assistant_tool_calls.append(
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": json.dumps(tool_call.arguments),
},
}
)
# Drop assistant text when tool calls are present — Nemotron often
# streams a partial answer in content alongside tools, which breaks
# the tool loop and can interleave with local tool trace output.
api_messages.append(
{
"role": "assistant",
"content": "" if response.tool_calls else (response.content or ""),
"tool_calls": assistant_tool_calls,
}
)
for tool_call in response.tool_calls:
result = execute_tool(tool_call.name, tool_call.arguments)
if result.success:
logger.info(
"[chat] Tool %s OK — %s",
tool_call.name,
json.dumps(result.result)[:200],
)
else:
logger.warning(
"[chat] Tool %s failed: %s (args=%s)",
tool_call.name,
result.error,
json.dumps(tool_call.arguments),
)
api_messages.append(
{
"role": "tool",
"content": tool_result_to_json(result),
"tool_call_id": tool_call.id,
}
)
if food_log_requested and not turn_had_successful_tool(api_messages, "log_food"):
context_text = recent_user_text(user_message, extra_messages)
description = extract_food_description(context_text)
if description:
meal_type = infer_meal_type_from_text(context_text)
fallback = execute_tool(
"log_food",
{
"meal_type": meal_type,
"food_description": description,
},
)
if fallback.success:
logger.info(
"[chat] Fallback log_food saved %s: %s",
meal_type,
description,
)
return (
f"Got it — I've logged your {meal_type}: {description}. "
"You can see it on the Nutrition tab."
)
logger.warning("[chat] Fallback log_food failed: %s", fallback.error)
return self._final_chat_reply_with_schema(api_messages)
def _final_chat_reply_with_schema(
self,
api_messages: list[ChatCompletionMessageParam],
) -> str:
"""Force the final coach answer through the strict reply schema.
Mirrors the onboarding/daily-planner approach: constrained decoding
guarantees shape, then the reply field is validated before use.
"""
constrained_messages = list(api_messages)
constrained_messages.append(
{
"role": "user",
"content": (
"Respond to the conversation above with your final answer as JSON "
'matching {"reply": "<your answer>"}. The reply must be plain '
"conversational text — no tool calls, no nested JSON."
),
}
)
response: LlmResponse | None = None
try:
response = self._run_completion_on_api_messages(
constrained_messages,
use_tools=False,
json_mode=True,
json_schema=COACH_REPLY_JSON_SCHEMA,
)
except Exception as error:
# Older vLLM builds reject json_schema response_format; fall back.
logger.warning(
"[chat] json_schema response_format failed (%s); "
"falling back to json_object mode.",
error,
)
if response is None:
response = self._run_completion_on_api_messages(
constrained_messages,
use_tools=False,
json_mode=True,
)
try:
payload = self._parse_json_object(response.content, log_context="chat")
reply_value = payload.get("reply")
if isinstance(reply_value, str):
validated_reply = _validate_chat_reply(reply_value)
if validated_reply is not None:
return validated_reply
except ValueError as error:
logger.warning("[chat] Schema-constrained reply was not valid JSON: %s", error)
# Last resort: surface whatever text exists rather than an empty bubble.
fallback = response.content.strip()
if fallback:
return fallback
return "I couldn't put together a reply just now. Please try again."
def generate_json(
self,
user_prompt: str,
system_addition: str = "",
retry_once: bool = True,
) -> dict[str, object]:
"""Request structured JSON output with validation and one retry."""
system_prompt = trim_prompt_to_budget(
build_system_prompt(include_tools_instruction=False),
self.config.context_limit_tokens,
)
if system_addition.strip():
system_prompt = f"{system_prompt}\n\n{system_addition.strip()}"
messages: list[ChatMessage] = [
ChatMessage(role="system", content=system_prompt),
ChatMessage(role="user", content=user_prompt),
]
last_error = "Unknown JSON parse error."
attempts = 2 if retry_once else 1
for attempt in range(attempts):
response = self.complete(messages, use_tools=False, json_mode=True)
try:
return self._parse_json_object(response.content)
except ValueError as error:
last_error = str(error)
if attempt + 1 >= attempts:
break
messages.append(
ChatMessage(
role="user",
content=(
"Your previous response was not valid JSON matching the required "
f"schema. Error: {last_error}. Return ONLY valid JSON."
),
)
)
raise ValueError(f"Failed to parse JSON from LLM response: {last_error}")
def generate_onboarding_json(
self,
user_prompt: str,
system_prompt: str,
retry_once: bool = True,
json_schema: dict[str, object] | None = None,
) -> dict[str, object]:
"""Request onboarding JSON using a dedicated system prompt (no runtime DB context)."""
from llm.system_prompt import ROLE_DEFINITION, SAFETY_GUARDRAIL
full_system = trim_prompt_to_budget(
f"{ROLE_DEFINITION}\n\n{SAFETY_GUARDRAIL}\n\n{system_prompt.strip()}",
self.config.context_limit_tokens,
)
messages: list[ChatMessage] = [
ChatMessage(role="system", content=full_system),
ChatMessage(role="user", content=user_prompt),
]
last_error = "Unknown JSON parse error."
attempts = 2 if retry_once else 1
schema_supported = json_schema is not None
for attempt in range(attempts):
response: LlmResponse | None = None
if schema_supported and json_schema is not None:
try:
response = self.complete(
messages,
use_tools=False,
json_mode=True,
json_schema=json_schema,
)
except Exception as error:
# Older vLLM builds reject json_schema; fall back permanently.
logger.warning(
"[onboarding] json_schema response_format failed (%s); "
"falling back to json_object mode.",
error,
)
schema_supported = False
if response is None:
response = self.complete(messages, use_tools=False, json_mode=True)
try:
return self._parse_json_object(response.content, log_context="onboarding")
except ValueError as error:
last_error = str(error)
if attempt + 1 >= attempts:
break
messages.append(
ChatMessage(
role="user",
content=(
"Your previous response was not valid JSON matching the required "
f"schema. Error: {last_error}. Return ONLY valid JSON."
),
)
)
raise ValueError(f"Failed to parse JSON from LLM response: {last_error}")
def _build_daily_schedule_messages(
self,
full_system: str,
user_prompt: str,
last_error: str,
attempt: int,
) -> list[ChatCompletionMessageParam]:
"""Start a fresh message list for one daily-plan attempt."""
messages: list[ChatCompletionMessageParam] = [
{"role": "system", "content": full_system},
{"role": "user", "content": user_prompt},
]
if attempt > 0:
messages.append(
{
"role": "user",
"content": (
"Your previous JSON plan failed to parse. "
f"Error: {last_error}. "
"Return ONE compact valid JSON object: 5 hydration jobs, "
"3 meals, 1 exercise. Keep each context under 60 characters. "
"No markdown, no commentary."
),
}
)
return messages
def _request_daily_plan_json(
self,
api_messages: list[ChatCompletionMessageParam],
json_schema: dict[str, object] | None,
) -> dict[str, object]:
"""Request the daily-plan JSON, preferring schema-constrained decoding."""
schedule_tokens = self.config.daily_schedule_max_tokens
json_response: LlmResponse | None = None
if json_schema is not None:
try:
json_response = self._run_completion_on_api_messages(
api_messages,
use_tools=False,
json_mode=True,
max_tokens=schedule_tokens,
json_schema=json_schema,
)
except Exception as error:
# Older vLLM builds reject json_schema response_format; fall back.
logger.warning(
"[daily_schedule] json_schema response_format failed (%s); "
"falling back to json_object mode.",
error,
)
json_response = None
if json_response is None:
json_response = self._run_completion_on_api_messages(
api_messages,
use_tools=False,
json_mode=True,
max_tokens=schedule_tokens,
)
if not json_response.content.strip():
logger.warning(
"[daily_schedule] json_mode returned empty content; retrying without response_format."
)
api_messages.append(
{
"role": "user",
"content": "Output ONLY the JSON object. No other text before or after.",
}
)
json_response = self._run_completion_on_api_messages(
api_messages,
use_tools=False,
json_mode=False,
max_tokens=schedule_tokens,
)
if json_response.raw_finish_reason == "length":
preview = json_response.content[:300]
logger.warning(
"[daily_schedule] JSON truncated at %s tokens. Preview: %s...",
schedule_tokens,
preview,
)
raise ValueError(
f"JSON response truncated (max_tokens={schedule_tokens})."
)
return self._parse_json_object(json_response.content, log_context="daily_schedule")
def generate_daily_schedule_json(
self,
user_prompt: str,
system_prompt: str,
json_schema: dict[str, object] | None = None,
) -> dict[str, object]:
"""Generate the daily plan in one schema-constrained call with retries.
All planning context (profile, frameworks, meds, logs, weather) is
embedded in the user prompt, so no tool loop is needed — this keeps
the full 8K context window available for the JSON output.
"""
from llm.system_prompt import ROLE_DEFINITION, SAFETY_GUARDRAIL
full_system = trim_prompt_to_budget(
f"{ROLE_DEFINITION}\n\n{SAFETY_GUARDRAIL}\n\n{system_prompt.strip()}",
self.config.context_limit_tokens,
)
last_error = "Unknown daily schedule error."
attempts = self.config.daily_schedule_max_attempts
for attempt in range(attempts):
api_messages = self._build_daily_schedule_messages(
full_system,
user_prompt,
last_error,
attempt,
)
try:
return self._request_daily_plan_json(api_messages, json_schema)
except Exception as error:
last_error = str(error)
logger.warning(
"[daily_schedule] Attempt %s/%s failed: %s",
attempt + 1,
attempts,
last_error,
)
if attempt + 1 >= attempts:
break
delay = self.config.daily_schedule_retry_delay_seconds
logger.info(
"[daily_schedule] Retrying in %ss with a fresh context...",
delay,
)
time.sleep(delay)
raise ValueError(f"Daily schedule LLM failed after {attempts} attempts: {last_error}")
def _parse_json_object(
self,
content: str,
log_context: str = "llm",
) -> dict[str, object]:
"""Extract and validate a JSON object from model output."""
stripped = content.strip()
if not stripped:
logger.warning("[%s] Empty JSON response from model.", log_context)
raise ValueError("Empty JSON response.")
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict):
return cast(dict[str, object], parsed)
except json.JSONDecodeError as error:
logger.warning(
"[%s] JSON parse error: %s. Response preview: %s",
log_context,
error,
stripped[:400],
)
match = _JSON_BLOCK_PATTERN.search(stripped)
if match is None:
raise ValueError("No JSON object found in response.")
try:
parsed = json.loads(match.group(0))
except json.JSONDecodeError as error:
logger.warning(
"[%s] Extracted JSON block still invalid: %s. Block preview: %s",
log_context,
error,
match.group(0)[:400],
)
raise ValueError(f"Malformed JSON in response: {error}") from error
if not isinstance(parsed, dict):
raise ValueError("JSON response must be an object.")
return cast(dict[str, object], parsed)
_client: LlmClient | None = None
def get_llm_client() -> LlmClient:
"""Return the shared LLM client instance."""
global _client
if _client is None:
_client = LlmClient()
return _client
def reset_llm_client() -> None:
"""Reset the shared client (used by tests)."""
global _client
_client = None