url reading tool: fetch_url with ssrf guard, html->text, page images to vision cache
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user