| 1 | import json
|
| 2 | import logging
|
| 3 | import time
|
| 4 |
|
| 5 | import requests
|
| 6 |
|
| 7 | from minisweagent.models import GLOBAL_MODEL_STATS
|
| 8 | from minisweagent.models.openrouter_model import (
|
| 9 | OpenRouterAPIError,
|
| 10 | OpenRouterAuthenticationError,
|
| 11 | OpenRouterModel,
|
| 12 | OpenRouterModelConfig,
|
| 13 | OpenRouterRateLimitError,
|
| 14 | )
|
| 15 | from minisweagent.models.utils.actions_toolcall_response import (
|
| 16 | BASH_TOOL_RESPONSE_API,
|
| 17 | format_toolcall_observation_messages,
|
| 18 | parse_toolcall_actions_response,
|
| 19 | )
|
| 20 | from minisweagent.models.utils.retry import retry
|
| 21 |
|
| 22 | logger = logging.getLogger("openrouter_response_model")
|
| 23 |
|
| 24 |
|
| 25 | class OpenRouterResponseModelConfig(OpenRouterModelConfig):
|
| 26 | pass
|
| 27 |
|
| 28 |
|
| 29 | class OpenRouterResponseModel(OpenRouterModel):
|
| 30 | """OpenRouter model using the Responses API with native tool calling.
|
| 31 |
|
| 32 | Note: OpenRouter's Responses API is stateless - each request must include
|
| 33 | the full conversation history. previous_response_id is not supported.
|
| 34 | See: https://openrouter.ai/docs/api/reference/responses/overview
|
| 35 | """
|
| 36 |
|
| 37 | def __init__(self, **kwargs):
|
| 38 | super().__init__(**kwargs)
|
| 39 | self.config = OpenRouterResponseModelConfig(**kwargs)
|
| 40 | self._api_url = "https://openrouter.ai/api/v1/responses"
|
| 41 |
|
| 42 | def _query(self, messages: list[dict[str, str]], **kwargs):
|
| 43 | headers = {
|
| 44 | "Authorization": f"Bearer {self._api_key}",
|
| 45 | "Content-Type": "application/json",
|
| 46 | }
|
| 47 | payload = {
|
| 48 | "model": self.config.model_name,
|
| 49 | "input": messages,
|
| 50 | "tools": [BASH_TOOL_RESPONSE_API],
|
| 51 | **(self.config.model_kwargs | kwargs),
|
| 52 | }
|
| 53 | try:
|
| 54 | response = requests.post(self._api_url, headers=headers, data=json.dumps(payload), timeout=60)
|
| 55 | response.raise_for_status()
|
| 56 | return response.json()
|
| 57 | except requests.exceptions.HTTPError as e:
|
| 58 | if response.status_code == 401:
|
| 59 | error_msg = "Authentication failed. You can permanently set your API key with `mini-extra config set OPENROUTER_API_KEY YOUR_KEY`."
|
| 60 | raise OpenRouterAuthenticationError(error_msg) from e
|
| 61 | elif response.status_code == 429:
|
| 62 | raise OpenRouterRateLimitError("Rate limit exceeded") from e
|
| 63 | else:
|
| 64 | raise OpenRouterAPIError(f"HTTP {response.status_code}: {response.text}") from e
|
| 65 | except requests.exceptions.RequestException as e:
|
| 66 | raise OpenRouterAPIError(f"Request failed: {e}") from e
|
| 67 |
|
| 68 | def _prepare_messages_for_api(self, messages: list[dict]) -> list[dict]:
|
| 69 | """Prepare messages for OpenRouter's stateless Responses API.
|
| 70 |
|
| 71 | Flattens response objects into their output items since OpenRouter
|
| 72 | doesn't support previous_response_id.
|
| 73 | """
|
| 74 | result = []
|
| 75 | for msg in messages:
|
| 76 | if msg.get("object") == "response":
|
| 77 | for item in msg.get("output", []):
|
| 78 | result.append({k: v for k, v in item.items() if k != "extra"})
|
| 79 | else:
|
| 80 | result.append({k: v for k, v in msg.items() if k != "extra"})
|
| 81 | return result
|
| 82 |
|
| 83 | def query(self, messages: list[dict[str, str]], **kwargs) -> dict:
|
| 84 | for attempt in retry(logger=logger, abort_exceptions=self.abort_exceptions):
|
| 85 | with attempt:
|
| 86 | response = self._query(self._prepare_messages_for_api(messages), **kwargs)
|
| 87 | cost_output = self._calculate_cost(response)
|
| 88 | GLOBAL_MODEL_STATS.add(cost_output["cost"])
|
| 89 | message = dict(response)
|
| 90 | message["extra"] = {
|
| 91 | "actions": self._parse_actions(response),
|
| 92 | **cost_output,
|
| 93 | "timestamp": time.time(),
|
| 94 | }
|
| 95 | return message
|
| 96 |
|
| 97 | def _parse_actions(self, response: dict) -> list[dict]:
|
| 98 | return parse_toolcall_actions_response(
|
| 99 | response.get("output", []), format_error_template=self.config.format_error_template
|
| 100 | )
|
| 101 |
|
| 102 | def format_message(self, **kwargs) -> dict:
|
| 103 | role = kwargs.get("role", "user")
|
| 104 | content = kwargs.get("content", "")
|
| 105 | extra = kwargs.get("extra")
|
| 106 | content_items = [{"type": "input_text", "text": content}] if isinstance(content, str) else content
|
| 107 | msg = {"type": "message", "role": role, "content": content_items}
|
| 108 | if extra:
|
| 109 | msg["extra"] = extra
|
| 110 | return msg
|
| 111 |
|
| 112 | def format_observation_messages(
|
| 113 | self, message: dict, outputs: list[dict], template_vars: dict | None = None
|
| 114 | ) -> list[dict]:
|
| 115 | """Format execution outputs into tool result messages."""
|
| 116 | actions = message.get("extra", {}).get("actions", [])
|
| 117 | return format_toolcall_observation_messages(
|
| 118 | actions=actions,
|
| 119 | outputs=outputs,
|
| 120 | observation_template=self.config.observation_template,
|
| 121 | template_vars=template_vars,
|
| 122 | multimodal_regex=self.config.multimodal_regex,
|
| 123 | )
|
| 124 |
|