added effort and thinking options

This commit is contained in:
esef committed 2026-06-30 12:16:27 +03:00
1 parent 9c410d3daa
commit c34e07205e
3 files changed
+218 -52

No files matched your search

+202 -36
View File
@@ -1,3 +1,6 @@
# ==========================================
# IMPORTS & CONSTANTS
# ==========================================
import os
import re
import time
@@ -7,13 +10,43 @@ from discord.ext import commands
from collections import defaultdict, deque
from openai import AsyncOpenAI
#THIS IS DEEPSEEK-V4-FLASH, BUT CAN SWITCH TO V4-PRO
#only last 12 messages on discord will be fed into the ai's memory
MEMORY_SIZE = 12
# These are the ONLY two valid DeepSeek V4 model IDs. deepseek-chat and
# deepseek-reasoner are legacy aliases scheduled for retirement on
# 2026/07/24 and are never used in this file.
MODEL_FLASH = 'deepseek-v4-flash'
MODEL_PRO = 'deepseek-v4-pro'
VALID_MODELS = (MODEL_FLASH, MODEL_PRO)
# reasoning_effort only accepts these two values on the DeepSeek API.
# low/medium silently collapse to high, xhigh collapses to max, so there's
# no point exposing anything but these two.
VALID_EFFORTS = ('high', 'max')
# Thinking mode is enabled by default on DeepSeek's API unless you
# explicitly send {"thinking": {"type": "disabled"}} on every request.
# When thinking is on, the reasoning trace shares the same token budget
# as the visible reply (max_tokens covers both), so a small budget can let
# reasoning eat the whole thing and leave nothing for the actual message.
MAX_TOKENS_NORMAL = 1024
MAX_TOKENS_THINKING = 4096
# Per-1M-token pricing (USD), per DeepSeek's official pricing page.
# Pro costs roughly 3x flash on input and output — tracked separately so
# !aicost is accurate when you've been toggling !turbo.
PRICING = {
MODEL_FLASH: {'cache_hit': 0.0028, 'cache_miss': 0.14, 'output': 0.28},
MODEL_PRO: {'cache_hit': 0.003625, 'cache_miss': 0.435, 'output': 0.87},
}
#the very much needed ai shit
# ==========================================
# HELPER FUNCTIONS
# ==========================================
#loads persona file
def load_persona(name, tier='flash'):
suffix = '_prompt_pro.txt' if tier == 'pro' else '_prompt.txt'
@@ -50,6 +83,57 @@ def split_msg(text, limit=2000):
chunks.append(text)
return chunks
# ==========================================
# UI COMPONENTS (MENUS & VIEWS)
# ==========================================
#dropdown for thinking mode — scoped to one channel, not global
class ThinkingSelect(discord.ui.Select):
def __init__(self, cog, channel_id):
self.cog = cog
self.channel_id = channel_id
current = cog.thinking_effort.get(channel_id, 'off') if channel_id in cog.thinking_channels else 'off'
options = [
discord.SelectOption(label='Off', value='off', emoji='⚡',
description='Instant, in-character replies (default)',
default=(current == 'off')),
discord.SelectOption(label='High', value='high', emoji='🧠',
description='Standard reasoning before replying',
default=(current == 'high')),
discord.SelectOption(label='Max', value='max', emoji='🔬',
description='Deepest reasoning — slower & pricier',
default=(current == 'max')),
]
super().__init__(placeholder='Choose thinking mode...', options=options, min_values=1, max_values=1)
async def callback(self, interaction: discord.Interaction):
if not self.cog.is_allowed(interaction.user):
await interaction.response.send_message("You don't have access to this.", ephemeral=True)
return
choice = self.values[0]
if choice == 'off':
self.cog.thinking_channels.discard(self.channel_id)
self.cog.thinking_effort.pop(self.channel_id, None)
desc = 'Thinking mode is now **off** — fast, in-character replies.'
else:
self.cog.thinking_channels.add(self.channel_id)
self.cog.thinking_effort[self.channel_id] = choice
desc = f'Thinking mode is now **on**, effort set to **{choice}**.'
embed = discord.Embed(title='🧠 Thinking Mode', description=desc, color=0x2b2d31)
embed.set_footer(text=f'model: {self.cog.active_model} · this setting is per-channel')
await interaction.response.edit_message(embed=embed, view=None)
class ThinkingView(discord.ui.View):
def __init__(self, cog, channel_id):
super().__init__(timeout=60)
self.add_item(ThinkingSelect(cog, channel_id))
# ==========================================
# MAIN COG CLASS
# ==========================================
class AIRoleplay(commands.Cog):
def __init__(self, bot):
self.bot = bot
@@ -73,11 +157,22 @@ class AIRoleplay(commands.Cog):
self.input_tokens = 0
self.output_tokens = 0
self.total_cost = 0.0
self.session_start = time.time()
self.webhooks = {}
self.active_model = 'deepseek-v4-flash'
self._webhook_avatar_persona = {} # cache to skip redundant avatar edits
self.active_model = MODEL_FLASH
self.active_channels = set()
# per-channel thinking mode — NOT global, so one channel toggling
# it on doesn't silently change behavior everywhere else
self.thinking_channels = set() # channel_ids with thinking ON
self.thinking_effort = {} # channel_id -> 'high' | 'max'
# ==========================================
# CORE AI LOGIC & LISTENERS
# ==========================================
#only accepts me and hizuki
def is_allowed(self, user):
return user.id in self.allowed_users
@@ -118,22 +213,34 @@ class AIRoleplay(commands.Cog):
wh = await self.get_webhook(message.channel)
chunks = split_msg(text)
if wh:
display_name = persona.capitalize()
# set webhook avatar before sending (avatar takes bytes via edit, not send)
if avatar_bytes:
await wh.edit(avatar=avatar_bytes)
for chunk in chunks:
await wh.send(content=chunk, username=display_name, wait=True)
else:
first = True
for chunk in chunks:
if first:
await message.reply(chunk)
first = False
else:
await message.channel.send(chunk)
for attempt in range(2):
try:
if avatar_bytes and self._webhook_avatar_persona.get(message.channel.id) != persona:
await wh.edit(avatar=avatar_bytes)
self._webhook_avatar_persona[message.channel.id] = persona
for chunk in chunks:
await wh.send(content=chunk, username=display_name, wait=True)
return # all chunks sent successfully
except discord.NotFound:
# webhook was deleted externally — purge caches and recreate once
self.webhooks.pop(message.channel.id, None)
self._webhook_avatar_persona.pop(message.channel.id, None)
wh = await self.get_webhook(message.channel)
if wh is None:
break # still Forbidden, fall through to bot-reply fallback
# webhooks completely unavailable — fall back to sending as the bot
# fallback: send as the bot itself (no webhook, or webhook failed twice)
first = True
for chunk in chunks:
if first:
await message.reply(chunk)
first = False
else:
await message.channel.send(chunk)
#this is the main listener, it handles all messages
@commands.Cog.listener()
@@ -190,29 +297,61 @@ class AIRoleplay(commands.Cog):
mem.append({'role': 'user', 'content': final_content})
api_msgs = [{'role': 'system', 'content': prompt}] + list(mem)
model_to_use = self.active_model
model_to_use = self.active_model # always MODEL_FLASH or MODEL_PRO, never swapped
#VISION SUPPORT END
#this sends the message to the ai and handles the response
# ----- thinking mode (per channel, correct DeepSeek params) -----
# The "thinking" key must be sent EVERY request — the API defaults
# to enabled, so omitting it when you want it off does nothing.
thinking_on = cid in self.thinking_channels
thinking_body = {"type": "enabled" if thinking_on else "disabled"}
if thinking_on:
thinking_body["reasoning_effort"] = self.thinking_effort.get(cid, "high")
kwargs = {
"model": model_to_use,
"messages": api_msgs,
"max_tokens": MAX_TOKENS_THINKING if thinking_on else MAX_TOKENS_NORMAL,
"extra_body": {"thinking": thinking_body},
}
async with message.channel.typing():
try:
resp = await self.client.chat.completions.create(
model=model_to_use,
messages=api_msgs,
max_tokens=1024
)
resp = await self.client.chat.completions.create(**kwargs)
except Exception as e:
await message.reply(f'api error: {e}')
mem.pop()
return
reply = resp.choices[0].message.content.strip()
reply = (resp.choices[0].message.content or '').strip()
if not reply:
# thinking mode burned the whole max_tokens budget on reasoning
# and left nothing for the actual answer
reply = "...lost the thread there, try again or drop to !think off/high"
if resp.usage:
self.input_tokens += resp.usage.prompt_tokens
self.output_tokens += resp.usage.completion_tokens
# cache_hit/cache_miss split isn't always present depending on
# SDK parsing — fall back to treating it all as cache-miss
# (worst case) rather than silently undercounting cost
cache_hit = getattr(resp.usage, 'prompt_cache_hit_tokens', 0) or 0
cache_miss = getattr(resp.usage, 'prompt_cache_miss_tokens', 0) or 0
if cache_hit == 0 and cache_miss == 0:
cache_miss = resp.usage.prompt_tokens
price = PRICING.get(model_to_use, PRICING[MODEL_FLASH])
self.total_cost += (
cache_hit * (price['cache_hit'] / 1_000_000)
+ cache_miss * (price['cache_miss'] / 1_000_000)
+ resp.usage.completion_tokens * (price['output'] / 1_000_000)
)
# NOTE: we only ever store `content` in memory, never
# `reasoning_content`. Per DeepSeek's docs this is correct as long
# as no tool calls are involved (they aren't here) — the CoT from
# previous turns isn't supposed to be re-fed into context anyway.
mem.append({'role': 'assistant', 'content': reply})
# simple typing delay calculation for better rp
@@ -222,6 +361,10 @@ class AIRoleplay(commands.Cog):
await self.send_as_char(message, reply, persona)
# ==========================================
# COMMANDS
# ==========================================
#this command lists all the ai commands, not an actual ai command##
@commands.command()
async def ai(self, ctx):
@@ -289,14 +432,11 @@ class AIRoleplay(commands.Cog):
hours, r = divmod(int(elapsed), 3600)
mins, _ = divmod(r, 60)
cost_in = self.input_tokens * (0.14 / 1_000_000)
cost_out = self.output_tokens * (0.28 / 1_000_000)
total = cost_in + cost_out
embed = discord.Embed(title='ai usage', color=0x00ff00)
embed.add_field(name='uptime', value=f'{hours}h {mins}m', inline=False)
embed.add_field(name='tokens', value=f'in: {self.input_tokens:,}\nout: {self.output_tokens:,}', inline=True)
embed.add_field(name='cost', value=f'~${total:.5f}', inline=True)
embed.add_field(name='cost', value=f'~${self.total_cost:.5f}', inline=True)
embed.set_footer(text='cost tracked per-call using actual model + cache pricing')
await ctx.reply(embed=embed)
#THIS IS WHERE TURBO COMMANDS LIVE IN
@@ -304,14 +444,14 @@ class AIRoleplay(commands.Cog):
async def turbo(self, ctx):
if not self.is_allowed(ctx.author):
return
if self.active_model == 'deepseek-v4-flash':
self.active_model = 'deepseek-v4-pro'
if self.active_model == MODEL_FLASH:
self.active_model = MODEL_PRO
self.prompts.clear()
await ctx.reply('switched to **deepseek-v4-pro**')
await ctx.reply(f'switched to **{MODEL_PRO}**')
else:
self.active_model = 'deepseek-v4-flash'
self.active_model = MODEL_FLASH
self.prompts.clear()
await ctx.reply('switched to **deepseek-v4-flash**')
await ctx.reply(f'switched to **{MODEL_FLASH}**')
#this shows the current model, only usable by me and hizu
@commands.command()
@@ -320,5 +460,31 @@ class AIRoleplay(commands.Cog):
return
await ctx.reply(f'currently using: **{self.active_model}**')
#this opens the thinking mode menu (off/high/max), only usable by me and hizu
@commands.command()
async def think(self, ctx):
if not self.is_allowed(ctx.author):
return
cid = ctx.channel.id
state = 'on' if cid in self.thinking_channels else 'off'
effort = self.thinking_effort.get(cid)
embed = discord.Embed(title='🧠 Thinking Mode', color=0x2b2d31)
embed.add_field(name='current state', value=f'**{state}**' + (f' (`{effort}`)' if effort else ''), inline=False)
embed.set_footer(text=f'model: {self.active_model} · this channel only')
await ctx.reply(embed=embed, view=ThinkingView(self, cid))
#this checks thinking status without opening the menu, only usable by me and hizu
@commands.command()
async def thinkstatus(self, ctx):
if not self.is_allowed(ctx.author):
return
cid = ctx.channel.id
if cid in self.thinking_channels:
effort = self.thinking_effort.get(cid, 'high')
await ctx.reply(f"🧠 thinking is **on** for this channel (effort: `{effort}`, model: `{self.active_model}`)")
else:
await ctx.reply(f"🧠 thinking is **off** for this channel (model: `{self.active_model}`)")
async def setup(bot):
await bot.add_cog(AIRoleplay(bot))