""" Utility for logging traces from LLM calls. This module provides structured JSONL logging of LLM inputs/outputs. """ import fcntl import json import time from pathlib import Path from typing import Any from pydantic import BaseModel from src.config import ( ConfiguredModelSettings, ModelConfig, settings, ) def get_reasoning_traces_file_path() -> Path | None: """Get the traces file path from settings.""" if settings.REASONING_TRACES_FILE: return Path(settings.REASONING_TRACES_FILE) return None def log_reasoning_trace( task_type: str, model_config: ModelConfig | ConfiguredModelSettings, prompt: str, response: Any, *, max_tokens: int | None = None, thinking_budget_tokens: int | None = None, reasoning_effort: str | None = None, json_mode: bool = False, stop_seqs: list[str] | None = None, messages: list[dict[str, Any]] | None = None, ) -> None: """ Log a trace to the configured JSONL file. Args: task_type: Type of task (e.g., "minimal_deriver", "dialectic_chat") model_config: Model configuration used for the call prompt: The full prompt text sent to the LLM (used if messages is None) response: HonchoLLMCallResponse object with the LLM response max_tokens: Max output tokens setting thinking_budget_tokens: Anthropic thinking budget (if used) reasoning_effort: OpenAI reasoning effort (if used) json_mode: Whether JSON mode was enabled stop_seqs: Stop sequences used (if any) messages: Full conversation history for multi-turn/agentic calls """ traces_file = get_reasoning_traces_file_path() if not traces_file: return # Serialize response content - handle Pydantic models content = response.content if isinstance(content, BaseModel): content = content.model_dump() trace_entry: dict[str, Any] = { "timestamp": time.time(), "task_type": task_type, "provider": model_config.transport, "model": model_config.model, "settings": { "max_tokens": max_tokens, "thinking_budget_tokens": thinking_budget_tokens, "reasoning_effort": reasoning_effort, "json_mode": json_mode, "stop_seqs": stop_seqs, }, "input": { "tokens": response.input_tokens, }, "output": { "content": content, "tokens": response.output_tokens, "finish_reasons": response.finish_reasons, "thinking_content": response.thinking_content, }, } # Use messages for multi-turn/agentic calls, otherwise use prompt if messages is not None: trace_entry["input"]["messages"] = messages else: trace_entry["input"]["prompt"] = prompt # Include tool calls if present if hasattr(response, "tool_calls_made") and response.tool_calls_made: trace_entry["output"]["tool_calls"] = response.tool_calls_made # Use file locking to handle concurrent writes from multiple processes with open(traces_file, "a") as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX) f.write(json.dumps(trace_entry) + "\n") fcntl.flock(f.fileno(), fcntl.LOCK_UN)