aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@noreply.codeberg.org>2026-06-03 14:00:03 +0200
committersalmonik <salmonik@noreply.codeberg.org>2026-06-03 14:00:03 +0200
commitc027964952bd0d9700a9c83f72fb23bd5bffad3e (patch)
tree1a7f31b08539288eab7fb58bd0ce3b14b2bb0194
parentMerge pull request 'Voice related fixes, alongside simplifying assistant' (#5... (diff)
parentfeat: add local API support for OpenAI compatible APIs (diff)
downloadsenator-sperma-c027964952bd0d9700a9c83f72fb23bd5bffad3e.tar.gz
senator-sperma-c027964952bd0d9700a9c83f72fb23bd5bffad3e.zip
Merge pull request 'feat: add local API support for OpenAI compatible APIs' (#6) from dev into main
Reviewed-on: https://codeberg.org/salmonik/senator-sperma/pulls/6
-rw-r--r--.env.example8
-rw-r--r--cogs/ai.py75
-rw-r--r--cogs/assistant.py53
-rw-r--r--cogs/utils/llm.py62
-rw-r--r--tests/cogs/test_ai.py60
5 files changed, 122 insertions, 136 deletions
diff --git a/.env.example b/.env.example
index 9623155..b7c7c61 100644
--- a/.env.example
+++ b/.env.example
@@ -11,5 +11,9 @@ COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads
11COPYPARTY_PASSWORD= 11COPYPARTY_PASSWORD=
12 12
13# Optional - AI commands 13# Optional - AI commands
14OPENROUTER_API_KEY= 14# LLM_API_URL=http://localhost:8080/v1/chat/completions # llama.cpp server (use http://, no TLS)
15OPENROUTER_MODEL= 15# LLM_API_KEY= # not needed for llama.cpp
16# LLM_API_VERIFY=true # SSL verify (false for self-signed certs)
17# LLM_MODEL= # model name for llama.cpp
18OPENROUTER_API_KEY= # deprecated, use LLM_API_KEY instead
19OPENROUTER_MODEL= # deprecated, use LLM_MODEL instead
diff --git a/cogs/ai.py b/cogs/ai.py
index ef2ba7c..dbdb02e 100644
--- a/cogs/ai.py
+++ b/cogs/ai.py
@@ -7,12 +7,14 @@ import os
7import discord 7import discord
8from discord.ext import commands 8from discord.ext import commands
9import logging 9import logging
10import httpx
11from gtts import gTTS 10from gtts import gTTS
12import io 11import io
12from cogs.utils.llm import llm_completion
13 13
14logger = logging.getLogger(__name__) 14logger = logging.getLogger(__name__)
15 15
16_DEFAULT_SENATOR_MODEL = "cognitivecomputations/dolphin-mistral-24b-venice-edition:free"
17
16class AI(commands.Cog): 18class AI(commands.Cog):
17 def __init__(self, bot): 19 def __init__(self, bot):
18 self.bot = bot 20 self.bot = bot
@@ -29,48 +31,41 @@ class AI(commands.Cog):
29 ): 31 ):
30 await ctx.defer() 32 await ctx.defer()
31 33
34 model = (
35 os.getenv("LLM_MODEL")
36 or os.getenv("OPENROUTER_MODEL")
37 or _DEFAULT_SENATOR_MODEL
38 )
39 messages = [
40 {
41 "role": "system",
42 "content": "You are Senator Steaven Sperma Armstrong from Metal Gear Rising Vengeance. \
43 You are racist and you hate other minorities. Respond in plain text, no markdown tricks. \
44 If the message is in Polish, respond in Polish. Respond in 100 words."
45 },
46 {
47 "role": "user",
48 "content": text
49 }
50 ]
32 51
33 url = "https://openrouter.ai/api/v1/chat/completions" 52 response_text = await llm_completion(messages, model=model)
34 headers = {
35 "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
36 "Content-Type": "application/json",
37 }
38 payload = {
39 "model": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
40 "messages": [
41 {
42 "role": "system",
43 "content": "You are Senator Steaven Sperma Armstrong from Metal Gear Rising Vengeance. \
44 You are racist and you hate other minorities. Respond in plain text, no markdown tricks. \
45 If the message is in Polish, respond in Polish. Respond in 100 words."
46 },
47 {
48 "role": "user",
49 "content": text
50 }
51 ],
52 53
53 } 54 if response_text is None:
54 async with httpx.AsyncClient() as client: 55 await ctx.send_followup("bot siem zesroł")
55 response = await client.post(url=url, headers=headers, json=payload) 56 return
56 response_data = response.json()
57 logger.info(response_data)
58 if not response_data["choices"]:
59 await ctx.send_followup("bot siem zesroł")
60 else:
61 response_text = response_data["choices"][0]["message"]["content"]
62 if tts:
63 tts_obj = gTTS(text=response_text, lang='pl')
64 tts_buffer = io.BytesIO()
65 tts_obj.write_to_fp(tts_buffer)
66 tts_buffer.seek(0)
67 from cogs.voice import get_voice_manager
68 vm = get_voice_manager(self.bot)
69 channel = ctx.author.voice.channel
70 await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False)
71 57
72 else: 58 if tts:
73 await ctx.send_followup(response_text) 59 tts_obj = gTTS(text=response_text, lang='pl')
60 tts_buffer = io.BytesIO()
61 tts_obj.write_to_fp(tts_buffer)
62 tts_buffer.seek(0)
63 from cogs.voice import get_voice_manager
64 vm = get_voice_manager(self.bot)
65 channel = ctx.author.voice.channel
66 await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False)
67 else:
68 await ctx.send_followup(response_text)
74 69
75 70
76 71
diff --git a/cogs/assistant.py b/cogs/assistant.py
index fed7825..ffb664e 100644
--- a/cogs/assistant.py
+++ b/cogs/assistant.py
@@ -5,9 +5,9 @@
5 5
6import discord 6import discord
7from discord.ext import commands 7from discord.ext import commands
8import httpx
9import logging 8import logging
10import os 9import os
10from cogs.utils.llm import llm_completion
11 11
12logger = logging.getLogger(__name__) 12logger = logging.getLogger(__name__)
13 13
@@ -32,24 +32,20 @@ class Jarvis(commands.Cog):
32 32
33 @staticmethod 33 @staticmethod
34 async def assistant(message): 34 async def assistant(message):
35 headers = {
36 "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
37 "Content-Type": "application/json"
38 }
39
40 chain = await Jarvis._build_message_chain(message) 35 chain = await Jarvis._build_message_chain(message)
41 messages = Jarvis._build_messages(message, chain) 36 messages = Jarvis._build_messages(message, chain)
42 payload = Jarvis._build_payload(messages)
43 37
44 response_data = await Jarvis._safe_api_call(payload, headers) 38 extra = None
45 if response_data is None: 39 api_url = os.getenv("LLM_API_URL", "https://openrouter.ai/api/v1/chat/completions")
40 if "openrouter" in api_url.lower():
41 extra = {"plugins": [{"id": "response-healing"}]}
42
43 response_text = await llm_completion(messages, extra=extra)
44 if response_text is None:
46 await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.") 45 await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.")
47 return 46 return
48 47
49 response_message = Jarvis._parse_response(response_data) 48 await message.reply(response_text)
50
51 channel = message.channel
52 await message.reply(response_message)
53 49
54 @staticmethod 50 @staticmethod
55 async def _build_message_chain(message): 51 async def _build_message_chain(message):
@@ -93,38 +89,7 @@ class Jarvis(commands.Cog):
93 89
94 return messages 90 return messages
95 91
96 @staticmethod
97 def _build_payload(messages):
98 return {
99 "model": os.getenv("OPENROUTER_MODEL"),
100 "messages": messages,
101 "plugins": [{"id": "response-healing"}]
102 }
103
104 @staticmethod
105 async def _make_api_request(payload, headers):
106 async with httpx.AsyncClient(timeout=30.0) as client:
107 url = "https://openrouter.ai/api/v1/chat/completions"
108 response = await client.post(url=url, headers=headers, json=payload)
109 response_data = response.json()
110 logger.info(response_data)
111 return response_data
112
113 @staticmethod
114 async def _safe_api_call(payload, headers):
115 try:
116 return await Jarvis._make_api_request(payload, headers)
117 except httpx.HTTPError as e:
118 logger.error(f"API request failed: {e}")
119 return None
120
121 @staticmethod
122 def _parse_response(response_data):
123 content = response_data['choices'][0]['message']['content']
124
125 logger.info(content)
126 92
127 return content
128 93
129def setup(bot): 94def setup(bot):
130 bot.add_cog(Jarvis(bot)) 95 bot.add_cog(Jarvis(bot))
diff --git a/cogs/utils/llm.py b/cogs/utils/llm.py
new file mode 100644
index 0000000..2b05b12
--- /dev/null
+++ b/cogs/utils/llm.py
@@ -0,0 +1,62 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import os
7import httpx
8import logging
9
10logger = logging.getLogger(__name__)
11
12BACKWARD_COMPAT_MSG = (
13 "OPENROUTER_API_KEY/OPENROUTER_MODEL are deprecated. "
14 "Use LLM_API_KEY, LLM_MODEL instead."
15)
16
17
18def _get_config():
19 api_url = os.getenv("LLM_API_URL", "https://openrouter.ai/api/v1/chat/completions")
20 api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENROUTER_API_KEY") or ""
21 model = os.getenv("LLM_MODEL") or os.getenv("OPENROUTER_MODEL") or ""
22
23 if os.getenv("OPENROUTER_API_KEY") and not os.getenv("LLM_API_KEY"):
24 logger.warning(BACKWARD_COMPAT_MSG)
25 if os.getenv("OPENROUTER_MODEL") and not os.getenv("LLM_MODEL"):
26 logger.warning(BACKWARD_COMPAT_MSG)
27
28 return api_url, api_key, model
29
30
31async def llm_completion(
32 messages: list[dict],
33 model: str | None = None,
34 api_url: str | None = None,
35 api_key: str | None = None,
36 extra: dict | None = None,
37 timeout: float = 180.0,
38) -> str | None:
39 url = api_url or os.getenv("LLM_API_URL", "https://openrouter.ai/api/v1/chat/completions")
40 model = model or os.getenv("LLM_MODEL") or os.getenv("OPENROUTER_MODEL") or ""
41 api_key = api_key or os.getenv("LLM_API_KEY") or os.getenv("OPENROUTER_API_KEY") or ""
42
43 headers = {"Content-Type": "application/json"}
44 if api_key:
45 headers["Authorization"] = f"Bearer {api_key}"
46
47 payload: dict = {"model": model, "messages": messages}
48 if extra:
49 payload.update(extra)
50
51 verify = os.getenv("LLM_API_VERIFY", "true").lower() in ("true", "1", "yes")
52
53 try:
54 async with httpx.AsyncClient(timeout=timeout, verify=verify) as client:
55 resp = await client.post(url, headers=headers, json=payload)
56 resp.raise_for_status()
57 data = resp.json()
58 logger.info("LLM response: %s", data)
59 return data["choices"][0]["message"]["content"]
60 except httpx.HTTPError as e:
61 logger.error("LLM API request failed (url=%s, model=%s): %s", url, model, e, exc_info=True)
62 return None
diff --git a/tests/cogs/test_ai.py b/tests/cogs/test_ai.py
index 6d92f55..aab9861 100644
--- a/tests/cogs/test_ai.py
+++ b/tests/cogs/test_ai.py
@@ -1,7 +1,5 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4import httpx
5from conftest import MockApplicationContext 3from conftest import MockApplicationContext
6 4
7 5
@@ -12,36 +10,14 @@ def ai_cog(bot):
12 return cog 10 return cog
13 11
14 12
15@pytest.fixture
16def mock_ai_response():
17 return {
18 "choices": [
19 {
20 "message": {
21 "content": "Test response from AI"
22 }
23 }
24 ]
25 }
26
27
28@pytest.mark.asyncio 13@pytest.mark.asyncio
29async def test_senator_command_success(ai_cog, mock_channel, mock_guild, bot, mock_ai_response): 14async def test_senator_command_success(ai_cog, mock_channel, mock_guild, bot):
30 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) 15 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
31 16
32 mock_response = MagicMock() 17 with patch('cogs.ai.llm_completion', new_callable=AsyncMock, return_value="Test response from AI") as mock_llm:
33 mock_response.json = MagicMock(return_value=mock_ai_response)
34
35 mock_client = MagicMock()
36 mock_client.post = AsyncMock(return_value=mock_response)
37 mock_client.__aenter__ = AsyncMock(return_value=mock_response)
38 mock_client.__aexit__ = AsyncMock(return_value=None)
39
40 with patch('cogs.ai.httpx.AsyncClient') as mock_httpx:
41 instance = mock_httpx.return_value.__aenter__.return_value
42 instance.post = AsyncMock(return_value=mock_response)
43 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: 18 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup:
44 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) 19 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False)
20 mock_llm.assert_awaited_once()
45 mock_followup.assert_called_once_with("Test response from AI") 21 mock_followup.assert_called_once_with("Test response from AI")
46 22
47 23
@@ -49,19 +25,10 @@ async def test_senator_command_success(ai_cog, mock_channel, mock_guild, bot, mo
49async def test_senator_command_empty_choices(ai_cog, mock_channel, mock_guild, bot): 25async def test_senator_command_empty_choices(ai_cog, mock_channel, mock_guild, bot):
50 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) 26 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
51 27
52 mock_response = MagicMock() 28 with patch('cogs.ai.llm_completion', new_callable=AsyncMock, return_value=None) as mock_llm:
53 mock_response.json = MagicMock(return_value={"choices": []})
54
55 mock_client = MagicMock()
56 mock_client.post = AsyncMock(return_value=mock_response)
57 mock_client.__aenter__ = AsyncMock(return_value=mock_response)
58 mock_client.__aexit__ = AsyncMock(return_value=None)
59
60 with patch('cogs.ai.httpx.AsyncClient') as mock_httpx:
61 instance = mock_httpx.return_value.__aenter__.return_value
62 instance.post = AsyncMock(return_value=mock_response)
63 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: 29 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup:
64 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) 30 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False)
31 mock_llm.assert_awaited_once()
65 mock_followup.assert_called_once_with("bot siem zesroł") 32 mock_followup.assert_called_once_with("bot siem zesroł")
66 33
67 34
@@ -69,15 +36,8 @@ async def test_senator_command_empty_choices(ai_cog, mock_channel, mock_guild, b
69async def test_senator_command_no_choices_key(ai_cog, mock_channel, mock_guild, bot): 36async def test_senator_command_no_choices_key(ai_cog, mock_channel, mock_guild, bot):
70 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) 37 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
71 38
72 mock_response = MagicMock() 39 with patch('cogs.ai.llm_completion', new_callable=AsyncMock, return_value=None) as mock_llm:
73 mock_response.json = MagicMock(return_value={}) 40 with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup:
74 41 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False)
75 with patch('cogs.ai.httpx.AsyncClient') as mock_httpx: 42 mock_llm.assert_awaited_once()
76 instance = mock_httpx.return_value.__aenter__.return_value 43 mock_followup.assert_called_once_with("bot siem zesroł")
77 instance.post = AsyncMock(return_value=mock_response)
78 with patch('cogs.ai.logger') as mock_logger:
79 try:
80 await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False)
81 except KeyError:
82 pass
83 assert mock_logger.info.called