Coverage for src / openenv / integrations / openrouter.py: 90.31%
142 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
1"""OpenRouter integration for improving bot markdown documents with tool calling."""
3from __future__ import annotations
5import json
6import urllib.error
7import urllib.request
8from collections.abc import Callable
9from typing import Any
11from openenv.core.errors import OpenEnvError
14OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
15DEFAULT_OPENROUTER_MODEL = "google/gemini-2.0-flash-001"
16DEFAULT_OUTPUT_LANGUAGE = "English"
17DEFAULT_DOCUMENT_BATCH_SIZE = 2
18MAX_TOOL_CALL_ROUNDS = 8
21def improve_markdown_documents_with_openrouter(
22 *,
23 api_key: str,
24 bot_name: str,
25 context_payload: dict[str, Any],
26 instruction: str,
27 write_document: Callable[[str, str], None],
28 model: str | None = None,
29 output_language: str = DEFAULT_OUTPUT_LANGUAGE,
30 batch_size: int = DEFAULT_DOCUMENT_BATCH_SIZE,
31) -> str:
32 """Use OpenRouter tool calling to inspect and rewrite bot markdown files."""
33 documents = context_payload.get("documents")
34 if not isinstance(documents, dict): 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 raise OpenEnvError("context_payload.documents must be an object mapping files to text.")
36 if batch_size < 1: 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 raise OpenEnvError("batch_size must be at least 1.")
39 working_context = _clone_context_payload(context_payload)
40 allowed_files = sorted(working_context["documents"].keys())
41 if not allowed_files: 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true
42 return "No markdown documents were available for improvement."
44 batches = list(_document_batches(allowed_files, batch_size))
45 summaries: list[str] = []
47 def tracked_write_document(file_name: str, content: str) -> None:
48 """Persist one updated file and refresh the working context for later batches."""
49 write_document(file_name, content)
50 working_context["documents"][file_name] = content
52 for index, batch_files in enumerate(batches, start=1):
53 batch_context = _context_payload_for_batch(
54 working_context,
55 batch_files=batch_files,
56 batch_index=index,
57 total_batches=len(batches),
58 )
59 summary = _improve_markdown_documents_batch(
60 api_key=api_key,
61 bot_name=bot_name,
62 context_payload=batch_context,
63 instruction=instruction,
64 write_document=tracked_write_document,
65 model=model,
66 output_language=output_language,
67 )
68 if summary: 68 ↛ 52line 68 didn't jump to line 52 because the condition on line 68 was always true
69 summaries.append(summary)
71 if len(summaries) == 1:
72 return summaries[0]
73 return " | ".join(
74 f"Batch {index}: {summary}" for index, summary in enumerate(summaries, start=1)
75 )
78def _improve_markdown_documents_batch(
79 *,
80 api_key: str,
81 bot_name: str,
82 context_payload: dict[str, Any],
83 instruction: str,
84 write_document: Callable[[str, str], None],
85 model: str | None = None,
86 output_language: str = DEFAULT_OUTPUT_LANGUAGE,
87) -> str:
88 """Use OpenRouter tool calling to inspect and rewrite one document batch."""
89 allowed_files = sorted(context_payload["documents"].keys())
90 batch_info = _batch_prompt_suffix(context_payload.get("document_batch"))
91 messages: list[dict[str, Any]] = [
92 {
93 "role": "system",
94 "content": (
95 "You improve markdown documents for an OpenClaw bot. "
96 "Always call get_bot_context first. Then decide whether to call "
97 "write_bot_documents to update one or more files. "
98 "Keep the docs concise, internally consistent, and aligned with "
99 "the bot manifest, skills, access notes, and runtime constraints. "
100 f"All resulting markdown files must be written in {output_language}. "
101 "If source documents use another language, translate and normalize "
102 f"them into consistent {output_language}. "
103 "Do not expose or invent secret values. "
104 "Only write the allowed markdown files. "
105 f"{batch_info}"
106 "After finishing, respond with a brief summary of what changed."
107 ),
108 },
109 {
110 "role": "user",
111 "content": (
112 f"Improve the markdown files for bot `{bot_name}`.\n"
113 "The final versions of every markdown document must be consistently "
114 f"written in {output_language}.\n"
115 f"{batch_info}"
116 f"User instruction: {instruction.strip() or 'Improve overall quality and consistency.'}"
117 ),
118 },
119 ]
120 tools = _tool_definitions(allowed_files)
122 for _ in range(MAX_TOOL_CALL_ROUNDS): 122 ↛ 154line 122 didn't jump to line 154 because the loop on line 122 didn't complete
123 response_message = _openrouter_chat_completion(
124 api_key=api_key,
125 model=model or DEFAULT_OPENROUTER_MODEL,
126 messages=messages,
127 tools=tools,
128 )
129 assistant_message = _normalize_assistant_message(response_message)
130 messages.append(assistant_message)
131 tool_calls = assistant_message.get("tool_calls") or []
132 if not tool_calls:
133 return _assistant_text(assistant_message) or "Markdown documents reviewed."
134 for tool_call in tool_calls:
135 tool_name = tool_call["function"]["name"]
136 arguments = _decode_tool_arguments(tool_call["function"].get("arguments", "{}"))
137 if tool_name == "get_bot_context":
138 result = context_payload
139 elif tool_name == "write_bot_documents": 139 ↛ 146line 139 didn't jump to line 146 because the condition on line 139 was always true
140 result = _apply_document_updates(
141 arguments,
142 allowed_files=allowed_files,
143 write_document=write_document,
144 )
145 else:
146 raise OpenEnvError(f"Unsupported OpenRouter tool call: {tool_name}")
147 messages.append(
148 {
149 "role": "tool",
150 "tool_call_id": tool_call["id"],
151 "content": json.dumps(result, ensure_ascii=False),
152 }
153 )
154 raise OpenEnvError("OpenRouter did not finish the document update flow.")
157def _clone_context_payload(context_payload: dict[str, Any]) -> dict[str, Any]:
158 """Copy the mutable document mapping so batching can update it safely in place."""
159 documents = context_payload.get("documents")
160 if not isinstance(documents, dict): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 raise OpenEnvError("context_payload.documents must be an object mapping files to text.")
162 cloned = dict(context_payload)
163 cloned["documents"] = dict(documents)
164 return cloned
167def _document_batches(files: list[str], batch_size: int) -> list[list[str]]:
168 """Split allowed document names into deterministic fixed-size batches."""
169 return [files[index : index + batch_size] for index in range(0, len(files), batch_size)]
172def _context_payload_for_batch(
173 context_payload: dict[str, Any],
174 *,
175 batch_files: list[str],
176 batch_index: int,
177 total_batches: int,
178) -> dict[str, Any]:
179 """Narrow the context payload to the files processed in one OpenRouter request."""
180 documents = context_payload["documents"]
181 return {
182 **context_payload,
183 "document_batch": {
184 "batch_index": batch_index,
185 "total_batches": total_batches,
186 "batch_files": list(batch_files),
187 "all_files": sorted(documents.keys()),
188 },
189 "documents": {file_name: documents[file_name] for file_name in batch_files},
190 }
193def _batch_prompt_suffix(batch_payload: Any) -> str:
194 """Render additional prompt instructions that constrain one batch to its file subset."""
195 if not isinstance(batch_payload, dict): 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true
196 return ""
197 batch_index = batch_payload.get("batch_index")
198 total_batches = batch_payload.get("total_batches")
199 batch_files = batch_payload.get("batch_files")
200 if (
201 not isinstance(batch_index, int)
202 or not isinstance(total_batches, int)
203 or total_batches <= 1
204 or not isinstance(batch_files, list)
205 ):
206 return ""
207 files_text = ", ".join(file_name for file_name in batch_files if isinstance(file_name, str))
208 return (
209 f"You are processing batch {batch_index} of {total_batches}. "
210 f"Only update these files in this batch: {files_text}. "
211 )
214def _tool_definitions(allowed_files: list[str]) -> list[dict[str, Any]]:
215 """Return the tool schema exposed to OpenRouter for document inspection and writes."""
216 return [
217 {
218 "type": "function",
219 "function": {
220 "name": "get_bot_context",
221 "description": (
222 "Return the current bot configuration and the current markdown documents."
223 ),
224 "parameters": {
225 "type": "object",
226 "properties": {},
227 "additionalProperties": False,
228 },
229 },
230 },
231 {
232 "type": "function",
233 "function": {
234 "name": "write_bot_documents",
235 "description": "Write updated content to one or more bot markdown files.",
236 "parameters": {
237 "type": "object",
238 "properties": {
239 "updates": {
240 "type": "array",
241 "items": {
242 "type": "object",
243 "properties": {
244 "file": {
245 "type": "string",
246 "enum": allowed_files,
247 },
248 "content": {"type": "string"},
249 },
250 "required": ["file", "content"],
251 "additionalProperties": False,
252 },
253 }
254 },
255 "required": ["updates"],
256 "additionalProperties": False,
257 },
258 },
259 },
260 ]
263def _openrouter_chat_completion(
264 *,
265 api_key: str,
266 model: str,
267 messages: list[dict[str, Any]],
268 tools: list[dict[str, Any]],
269) -> dict[str, Any]:
270 """Send one chat-completions request to OpenRouter and return the assistant message."""
271 payload = {
272 "model": model,
273 "messages": messages,
274 "tools": tools,
275 }
276 request = urllib.request.Request(
277 OPENROUTER_API_URL,
278 data=json.dumps(payload).encode("utf-8"),
279 headers={
280 "Authorization": f"Bearer {api_key}",
281 "Content-Type": "application/json",
282 "X-Title": "OpenClawenv",
283 },
284 method="POST",
285 )
286 try:
287 with urllib.request.urlopen(request) as response:
288 data = json.loads(response.read().decode("utf-8"))
289 except urllib.error.HTTPError as exc:
290 detail = exc.read().decode("utf-8", errors="replace")
291 raise OpenEnvError(f"OpenRouter request failed with HTTP {exc.code}: {detail}") from exc
292 except urllib.error.URLError as exc:
293 raise OpenEnvError(f"OpenRouter is not reachable: {exc.reason}") from exc
294 try:
295 return data["choices"][0]["message"]
296 except (KeyError, IndexError, TypeError) as exc:
297 raise OpenEnvError("OpenRouter returned an unexpected response payload.") from exc
300def _normalize_assistant_message(message: dict[str, Any]) -> dict[str, Any]:
301 """Trim the raw OpenRouter assistant payload to the fields reused in later rounds."""
302 normalized: dict[str, Any] = {"role": message.get("role", "assistant")}
303 if "content" in message: 303 ↛ 305line 303 didn't jump to line 305 because the condition on line 303 was always true
304 normalized["content"] = message["content"]
305 if "tool_calls" in message:
306 normalized["tool_calls"] = message["tool_calls"]
307 return normalized
310def _assistant_text(message: dict[str, Any]) -> str:
311 """Extract plain assistant text from either string or structured content payloads."""
312 content = message.get("content")
313 if isinstance(content, str):
314 return content.strip()
315 if isinstance(content, list): 315 ↛ 323line 315 didn't jump to line 323 because the condition on line 315 was always true
316 parts: list[str] = []
317 for item in content:
318 if isinstance(item, dict) and item.get("type") == "text":
319 text = item.get("text")
320 if isinstance(text, str): 320 ↛ 317line 320 didn't jump to line 317 because the condition on line 320 was always true
321 parts.append(text)
322 return "\n".join(part for part in parts if part).strip()
323 return ""
326def _decode_tool_arguments(raw_arguments: str) -> dict[str, Any]:
327 """Decode JSON tool arguments returned by OpenRouter and enforce object shape."""
328 try:
329 decoded = json.loads(raw_arguments)
330 except json.JSONDecodeError as exc:
331 raise OpenEnvError(f"OpenRouter returned invalid tool arguments: {raw_arguments}") from exc
332 if not isinstance(decoded, dict):
333 raise OpenEnvError("OpenRouter tool arguments must decode to an object.")
334 return decoded
337def _apply_document_updates(
338 arguments: dict[str, Any],
339 *,
340 allowed_files: list[str],
341 write_document: Callable[[str, str], None],
342) -> dict[str, Any]:
343 """Validate requested document writes and persist them through the caller callback."""
344 updates = arguments.get("updates")
345 if not isinstance(updates, list):
346 raise OpenEnvError("write_bot_documents requires an updates list.")
347 written_files: list[str] = []
348 allowed = set(allowed_files)
349 for update in updates:
350 if not isinstance(update, dict):
351 raise OpenEnvError("Each write_bot_documents update must be an object.")
352 file_name = update.get("file")
353 content = update.get("content")
354 if not isinstance(file_name, str) or file_name not in allowed:
355 raise OpenEnvError(f"OpenRouter tried to write a disallowed file: {file_name!r}")
356 if not isinstance(content, str) or not content.strip():
357 raise OpenEnvError(f"OpenRouter tried to write empty content to {file_name}.")
358 write_document(file_name, content)
359 written_files.append(file_name)
360 return {"written_files": written_files}