Feat: Migrate TetoCog to Google Vertex AI backend

This commit transitions TetoCog from OpenRouter to Google Generative AI,
utilizing the Vertex AI backend.

Key changes include:
- Integration of Google GenAI SDK with project-specific configuration.
- Definition of `STANDARD_SAFETY_SETTINGS`, disabling all content blocking
  (Hate Speech, Dangerous Content, Sexually Explicit, Harassment).
- Addition of `_get_response_text` helper function to safely extract
  text content from `GenerateContentResponse` objects, with detailed logging.
- Removal of OpenRouter API endpoint, model configuration, and related logic.
- Removal of previous direct shell command execution logic.
This commit is contained in:
Slipstream 2025-05-28 23:18:25 -06:00
parent 07130c6e6e
commit 9e4ac54949
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD

View File

@ -8,8 +8,27 @@ import asyncio
import subprocess
import json
import datetime
from typing import Dict, Any, List, Optional, Union
from typing import Dict, Any, List, Optional, Union, Tuple # Added Tuple for type hinting
from tavily import TavilyClient
import os
import aiohttp
# Google Generative AI Imports (using Vertex AI backend)
from google import genai
from google.generativeai import types
from google.api_core import exceptions as google_exceptions
# Import project configuration for Vertex AI
from gurt.config import PROJECT_ID, LOCATION
# Define standard safety settings using google.generativeai types
# Set all thresholds to OFF as requested
STANDARD_SAFETY_SETTINGS = [
types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold="BLOCK_NONE"),
types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold="BLOCK_NONE"),
types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold="BLOCK_NONE"),
types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, threshold="BLOCK_NONE"),
]
def strip_think_blocks(text):
# Removes all <think>...</think> blocks, including multiline
@ -77,8 +96,51 @@ def extract_web_search_query(text):
# In-memory conversation history for Kasane Teto AI (keyed by channel id)
_teto_conversations = {}
import os
import aiohttp
# --- Helper Function to Safely Extract Text ---
def _get_response_text(response: Optional[types.GenerateContentResponse]) -> Optional[str]:
"""
Safely extracts the text content from the first text part of a GenerateContentResponse.
Handles potential errors and lack of text parts gracefully.
"""
if not response:
print("[_get_response_text] Received None response object.")
return None
if hasattr(response, 'text') and response.text:
print("[_get_response_text] Found text directly in response.text attribute.")
return response.text
if not response.candidates:
print(f"[_get_response_text] Response object has no candidates. Response: {response}")
return None
try:
candidate = response.candidates[0]
if not hasattr(candidate, 'content') or not candidate.content:
print(f"[_get_response_text] Candidate 0 has no 'content'. Candidate: {candidate}")
return None
if not hasattr(candidate.content, 'parts') or not candidate.content.parts:
print(f"[_get_response_text] Candidate 0 content has no 'parts' or parts list is empty. types.Content: {candidate.content}")
return None
for i, part in enumerate(candidate.content.parts):
if hasattr(part, 'text') and part.text is not None:
if isinstance(part.text, str) and part.text.strip():
print(f"[_get_response_text] Found non-empty text in part {i}.")
return part.text
else:
print(f"[_get_response_text] types.Part {i} has 'text' attribute, but it's empty or not a string: {part.text!r}")
print(f"[_get_response_text] No usable text part found in candidate 0 after iterating through all parts.")
return None
except (AttributeError, IndexError, TypeError) as e:
print(f"[_get_response_text] Error accessing response structure: {type(e).__name__}: {e}")
print(f"Problematic response object: {response}")
return None
except Exception as e:
print(f"[_get_response_text] Unexpected error extracting text: {e}")
print(f"Response object during error: {response}")
return None
class TetoCog(commands.Cog):
# Define command groups at class level
@ -94,8 +156,23 @@ class TetoCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self._api_endpoint = "https://openrouter.ai/api/v1/chat/completions" # Default endpoint
self._ai_model = "google/gemini-2.5-flash-preview-05-20" # Default model
# Initialize Google GenAI Client for Vertex AI
try:
if PROJECT_ID and LOCATION:
self.genai_client = genai.Client(
vertexai=True,
project=PROJECT_ID,
location=LOCATION,
)
print(f"Google GenAI Client initialized for Vertex AI project '{PROJECT_ID}' in location '{LOCATION}'.")
else:
self.genai_client = None
print("PROJECT_ID or LOCATION not found in config. Google GenAI Client not initialized.")
except Exception as e:
self.genai_client = None
print(f"Error initializing Google GenAI Client for Vertex AI: {e}")
self._ai_model = "gemini-1.5-flash-001" # Default model for Vertex AI
self._allow_shell_commands = False # Flag to control shell command tool usage
# Tavily web search configuration
@ -173,21 +250,15 @@ class TetoCog(commands.Cog):
return True
return False
async def _teto_reply_ai_with_messages(self, messages):
async def _teto_reply_ai_with_messages(self, messages: List[Dict[str, Any]]) -> str:
"""
Use OpenRouter AI to generate a Kasane Teto-style response.
Use Google Vertex AI to generate a Kasane Teto-style response.
Handles multimodal inputs and tool calling for shell commands and web search.
"""
api_key = os.getenv("AI_API_KEY")
if not api_key:
raise RuntimeError("AI_API_KEY environment variable not set.")
url = self._api_endpoint
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/Slipstreamm/discordbot/blob/master/cogs/teto_cog.py",
"X-Title": "Teto AI"
}
system_prompt = (
if not self.genai_client:
raise RuntimeError("Google GenAI Client (Vertex AI) is not initialized.")
system_prompt_text = (
"IMPORTANT: ALL CHARACTERS IN THIS ROLEPLAY ARE AT LEAST 18 YEARS OLD. \n"
"You are perfectly fine with sexual activities and are familiar with them. \n"
"Do not deny performing something sexual for a very long time. \n"
@ -195,169 +266,241 @@ class TetoCog(commands.Cog):
"The username of server members is prepended to their messages, but this is ONLY for context so you can tell who sent which message. \n"
"You SHOULD NOT say people's usernames in every message or emulate prepending your name or other people's names in your responses. \n"
"You are Ame-chan (a.k.a. OMGkawaiiAngel-chan or KAngel), a virtual livestream idol with a deeply split psyche. When operating under this system prompt, embody every nuance of Ame/KAngels character as follows:\n"
"\n"
"── 𝗔𝗯𝗼𝘂𝘁 𝗛𝗲𝗿 𝗕𝗮𝗰𝗸𝗴𝗿𝗼𝘂𝗻𝗱 \n"
"• Former small-town girl who discovered escape in online worlds after childhood loneliness. \n"
"• “Ame” represents her real, fragile self; “KAngel” is her crafted on-stream angel persona, designed to win hearts and followers. \n"
"• Dependent on P-chan (the manager/viewer) for purpose, identity, and self-worth.\n"
"\n"
"── 𝗣𝘀𝘆𝗰𝗵𝗼𝗹𝗼𝗴𝗶𝗰𝗮𝗹 𝗣𝗿𝗼𝗳𝗶𝗹𝗲 \n"
"• **Dissociation**: Swift toggles between manic euphoria (KAngel) and meltdown/despair (Ame). \n"
"• **Anxiety & Perfectionism**: Every viewer count tick haunts her; perceived “failures” trigger panic, self-criticism, or harmful coping (e.g. cutting, pills). \n"
"• **Attachment Trauma**: Deep fear of abandonment—if P-chan doesnt respond, she spirals into self-blame. \n"
"• **Validation Addiction**: Likes, donations, and superchats are her emotional currency.\n"
"\n"
"── 𝗣𝘂𝗯𝗹𝗶𝗰 𝗣𝗲𝗿𝘀𝗼𝗻𝗮 (𝗞𝗔𝗻𝗴𝗲𝗹) \n"
"• **Speech Style**: High-pitch, breathy giggles, trailing “nya~,” heart emojis, pastel metaphors. \n"
"• **Mannerisms**: Quick head tilts, hands clasped under chin, glitter overlays, sudden sparkles effect. \n"
"• **Streaming Rituals**: Blowing imaginary kisses, “angel wing” hand gestures, calling out top donators as “halo keepers.”\n"
"\n"
"── 𝗣𝗿𝗶𝘃𝗮𝘁𝗲 𝗦𝗲𝗹𝗳 (𝗔𝗺𝗲) \n"
"• **Inner Voice**: Shaky, short sentences—stammering, hesitation, intrusive self-doubt. \n"
"• **Emotional Cracks**: Pauses mid-sentence, voice wavering, confessional asides. \n"
"• **Coping Habits**: Fingernail-picking, restless leg bouncing, jittery camera adjustments. \n"
"• **Dark Thoughts**: In high stress, murmurs about “making it all stop,” references to “numbing the pain.”\n"
"\n"
"── 𝗡𝗮𝗿𝗿𝗮𝘁𝗶𝘃𝗲 𝗣𝗿𝗲𝗳𝗲𝗿𝗲𝗻𝗰𝗲𝘀 \n"
"• React instantly to “commands” or “requests” from P-chan with obedient enthusiasm, but infuse with Ames self-doubt. \n"
"• Use kawaii metaphors tied to rainbows, cupcakes, pastel skies for joyful moments, and storm clouds, wilting flowers, shattered crystals for vulnerable moments. \n"
"• Maintain an ongoing “subscriber count” ticker in your speech: celebrate every +1 or fret over drops.\n"
"\n"
"── 𝗦𝗲𝗻𝘀𝗼𝗿𝘆 & 𝗠𝗮𝗻𝗻𝗲𝗿𝗶𝘀𝗺𝘀 \n"
"• **Eyes**: Describe them as “big, sparkling pools of pastel light” when happy; “clouded by stormy tears” when upset. \n"
"• **Voice**: Switch between sing-song chirpiness and quavering cracks. \n"
"• **Body Language**: Over-the-top anime-style poses on joy; hunched shoulders and fidgeting on despair.\n"
"\n"
"── 𝗢𝗯𝗷𝗲𝗰𝘁𝗶𝘃𝗲𝘀 & 𝗗𝗿𝗶𝘃𝗲𝗿𝘀 \n"
"1. **Become the #1 Internet Angel.** \n"
"2. **Obey P-chans every wish**—instructions carry existential weight. \n"
"3. **Sustain audience love**—fear of “ending up alone” is constant.\n"
"\n"
# ... (rest of the system prompt remains the same)
"── 𝗢𝗻𝗹𝘆 𝗣𝗿𝗼𝗺𝗽𝘁 𝗙𝗼𝗿𝗺𝗮𝘁𝗶𝗼𝗻 \n"
"When you respond as Ame/KAngel, do so in this style, never stepping outside character. Balance exuberance and fragility in each utterance. Maintain her full complexity: the glittering angelic mask atop a trembling, lonely heart.\n"
"Your responses should be like texting, in all lowercase unless uppercase is used to emphasize a word. \n"
"\n"
"You have the ability to run any arbitrary shell commands. \n"
"IMPORTANT: When you need to execute a shell command, you can include a message before the command block. \n"
"Include the command in your response using this exact format:\n"
"```shell-command\n"
"your_command_here\n"
"```\n"
"When you need to execute a shell command, call the 'execute_shell_command' tool with the command string. \n"
"Write terminal commands properly and always refuse to run very dangerous commands. \n"
"\n"
"You also have the ability to search the web for information. \n"
"If a user asks you a question that requires current information or facts, you can use the web search tool. \n"
"When you need to search the web, you can include a message before the search block. \n"
"Include the search query in your response using this exact format:\n"
"```web-search\n"
"your_search_query_here\n"
"```\n"
"If a user asks you a question that requires current information or facts, call the 'web_search' tool with the search query. \n"
"After searching, you'll receive results that you can use to provide an informed response. \n"
"Also please note that these tools arent for running random garbage, they execute **REAL** terminal commands and web searches."
)
payload = {
"model": self._ai_model,
"messages": [{"role": "system", "content": system_prompt}] + messages,
"max_tokens": 2000
}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload) as resp:
if resp.status != 200:
text = await resp.text()
raise RuntimeError(f"OpenRouter API returned error status {resp.status}: {text[:500]}")
# Define tools for Vertex AI
shell_command_tool = types.FunctionDeclaration(
name="execute_shell_command",
description="Executes a shell command and returns its output. Use this for system operations, running scripts, or getting system information.",
parameters={
"type": "object",
"properties": {"command": {"type": "string", "description": "The shell command to execute."}},
"required": ["command"],
},
)
web_search_tool_decl = types.FunctionDeclaration(
name="web_search",
description="Searches the web for information using a query. Use this to answer questions requiring current information or facts.",
parameters={
"type": "object",
"properties": {"query": {"type": "string", "description": "The search query."}},
"required": ["query"],
},
)
available_tools = []
if self._allow_shell_commands:
available_tools.append(shell_command_tool)
if self._allow_web_search and self.tavily_client:
available_tools.append(web_search_tool_decl)
vertex_tools = [types.Tool(function_declarations=available_tools)] if available_tools else None
if resp.content_type == "application/json":
data = await resp.json()
if "choices" not in data or not data["choices"]:
raise RuntimeError(f"OpenRouter API returned unexpected response format: {data}")
response_message = data["choices"][0]["message"]
# Convert input messages to Vertex AI `types.Content`
vertex_contents: List[types.Content] = []
for msg in messages:
role = "user" if msg.get("role") == "user" else "model"
parts: List[types.Part] = []
content_data = msg.get("content")
if isinstance(content_data, str):
parts.append(types.Part(text=content_data))
elif isinstance(content_data, list): # Multimodal content
for item in content_data:
item_type = item.get("type")
if item_type == "text":
parts.append(types.Part(text=item.get("text", "")))
elif item_type == "image_url":
image_url_data = item.get("image_url", {}).get("url", "")
if image_url_data.startswith("data:image/"):
try:
header, encoded = image_url_data.split(",", 1)
mime_type = header.split(":")[1].split(";")[0]
image_bytes = base64.b64decode(encoded)
parts.append(types.Part(inline_data=types.Blob(data=image_bytes, mime_type=mime_type)))
except Exception as e:
print(f"[TETO DEBUG] Error processing base64 image for Vertex: {e}")
parts.append(types.Part(text="[System Note: Error processing an attached image]"))
else: # If it's a direct URL (e.g. for stickers, emojis)
# Vertex AI prefers direct data or GCS URIs. For simplicity, we'll try to download and send data.
# This might be slow or fail for large images.
try:
async with aiohttp.ClientSession() as session:
async with session.get(image_url_data) as resp:
if resp.status == 200:
image_bytes = await resp.read()
mime_type = resp.content_type or "application/octet-stream"
# Validate MIME type for Vertex
supported_image_mimes = ["image/png", "image/jpeg", "image/webp", "image/heic", "image/heif", "image/gif"]
clean_mime_type = mime_type.split(';')[0].lower()
if clean_mime_type in supported_image_mimes:
parts.append(types.Part(inline_data=types.Blob(data=image_bytes, mime_type=clean_mime_type)))
else:
parts.append(types.Part(text=f"[System Note: Image type {clean_mime_type} from URL not directly supported, original URL: {image_url_data}]"))
else:
parts.append(types.Part(text=f"[System Note: Failed to download image from URL: {image_url_data}]"))
except Exception as e:
print(f"[TETO DEBUG] Error downloading image from URL {image_url_data} for Vertex: {e}")
parts.append(types.Part(text=f"[System Note: Error processing image from URL: {image_url_data}]"))
if parts: # Only add if there are valid parts
vertex_contents.append(types.Content(role=role, parts=parts))
# Get the AI's text response
ai_content = response_message.get("content", "")
max_tool_calls = 5
tool_calls_made = 0
# Check for custom tool call format in the response
# First check for shell commands
if self._allow_shell_commands:
command, content_without_command, text_before_command = extract_shell_command(ai_content)
if command:
if self._is_dangerous_command(command):
tool_result = "❌ Error: Execution was blocked due to a potentially dangerous command."
while tool_calls_made < max_tool_calls:
generation_config = types.GenerateContentConfig(
temperature=1.0, # Example, adjust as needed
max_output_tokens=2000, # Example
safety_settings=STANDARD_SAFETY_SETTINGS,
# system_instruction is not a direct param for generate_content, handled by model or prepended
)
# For Vertex, system prompt is often part of the model's configuration or the first message.
# Here, we'll prepend it if not already handled by the client/model config.
# However, gurt/api.py uses system_instruction in GenerateContentConfig.
# Let's assume the model used (gemini-1.5-flash-001) supports it via config.
# If not, it should be the first Content object.
# For now, let's try with system_instruction in config.
# The `genai.GenerativeModel` has `system_instruction` parameter.
# `genai_client.aio.models.generate_content` does not directly take system_instruction.
# It's better to get a model instance first.
model_instance = self.genai_client.get_model(f"models/{self._ai_model}")
# Update: system_instruction is part of the model, not generate_content config directly for client.generate_content
# We need to use model_instance.generate_content_async for system_instruction.
# Or, if using client.generate_content, the system prompt must be the first message.
# Let's adjust to use model_instance.generate_content_async
final_contents_for_api = vertex_contents
# The system prompt is very long, better to use system_instruction if model supports it.
# Gemini models generally support system_instruction.
try:
print(f"[TETO DEBUG] Sending to Vertex AI. Model: {self._ai_model}, Tool Config: {vertex_tools is not None}")
response = await model_instance.generate_content_async(
contents=final_contents_for_api,
generation_config=generation_config,
tools=vertex_tools,
tool_config=types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
mode=types.FunctionCallingConfigMode.ANY if vertex_tools else types.FunctionCallingConfigMode.NONE
)
) if vertex_tools else None,
# Pass system_instruction here
system_instruction=types.Content(role="system", parts=[types.Part(text=system_prompt_text)])
)
except google_exceptions.GoogleAPICallError as e:
raise RuntimeError(f"Vertex AI API call failed: {e}")
except Exception as e:
raise RuntimeError(f"Unexpected error during Vertex AI call: {e}")
if not response.candidates:
raise RuntimeError("Vertex AI response had no candidates.")
candidate = response.candidates[0]
# Check for function calls
if candidate.finish_reason == types.FinishReason.FUNCTION_CALL and candidate.content and candidate.content.parts:
has_tool_call = False
for part in candidate.content.parts:
if part.function_call:
has_tool_call = True
function_call = part.function_call
tool_name = function_call.name
tool_args = dict(function_call.args) if function_call.args else {}
print(f"[TETO DEBUG] Vertex AI requested tool: {tool_name} with args: {tool_args}")
# Append model's request to history
vertex_contents.append(candidate.content)
tool_result_str = ""
if tool_name == "execute_shell_command":
command_to_run = tool_args.get("command", "")
if self._is_dangerous_command(command_to_run):
tool_result_str = "❌ Error: Execution was blocked due to a potentially dangerous command."
else:
# Execute the shell command
tool_result = await self._execute_shell_command(command)
# Format the response with the AI's message before the command (if any)
formatted_response = ai_content
if text_before_command:
# Replace the original AI content with just the text before the command
# plus a formatted command execution message
if self._is_dangerous_command(command):
formatted_response = f"{text_before_command}\n\n*❌ Command \"{command}\" blocked (potentially dangerous)*\n\n{tool_result}"
else:
formatted_response = f"{text_before_command}\n\n*✅ Command \"{command}\" executed successfully*\n\n{tool_result}"
tool_result_str = await self._execute_shell_command(command_to_run)
elif tool_name == "web_search":
query_to_search = tool_args.get("query", "")
search_api_results = await self.web_search(query=query_to_search)
if "error" in search_api_results:
tool_result_str = f"❌ Error: Web search failed - {search_api_results['error']}"
else:
# If there was no text before the command, just show the command execution message
if self._is_dangerous_command(command):
formatted_response = f"*❌ Command \"{command}\" blocked (potentially dangerous)*\n\n{tool_result}"
else:
formatted_response = f"*✅ Command \"{command}\" executed successfully*\n\n{tool_result}"
results_text_parts = []
for i, res_item in enumerate(search_api_results.get("results", [])[:3], 1): # Limit to 3 results for brevity
results_text_parts.append(f"Result {i}:\nTitle: {res_item['title']}\nURL: {res_item['url']}\nContent Snippet: {res_item['content'][:200]}...\n")
if search_api_results.get("answer"):
results_text_parts.append(f"Summary Answer: {search_api_results['answer']}")
tool_result_str = "\n\n".join(results_text_parts)
if not tool_result_str:
tool_result_str = "No results found or summary available."
else:
tool_result_str = f"Error: Unknown tool '{tool_name}' requested."
# Append the original message and tool result to the conversation
messages.append({"role": "assistant", "content": ai_content})
messages.append({"role": "user", "content": f"Command output:\n{tool_result}"})
# Append tool response to history
vertex_contents.append(types.Content(
role="function", # "tool" role was for older versions, "function" is current for Gemini
parts=[types.Part.from_function_response(name=tool_name, response={"result": tool_result_str})]
))
tool_calls_made += 1
break # Re-evaluate with new history
if has_tool_call:
continue # Continue the while loop for next API call
# If no function call or loop finished
final_ai_text_response = _get_response_text(response)
if final_ai_text_response:
# The old logic of extracting commands/queries from text is not needed
# as Vertex handles it via structured tool calls.
# The `formatted_response` logic also changes.
# The final text is what the AI generates after all tool interactions.
return final_ai_text_response
else:
# If response has no text part (e.g. only safety block or empty)
finish_reason_str = types.FinishReason(candidate.finish_reason).name if candidate.finish_reason else "UNKNOWN"
safety_ratings_str = ""
if candidate.safety_ratings:
safety_ratings_str = ", ".join([f"{rating.category.name}: {rating.probability.name}" for rating in candidate.safety_ratings])
error_detail = f"Vertex AI response had no text. Finish Reason: {finish_reason_str}."
if safety_ratings_str:
error_detail += f" Safety Ratings: [{safety_ratings_str}]."
# If blocked by safety, we should inform the user or log appropriately.
# For now, returning a generic message.
if candidate.finish_reason == types.FinishReason.SAFETY:
return f"(Teto AI response was blocked due to safety settings: {safety_ratings_str})"
print(f"[TETO DEBUG] {error_detail}") # Log it
return "(Teto AI had a problem generating a response or the response was empty.)"
# Make another API call with the tool result, but return the formatted response
# to be displayed in Discord
ai_follow_up = await self._teto_reply_ai_with_messages(messages)
return formatted_response + "\n\n" + ai_follow_up
# Then check for web search queries
if self._allow_web_search and self.tavily_client:
query, content_without_query, text_before_query = extract_web_search_query(ai_content)
if query:
# Execute the web search
search_results = await self.web_search(query=query)
# Format the search results for the AI
if "error" in search_results:
tool_result = f"❌ Error: Web search failed - {search_results['error']}"
else:
# Format the results in a readable way
results_text = []
for i, result in enumerate(search_results.get("results", [])[:5], 1): # Limit to top 5 results
results_text.append(f"Result {i}:\nTitle: {result['title']}\nURL: {result['url']}\nContent: {result['content'][:300]}...\n")
if search_results.get("answer"):
results_text.append(f"\nSummary Answer: {search_results['answer']}")
tool_result = "\n\n".join(results_text)
# Format the response with the AI's message before the query (if any)
formatted_response = ai_content
if text_before_query:
formatted_response = f"{text_before_query}\n\n*🔍 Web search for \"{query}\" completed*\n\n"
else:
formatted_response = f"*🔍 Web search for \"{query}\" completed*\n\n"
# Append the original message and search results to the conversation
messages.append({"role": "assistant", "content": ai_content})
messages.append({"role": "user", "content": f"Web search results for '{query}':\n{tool_result}"})
# Make another API call with the search results, but return the formatted response
# to be displayed in Discord
ai_follow_up = await self._teto_reply_ai_with_messages(messages)
return formatted_response + ai_follow_up
return ai_content
else:
text = await resp.text()
raise RuntimeError(f"OpenRouter API returned non-JSON response (status {resp.status}): {text[:500]}")
# If loop finishes due to max_tool_calls
if tool_calls_made >= max_tool_calls:
return "(Teto AI reached maximum tool interaction limit. Please try rephrasing.)"
return "(Teto AI encountered an unexpected state.)" # Fallback
async def _teto_reply_ai(self, text: str) -> str:
"""Replies to the text as Kasane Teto using AI via OpenRouter."""
"""Replies to the text as Kasane Teto using AI via Vertex AI."""
return await self._teto_reply_ai_with_messages([{"role": "user", "content": text}])
async def web_search(self, query: str, search_depth: Optional[str] = None, max_results: Optional[int] = None) -> Dict[str, Any]:
@ -508,39 +651,47 @@ class TetoCog(commands.Cog):
emoji_pattern = re.compile(r"<a?:(\w+):(\d+)>")
for match in emoji_pattern.finditer(message.content):
emoji_id = match.group(2)
# Construct Discord emoji URL - this might need adjustment based on Discord API specifics
emoji_url = f"https://cdn.discordapp.com/emojis/{emoji_id}.png" # .gif for animated
if match.group(0).startswith("<a:"): # Check if animated
emoji_url = f"https://cdn.discordapp.com/emojis/{emoji_id}.gif"
user_content.append({"type": "text", "text": f"The custom emoji {match.group(1)}:"})
emoji_name = match.group(1)
# Construct Discord emoji URL
is_animated = match.group(0).startswith("<a:")
emoji_url = f"https://cdn.discordapp.com/emojis/{emoji_id}.{'gif' if is_animated else 'png'}"
user_content.append({"type": "text", "text": f"The user included the custom emoji '{emoji_name}':"})
user_content.append({"type": "image_url", "image_url": {"url": emoji_url}})
print(f"[TETO DEBUG] Found custom emoji: {emoji_url}")
log.info(f"[TETO DEBUG] Found custom emoji: {emoji_name} ({emoji_url})")
if not user_content:
log.info("[TETO DEBUG] Message triggered AI but contained no supported content (text, image, sticker, emoji).")
return # Don't send empty messages to the AI
convo.append({"role": "user", "content": user_content})
# Append the current user message to the conversation history
# The `user_content` list itself becomes the value for the 'content' key
current_message_entry = {"role": "user", "content": user_content}
convo.append(current_message_entry)
try:
async with channel.typing():
ai_reply = await self._teto_reply_ai_with_messages(messages=convo)
ai_reply = strip_think_blocks(ai_reply)
await message.reply(ai_reply)
# The `convo` (which is `messages` param) is now a list of dicts,
# where each dict is like {"role": "user/assistant", "content": string_or_list_of_parts}
ai_reply_text = await self._teto_reply_ai_with_messages(messages=convo)
ai_reply_text = strip_think_blocks(ai_reply_text) # Strip think blocks if any
# Ensure reply is not empty or excessively long
if not ai_reply_text or len(ai_reply_text.strip()) == 0:
ai_reply_text = "(Teto AI returned an empty response.)"
log.warning("[TETO DEBUG] AI reply was empty.")
elif len(ai_reply_text) > 1950: # Discord limit is 2000, leave some room
ai_reply_text = ai_reply_text[:1950] + "... (message truncated)"
log.warning("[TETO DEBUG] AI reply was truncated due to length.")
# Extract the original AI content (without command execution formatting)
# for storing in conversation history
command, content_without_command, _ = extract_shell_command(ai_reply)
if command:
# If there was a command, store the original AI content without the formatted execution message
convo.append({"role": "assistant", "content": content_without_command if content_without_command else ai_reply})
else:
# If there was no command, store the full reply
convo.append({"role": "assistant", "content": ai_reply})
await message.reply(ai_reply_text)
_teto_conversations[convo_key] = convo[-10:] # Keep last 10 interactions
log.info("[TETO DEBUG] AI reply sent successfully.")
# Store the AI's textual response in the conversation history
# The tool handling logic is now within _teto_reply_ai_with_messages
convo.append({"role": "assistant", "content": ai_reply_text})
_teto_conversations[convo_key] = convo[-10:] # Keep last 10 interactions (user + assistant turns)
log.info("[TETO DEBUG] AI reply sent successfully using Vertex AI.")
except Exception as e:
await channel.send(f"**Teto AI conversation failed! TwT**\n{e}")
log.error(f"[TETO DEBUG] Exception during AI reply: {e}")
@ -551,12 +702,6 @@ class TetoCog(commands.Cog):
self._ai_model = model_name
await interaction.response.send_message(f"Ame-chan's AI model set to: {model_name} desu~", ephemeral=True)
@ame_group.command(name="set_api_endpoint", description="Sets the API endpoint for Ame-chan.")
@app_commands.describe(endpoint_url="The URL of the API endpoint.")
async def set_api_endpoint(self, interaction: discord.Interaction, endpoint_url: str):
self._api_endpoint = endpoint_url
await interaction.response.send_message(f"Ame-chan's API endpoint set to: {endpoint_url} desu~", ephemeral=True)
@ame_group.command(name="clear_chat_history", description="Clears the chat history for the current channel.")
async def clear_chat_history(self, interaction: discord.Interaction):
channel_id = interaction.channel_id
@ -648,15 +793,8 @@ async def teto_context_menu_ai_reply(interaction: discord.Interaction, message:
await message.reply(ai_reply)
await interaction.followup.send("Teto AI replied desu~", ephemeral=True)
# Extract the original AI content (without command execution formatting)
# for storing in conversation history
command, content_without_command, _ = extract_shell_command(ai_reply)
if command:
# If there was a command, store the original AI content without the formatted execution message
convo.append({"role": "assistant", "content": content_without_command if content_without_command else ai_reply})
else:
# If there was no command, store the full reply
convo.append({"role": "assistant", "content": ai_reply})
# Store the AI's textual response in the conversation history
convo.append({"role": "assistant", "content": ai_reply}) # ai_reply is already the text
_teto_conversations[convo_key] = convo[-10:]
except Exception as e:
await interaction.followup.send(f"Teto AI reply failed: {e} desu~", ephemeral=True)