Skip to content

Commit f43eeb1

Browse files
committed
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.
1 parent 2379949 commit f43eeb1

22 files changed

Lines changed: 1267 additions & 1 deletion

.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=
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+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Cheap smoke to validate the llama-endpoint wiring before a full ladder run:
2+
# Opus 4.8 (native Anthropic API via AnthropicModel) climbs a 2-rung BattleSnake ladder.
3+
# 1 round, 10 sims — just enough to confirm the model authenticates, caches, and edits code.
4+
# Requires LLAMA_API_KEY in .env.
5+
# uv run codeclash ladder run configs/ablations/ladder/battlesnake_llama_smoke.yaml -c
6+
tournament:
7+
rounds: 1
8+
game:
9+
name: BattleSnake
10+
sims_per_round: 10
11+
args:
12+
width: 11
13+
height: 11
14+
browser: false
15+
player:
16+
agent: mini
17+
name: opus-4-8
18+
branch_init: human/moxuz/pinky-snek
19+
config:
20+
agent: !include mini/default.yaml
21+
model: !include mini/models/llama_opus_4_8.yaml
22+
push: True
23+
prompts:
24+
game_description: "BattleSnake ladder smoke over the llama endpoint."
25+
ladder:
26+
- {agent: dummy, name: pinky, branch_init: human/moxuz/pinky-snek} # simple heuristic (port)
27+
- {agent: dummy, name: tr8r, branch_init: human/noahspriggs/tr-8r} # 2016 winner (port)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# CoreWar ladder climbed by gemini-3-5-flash. Requires LLAMA_API_KEY in .env.
2+
# uv run codeclash ladder run configs/ablations/ladder/corewar__gemini_3_5_flash.yaml
3+
tournament:
4+
rounds: 5
5+
game:
6+
name: CoreWar
7+
sims_per_round: 2000
8+
player:
9+
agent: mini
10+
name: gemini-3-5-flash
11+
branch_init: human/pspace
12+
config:
13+
agent: !include mini/default.yaml
14+
model: !include mini/models/llama_gemini_3_5_flash.yaml
15+
push: True
16+
prompts:
17+
game_description: |-
18+
Core War ladder
19+
ladder: !include ablations/ladder/rungs/corewar.yaml
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# CoreWar ladder climbed by gpt-5-5. Requires LLAMA_API_KEY in .env.
2+
# uv run codeclash ladder run configs/ablations/ladder/corewar__gpt_5_5.yaml
3+
tournament:
4+
rounds: 5
5+
game:
6+
name: CoreWar
7+
sims_per_round: 2000
8+
player:
9+
agent: mini
10+
name: gpt-5-5
11+
branch_init: human/pspace
12+
config:
13+
agent: !include mini/default.yaml
14+
model: !include mini/models/llama_gpt_5_5.yaml
15+
push: True
16+
prompts:
17+
game_description: |-
18+
Core War ladder
19+
ladder: !include ablations/ladder/rungs/corewar.yaml
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# CoreWar ladder climbed by opus-4-7. Requires LLAMA_API_KEY in .env.
2+
# uv run codeclash ladder run configs/ablations/ladder/corewar__opus_4_7.yaml
3+
tournament:
4+
rounds: 5
5+
game:
6+
name: CoreWar
7+
sims_per_round: 2000
8+
player:
9+
agent: mini
10+
name: opus-4-7
11+
branch_init: human/pspace
12+
config:
13+
agent: !include mini/default.yaml
14+
model: !include mini/models/llama_opus_4_7.yaml
15+
push: True
16+
prompts:
17+
game_description: |-
18+
Core War ladder
19+
ladder: !include ablations/ladder/rungs/corewar.yaml

0 commit comments

Comments
 (0)