Metropolis-Chess-Club / models /claude_api.py
Forkei's picture
Upload folder using huggingface_hub
52a4f3c verified
"""
Claude API wrapper for the Chess Master agent (fallback).
Handles:
- Structured JSON output via response_format
- Tool use / function calling
- Error handling and retries
- Configuration from settings
"""
import json
import asyncio
import logging
from typing import Optional, Dict, Any
import anthropic
from anthropic import APIError, RateLimitError
from config.settings import CLAUDE_API_KEY, CLAUDE_MODEL
logger = logging.getLogger(__name__)
class ClaudeClient:
"""
Claude API client for Chess Master agent (fallback to Gemini).
Supports:
- Structured JSON output
- Tool use / function calling
- Automatic retries with exponential backoff
"""
def __init__(
self,
api_key: Optional[str] = None,
model: Optional[str] = None,
temperature: float = 0.8,
max_tokens: int = 1024,
max_retries: int = 3,
):
"""
Initialize Claude client.
Args:
api_key: Claude API key (defaults to CLAUDE_API_KEY from config)
model: Model name (defaults to CLAUDE_MODEL from config)
temperature: Sampling temperature (0.0-1.0)
max_tokens: Maximum tokens in response
max_retries: Number of retries on failure
"""
self.api_key = api_key or CLAUDE_API_KEY
self.model = model or CLAUDE_MODEL
self.temperature = temperature
self.max_tokens = max_tokens
self.max_retries = max_retries
if not self.api_key:
raise ValueError("CLAUDE_API_KEY not set in environment or config")
self.client = anthropic.Anthropic(api_key=self.api_key)
self.call_count = 0
self.error_count = 0
async def respond(
self,
system_prompt: str,
user_prompt: str,
player_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Generate a response from Claude with structured JSON output.
Args:
system_prompt: System context and personality
user_prompt: User input/game context
player_id: Optional player ID for context
Returns:
Parsed JSON response with action, content, tone, etc.
"""
self.call_count += 1
for attempt in range(self.max_retries):
try:
logger.debug(f"Claude API call #{self.call_count}, attempt {attempt + 1}")
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None,
lambda: self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
temperature=self.temperature,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
)
)
# Parse response
response_text = response.content[0].text
parsed = self._parse_response(response_text)
logger.info(f"Claude response: action={parsed.get('action')}")
return parsed
except (RateLimitError, APIError) as e:
self.error_count += 1
logger.warning(f"Claude API error (attempt {attempt + 1}/{self.max_retries}): {e}")
if attempt < self.max_retries - 1:
wait_time = 2 ** attempt
logger.debug(f"Retrying in {wait_time}s...")
await asyncio.sleep(wait_time)
else:
logger.error(f"Failed after {self.max_retries} attempts")
raise
except json.JSONDecodeError as e:
self.error_count += 1
logger.error(f"Failed to parse JSON response: {e}")
raise
raise RuntimeError("All retries exhausted")
def _build_full_prompt(self, system_prompt: str, user_prompt: str) -> str:
"""
Combine system and user prompts with JSON instruction.
"""
return f"""{user_prompt}
---
IMPORTANT: You must respond ONLY with valid JSON. No markdown, no extra text.
The JSON must have this structure:
{{
"thinking": "optional pre-response reasoning",
"action": "send_message | stop | save_memory | set_emotion",
"content": "the message, memory content, or emotion",
"tone": "optional tone indicator",
"metadata": {{optional additional context}}
}}
Required fields: action, content
Optional fields: thinking, tone, metadata
Examples:
{{"action": "send_message", "content": "Nice move.", "tone": "respectful"}}
{{"action": "save_memory", "content": "Alice always plays the Sicilian", "memory_type": "player_behavior"}}
{{"action": "stop"}}
"""
def _parse_response(self, response_text: str) -> Dict[str, Any]:
"""
Parse Claude's JSON response.
Handles:
- Extracting JSON from markdown code blocks
- Validating required fields
- Type conversion
"""
text = response_text.strip()
# Try to extract JSON from markdown code block
if text.startswith("```"):
lines = text.split("\n")
json_lines = [l for l in lines[1:] if l and not l.startswith("```")]
text = "\n".join(json_lines)
# Parse JSON
try:
parsed = json.loads(text)
except json.JSONDecodeError:
logger.error(f"Invalid JSON response: {text[:200]}")
raise
# Validate required fields
if "action" not in parsed:
raise ValueError(f"Missing 'action' field in response: {parsed}")
if "content" not in parsed and parsed.get("action") != "stop":
raise ValueError(f"Missing 'content' field in response: {parsed}")
# Ensure action is valid
valid_actions = ["send_message", "stop", "save_memory", "set_emotion"]
if parsed["action"] not in valid_actions:
raise ValueError(f"Invalid action '{parsed['action']}'. Must be one of: {valid_actions}")
# Set defaults
if "tone" not in parsed:
parsed["tone"] = None
if "metadata" not in parsed:
parsed["metadata"] = {}
return parsed
def get_stats(self) -> Dict[str, Any]:
"""Get API call statistics."""
return {
"total_calls": self.call_count,
"total_errors": self.error_count,
"error_rate": self.error_count / self.call_count if self.call_count > 0 else 0,
}
def reset_stats(self) -> None:
"""Reset API statistics."""
self.call_count = 0
self.error_count = 0
__all__ = ["ClaudeClient"]