From 89c96259fa64aed961c1ab45f2fb7329a05b3901 Mon Sep 17 00:00:00 2001 From: owen Date: Mon, 14 Sep 2026 18:30:31 -0500 Subject: [PATCH] assistant: add local Ollama backend for the humor chatbot Make the API base URL configurable (new global config base_url, default to the local Ollama OpenAI-compatible endpoint http://127.0.0.1:11434/v1 on gpu1). Local endpoints (127.0.0.1/localhost) need no API key: the on_message, _chat, and models guards allow a missing key when local, and the Authorization header is only sent when a key is set. Default guild model is now llama3.2:3b-instruct-q4_K_M with a humor-focused system prompt. Adds [p]assistant baseurl to switch endpoints (omit to reset to local), shows the backend in settings, and raises the request timeout to 120s for local inference. GreenPT remains usable as a fallback by setting the base URL back to it and providing a key. Note: Llama-3.2-3B-Instruct is text-only; the vision path needs a vision-capable model (e.g. a local llava/llama3.2-vision) or GreenPT. --- assistant/assistant.py | 86 +++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/assistant/assistant.py b/assistant/assistant.py index 1618b25..e686125 100644 --- a/assistant/assistant.py +++ b/assistant/assistant.py @@ -35,7 +35,11 @@ class APIKeyButton(discord.ui.View): class Assistant(commands.Cog): """AI chatbot assistant. Set a chat channel or get responses when the bot is pinged.""" - BASE_URL = "https://api.greenpt.ai/v1" + # Default backend: the local Ollama server (OpenAI-compatible) on gpu1. + # Override per deployment with [p]assistant baseurl. GreenPT + # (https://api.greenpt.ai/v1) remains usable as a fallback by setting the + # base URL back to it and providing an API key. + DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1" def __init__(self, bot: Red): self.bot = bot @@ -44,17 +48,27 @@ class Assistant(commands.Cog): self.config.register_guild( enabled=True, chat_channel=None, - model="gemma4", - vision_model="gemma4", + model="llama3.2:3b-instruct-q4_K_M", + vision_model="llama3.2:3b-instruct-q4_K_M", temperature=0.7, - system_prompt="You are a helpful assistant.", + system_prompt=( + "You are a witty, quick-humored chatbot in a Discord server. " + "Keep replies short, punchy, and funny. Favor clever, playful " + "banter over long explanations. Stay good-natured; never be " + "genuinely mean or offensive." + ), question_mode=False, max_tokens=None, reasoning_effort=None, ) - self.config.register_global(api_key=None) + self.config.register_global(api_key=None, base_url=self.DEFAULT_BASE_URL) self.history: dict[int, list[dict]] = {} + @staticmethod + def _is_local(base_url: str) -> bool: + """A local endpoint (Ollama) does not require an API key.""" + return "127.0.0.1" in base_url or "localhost" in base_url + async def cog_load(self): self.session = aiohttp.ClientSession() @@ -79,8 +93,10 @@ class Assistant(commands.Cog): if not enabled: return + # A local (Ollama) endpoint needs no API key; a remote one does. + base_url = await self.config.base_url() api_key = await self.config.api_key() - if not api_key: + if not self._is_local(base_url) and not api_key: return chat_channel_id = await self.config.guild(guild).chat_channel() @@ -124,7 +140,7 @@ class Assistant(commands.Cog): if question_mode and not content.rstrip().endswith("?"): if is_ping: - await message.reply("Question mode is on — messages must end with **?**.") + await message.reply("Question mode is on - messages must end with **?**.") return async with message.channel.typing(): @@ -150,8 +166,11 @@ class Assistant(commands.Cog): # --------------------------------------------------------------------------- async def _chat(self, guild: discord.Guild, channel_id: int, user_msg: str, image_urls: list[str] | None = None) -> str | None: + if not self.session: + return None + base_url = await self.config.base_url() api_key = await self.config.api_key() - if not api_key or not self.session: + if not self._is_local(base_url) and not api_key: return None model = await self.config.guild(guild).model() @@ -196,15 +215,16 @@ class Assistant(commands.Cog): if reasoning_effort: payload["reasoning_effort"] = reasoning_effort + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + try: async with self.session.post( - f"{self.BASE_URL}/chat/completions", + f"{base_url}/chat/completions", json=payload, - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - timeout=aiohttp.ClientTimeout(total=60), + headers=headers, + timeout=aiohttp.ClientTimeout(total=120), ) as resp: if resp.status != 200: body = await resp.text() @@ -246,6 +266,23 @@ class Assistant(commands.Cog): view = APIKeyButton(self.config) await ctx.send("Click below to set the API key:", view=view) + @assistant.command(name="baseurl") + @checks.is_owner() + async def assistant_baseurl(self, ctx: commands.Context, url: str | None = None): + """Set the API base URL. Omit to reset to the local Ollama endpoint. + + Examples: + [p]assistant baseurl (reset to local Ollama) + [p]assistant baseurl https://api.greenpt.ai/v1 (use GreenPT) + A local endpoint (127.0.0.1/localhost) needs no API key. + """ + if url is None: + url = self.DEFAULT_BASE_URL + url = url.rstrip("/") + await self.config.base_url.set(url) + note = " (local, no API key needed)" if self._is_local(url) else " (remote, API key required)" + await ctx.send(f"Base URL set to `{url}`{note}.") + @assistant.command(name="toggle") async def assistant_toggle(self, ctx: commands.Context): """Toggle the assistant on or off for this server.""" @@ -266,7 +303,7 @@ class Assistant(commands.Cog): @assistant.command(name="model") async def assistant_model(self, ctx: commands.Context, model: str): - """Set the model (e.g. gemma4, green-l, green-r).""" + """Set the model. Local (Ollama): llama3.2:3b-instruct-q4_K_M. Remote (GreenPT): gemma4, green-l.""" await self.config.guild(ctx.guild).model.set(model) await ctx.send(f"Model set to **{model}**.") @@ -330,16 +367,21 @@ class Assistant(commands.Cog): "qwen3-embedding-8b", } + base_url = await self.config.base_url() api_key = await self.config.api_key() - if not api_key or not self.session: + if not self.session or (not self._is_local(base_url) and not api_key): await ctx.send("API key not set. Use `[p]assistant setkey` first.") return + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + # Fetch live model list from API try: async with self.session.get( - f"{self.BASE_URL}/models", - headers={"Authorization": f"Bearer {api_key}"}, + f"{base_url}/models", + headers=headers, timeout=aiohttp.ClientTimeout(total=15), ) as resp: if resp.status != 200: @@ -376,10 +418,10 @@ class Assistant(commands.Cog): marker = " ◀" if model_id == current_model else "" if pricing: lines.append( - f"`{model_id}` — €{pricing['input']:.2f} in / €{pricing['output']:.2f} out{marker}" + f"`{model_id}` - €{pricing['input']:.2f} in / €{pricing['output']:.2f} out{marker}" ) else: - lines.append(f"`{model_id}` — pricing unknown{marker}") + lines.append(f"`{model_id}` - pricing unknown{marker}") embed = discord.Embed( title="Available Models (cheapest first)", @@ -420,7 +462,7 @@ class Assistant(commands.Cog): valid_levels = ("none", "minimal", "low", "medium", "high") if level is None: await self.config.guild(ctx.guild).reasoning_effort.set(None) - await ctx.send("Reasoning effort cleared (model default — thinking enabled).") + await ctx.send("Reasoning effort cleared (model default - thinking enabled).") elif level.lower() not in valid_levels: await ctx.send(f"Invalid level. Choose from: {', '.join(valid_levels)}") else: @@ -473,6 +515,7 @@ class Assistant(commands.Cog): q_mode = await cfg.question_mode() max_tokens = await cfg.max_tokens() reasoning_effort = await cfg.reasoning_effort() + base_url = await self.config.base_url() has_key = await self.config.api_key() is not None channel_str = f"<#{chat_channel}>" if chat_channel else "None" @@ -481,6 +524,7 @@ class Assistant(commands.Cog): embed.add_field(name="Enabled", value=str(enabled), inline=True) embed.add_field(name="Chat Channel", value=channel_str, inline=True) embed.add_field(name="API Key", value="Set" if has_key else "Not set", inline=True) + embed.add_field(name="Backend", value=base_url, inline=False) embed.add_field(name="Model", value=model, inline=True) embed.add_field(name="Vision Model", value=vision_model, inline=True) embed.add_field(name="Temperature", value=str(temp), inline=True)