url reading tool: fetch_url with ssrf guard, html->text, page images to vision cache

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 18:10:54 +02:00
parent df1924bb80
commit d0819c2683
5 changed files with 410 additions and 26 deletions
+41 -26
View File
@@ -12,6 +12,7 @@ from .ai_responder import AIResponder, exponential_backoff, sanitize_external_te
from .igdblib import IGDBQuery
from .leonardo_draw import LeonardoAIDrawMixIn
from .quota import QuotaLedger
from .url_reader import FETCH_URL_TOOL, URLReader
# The response envelope, enforced server-side via structured outputs
# (ENV-19). All fields required, closed object, nullable where the
@@ -153,6 +154,33 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
else:
logging.warning("❌ IGDB integration DISABLED - missing configuration or disabled in config")
# URL reading tool (SPEC-011); shares the image cache for page images
self.url_reader = URLReader(lambda: self.config, self.image_cache)
def _available_tools(self) -> List[Dict[str, Any]]:
"""Assemble the function-tool list from every enabled provider (URL-01)."""
functions: List[Dict[str, Any]] = []
if self.igdb and self.config.get("enable-game-info", False):
try:
igdb_functions = self.igdb.get_openai_functions()
if isinstance(igdb_functions, list):
functions.extend(igdb_functions)
except (TypeError, AttributeError) as err:
logging.warning(f"Error setting up IGDB functions: {err}")
if self.url_reader.enabled():
functions.append(FETCH_URL_TOOL)
return functions
async def _dispatch_tool(self, name: str, args: Dict[str, Any], author: str) -> Any:
"""Route a tool call to its provider (IGDB or URL reader)."""
if name == "fetch_url":
per_user_cap = int(self.config.get("url-daily-per-user", 20))
if self.ledger._get(f"url-fetch:{author}") >= per_user_cap: # URL-07
return {"error": "daily URL fetch limit reached"}
self.ledger._add(f"url-fetch:{author}", 1)
return await self.url_reader.fetch(str(args.get("url", "")), self.channel, author or "user")
return await self._execute_igdb_function(name, args)
async def draw_openai(self, description: str, count: int = 1) -> List[BytesIO]:
if not self.ledger.budget_ok():
raise RuntimeError("daily budget exhausted - refusing image call")
@@ -241,24 +269,13 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
# hashed, never the raw Discord name (SAF-10)
chat_kwargs["safety_identifier"] = "discord-" + hashlib.sha256(author.encode()).hexdigest()[:16]
if self.igdb and self.config.get("enable-game-info", False):
try:
igdb_functions = self.igdb.get_openai_functions()
if igdb_functions and isinstance(igdb_functions, list):
chat_kwargs["tools"] = [{"type": "function", "function": func} for func in igdb_functions]
chat_kwargs["tool_choice"] = "auto"
# gpt-5.6 rejects tools + reasoning on chat/completions (ENV-21)
chat_kwargs["reasoning_effort"] = self.config.get("reasoning-effort", "none")
logging.info(f"🎮 IGDB functions available to AI: {[f['name'] for f in igdb_functions]}")
logging.debug(f" Full chat_kwargs with tools: {list(chat_kwargs.keys())}")
except (TypeError, AttributeError) as e:
logging.warning(f"Error setting up IGDB functions: {e}")
else:
logging.debug(
"🎮 IGDB not available for this request (igdb={}, enabled={})".format(
self.igdb is not None, self.config.get("enable-game-info", False)
)
)
available_tools = self._available_tools()
if available_tools:
chat_kwargs["tools"] = [{"type": "function", "function": func} for func in available_tools]
chat_kwargs["tool_choice"] = "auto"
# gpt-5.6 rejects tools + reasoning on chat/completions (ENV-21)
chat_kwargs["reasoning_effort"] = self.config.get("reasoning-effort", "none")
logging.info(f"🔧 Tools available to AI: {[func['name'] for func in available_tools]}")
result = await openai_chat(self.client, **chat_kwargs)
self._record_usage(result)
@@ -278,10 +295,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
tool_names = [tc.function.name for tc in message.tool_calls]
logging.info(f"🔧 OpenAI requested function calls: {tool_names}")
# Check if we have function/tool calls and IGDB is enabled
has_tool_calls = (
hasattr(message, "tool_calls") and message.tool_calls and self.igdb and self.config.get("enable-game-info", False)
)
# Any offered tool may have been called (IGDB or fetch_url)
has_tool_calls = bool(hasattr(message, "tool_calls") and message.tool_calls and available_tools)
# Clean up any existing tool messages in the history to avoid conflicts
if has_tool_calls:
@@ -304,12 +319,12 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
logging.info(f"🎮 Executing IGDB function: {function_name} with args: {function_args}")
logging.info(f"🔧 Executing tool: {function_name} with args: {function_args}")
# Execute IGDB function
function_result = await self._execute_igdb_function(function_name, function_args)
# Route to the right provider (IGDB or URL reader)
function_result = await self._dispatch_tool(function_name, function_args, self._last_author(messages) or "")
logging.info(f"🎮 IGDB function result: {type(function_result)} - {str(function_result)[:200]}...")
logging.info(f"🔧 Tool result: {type(function_result)} - {str(function_result)[:200]}...")
messages.append(
{