11import discord
22import time
3+ from anthropic import AsyncAnthropic
34from discord .ext import commands
5+ from google import genai
6+ from google .genai import types as genai_types
47from openai import AsyncOpenAI
58from openai .types .responses import WebSearchToolParam
69from openai .types .shared import ReasoningEffort
1215
1316
1417class OpenAi (commands .Cog ):
15- """OpenAI-powered commands for AI assistance and text generation ."""
18+ """LLM chat commands (OpenAI / Anthropic Claude / Google Gemini) with optional web search ."""
1619
1720 def __init__ (self , bot : Bot ) -> None :
1821 self .bot : Bot = bot
1922 self ._bot_settings : BotSettings = get_bot_settings ()
2023 self ._openai_client : AsyncOpenAI = AsyncOpenAI (api_key = self ._bot_settings .openai_api_key )
24+ self ._anthropic_client : AsyncAnthropic = AsyncAnthropic (api_key = self ._bot_settings .anthropic_api_key )
25+ self ._gemini_client : genai .Client = genai .Client (api_key = self ._bot_settings .gemini_api_key )
2126 self ._effort : ReasoningEffort = "xhigh"
2227 self ._instructions : str = "You are a helpful AI assistant."
2328 self ._instructions_web : str = (
@@ -26,33 +31,54 @@ def __init__(self, bot: Bot) -> None:
2631 "numbers, statistics, or breakdowns that the sources do not explicitly state. Cite the source URL(s)."
2732 )
2833
34+ # ─────────────────────────── Commands ───────────────────────────
35+
36+ @commands .command ()
37+ @commands .guild_only ()
38+ @commands .cooldown (1 , CoolDowns .OpenAI .value , commands .BucketType .user )
39+ async def gpt (self , ctx : commands .Context , * , msg_text : str ) -> None :
40+ """Ask OpenAI's GPT model for a direct answer (no web search)."""
41+ await self ._run_chat (ctx , msg_text , provider = "openai" , use_web = False )
42+
43+ @commands .command ()
44+ @commands .guild_only ()
45+ @commands .cooldown (1 , CoolDowns .OpenAI .value , commands .BucketType .user )
46+ async def gptweb (self , ctx : commands .Context , * , msg_text : str ) -> None :
47+ """Ask OpenAI's GPT model with web search enabled — for current/factual info."""
48+ await self ._run_chat (ctx , msg_text , provider = "openai" , use_web = True )
49+
50+ @commands .command ()
51+ @commands .guild_only ()
52+ @commands .cooldown (1 , CoolDowns .OpenAI .value , commands .BucketType .user )
53+ async def claude (self , ctx : commands .Context , * , msg_text : str ) -> None :
54+ """Ask Anthropic's Claude model for a direct answer (no web search)."""
55+ await self ._run_chat (ctx , msg_text , provider = "anthropic" , use_web = False )
56+
2957 @commands .command ()
58+ @commands .guild_only ()
3059 @commands .cooldown (1 , CoolDowns .OpenAI .value , commands .BucketType .user )
31- async def ai (self , ctx : commands .Context , * , msg_text : str ) -> None :
32- """Ask OpenAI for a direct answer (no web search).
60+ async def claudeweb (self , ctx : commands .Context , * , msg_text : str ) -> None :
61+ """Ask Anthropic's Claude model with web search enabled — for current/factual info."""
62+ await self ._run_chat (ctx , msg_text , provider = "anthropic" , use_web = True )
3363
34- Usage:
35- ai What is Python?
36- ai Write a haiku about programming
37- ai Explain quantum computing in simple terms
38- """
39- await self ._run_ai (ctx , msg_text , use_web = False )
64+ @ commands . command ()
65+ @ commands . guild_only ()
66+ @ commands . cooldown ( 1 , CoolDowns . OpenAI . value , commands . BucketType . user )
67+ async def gemini ( self , ctx : commands . Context , * , msg_text : str ) -> None :
68+ """Ask Google's Gemini model for a direct answer (no web search)."""
69+ await self ._run_chat (ctx , msg_text , provider = "gemini" , use_web = False )
4070
4171 @commands .command ()
72+ @commands .guild_only ()
4273 @commands .cooldown (1 , CoolDowns .OpenAI .value , commands .BucketType .user )
43- async def aiweb (self , ctx : commands .Context , * , msg_text : str ) -> None :
44- """Ask OpenAI to search the web before answering — best for current/factual info.
45-
46- Usage:
47- aiweb What's the latest news about <topic>
48- aiweb How many support gems does Path of Exile 2 have
49- """
50- await self ._run_ai (ctx , msg_text , use_web = True )
51-
52- async def _run_ai (self , ctx : commands .Context , msg_text : str , use_web : bool ) -> None :
53- """Shared body for the `ai` and `aiweb` commands."""
54- # Reasoning (and web search) can take a couple of minutes, so show a progress
55- # message immediately so the user knows the bot is working (not stuck).
74+ async def geminiweb (self , ctx : commands .Context , * , msg_text : str ) -> None :
75+ """Ask Google's Gemini model with Google Search grounding enabled."""
76+ await self ._run_chat (ctx , msg_text , provider = "gemini" , use_web = True )
77+
78+ # ─────────────────────────── Shared flow ───────────────────────────
79+
80+ async def _run_chat (self , ctx : commands .Context , msg_text : str , provider : str , use_web : bool ) -> None :
81+ """Send progress message, dispatch to provider, time, post answer."""
5682 progress_text = (
5783 "Please wait, I'm thinking and searching the web for an accurate answer..."
5884 if use_web
@@ -67,38 +93,50 @@ async def _run_ai(self, ctx: commands.Context, msg_text: str, use_web: bool) ->
6793
6894 start = time .monotonic ()
6995 try :
70- response_text = await self ._get_ai_response ( msg_text , use_web = use_web )
96+ response_text = await self ._dispatch ( provider , msg_text , use_web )
7197 color = discord .Color .green ()
7298 description = response_text
7399 except Exception as e :
74- self .bot .log .error (f"OpenAI API error: { e } " )
100+ self .bot .log .error (f"{ provider } API error: { e } " )
75101 color = discord .Color .red ()
76102 description = f"Sorry, I encountered an error: { e } "
77103 elapsed = time .monotonic () - start
78104
79- # Remove the progress message before sending the final answer.
80105 try :
81106 await progress_msg .delete ()
82107 except discord .HTTPException :
83108 pass
84109
85- embeds = self ._create_ai_embeds (ctx , description , color , elapsed )
110+ model = self ._model_for (provider )
111+ embeds = self ._create_ai_embeds (ctx , description , color , elapsed , model )
86112 if len (embeds ) == 1 :
87113 await bot_utils .send_embed (ctx , embeds [0 ], False )
88114 else :
89115 view = bot_utils .EmbedPaginatorView (embeds , ctx .author .id )
90116 await view .send_and_save (ctx )
91117
92- async def _get_ai_response (self , message : str , use_web : bool ) -> str :
93- """Get response from OpenAI API.
94-
95- use_web: when True, enables the built-in web_search tool and uses the
96- web-grounded instructions. When False, the model answers from its own
97- knowledge with plain instructions.
98- """
118+ async def _dispatch (self , provider : str , message : str , use_web : bool ) -> str :
119+ if provider == "openai" :
120+ return await self ._get_openai_response (message , use_web = use_web )
121+ if provider == "anthropic" :
122+ return await self ._get_claude_response (message , use_web = use_web )
123+ if provider == "gemini" :
124+ return await self ._get_gemini_response (message , use_web = use_web )
125+ raise ValueError (f"Unknown provider: { provider } " )
126+
127+ def _model_for (self , provider : str ) -> str :
128+ return {
129+ "openai" : self ._bot_settings .openai_model ,
130+ "anthropic" : self ._bot_settings .anthropic_model ,
131+ "gemini" : self ._bot_settings .gemini_model ,
132+ }[provider ]
133+
134+ # ─────────────────────────── Provider calls ───────────────────────────
135+
136+ async def _get_openai_response (self , message : str , use_web : bool ) -> str :
137+ """Call OpenAI's Responses API with optional web_search tool."""
99138 instructions = self ._instructions_web if use_web else self ._instructions
100139 tools : list [WebSearchToolParam ] = [WebSearchToolParam (type = "web_search" )] if use_web else []
101-
102140 response = await self ._openai_client .responses .create (
103141 instructions = instructions ,
104142 model = self ._bot_settings .openai_model ,
@@ -107,10 +145,44 @@ async def _get_ai_response(self, message: str, use_web: bool) -> str:
107145 max_output_tokens = None ,
108146 input = message ,
109147 )
110-
111148 content = response .output_text
112149 return content .strip () if content else ""
113150
151+ async def _get_claude_response (self , message : str , use_web : bool ) -> str :
152+ """Call Anthropic Messages API with optional web_search server tool."""
153+ instructions = self ._instructions_web if use_web else self ._instructions
154+ tools = [{"type" : "web_search_20250305" , "name" : "web_search" }] if use_web else []
155+ response = await self ._anthropic_client .messages .create (
156+ model = self ._bot_settings .anthropic_model ,
157+ max_tokens = 4096 ,
158+ system = instructions ,
159+ messages = [{"role" : "user" , "content" : message }],
160+ tools = tools ,
161+ )
162+ # Concatenate all text blocks (web search may interleave tool-use blocks).
163+ text_parts = [
164+ getattr (block , "text" , "" ) for block in response .content if getattr (block , "type" , None ) == "text"
165+ ]
166+ content = "" .join (text_parts ).strip ()
167+ return content
168+
169+ async def _get_gemini_response (self , message : str , use_web : bool ) -> str :
170+ """Call Google Gemini with optional Google Search grounding."""
171+ instructions = self ._instructions_web if use_web else self ._instructions
172+ config_kwargs : dict = {"system_instruction" : instructions }
173+ if use_web :
174+ config_kwargs ["tools" ] = [genai_types .Tool (google_search = genai_types .GoogleSearch ())]
175+ config = genai_types .GenerateContentConfig (** config_kwargs )
176+ response = await self ._gemini_client .aio .models .generate_content (
177+ model = self ._bot_settings .gemini_model ,
178+ contents = message ,
179+ config = config ,
180+ )
181+ content = response .text or ""
182+ return content .strip ()
183+
184+ # ─────────────────────────── Embed formatting ───────────────────────────
185+
114186 @staticmethod
115187 def _format_duration (seconds : float ) -> str :
116188 """Format an elapsed duration as e.g. '5ms' (sub-second) or '20s'."""
@@ -119,13 +191,19 @@ def _format_duration(seconds: float) -> str:
119191 return f"{ round (seconds )} s"
120192
121193 def _create_ai_embeds (
122- self , ctx : commands .Context , description : str , color : discord .Color , elapsed : float = 0.0
194+ self ,
195+ ctx : commands .Context ,
196+ description : str ,
197+ color : discord .Color ,
198+ elapsed : float = 0.0 ,
199+ model : str | None = None ,
123200 ) -> list [discord .Embed ]:
124- """Create formatted embed(s) for AI response, paginating if needed."""
125- model = self ._bot_settings .openai_model
201+ """Create formatted embed(s) for an AI response, paginating if needed."""
202+ if model is None :
203+ model = self ._bot_settings .openai_model
126204 duration = self ._format_duration (elapsed )
127205 max_length = 2000
128- chunks = []
206+ chunks : list [ str ] = []
129207
130208 while description :
131209 if len (description ) <= max_length :
0 commit comments