Skip to content

Commit 5835b2e

Browse files
authored
Add ladder configurations (#112)
* Add custom Anthropic-endpoint support for ladder runs Add codeclash/agents/mini_anthropic_model.py: a mini-swe-agent model class that drives Claude through a custom Anthropic-compatible base URL using the native Anthropic API (so prompt caching is preserved) with explicit per-token cost tracking. The API key, base URL, and model name are read from the environment (api_key_env / base_url_env / model_name_env) so no endpoint-specific values live in committed configs. Add the optional 'anthropic' dep as a [llama] extra (locked in uv.lock), document LLAMA_* env vars in .env.example, and add ready-to-run ladder configs (RobotRumble + a cheap BattleSnake smoke) whose climbing player uses the endpoint. * Add llama-gateway ladder support: model class + per-model configs - codeclash/agents/mini_anthropic_model.py: mini-swe-agent model class driving Claude through a custom Anthropic-compatible base URL (native Anthropic API, so prompt caching is preserved) with explicit per-token cost tracking. API key via api_key_env; base URL / model optionally from env (base_url_env / model_name_env). - pyproject.toml: optional 'anthropic' dep as a [llama] extra (locked in uv.lock). - configs/mini/models/llama_*.yaml: reusable model blocks for the 5 gateway models (Opus 4.8, Opus 4.7, Sonnet 5 via AnthropicModel; Gemini 3.5 Flash via litellm OpenAI-compat; GPT-5.5 via litellm Responses API). - configs/ablations/ladder/rungs/{robotrumble,corewar}.yaml: rung lists factored out for reuse. - configs/ablations/ladder/{robotrumble,corewar}__<model>.yaml: 10 ready-to-run ladder configs, one per (game, model) pair — 'uv run codeclash ladder run <config>'. - configs/ablations/ladder/battlesnake_llama_smoke.yaml: cheap wiring smoke (Opus 4.8). - .env.example documents the LLAMA_* vars. Only LLAMA_API_KEY is required at runtime; model names + endpoints are verbatim in the configs. * Fix Claude gateway 400 + add Gemini/GPT price tracking - Claude gateway models (Opus 4.8/4.7, Sonnet 5) run with extended thinking enabled, which requires temperature unset (or 1.0). The model blocks set temperature: 0.2, causing every request to fail with HTTP 400. Remove model_kwargs/temperature and raise max_tokens 4096 -> 16384. Verified against the anthropic-passthrough endpoint with the full agent request shape (system + tools + cache_control): now returns 200 / tool_use. - Register litellm prices for gemini-3-5-flash-fair and gpt-5-5-genai-responses (from reveng's model_prices.json) so their cost is tracked instead of ignored. Claude self-computes cost from its cost: block (verified against reveng: opus/sonnet numbers match). - Remove robotrumble_llama.yaml (stale env-indirection config reintroduced by a merge). * Add win condition params for ladder * Add model-specific battlsnake configs * Update readme * Fix pre-commit * tweaks * Tweak pyproject, uv
1 parent 1f6dec5 commit 5835b2e

30 files changed

Lines changed: 1579 additions & 752 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ OPENAI_API_KEY=
1111
GEMINI_API_KEY=
1212
XAI_API_KEY=
1313
DASHSCOPE_API_KEY=
14+
15+
# Optional: Claude via a custom Anthropic-compatible endpoint (mini_anthropic_model.AnthropicModel).
16+
# Used by the *_llama.yaml ladder configs. Set all three to route Claude through your endpoint.
17+
LLAMA_API_KEY=
18+
LLAMA_BASE_URL=
19+
LLAMA_MODEL=

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,6 @@ docs/visualization/*.jsonl
233233
docs/visualization/*.png
234234
docs/visualization/code_org
235235
docs/visualization/elo_plots
236+
237+
# Per-model llama endpoint configs — kept local only (obscures internal API details)
238+
configs/mini/models/
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
"""Custom mini-swe-agent model class for Claude served through a custom Anthropic-compatible
2+
base URL, with explicit per-token cost tracking.
3+
4+
mini-swe-agent's default LitellmModel converts everything to the OpenAI API format. Behind
5+
some proxies/gateways that drops the `cache_control` markers, disabling prompt caching and
6+
making Claude calls slow and expensive. This class speaks the native Anthropic API instead
7+
(via the `anthropic` SDK against a configurable `base_url`), so caching is preserved, and it
8+
computes request cost from its own `cost:` block rather than relying on a litellm price registry.
9+
10+
To use it, point a player's `model` block at
11+
`model_class: codeclash.agents.mini_anthropic_model.AnthropicModel` and provide the API key,
12+
base URL, and model name (see the `*_env` config fields, which keep endpoint-specific values in
13+
the environment rather than in committed configs). Requires the optional `anthropic` dependency
14+
(`uv pip install -e '.[llama]'`). See configs/ablations/ladder/robotrumble_llama.yaml.
15+
"""
16+
17+
import json
18+
import logging
19+
import os
20+
import time
21+
from types import SimpleNamespace
22+
from typing import Any
23+
24+
import anthropic
25+
from jinja2 import StrictUndefined, Template
26+
from minisweagent.models import GLOBAL_MODEL_STATS
27+
from minisweagent.models.utils.actions_toolcall import parse_toolcall_actions
28+
from minisweagent.models.utils.retry import retry
29+
from pydantic import BaseModel
30+
31+
logger = logging.getLogger("anthropic_model")
32+
33+
# Map Anthropic stop reasons to OpenAI-style finish_reasons so that finish_reason-based
34+
# format_error_templates (e.g. the truncation branch) behave the same as for litellm models.
35+
_ANTHROPIC_FINISH_REASON = {
36+
"max_tokens": "length",
37+
"tool_use": "tool_calls",
38+
"end_turn": "stop",
39+
"stop_sequence": "stop",
40+
}
41+
42+
43+
def _finish_reason_from_anthropic(stop_reason: str | None) -> str | None:
44+
return _ANTHROPIC_FINISH_REASON.get(stop_reason, stop_reason)
45+
46+
47+
ANTHROPIC_BASH_TOOL = {
48+
"name": "bash",
49+
"description": "Execute a bash command",
50+
"input_schema": {
51+
"type": "object",
52+
"properties": {
53+
"command": {
54+
"type": "string",
55+
"description": "The bash command to execute",
56+
}
57+
},
58+
"required": ["command"],
59+
},
60+
}
61+
62+
63+
class CostPerToken(BaseModel):
64+
"""Per-token costs in USD."""
65+
66+
input: float = 0.0
67+
output: float = 0.0
68+
cache_creation_input: float = 0.0
69+
cache_read_input: float = 0.0
70+
71+
72+
class AnthropicModelConfig(BaseModel):
73+
model_name: str
74+
"""Anthropic model name, e.g. `claude-sonnet-4-5-20250929`. Overridden by `model_name_env`
75+
if that names a set environment variable (so endpoint-specific model ids stay out of configs)."""
76+
model_name_env: str | None = None
77+
"""Optional env var to read the model name from, overriding `model_name` when set."""
78+
model_kwargs: dict[str, Any] = {}
79+
"""Additional arguments passed to the API."""
80+
drop_none_model_kwargs: bool = True
81+
"""Drop all model_kwargs that are None.
82+
This is so we can easily recursively merge this config with other configs targeting litellm.
83+
"""
84+
max_tokens: int = 16384
85+
"""Maximum number of output tokens."""
86+
base_url: str | None = None
87+
"""Custom base URL for the Anthropic API (e.g. for proxies). Overridden by `base_url_env`."""
88+
base_url_env: str | None = None
89+
"""Optional env var to read the base URL from, overriding `base_url` when set (so endpoint
90+
URLs stay out of configs)."""
91+
api_key_env: str = "ANTHROPIC_API_KEY"
92+
"""Environment variable name for the API key."""
93+
cost: CostPerToken = CostPerToken()
94+
"""Per-token costs in USD for computing request cost from usage."""
95+
format_error_template: str = "{{ error }}"
96+
"""Template used when the LM's output is not in the expected format."""
97+
observation_template: str = (
98+
"{% if output.exception_info %}<exception>{{output.exception_info}}</exception>\n{% endif %}"
99+
"<returncode>{{output.returncode}}</returncode>\n<output>\n{{output.output}}</output>"
100+
)
101+
"""Template used to render the observation after executing an action."""
102+
103+
104+
class AnthropicModel:
105+
abort_exceptions: list[type[Exception]] = [
106+
anthropic.BadRequestError,
107+
anthropic.AuthenticationError,
108+
anthropic.PermissionDeniedError,
109+
anthropic.NotFoundError,
110+
KeyboardInterrupt,
111+
]
112+
113+
def __init__(self, *, config_class: type = AnthropicModelConfig, **kwargs):
114+
self.config = config_class(**kwargs)
115+
# Resolve endpoint-specific values from the environment so they stay out of configs.
116+
if self.config.model_name_env:
117+
model_name = os.getenv(self.config.model_name_env, "")
118+
if not model_name:
119+
raise ValueError(f"Set the {self.config.model_name_env} environment variable to the model name.")
120+
self.config.model_name = model_name
121+
if self.config.base_url_env:
122+
base_url = os.getenv(self.config.base_url_env, "")
123+
if not base_url:
124+
raise ValueError(f"Set the {self.config.base_url_env} environment variable to the API base URL.")
125+
self.config.base_url = base_url
126+
api_key = os.getenv(self.config.api_key_env, "")
127+
if not api_key:
128+
raise ValueError(f"API key not found. Set the {self.config.api_key_env} environment variable.")
129+
client_kwargs: dict[str, Any] = {"api_key": api_key}
130+
if self.config.base_url:
131+
client_kwargs["base_url"] = self.config.base_url
132+
self.client = anthropic.Anthropic(**client_kwargs)
133+
134+
@staticmethod
135+
def _set_cache_control_on_last_message(messages: list[dict]) -> list[dict]:
136+
"""Add cache_control to the last block of the last message."""
137+
import copy
138+
139+
messages = copy.deepcopy(messages)
140+
if not messages:
141+
return messages
142+
last = messages[-1]
143+
content = last["content"]
144+
if content is None:
145+
last["cache_control"] = {"type": "ephemeral"}
146+
elif isinstance(content, str):
147+
last["content"] = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]
148+
else:
149+
content[-1]["cache_control"] = {"type": "ephemeral"}
150+
return messages
151+
152+
@staticmethod
153+
def _strip_display_text(content: list[dict] | str) -> list[dict] | str:
154+
"""Remove the 'text' key we add to non-text blocks for interactive display
155+
and 'caller' (None) that model_dump() emits from newer SDK versions."""
156+
if not isinstance(content, list):
157+
return content
158+
return [
159+
{
160+
k: v
161+
for k, v in block.items()
162+
if not (k == "text" and block.get("type") != "text") and not (k == "caller" and v is None)
163+
}
164+
for block in content
165+
]
166+
167+
def _query(self, messages: list[dict], **kwargs):
168+
assert messages[0]["role"] == "system"
169+
api_messages = self._set_cache_control_on_last_message(
170+
[{"role": m["role"], "content": self._strip_display_text(m["content"])} for m in messages[1:]]
171+
)
172+
extra_model_kwargs = self.config.model_kwargs | kwargs
173+
if self.config.drop_none_model_kwargs:
174+
extra_model_kwargs = {k: v for k, v in extra_model_kwargs.items() if v is not None}
175+
return self.client.messages.create(
176+
model=self.config.model_name,
177+
max_tokens=self.config.max_tokens,
178+
system=messages[0]["content"],
179+
messages=api_messages,
180+
tools=[ANTHROPIC_BASH_TOOL],
181+
**extra_model_kwargs,
182+
)
183+
184+
def query(self, messages: list[dict[str, str]], **kwargs) -> dict:
185+
for attempt in retry(logger=logger, abort_exceptions=self.abort_exceptions):
186+
with attempt:
187+
response = self._query(messages, **kwargs)
188+
actions = self._parse_actions(response)
189+
cost_output = self._calculate_cost(response)
190+
GLOBAL_MODEL_STATS.add(cost_output["cost"])
191+
content = []
192+
for block in response.content:
193+
d = block.model_dump()
194+
if block.type == "tool_use":
195+
d["text"] = f"```\n{block.input.get('command', json.dumps(block.input))}\n```"
196+
content.append(d)
197+
return {
198+
"role": "assistant",
199+
"content": content,
200+
"extra": {
201+
"actions": actions,
202+
"response": response.model_dump(),
203+
**cost_output,
204+
"timestamp": time.time(),
205+
},
206+
}
207+
208+
def _parse_actions(self, response: anthropic.types.Message) -> list[dict]:
209+
tool_calls = [
210+
SimpleNamespace(
211+
id=block.id,
212+
function=SimpleNamespace(name=block.name, arguments=json.dumps(block.input)),
213+
)
214+
for block in response.content
215+
if block.type == "tool_use"
216+
]
217+
return parse_toolcall_actions(
218+
tool_calls,
219+
format_error_template=self.config.format_error_template,
220+
template_kwargs={"finish_reason": _finish_reason_from_anthropic(response.stop_reason)},
221+
)
222+
223+
def _calculate_cost(self, response: anthropic.types.Message) -> dict[str, float]:
224+
usage = response.usage
225+
c = self.config.cost
226+
return {
227+
"cost": (
228+
usage.input_tokens * c.input
229+
+ usage.output_tokens * c.output
230+
+ getattr(usage, "cache_creation_input_tokens", 0) * c.cache_creation_input
231+
+ getattr(usage, "cache_read_input_tokens", 0) * c.cache_read_input
232+
)
233+
}
234+
235+
def format_message(self, **kwargs) -> dict:
236+
return kwargs
237+
238+
def format_observation_messages(
239+
self, message: dict, outputs: list[dict], template_vars: dict | None = None
240+
) -> list[dict]:
241+
"""Format execution outputs as a single user message with tool_result blocks (Anthropic format)."""
242+
actions = message.get("extra", {}).get("actions", [])
243+
not_executed = {"output": "", "returncode": -1, "exception_info": "action was not executed"}
244+
padded_outputs = outputs + [not_executed] * (len(actions) - len(outputs))
245+
tool_results = []
246+
extras = []
247+
for action, output in zip(actions, padded_outputs):
248+
content = Template(self.config.observation_template, undefined=StrictUndefined).render(
249+
output=output, **(template_vars or {})
250+
)
251+
tool_results.append(
252+
{
253+
"type": "tool_result",
254+
"tool_use_id": action["tool_call_id"],
255+
"content": content,
256+
"text": content,
257+
}
258+
)
259+
extras.append(
260+
{
261+
"raw_output": output.get("output", ""),
262+
"returncode": output.get("returncode"),
263+
"timestamp": time.time(),
264+
"exception_info": output.get("exception_info"),
265+
**output.get("extra", {}),
266+
}
267+
)
268+
return [
269+
{
270+
"role": "user",
271+
"content": tool_results,
272+
"extra": {"observations": extras},
273+
}
274+
]
275+
276+
def get_template_vars(self, **kwargs) -> dict[str, Any]:
277+
return self.config.model_dump()
278+
279+
def serialize(self) -> dict:
280+
return {
281+
"info": {
282+
"config": {
283+
"model": self.config.model_dump(mode="json"),
284+
"model_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
285+
},
286+
}
287+
}

codeclash/cli/ladder.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,43 @@
1717

1818
logger = get_logger("ladder")
1919

20+
21+
def _resolve_ladder_rules(ladder_rules: dict, rounds: int) -> tuple[float, int]:
22+
"""Validate the optional ``ladder_rules`` block and return ``(min_round_win_fraction, win_last_k)``.
23+
24+
Defaults reproduce the historical behavior: win a strict majority of rounds
25+
(``min_round_win_fraction=0.5``) AND win the final round (``win_last_k=1``).
26+
"""
27+
min_round_win_fraction = ladder_rules.get("min_round_win_fraction", 0.5)
28+
win_last_k = ladder_rules.get("win_last_k", 1)
29+
30+
# win_last_k: number of trailing rounds the player must win (1 == just the final round).
31+
if isinstance(win_last_k, bool) or not isinstance(win_last_k, int):
32+
typer.echo(f"ladder_rules.win_last_k must be an integer, got {win_last_k!r}.")
33+
raise typer.Exit(1)
34+
if win_last_k < 1:
35+
typer.echo(
36+
f"ladder_rules.win_last_k must be >= 1, got {win_last_k}. Use 1 to require winning only the final round."
37+
)
38+
raise typer.Exit(1)
39+
if win_last_k > rounds:
40+
typer.echo(f"ladder_rules.win_last_k ({win_last_k}) cannot exceed tournament.rounds ({rounds}).")
41+
raise typer.Exit(1)
42+
43+
# min_round_win_fraction: player must win strictly more than this fraction of rounds.
44+
if isinstance(min_round_win_fraction, bool) or not isinstance(min_round_win_fraction, (int, float)):
45+
typer.echo(f"ladder_rules.min_round_win_fraction must be a number, got {min_round_win_fraction!r}.")
46+
raise typer.Exit(1)
47+
if not 0 <= min_round_win_fraction < 1:
48+
typer.echo(
49+
f"ladder_rules.min_round_win_fraction must be in [0, 1), got {min_round_win_fraction}. "
50+
"0.5 requires a strict majority; 0 drops the majority requirement."
51+
)
52+
raise typer.Exit(1)
53+
54+
return float(min_round_win_fraction), win_last_k
55+
56+
2057
ladder_app = typer.Typer(
2158
no_args_is_help=True,
2259
add_completion=False,
@@ -101,9 +138,16 @@ def run(
101138
config["tournament"]["rounds"],
102139
config["game"]["sims_per_round"],
103140
)
141+
min_round_win_fraction, win_last_k = _resolve_ladder_rules(config.get("ladder_rules", {}), rounds)
104142
timestamp = time.strftime("%y%m%d%H%M%S")
105143
del config["player"]
106144
del config["ladder"]
145+
config.pop("ladder_rules", None)
146+
147+
print(
148+
f"Ladder advancement rule: win > {min_round_win_fraction:.0%} of {rounds} rounds "
149+
f"and win the last {win_last_k} round(s)."
150+
)
107151
ladder_folder = f"LadderTournament.{config['game']['name']}.r{rounds}.s{sims}.{timestamp}"
108152
player["branch"] = ladder_folder
109153
parent_dir = LOCAL_LOG_DIR / getpass.getuser() / ladder_folder
@@ -143,12 +187,22 @@ def run(
143187
metadata = yaml.safe_load(f)
144188
round_winners = [r["winner"] for r in metadata["round_stats"].values()]
145189

146-
# Player must have won majority of rounds and the last round to continue ladder
190+
# Advancement rule (configurable via `ladder_rules`): win strictly more than
191+
# `min_round_win_fraction` of rounds AND win the last `win_last_k` rounds.
147192
player_wins = sum(1 for w in round_winners if w == player["name"])
148-
player_won_last = round_winners[-1] == player["name"]
149-
150-
if not player_wins > len(round_winners) // 2 or not player_won_last:
151-
# If player lost tournament, ladder challenge ends
193+
won_majority = player_wins > len(round_winners) * min_round_win_fraction
194+
won_last_k = all(w == player["name"] for w in round_winners[-win_last_k:])
195+
196+
if not won_majority or not won_last_k:
197+
# Player failed the advancement rule; the ladder challenge ends here.
198+
print("=" * 10)
199+
print(
200+
f"{player['name']} did not clear {opponent['name']} "
201+
f"(rank {opponent_rank}/{len(ladder)}): won {player_wins}/{len(round_winners)} rounds "
202+
f"(needed > {min_round_win_fraction:.0%}), last {win_last_k} round(s) won: {won_last_k}.\n"
203+
"Ladder challenge ends."
204+
)
205+
print("=" * 10)
152206
break
153207

154208
print("=" * 10)

0 commit comments

Comments
 (0)