diff --git a/README.md b/README.md index 067b14a..28cd45e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ChatGPT Telegram Bot: **Fast. No daily limits. Special chat modes** +# ChatGPT Telegram Bot: **GPT-4. Fast. No daily limits. Special chat modes**
@@ -16,7 +16,7 @@
We all love [chat.openai.com](https://chat.openai.com), but... It's TERRIBLY laggy, has daily limits, and is only accessible through an archaic web interface.
-This repo is ChatGPT re-created with GPT-3.5 LLM as Telegram Bot. **And it works great.**
+This repo is ChatGPT re-created as Telegram Bot. **And it works great.**
You can deploy your own bot, or use mine: [@chatgpt_karfly_bot](https://t.me/chatgpt_karfly_bot)
@@ -24,6 +24,7 @@ You can deploy your own bot, or use mine: [@chatgpt_karfly_bot](https://t.me/cha
- Low latency replies (it usually takes about 3-5 seconds)
- No request limits
- Message streaming (watch demo)
+- GPT-4 support
- Voice message recognition
- Code highlighting
- Special chat modes: 👩🏼🎓 Assistant, 👩🏼💻 Code Assistant, 📝 Text Improver and 🎬 Movie Expert. You can easily create your own chat modes by editing `config/chat_modes.yml`
@@ -49,6 +50,7 @@ You can deploy your own bot, or use mine: [@chatgpt_karfly_bot](https://t.me/cha
If you want to add payments to your bot – write me on Telegram ([@karfly](https://t.me/karfly)).
## News
+- *24 Mar 2023*: GPT-4 support. Run `/settings` command to choose model
- *15 Mar 2023*: Added message streaming. Now you don't have to wait until the whole message is ready, it's streamed to Telegram part-by-part (watch demo)
- *9 Mar 2023*: Now you can easily create your own Chat Modes by editing `config/chat_modes.yml`
- *8 Mar 2023*: Added voice message recognition with [OpenAI Whisper API](https://openai.com/blog/introducing-chatgpt-and-whisper-apis). Record a voice message and ChatGPT will answer you!
@@ -59,6 +61,7 @@ If you want to add payments to your bot – write me on Telegram ([@karfly](http
- `/new` – Start new dialog
- `/mode` – Select chat mode
- `/balance` – Show balance
+- `/settings` – Show settings
- `/help` – Show help
## Setup
diff --git a/bot/bot.py b/bot/bot.py
index 37ecb85..c4945cb 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -43,6 +43,7 @@ HELP_MESSAGE = """Commands:
⚪ /retry – Regenerate last bot answer
⚪ /new – Start new dialog
⚪ /mode – Select chat mode
+⚪ /settings – Show settings
⚪ /balance – Show balance
⚪ /help – Show help
"""
@@ -70,6 +71,24 @@ async def register_user_if_not_exists(update: Update, context: CallbackContext,
if user.id not in user_semaphores:
user_semaphores[user.id] = asyncio.Semaphore(1)
+ if db.get_user_attribute(user.id, "current_model") is None:
+ db.set_user_attribute(user.id, "current_model", config.models["available_text_models"][0])
+
+ # back compatibility for n_used_tokens field
+ n_used_tokens = db.get_user_attribute(user.id, "n_used_tokens")
+ if isinstance(n_used_tokens, int): # old format
+ new_n_used_tokens = {
+ "gpt-3.5-turbo": {
+ "n_input_tokens": 0,
+ "n_output_tokens": n_used_tokens
+ }
+ }
+ db.set_user_attribute(user.id, "n_used_tokens", new_n_used_tokens)
+
+ # voice message transcription
+ if db.get_user_attribute(user.id, "n_transcribed_seconds") is None:
+ db.set_user_attribute(user.id, "n_transcribed_seconds", 0.0)
+
async def start_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
@@ -137,24 +156,25 @@ async def message_handle(update: Update, context: CallbackContext, message=None,
try:
message = message or update.message.text
+ current_model = db.get_user_attribute(user_id, "current_model")
dialog_messages = db.get_dialog_messages(user_id, dialog_id=None)
parse_mode = {
"html": ParseMode.HTML,
"markdown": ParseMode.MARKDOWN
}[openai_utils.CHAT_MODES[chat_mode]["parse_mode"]]
- chatgpt_instance = openai_utils.ChatGPT(use_chatgpt_api=config.use_chatgpt_api)
+ chatgpt_instance = openai_utils.ChatGPT(model=current_model)
if config.enable_message_streaming:
gen = chatgpt_instance.send_message_stream(message, dialog_messages=dialog_messages, chat_mode=chat_mode)
else:
- answer, n_used_tokens, n_first_dialog_messages_removed = await chatgpt_instance.send_message(
+ answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed = await chatgpt_instance.send_message(
message,
dialog_messages=dialog_messages,
chat_mode=chat_mode
)
async def fake_gen():
- yield "finished", answer, n_used_tokens, n_first_dialog_messages_removed
+ yield "finished", answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed
gen = fake_gen()
@@ -168,7 +188,7 @@ async def message_handle(update: Update, context: CallbackContext, message=None,
if status == "not_finished":
status, answer = gen_item
elif status == "finished":
- status, answer, n_used_tokens, n_first_dialog_messages_removed = gen_item
+ status, answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed = gen_item
else:
raise ValueError(f"Streaming status {status} is unknown")
@@ -207,7 +227,7 @@ async def message_handle(update: Update, context: CallbackContext, message=None,
dialog_id=None
)
- db.set_user_attribute(user_id, "n_used_tokens", n_used_tokens + db.get_user_attribute(user_id, "n_used_tokens"))
+ db.update_n_used_tokens(user_id, current_model, n_input_tokens, n_output_tokens)
except Exception as e:
error_text = f"Something went wrong during completion. Reason: {e}"
logger.error(error_text)
@@ -262,16 +282,11 @@ async def voice_message_handle(update: Update, context: CallbackContext):
text = f"🎤: {transcribed_text}"
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
+ # update n_transcribed_seconds
+ db.set_user_attribute(user_id, "n_transcribed_seconds", voice.duration + db.get_user_attribute(user_id, "n_transcribed_seconds"))
+
await message_handle(update, context, message=transcribed_text)
- # calculate spent dollars
- n_spent_dollars = voice.duration * (config.whisper_price_per_1_min / 60)
-
- # normalize dollars to tokens (it's very convenient to measure everything in a single unit)
- price_per_1000_tokens = config.chatgpt_price_per_1000_tokens if config.use_chatgpt_api else config.gpt_price_per_1000_tokens
- n_used_tokens = int(n_spent_dollars / (price_per_1000_tokens / 1000))
- db.set_user_attribute(user_id, "n_used_tokens", n_used_tokens + db.get_user_attribute(user_id, "n_used_tokens"))
-
async def new_dialog_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
@@ -317,23 +332,95 @@ async def set_chat_mode_handle(update: Update, context: CallbackContext):
await query.edit_message_text(f"{openai_utils.CHAT_MODES[chat_mode]['welcome_message']}", parse_mode=ParseMode.HTML)
+def get_settings_menu(user_id: int):
+ current_model = db.get_user_attribute(user_id, "current_model")
+ text = config.models["info"][current_model]["description"]
+
+ text += "\n\n"
+ score_dict = config.models["info"][current_model]["scores"]
+ for score_key, score_value in score_dict.items():
+ text += "🟢" * score_value + "⚪️" * (5 - score_value) + f" – {score_key}\n\n"
+
+ text += "\nSelect model:"
+
+ # buttons to choose models
+ buttons = []
+ for model_key in config.models["available_text_models"]:
+ title = config.models["info"][model_key]["name"]
+ if model_key == current_model:
+ title = "✅ " + title
+
+ buttons.append(
+ InlineKeyboardButton(title, callback_data=f"set_settings|{model_key}")
+ )
+ reply_markup = InlineKeyboardMarkup([buttons])
+
+ return text, reply_markup
+
+
+async def settings_handle(update: Update, context: CallbackContext):
+ await register_user_if_not_exists(update, context, update.message.from_user)
+ if await is_previous_message_not_answered_yet(update, context): return
+
+ user_id = update.message.from_user.id
+ db.set_user_attribute(user_id, "last_interaction", datetime.now())
+
+ text, reply_markup = get_settings_menu(user_id)
+ await update.message.reply_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
+
+
+async def set_settings_handle(update: Update, context: CallbackContext):
+ await register_user_if_not_exists(update.callback_query, context, update.callback_query.from_user)
+ user_id = update.callback_query.from_user.id
+
+ query = update.callback_query
+ await query.answer()
+
+ _, model_key = query.data.split("|")
+ db.set_user_attribute(user_id, "current_model", model_key)
+ db.start_new_dialog(user_id)
+
+ text, reply_markup = get_settings_menu(user_id)
+ try:
+ await query.edit_message_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
+ except telegram.error.BadRequest as e:
+ if str(e).startswith("Message is not modified"):
+ pass
+
+
async def show_balance_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
- n_used_tokens = db.get_user_attribute(user_id, "n_used_tokens")
+ # count total usage statistics
+ total_n_spent_dollars = 0
+ total_n_used_tokens = 0
- price_per_1000_tokens = config.chatgpt_price_per_1000_tokens if config.use_chatgpt_api else config.gpt_price_per_1000_tokens
- n_spent_dollars = n_used_tokens * (price_per_1000_tokens / 1000)
+ n_used_tokens_dict = db.get_user_attribute(user_id, "n_used_tokens")
+ n_transcribed_seconds = db.get_user_attribute(user_id, "n_transcribed_seconds")
- text = f"You spent {n_spent_dollars:.03f}$\n"
- text += f"You used {n_used_tokens} tokens\n\n"
+ details_text = "🏷️ Details:\n"
+ for model_key in sorted(n_used_tokens_dict.keys()):
+ n_input_tokens, n_output_tokens = n_used_tokens_dict[model_key]["n_input_tokens"], n_used_tokens_dict[model_key]["n_output_tokens"]
+ total_n_used_tokens += n_input_tokens + n_output_tokens
- text += "🏷️ Prices\n"
- text += f"- ChatGPT: {price_per_1000_tokens}$ per 1000 tokens\n"
- text += f"- Whisper (voice recognition): {config.whisper_price_per_1_min}$ per 1 minute"
+ n_input_spent_dollars = config.models["info"][model_key]["price_per_1000_input_tokens"] * (n_input_tokens / 1000)
+ n_output_spent_dollars = config.models["info"][model_key]["price_per_1000_output_tokens"] * (n_output_tokens / 1000)
+ total_n_spent_dollars += n_input_spent_dollars + n_output_spent_dollars
+
+ details_text += f"- {model_key}: {n_input_spent_dollars + n_output_spent_dollars:.03f}$ / {n_input_tokens + n_output_tokens} tokens\n"
+
+ voice_recognition_n_spent_dollars = config.models["info"]["whisper"]["price_per_1_min"] * (n_transcribed_seconds / 60)
+ if n_transcribed_seconds != 0:
+ details_text += f"- Whisper (voice recognition): {voice_recognition_n_spent_dollars:.03f}$ / {n_transcribed_seconds:.01f} seconds\n"
+
+ total_n_spent_dollars += voice_recognition_n_spent_dollars
+
+ text = f"You spent {total_n_spent_dollars:.03f}$\n"
+ text += f"You used {total_n_used_tokens} tokens\n\n"
+ text += details_text
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
@@ -374,6 +461,7 @@ async def post_init(application: Application):
BotCommand("/mode", "Select chat mode"),
BotCommand("/retry", "Re-generate response for previous query"),
BotCommand("/balance", "Show balance"),
+ BotCommand("/settings", "Show settings"),
BotCommand("/help", "Show help message"),
])
@@ -406,6 +494,9 @@ def run_bot() -> None:
application.add_handler(CommandHandler("mode", show_chat_modes_handle, filters=user_filter))
application.add_handler(CallbackQueryHandler(set_chat_mode_handle, pattern="^set_chat_mode"))
+ application.add_handler(CommandHandler("settings", settings_handle, filters=user_filter))
+ application.add_handler(CallbackQueryHandler(set_settings_handle, pattern="^set_settings"))
+
application.add_handler(CommandHandler("balance", show_balance_handle, filters=user_filter))
application.add_error_handler(error_handle)
diff --git a/bot/config.py b/bot/config.py
index 2cc96aa..5b6391f 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -24,7 +24,6 @@ mongodb_uri = f"mongodb://mongo:{config_env['MONGODB_PORT']}"
with open(config_dir / "chat_modes.yml", 'r') as f:
chat_modes = yaml.safe_load(f)
-# prices
-chatgpt_price_per_1000_tokens = config_yaml.get("chatgpt_price_per_1000_tokens", 0.002)
-gpt_price_per_1000_tokens = config_yaml.get("gpt_price_per_1000_tokens", 0.02)
-whisper_price_per_1_min = config_yaml.get("whisper_price_per_1_min", 0.006)
+# models
+with open(config_dir / "models.yml", 'r') as f:
+ models = yaml.safe_load(f)
diff --git a/bot/database.py b/bot/database.py
index 80aa3f6..278bda2 100644
--- a/bot/database.py
+++ b/bot/database.py
@@ -45,8 +45,11 @@ class Database:
"current_dialog_id": None,
"current_chat_mode": "assistant",
+ "current_model": config.models["available_text_models"][0],
- "n_used_tokens": 0
+ "n_used_tokens": {},
+
+ "n_transcribed_seconds": 0.0 # voice message transcription
}
if not self.check_if_user_exists(user_id):
@@ -61,6 +64,7 @@ class Database:
"user_id": user_id,
"chat_mode": self.get_user_attribute(user_id, "current_chat_mode"),
"start_time": datetime.now(),
+ "model": self.get_user_attribute(user_id, "current_model"),
"messages": []
}
@@ -80,7 +84,7 @@ class Database:
user_dict = self.user_collection.find_one({"_id": user_id})
if key not in user_dict:
- raise ValueError(f"User {user_id} does not have a value for {key}")
+ return None
return user_dict[key]
@@ -88,6 +92,20 @@ class Database:
self.check_if_user_exists(user_id, raise_exception=True)
self.user_collection.update_one({"_id": user_id}, {"$set": {key: value}})
+ def update_n_used_tokens(self, user_id: int, model: str, n_input_tokens: int, n_output_tokens: int):
+ n_used_tokens_dict = self.get_user_attribute(user_id, "n_used_tokens")
+
+ if model in n_used_tokens_dict:
+ n_used_tokens_dict[model]["n_input_tokens"] += n_input_tokens
+ n_used_tokens_dict[model]["n_output_tokens"] += n_output_tokens
+ else:
+ n_used_tokens_dict[model] = {
+ "n_input_tokens": n_input_tokens,
+ "n_output_tokens": n_output_tokens
+ }
+
+ self.set_user_attribute(user_id, "n_used_tokens", n_used_tokens_dict)
+
def get_dialog_messages(self, user_id: int, dialog_id: Optional[str] = None):
self.check_if_user_exists(user_id, raise_exception=True)
diff --git a/bot/openai_utils.py b/bot/openai_utils.py
index 032f90c..a5615ed 100644
--- a/bot/openai_utils.py
+++ b/bot/openai_utils.py
@@ -17,8 +17,9 @@ OPENAI_COMPLETION_OPTIONS = {
class ChatGPT:
- def __init__(self, use_chatgpt_api=True):
- self.use_chatgpt_api = use_chatgpt_api
+ def __init__(self, model="gpt-3.5-turbo"):
+ assert model in {"text-davinci-003", "gpt-3.5-turbo", "gpt-4"}, f"Unknown model: {model}"
+ self.model = model
async def send_message(self, message, dialog_messages=[], chat_mode="assistant"):
if chat_mode not in CHAT_MODES.keys():
@@ -28,26 +29,27 @@ class ChatGPT:
answer = None
while answer is None:
try:
- if self.use_chatgpt_api:
- messages = self._generate_prompt_messages_for_chatgpt_api(message, dialog_messages, chat_mode)
+ if self.model in {"gpt-3.5-turbo", "gpt-4"}:
+ messages = self._generate_prompt_messages(message, dialog_messages, chat_mode)
r = await openai.ChatCompletion.acreate(
- model="gpt-3.5-turbo",
+ model=self.model,
messages=messages,
**OPENAI_COMPLETION_OPTIONS
)
answer = r.choices[0].message["content"]
- else:
+ elif self.model == "text-davinci-003":
prompt = self._generate_prompt(message, dialog_messages, chat_mode)
r = await openai.Completion.acreate(
- engine="text-davinci-003",
+ engine=self.model,
prompt=prompt,
**OPENAI_COMPLETION_OPTIONS
)
answer = r.choices[0].text
+ else:
+ raise ValueError(f"Unknown model: {model}")
answer = self._postprocess_answer(answer)
- n_used_tokens = r.usage.total_tokens
-
+ n_input_tokens, n_output_tokens = r.usage.prompt_tokens, r.usage.completion_tokens
except openai.error.InvalidRequestError as e: # too many tokens
if len(dialog_messages) == 0:
raise ValueError("Dialog messages is reduced to zero, but still has too many tokens to make completion") from e
@@ -57,7 +59,7 @@ class ChatGPT:
n_first_dialog_messages_removed = n_dialog_messages_before - len(dialog_messages)
- return answer, n_used_tokens, n_first_dialog_messages_removed
+ return answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed
async def send_message_stream(self, message, dialog_messages=[], chat_mode="assistant"):
if chat_mode not in CHAT_MODES.keys():
@@ -67,10 +69,10 @@ class ChatGPT:
answer = None
while answer is None:
try:
- if self.use_chatgpt_api:
- messages = self._generate_prompt_messages_for_chatgpt_api(message, dialog_messages, chat_mode)
+ if self.model in {"gpt-3.5-turbo", "gpt-4"}:
+ messages = self._generate_prompt_messages(message, dialog_messages, chat_mode)
r_gen = await openai.ChatCompletion.acreate(
- model="gpt-3.5-turbo",
+ model=self.model,
messages=messages,
stream=True,
**OPENAI_COMPLETION_OPTIONS
@@ -83,11 +85,11 @@ class ChatGPT:
answer += delta.content
yield "not_finished", answer
- n_used_tokens = self._count_tokens_for_chatgpt(messages, answer, model="gpt-3.5-turbo")
- else:
+ n_input_tokens, n_output_tokens = self._count_tokens_from_messages(messages, answer, model=self.model)
+ elif self.model == "text-davinci-003":
prompt = self._generate_prompt(message, dialog_messages, chat_mode)
r_gen = await openai.Completion.acreate(
- engine="text-davinci-003",
+ engine=self.model,
prompt=prompt,
stream=True,
**OPENAI_COMPLETION_OPTIONS
@@ -98,7 +100,7 @@ class ChatGPT:
answer += r_item.choices[0].text
yield "not_finished", answer
- n_used_tokens = self._count_tokens_for_gpt(prompt, answer, model="text-davinci-003")
+ n_input_tokens, n_output_tokens = self._count_tokens_from_prompt(prompt, answer, model=self.model)
answer = self._postprocess_answer(answer)
@@ -111,7 +113,7 @@ class ChatGPT:
n_first_dialog_messages_removed = n_dialog_messages_before - len(dialog_messages)
- yield "finished", answer, n_used_tokens, n_first_dialog_messages_removed # sending final answer
+ yield "finished", answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed # sending final answer
def _generate_prompt(self, message, dialog_messages, chat_mode):
prompt = CHAT_MODES[chat_mode]["prompt_start"]
@@ -122,15 +124,15 @@ class ChatGPT:
prompt += "Chat:\n"
for dialog_message in dialog_messages:
prompt += f"User: {dialog_message['user']}\n"
- prompt += f"ChatGPT: {dialog_message['bot']}\n"
+ prompt += f"Assistant: {dialog_message['bot']}\n"
# current message
prompt += f"User: {message}\n"
- prompt += "ChatGPT: "
+ prompt += "Assistant: "
return prompt
- def _generate_prompt_messages_for_chatgpt_api(self, message, dialog_messages, chat_mode):
+ def _generate_prompt_messages(self, message, dialog_messages, chat_mode):
prompt = CHAT_MODES[chat_mode]["prompt_start"]
messages = [{"role": "system", "content": prompt}]
@@ -145,28 +147,41 @@ class ChatGPT:
answer = answer.strip()
return answer
- def _count_tokens_for_chatgpt(self, prompt_messages, answer, model="gpt-3.5-turbo"):
- prompt_messages += [{"role": "assistant", "content": answer}]
-
+ def _count_tokens_from_messages(self, messages, answer, model="gpt-3.5-turbo"):
encoding = tiktoken.encoding_for_model(model)
- n_tokens = 0
- for message in prompt_messages:
- n_tokens += 4 # every message follows "