Skip to content

Commit cffdf87

Browse files
S1M0N38claude
andcommitted
feat(bot): implement LLM-powered Balatro bot
Add core LLM bot implementation with OpenAI integration, template rendering, and game state management. Includes proxy validation and tool execution. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2231d87 commit cffdf87

1 file changed

Lines changed: 252 additions & 0 deletions

File tree

src/balatrollm/llm.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""LLM Bot implementation using BalatroClient"""
2+
3+
import asyncio
4+
import json
5+
import logging
6+
import os
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import httpx
11+
from balatrobot import BalatroClient
12+
from balatrobot.enums import State
13+
from jinja2 import Environment, FileSystemLoader
14+
from openai import AsyncOpenAI
15+
from openai.types.chat import ChatCompletion, ChatCompletionMessageToolCall
16+
17+
logger = logging.getLogger(__name__)
18+
logging.basicConfig(level=logging.INFO)
19+
20+
21+
class LLMBot:
22+
"""LLM-powered Balatro bot"""
23+
24+
def __init__(
25+
self,
26+
model: str,
27+
proxy_url: str = "http://localhost:4000",
28+
api_key: str = "sk-balatrollm-proxy-key",
29+
):
30+
self.llm_client = AsyncOpenAI(api_key=api_key, base_url=f"{proxy_url}/v1")
31+
self.model = model
32+
self.proxy_url = proxy_url
33+
self.api_key = api_key
34+
self.balatro_client = BalatroClient()
35+
36+
# Set up Jinja2 templates
37+
template_dir = Path(__file__).parent / "templates"
38+
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
39+
self.jinja_env.filters["from_json"] = json.loads
40+
self.responses: list[ChatCompletion] = []
41+
42+
# Load tools from JSON file
43+
tools_file = Path(__file__).parent / "tools.json"
44+
with open(tools_file) as f:
45+
self.tools = json.load(f)
46+
47+
async def validate_proxy_connection(self) -> bool:
48+
"""Validate that the LiteLLM proxy is running and accessible"""
49+
try:
50+
async with httpx.AsyncClient() as client:
51+
# Check if proxy health endpoint is available
52+
headers = {"Authorization": f"Bearer {self.api_key}"}
53+
response = await client.get(
54+
f"{self.proxy_url}/health", timeout=5.0, headers=headers
55+
)
56+
if response.status_code == 200:
57+
logger.info(f"LiteLLM proxy is running at {self.proxy_url}")
58+
return True
59+
else:
60+
logger.error(
61+
f"LiteLLM proxy health check failed: {response.status_code}"
62+
)
63+
return False
64+
except httpx.RequestError as e:
65+
logger.error(f"Failed to connect to LiteLLM proxy at {self.proxy_url}: {e}")
66+
return False
67+
68+
async def list_available_models(self) -> list[str]:
69+
"""Get list of available models from the LiteLLM proxy"""
70+
try:
71+
models = await self.llm_client.models.list()
72+
return [model.id for model in models.data]
73+
except Exception as e:
74+
logger.error(f"Failed to get models from proxy: {e}")
75+
return []
76+
77+
async def validate_model_exists(self) -> bool:
78+
"""Validate that the specified model exists in the proxy"""
79+
available_models = await self.list_available_models()
80+
if self.model in available_models:
81+
logger.info(f"Model '{self.model}' is available")
82+
return True
83+
else:
84+
logger.error(
85+
f"Model '{self.model}' not found. Available models: {available_models}"
86+
)
87+
return False
88+
89+
def __enter__(self):
90+
self.balatro_client.connect()
91+
return self
92+
93+
def __exit__(self, exc_type, exc_val, exc_tb):
94+
self.balatro_client.disconnect()
95+
96+
def _get_tools_for_state(self, current_state: State) -> list[dict[str, Any]]:
97+
"""Get OpenAI tools definition for the given state"""
98+
try:
99+
state_name = current_state.name
100+
if state_name not in self.tools:
101+
raise ValueError(f"No tools defined for state: {state_name}")
102+
return self.tools[state_name]
103+
except ValueError as e:
104+
if "is not a valid State" in str(e):
105+
raise ValueError(f"Unsupported state for LLM decision: {current_state}")
106+
raise
107+
108+
async def get_tool_call(self, game_state: dict):
109+
"""Use LLM to make decisions based on current game state"""
110+
111+
state_name = State(game_state["state"]).name
112+
113+
# Generate prompt
114+
system_template = self.jinja_env.get_template("system.md.jinja")
115+
system_prompt = system_template.render()
116+
117+
game_template = self.jinja_env.get_template("game_state.md.jinja")
118+
user_prompt = game_template.render(
119+
state_name=state_name,
120+
game=game_state.get("game"),
121+
hand=game_state.get("hand"),
122+
jokers=game_state.get("jokers"),
123+
shop_jokers=game_state.get("shop_jokers"),
124+
shop_vouchers=game_state.get("shop_vouchers"),
125+
shop_booster=game_state.get("shop_booster"),
126+
consumables=game_state.get("consumables"),
127+
responses=self.responses,
128+
)
129+
messages = [
130+
{"role": "system", "content": system_prompt},
131+
{"role": "user", "content": user_prompt},
132+
]
133+
134+
# Select tools based on current state
135+
tools = self.tools[state_name]
136+
137+
try:
138+
response = await self.llm_client.chat.completions.create(
139+
model=self.model,
140+
messages=messages, # type: ignore
141+
tools=tools, # type: ignore
142+
tool_choice="auto",
143+
extra_body={"allowed_openai_params": ["reasoning_effort"]},
144+
)
145+
self.responses.append(response)
146+
147+
# Extract tool call
148+
tool_calls = response.choices[0].message.tool_calls
149+
if not tool_calls:
150+
raise ValueError("No tool calls in LLM response")
151+
152+
tool_call = tool_calls[0]
153+
logger.info(
154+
f"LLM tool call: {tool_call.function.name} with args: {tool_call.function.arguments}"
155+
)
156+
return tool_call
157+
158+
except Exception as e:
159+
logger.error(f"LLM decision failed: {e}")
160+
raise
161+
162+
def execute_tool_call(
163+
self, tool_call: ChatCompletionMessageToolCall
164+
) -> dict[str, Any]:
165+
"""Execute the action decided by the LLM."""
166+
name = tool_call.function.name
167+
arguments = json.loads(tool_call.function.arguments)
168+
return self.balatro_client.send_message(name, arguments)
169+
170+
async def play_game(self) -> None:
171+
"""Main game loop"""
172+
logger.info("Starting LLM bot game loop")
173+
174+
try:
175+
# Start a new run
176+
game_state = self.balatro_client.send_message(
177+
"start_run",
178+
{"deck": "Red Deck", "stake": 1, "seed": "OOOO155", "challenge": None},
179+
)
180+
181+
while True:
182+
current_state = State(game_state["state"])
183+
logger.info(f"Current state: {current_state}")
184+
185+
match current_state:
186+
case State.BLIND_SELECT:
187+
# TODO: Enable LLM decision for blind selection
188+
# tool_call = await self.make_decision(game_state)
189+
game_state = self.balatro_client.send_message(
190+
"skip_or_select_blind", {"action": "select"}
191+
)
192+
193+
case State.SELECTING_HAND:
194+
tool_call = await self.get_tool_call(game_state)
195+
game_state = self.execute_tool_call(tool_call)
196+
197+
case State.ROUND_EVAL:
198+
logger.info("Cashing out")
199+
game_state = self.balatro_client.send_message("cash_out")
200+
201+
case State.SHOP:
202+
# TODO: Enable LLM decision for shop actions
203+
# tool_call = await self.make_decision(game_state)
204+
game_state = self.balatro_client.send_message(
205+
"shop", {"action": "next_round"}
206+
)
207+
208+
case State.GAME_OVER:
209+
logger.info("Game over!")
210+
break
211+
212+
case _:
213+
# Wait and check state again
214+
await asyncio.sleep(1)
215+
game_state = self.balatro_client.send_message("get_game_state")
216+
217+
except KeyboardInterrupt:
218+
logger.info("Game interrupted by user")
219+
except Exception as e:
220+
logger.error(f"Game loop failed: {e}")
221+
raise
222+
223+
224+
async def main():
225+
"""Example usage of the LLM bot."""
226+
227+
# Configuration for LiteLLM proxy
228+
model = os.getenv("LITELLM_MODEL", "cerebras-120b")
229+
proxy_url = os.getenv("LITELLM_PROXY_URL", "http://localhost:4000")
230+
api_key = os.getenv("LITELLM_API_KEY", "sk-balatrollm-proxy-key")
231+
232+
bot = LLMBot(model=model, proxy_url=proxy_url, api_key=api_key)
233+
234+
# Validate proxy connection and model before starting game
235+
if not await bot.validate_proxy_connection():
236+
logger.error(
237+
"Cannot connect to LiteLLM proxy. Please start the proxy with: litellm --config config/litellm.yaml"
238+
)
239+
return
240+
241+
if not await bot.validate_model_exists():
242+
logger.error(
243+
f"Model '{model}' not available. Use --list-models to see available models."
244+
)
245+
return
246+
247+
with bot:
248+
await bot.play_game()
249+
250+
251+
if __name__ == "__main__":
252+
asyncio.run(main())

0 commit comments

Comments
 (0)