added effort and thinking options
This commit is contained in:
1 parent
9c410d3daa
commit
c34e07205e
3 files changed
+218
-52
No files matched your search
+202
-36
@@ -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))
|
||||
Reference in new issue
Block a user