Commit 878bc55
Changed files (9)
src/ai/texts/claude.py
@@ -0,0 +1,268 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import contextlib
+import hashlib
+from typing import Literal
+from urllib.parse import quote_plus
+
+from anthropic import AsyncAnthropic, DefaultAioHttpClient
+from glom import Coalesce, glom
+from loguru import logger
+from pyrogram.client import Client
+from pyrogram.parser.markdown import BLOCKQUOTE_EXPANDABLE_DELIM
+from pyrogram.types import Message, ReplyParameters
+
+from ai.texts.contexts import get_anthropic_contexts
+from ai.utils import BOT_TIPS, EMOJI_REASONING_BEGIN, EMOJI_TEXT_BOT, beautify_llm_response, literal_eval, trim_none
+from config import AI, PROXY, TEXT_LENGTH
+from messages.progress import modify_progress
+from messages.utils import blockquote, count_without_entities, delete_message, smart_split
+from utils import number_to_emoji, rand_string, strings_list
+
+
+async def anthropic_responses(
+ client: Client,
+ message: Message,
+ *,
+ prefix: str = "",
+ model_id: str = AI.ANTHROPIC_MODEL_ID,
+ model_name: str = AI.ANTHROPIC_MODEL_ID,
+ anthropic_base_url: str = AI.ANTHROPIC_BASE_URL,
+ anthropic_api_keys: str = AI.ANTHROPIC_API_KEYS,
+ anthropic_client_config: str | dict = "",
+ anthropic_default_headers: str | dict = "",
+ anthropic_responses_config: str | dict = "",
+ anthropic_proxy: str | None = PROXY.ANTHROPIC,
+ cache_response_ttl: int = 0,
+ anthropic_media_send_as: Literal["base64", "file_id"] = "file_id",
+ anthropic_append_citation: bool = True,
+ silent: bool = False,
+ max_retries: int = 3,
+ **kwargs,
+) -> dict:
+ """Get Anthropic Responses.
+
+ Returns:
+ dict: {"texts": str, "thoughts": str, "prefix": str, "model_name": str, "sent_messages": list[Message]}
+ """
+ if not prefix:
+ prefix = f"{EMOJI_TEXT_BOT}**{model_name}**:{BOT_TIPS}\n"
+
+ if silent or not kwargs.get("show_progress"): # noqa: SIM108
+ status_msg = None
+ else:
+ status_msg = kwargs.get("progress") or await message.reply(f"{EMOJI_TEXT_BOT}**{model_name}**: 思考中...", quote=True)
+
+ sent_messages = [status_msg]
+ cache_hour = round(cache_response_ttl // 3600)
+ try:
+ anthropic_client = {}
+ if literal_eval(anthropic_client_config):
+ anthropic_client |= literal_eval(anthropic_client_config)
+ if literal_eval(anthropic_default_headers):
+ anthropic_client |= {"default_headers": literal_eval(anthropic_default_headers)}
+ if anthropic_proxy:
+ anthropic_client |= {"http_client": DefaultAioHttpClient(proxy=anthropic_proxy)}
+ except Exception as e:
+ logger.error(f"Anthropic client setup error: {e}")
+ return {"progress": status_msg} if isinstance(status_msg, Message) else {}
+ for api_key in strings_list(anthropic_api_keys, shuffle=True):
+ try:
+ anthropic_client |= {"base_url": anthropic_base_url, "api_key": api_key}
+ logger.trace(f"AsyncAnthropic(**{anthropic_client})")
+ anthropic = AsyncAnthropic(**anthropic_client)
+ params: dict = {
+ "model": model_id,
+ "max_tokens": 4096,
+ "messages": await get_anthropic_contexts(
+ client,
+ message,
+ anthropic=anthropic,
+ cache_hour=cache_hour,
+ media_send_as=anthropic_media_send_as,
+ ),
+ }
+ if literal_eval(anthropic_responses_config):
+ params |= literal_eval(anthropic_responses_config)
+ logger.debug(f"anthropic.messages.create(**{params})")
+ resp = await single_api_response(
+ client,
+ status_msg,
+ anthropic,
+ params=params,
+ prefix=prefix,
+ silent=silent,
+ max_retries=max_retries,
+ append_citation=anthropic_append_citation,
+ **kwargs,
+ )
+ if not resp.get("texts"):
+ continue
+ sent_messages.extend(resp.get("sent_messages", []))
+ return {
+ "success": True,
+ "texts": resp["texts"],
+ "thoughts": resp["thoughts"],
+ "prefix": prefix,
+ "model_name": model_name,
+ "sent_messages": [m for m in sent_messages if isinstance(m, Message)],
+ }
+ except Exception as e:
+ logger.error(f"Anthropic API error: {e}")
+ await modify_progress(status_msg, text=f"❌{e}", force_update=True, **kwargs)
+ return {"progress": status_msg} if isinstance(status_msg, Message) else {}
+
+
+async def single_api_response(
+ client: Client,
+ status_msg: Message | None,
+ anthropic: AsyncAnthropic,
+ params: dict,
+ *,
+ prefix: str = "",
+ append_citation: bool = True,
+ silent: bool = False,
+ retry: int = 0,
+ max_retries: int = 3,
+ **kwargs,
+) -> dict:
+ """Get Anthropic Chat Completions via single API.
+
+ Returns:
+ dict: {"texts": str, "thoughts": str, "sent_messages": list[Message]}
+ """
+ if retry > max_retries:
+ return {"texts": "", "thoughts": "", "sent_messages": []}
+ answers = "" # all model responses
+ thoughts = "" # all model thoughts
+ runtime_texts = "" # for a single telegram message
+ status_cid = status_msg.chat.id if isinstance(status_msg, Message) else 0
+ status_mid = status_msg.id if isinstance(status_msg, Message) else 0
+ sent_messages = []
+ full_response = {}
+ try:
+ tool_calls: list[dict] = [] # tool_call results
+ is_reasoning = False
+ async with anthropic.beta.messages.stream(**params) as stream:
+ async for chunk in stream:
+ resp = trim_none(chunk.model_dump())
+ logger.trace(resp)
+ response_type = glom(resp, Coalesce("delta.type", "content_block.type"), default="") or ""
+ chunk_answer = glom(resp, "delta.text", default="") or ""
+ chunk_thinking = glom(resp, "delta.thinking", default="") or ""
+ # 设置推理标志
+ if response_type == "thinking_delta": # 正在推理
+ is_reasoning = True
+ elif response_type == "text_delta": # 推理结束
+ is_reasoning = False
+
+ if response_type == "thinking" and len(thoughts) == 0: # 首次收到推理内容
+ runtime_texts += f"{BLOCKQUOTE_EXPANDABLE_DELIM}{EMOJI_REASONING_BEGIN}{chunk_thinking.lstrip()}"
+ elif chunk_thinking: # 收到推理内容
+ runtime_texts += chunk_thinking
+
+ if response_type == "text": # 收到初始回答
+ runtime_texts = chunk_answer.lstrip()
+ else:
+ runtime_texts += chunk_answer
+
+ if not chunk_answer and not chunk_thinking:
+ continue
+
+ runtime_texts = beautify_llm_response(runtime_texts)
+ length = await count_without_entities(prefix + runtime_texts)
+ if length <= TEXT_LENGTH - 10: # leave some flexibility
+ if len(runtime_texts.removeprefix(prefix)) > 10: # start response if answer is not empty
+ await modify_progress(message=status_msg, text=prefix + runtime_texts, detail_progress=True)
+ else: # answers is too long, split it into multiple messages
+ parts = await smart_split(prefix + runtime_texts)
+ if len(parts) == 1:
+ continue
+ if is_reasoning:
+ runtime_texts = f"{BLOCKQUOTE_EXPANDABLE_DELIM}{EMOJI_REASONING_BEGIN}{parts[-1].lstrip()}" # remove previous thinking
+ await modify_progress(message=status_msg, text=parts[0], force_update=True) # force send the first part
+ else:
+ await modify_progress(message=status_msg, text=blockquote(parts[0]), force_update=True) # force send the first part
+ runtime_texts = parts[-1] # keep the last part
+ if is_reasoning:
+ runtime_texts = f"{BLOCKQUOTE_EXPANDABLE_DELIM}{EMOJI_REASONING_BEGIN}{runtime_texts.lstrip()}"
+ if not silent:
+ status_msg = await client.send_message(status_cid, text=prefix + runtime_texts, reply_parameters=ReplyParameters(message_id=status_mid)) # the new message
+ sent_messages.append(status_msg)
+ status_mid = status_msg.id
+
+ thoughts += chunk_thinking
+ answers += chunk_answer
+
+ # all chunks are processed
+ if not answers.strip() and not thoughts.strip(): # empty response
+ return await single_api_response(
+ client,
+ status_msg,
+ anthropic,
+ params=params,
+ prefix=prefix,
+ retry=retry + 1,
+ max_retries=max_retries,
+ silent=silent,
+ **kwargs,
+ )
+ thoughts, answers = parse_final_block(resp, thoughts, answers, append_citation=append_citation)
+ if await count_without_entities(prefix + answers) <= TEXT_LENGTH - 10: # short answer in single msg
+ quoted = answers.strip()
+ await modify_progress(message=status_msg, text=f"{prefix}{blockquote(quoted)}", force_update=True)
+ else: # total length is too long, answers are splitted into multiple messages
+ await modify_progress(message=status_msg, text=prefix + blockquote(runtime_texts), force_update=True)
+
+ except Exception as e:
+ error = f"{EMOJI_TEXT_BOT}BOT请求失败, 重试次数: {retry + 1}/{max_retries}\n{e}"
+ if "resp" in locals():
+ error += f"\n{resp}"
+ logger.error(error)
+ with contextlib.suppress(Exception):
+ await modify_progress(status_msg, text=error, force_update=True, **kwargs)
+ [await delete_message(msg) for msg in sent_messages]
+ if retry + 1 < max_retries:
+ return await single_api_response(
+ client,
+ status_msg,
+ anthropic,
+ params=params,
+ prefix=prefix,
+ append_citation=append_citation,
+ retry=retry + 1,
+ max_retries=max_retries,
+ silent=silent,
+ **kwargs,
+ )
+ return {
+ "texts": answers,
+ "thoughts": thoughts,
+ "sent_messages": [m for m in sent_messages if isinstance(m, Message)],
+ }
+
+
+def parse_final_block(chunk: dict, thoughts: str, answers: str, *, append_citation: bool) -> tuple[str, str]:
+ if not append_citation:
+ return thoughts, answers
+ if chunk.get("type") != "message_stop":
+ return thoughts, answers
+ thoughts = ""
+ texts = ""
+ citations = {} # {cite_key: {index:int, title:str, url:str}}
+ for item in glom(chunk, "message.content", default=[]):
+ if item.get("type") == "thinking":
+ thoughts += item.get("thinking", "")
+ elif item.get("type") == "text":
+ texts += item.get("text", "")
+ for citation in glom(item, "citations", default=[]):
+ title = citation.get("title") or rand_string(8)
+ url = citation.get("url") or f"https://google.com/search?q=/{quote_plus(title)}"
+ cite_key = hashlib.sha256(f"{title}{url}".encode()).hexdigest()
+ cite_index = glom(citations, f"{cite_key}.index", default=None) or len(citations) + 1
+ citations[cite_key] = {"index": cite_index, "title": title, "url": url}
+ texts += f" [[{cite_index}]]({url})"
+ # append citations
+ for x in sorted(citations.values(), key=lambda x: x["index"]):
+ texts += f"\n{number_to_emoji(x['index'])}[{x['title']}]({x['url']})"
+ return thoughts.strip(), texts.strip()
src/ai/texts/contexts.py
@@ -6,8 +6,9 @@ import contextlib
import hashlib
import time
from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
+from anthropic import AsyncAnthropic
from glom import glom
from google import genai
from google.genai.types import FileState, Part, UploadFileConfig
@@ -27,6 +28,26 @@ if TYPE_CHECKING:
from io import BytesIO
+async def base64_media(client: Client, message: Message) -> dict:
+ data: BytesIO = await client.download_media(message, in_memory=True) # type: ignore
+ logger.debug(f"Downloaded message media: {data.name}")
+
+ ext = Path(data.name).suffix.removeprefix(".").replace("jpg", "jpeg")
+
+ # image, video
+ b64_encoding = base64.b64encode(data.getvalue()).decode("utf-8")
+
+ # text document
+ value = ""
+ with contextlib.suppress(Exception):
+ value = data.getvalue().decode("utf-8")
+ return {
+ "ext": ext,
+ "base64": b64_encoding,
+ "value": value,
+ }
+
+
async def get_openai_completion_contexts(client: Client, message: Message) -> list[dict]:
"""Generate OpenAI chat completion contexts."""
messages = [message]
@@ -158,18 +179,19 @@ async def single_openai_response_context(client: Client, message: Message, opena
sender = info["fwd_full_name"] or info["full_name"]
media_path = DOWNLOAD_DIR + "/" + info["file_name"]
media_send_as = openai_params.get("openai_media_send_as", "file_id")
+ file_id = ""
try:
if info["mtype"] == "photo":
if media_send_as == "file_id" and (file_id := await get_openai_file_id(client, msg, openai_params, info["mtype"])):
contexts.append({"type": "input_image", "file_id": file_id, "detail": "high"})
- elif media_send_as == "base64":
+ if not file_id:
res = await base64_media(client, msg)
contexts.append({"type": "input_image", "image_url": f"data:image/{res['ext']};base64,{res['base64']}", "detail": "high"})
elif info["mtype"] == "video":
if media_send_as == "file_id" and (file_id := await get_openai_file_id(client, msg, openai_params, info["mtype"])):
contexts.append({"type": "input_video", "file_id": file_id})
- elif media_send_as == "base64":
+ if not file_id:
res = await base64_media(client, msg)
contexts.append({"type": "input_video", "image_url": f"data:video/{res['ext']};base64,{res['base64']}"})
@@ -177,7 +199,7 @@ async def single_openai_response_context(client: Client, message: Message, opena
if info["mime_type"] == "application/pdf":
if media_send_as == "file_id" and (file_id := await get_openai_file_id(client, msg, openai_params, info["mtype"])):
contexts.append({"type": "input_file", "file_id": file_id})
- elif media_send_as == "base64":
+ if not file_id:
res = await base64_media(client, msg)
contexts.append({"type": "input_file", "file_data": f"data:application/pdf;base64,{res['base64']}", "filename": info["file_name"]})
@@ -234,7 +256,7 @@ async def get_openai_file_id(client: Client, message: Message, openai_params: di
if not openai_params["allow_file"] and mtype == "document":
return ""
- cache_day = 30
+ cache_day = openai_params.get("cache_day", 30)
api_key = openai_params["api_key"]
model_id = openai_params["model_id"]
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
@@ -349,21 +371,103 @@ async def get_gemini_contexts(client: Client, message: Message, gemini: genai.Cl
return contexts
-async def base64_media(client: Client, message: Message) -> dict:
- data: BytesIO = await client.download_media(message, in_memory=True) # type: ignore
- logger.debug(f"Downloaded message media: {data.name}")
+async def get_anthropic_contexts(client: Client, message: Message, **kwargs) -> list[dict]:
+ """Generate Anthropic contexts."""
+ messages = [message]
+ while message.reply_to_message:
+ message = message.reply_to_message
+ messages.append(message)
+ messages = messages[: int(AI.MAX_CONTEXTS_NUM)][::-1] # old to new
+ return [ctx for msg in messages if (ctx := await single_anthropic_context(client, msg, **kwargs))]
- ext = Path(data.name).suffix.removeprefix(".").replace("jpg", "jpeg")
- # image, video
- b64_encoding = base64.b64encode(data.getvalue()).decode("utf-8")
+async def single_anthropic_context(
+ client: Client,
+ message: Message,
+ anthropic: AsyncAnthropic,
+ cache_hour: int = 0,
+ media_send_as: Literal["base64", "file_id"] = "file_id",
+) -> dict:
+ """Generate Anthropic contexts for a single message.
- # text document
- value = ""
- with contextlib.suppress(Exception):
- value = data.getvalue().decode("utf-8")
- return {
- "ext": ext,
- "base64": b64_encoding,
- "value": value,
+ Returns:
+ {
+ "role": "user or assistant",
+ "content": [],
}
+ """
+ info = parse_msg(message, silent=True)
+ role = "assistant" if BOT_TIPS in info["text"] else "user"
+
+ if info["mtype"] not in ["text", "photo", "audio", "voice", "video", "document", "web_page"]:
+ return {}
+
+ extra_txt_extensions = [".sh", ".json", ".xml"] # treat these as txt file
+ extra_markdown_extensions = [".html", ".docx", ".pptx", ".xls", ".xlsx"] # convert to markdown
+
+ messages = await client.get_media_group(message.chat.id, message.id) if message.media_group_id else [message]
+ contexts = []
+ for msg in messages:
+ info = parse_msg(msg, silent=True)
+ sender = info["fwd_full_name"] or info["full_name"]
+ media_path = DOWNLOAD_DIR + "/" + info["file_name"]
+ file_id = ""
+ try:
+ if info["mtype"] == "photo":
+ if media_send_as == "file_id" and (file_id := await get_anthropic_file_id(client, msg, anthropic, cache_hour)):
+ contexts.append({"type": "image", "source": {"type": "file", "file_id": file_id}})
+ if not file_id:
+ res = await base64_media(client, msg)
+ contexts.append({"type": "image", "source": {"type": "base64", "media_type": f"image/{res['ext']}", "data": res["base64"]}})
+
+ elif info["mtype"] == "document":
+ if info["mime_type"] == "application/pdf":
+ if media_send_as == "file_id" and (file_id := await get_anthropic_file_id(client, msg, anthropic, cache_hour)):
+ contexts.append({"type": "document", "source": {"type": "file", "file_id": file_id}})
+ if not file_id:
+ res = await base64_media(client, msg)
+ contexts.append({"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": res["base64"]}})
+
+ elif info["mime_type"].startswith("text/") or Path(info["file_name"]).suffix in extra_txt_extensions:
+ fpath: str = await client.download_media(msg, media_path) # type: ignore
+ contexts.append({"type": "text", "text": f"[filename]: {info['file_name']}\n[file content]:\n{read_text(fpath).strip()}"})
+
+ elif Path(info["file_name"]).suffix in extra_markdown_extensions:
+ fpath: str = await client.download_media(msg, media_path) # type: ignore
+ text = convert_md(fpath)
+ Path(fpath).unlink(missing_ok=True)
+ contexts.append({"type": "text", "text": f"[filename]: {info['file_name']}\n[file content]:\n{text.strip()}"})
+ # user message has entity urls, use full html
+ clean_texts = clean_context(info["html"] or info["text"]) if role == "user" and info["entity_urls"] else clean_context(info["text"])
+ if not clean_texts:
+ continue
+ if role == "user" and sender: # noqa: SIM108
+ texts = f"<quote>{info['quote_text']}</quote>\n[username]: {sender}\n[message]:\n{clean_texts}"
+ else:
+ texts = f"<quote>{info['quote_text']}</quote>\n{clean_texts}"
+ texts = texts.removeprefix("<quote></quote>\n") # remove quote mark if no quote_text
+ contexts.append({"type": "text", "text": texts})
+ except Exception as e:
+ logger.warning(f"Download media from message failed: {e}")
+ continue
+ return {"role": role, "content": contexts} if contexts else {}
+
+
+async def get_anthropic_file_id(client: Client, message: Message, anthropic: AsyncAnthropic, cache_hour: int) -> str:
+ api_key: str = anthropic.api_key # ty:ignore[invalid-assignment]
+ key_hash = hashlib.sha256(api_key.encode()).hexdigest()
+ tid = get_thread_id(message)
+ cache_hour = cache_hour or 12
+ r2_key = f"TTL/{cache_hour}h/Anthropic/{key_hash}/{message.chat.id}/{message.id}{'/' + str(tid) if tid else ''}-file_id"
+ r2 = await head_cf_r2(r2_key)
+ if file_id := glom(r2, "Metadata.file_id", default=""):
+ return file_id
+ fpath: str = await client.download_media(message) # type: ignore
+ try:
+ resp = await anthropic.beta.files.upload(file=Path(fpath))
+ if glom(resp, "id", default=""):
+ return resp.id
+ logger.error(f"Upload media to Anthropic failed: {resp.model_dump()}")
+ except Exception as e:
+ logger.error(f"Upload media to Anthropic failed: {e}")
+ return ""
src/ai/texts/models.py
@@ -170,6 +170,13 @@ async def get_config_by_model_alias(model_alias: str, *, fallback_to_default: bo
"gemini_base_url": AI.GEMINI_BASE_URL,
"gemini_api_keys": AI.GEMINI_API_KEYS,
},
+ {
+ "model_id": AI.ANTHROPIC_MODEL_ID,
+ "model_name": AI.ANTHROPIC_MODEL_ID,
+ "api_type": "anthropic",
+ "anthropic_base_url": AI.ANTHROPIC_BASE_URL,
+ "anthropic_api_keys": AI.ANTHROPIC_API_KEYS,
+ },
{
"model_id": AI.OPENAI_MODEL_ID,
"model_name": AI.OPENAI_MODEL_ID,
src/ai/main.py
@@ -9,6 +9,7 @@ from ai.images.gemini import gemini_image_generation
from ai.images.models import get_image_model_configs
from ai.images.openai_img import openai_image_generation
from ai.images.post import http_post_image_generation
+from ai.texts.claude import anthropic_responses
from ai.texts.gemini import gemini_chat_completion
from ai.texts.models import get_config_by_model_alias, get_text_model_configs
from ai.texts.openai_chat import openai_chat_completions
@@ -53,6 +54,8 @@ async def ai_text_generation(client: Client, message: Message, **kwargs) -> dict
res = {}
if api_type == "gemini":
res = await gemini_chat_completion(client, message, **params)
+ elif api_type == "anthropic":
+ res = await anthropic_responses(client, message, **params)
elif api_type == "openai_responses":
res = await openai_responses_api(client, message, **params)
elif api_type == "openai_chat":
src/ai/utils.py
@@ -6,6 +6,9 @@ import json
import re
from datetime import datetime
+from anthropic import AsyncAnthropic, DefaultAioHttpClient
+from anthropic.types.beta.file_metadata import FileMetadata
+from glom import glom
from google import genai
from google.genai.types import HttpOptions
from loguru import logger
@@ -181,3 +184,24 @@ async def clean_gemini_files():
if delta.total_seconds() > AI.GEMINI_FILES_TTL:
logger.debug(f"Delete Gemini file: {f.name}")
await app.aio.files.delete(name=f.name)
+
+
+async def clean_anthropic_files():
+ """Clean Anthropic files.
+
+ Total storage: 100 GB per organization.
+ """
+ for api_key in strings_list(AI.ANTHROPIC_API_KEYS):
+ anthropic = AsyncAnthropic(
+ api_key=api_key,
+ base_url=AI.ANTHROPIC_BASE_URL,
+ http_client=DefaultAioHttpClient(proxy=PROXY.ANTHROPIC),
+ )
+ files = await anthropic.beta.files.list()
+ for f in glom(files, "data", default=[]):
+ if not isinstance(f, FileMetadata):
+ continue
+ delta = nowdt("UTC") - f.created_at
+ if delta.total_seconds() > AI.ANTHROPIC_FILES_TTL:
+ logger.debug(f"Delete Anthropic file: {f.filename}")
+ await anthropic.beta.files.delete(file_id=f.id)
src/config.py
@@ -183,6 +183,7 @@ class TOKEN:
class PROXY: # format: socks5://127.0.0.1:7890
AI_POST = os.getenv("AI_POST_PROXY", None)
ALI = os.getenv("ALI_PROXY", None)
+ ANTHROPIC = os.getenv("ANTHROPIC_PROXY", None)
CLOUDFLARE = os.getenv("CLOUDFLARE_PROXY", None)
CRYPTO = os.getenv("CRYPTO_PROXY", None)
D1 = os.getenv("D1_PROXY", None)
@@ -391,6 +392,13 @@ class AI:
GEMINI_BASE_URL = os.getenv("AI_GEMINI_BASE_URL", "https://generativelanguage.googleapis.com")
GEMINI_DEFAULT_HEADERS = os.getenv("AI_GEMINI_DEFAULT_HEADERS", "{}") # default headers passed to Gemini API. Should be a json string: '{"key": "value"}'
GEMINI_FILES_TTL = int(os.getenv("AI_GEMINI_FILES_TTL", "172800")) # clean gemini files after 48 hours
+
+ ANTHROPIC_MODEL_ID = os.getenv("AI_ANTHROPIC_MODEL_ID", "claude-opus-4-6")
+ ANTHROPIC_API_KEYS = os.getenv("AI_ANTHROPIC_API_KEYS", "") # comma separated keys for load balance. e.g. "key1,key2,key3"
+ ANTHROPIC_BASE_URL = os.getenv("AI_ANTHROPIC_BASE_URL", "https://api.anthropic.com")
+ ANTHROPIC_DEFAULT_HEADERS = os.getenv("AI_ANTHROPIC_DEFAULT_HEADERS", "{}") # default headers passed to Anthropic API. Should be a json string: '{"key": "value"}'
+ ANTHROPIC_FILES_TTL = int(os.getenv("AI_ANTHROPIC_FILES_TTL", "172800")) # clean anthropic files after 48 hours
+
OPENAI_MODEL_ID = os.getenv("AI_OPENAI_MODEL_ID", "gpt-4o")
OPENAI_API_KEYS = os.getenv("AI_OPENAI_API_KEYS", "") # comma separated keys for load balance. e.g. "key1,key2,key3"
OPENAI_BASE_URL = os.getenv("AI_OPENAI_BASE_URL", "https://api.openai.com/v1")
src/main.py
@@ -18,7 +18,7 @@ from pyrogram.sync import idle
from pyrogram.types import LinkPreviewOptions, Message
from ai.chat_summary import daily_summary
-from ai.utils import clean_gemini_files
+from ai.utils import clean_anthropic_files, clean_gemini_files
from bridge.chartimg import forward_chartimg_results
from bridge.ocr import forward_ocr_results
from bridge.social import forward_social_media_results
@@ -121,6 +121,7 @@ async def cron_minutely(client: Client):
async def cron_hourly(client: Client):
await daily_summary(client)
+ await clean_anthropic_files()
await clean_gemini_files()
await clean_r2_expired()
await sync_livechats()
@@ -159,7 +160,7 @@ if __name__ == "__main__":
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green>| <level>{level: <7}</level> |<cyan>{name: <12}</cyan>:<cyan>{function: ^20}</cyan>:<cyan>{line: >4}</cyan> - <level>{message}</level>",
)
# settings
- session_string = args.session_str if args.session_str else TOKEN.SESSION_STRING
+ session_string = args.session_str or TOKEN.SESSION_STRING
if not session_string:
logger.error("No session string, you should run python scripts/auth.py first")
os._exit(1)
pyproject.toml
@@ -1,6 +1,7 @@
[project]
dependencies = [
"aioboto3==15.5.0",
+ "anthropic==0.84.0",
"apscheduler>=3.11.0,<4.0.0",
"beautifulsoup4==4.14.3",
"bilibili-api-python==17.4.1",
@@ -14,13 +15,14 @@ dependencies = [
"feedparser==6.0.12",
"glom==25.12.0",
"google-genai==1.65.0",
+ "httpx-aiohttp==0.1.12",
"httpx-curl-cffi==0.1.5",
"httpx[http2,socks]==0.28.1",
"loguru==0.7.3",
"markdown==3.10.2",
"markitdown[docx,pdf,pptx,xls,xlsx]==0.1.5",
- "onnxruntime==1.24.2; sys_platform == 'linux'",
"onnxruntime<=1.23.2; sys_platform == 'darwin'",
+ "onnxruntime==1.24.2; sys_platform == 'linux'",
"openai==2.24.0",
"orjson==3.11.7",
"pathvalidate==3.3.1",
uv.lock
@@ -152,6 +152,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
+[[package]]
+name = "anthropic"
+version = "0.84.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "distro", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "docstring-parser", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "httpx", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "jiter", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "pydantic", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "sniffio", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "typing-extensions", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" },
+]
+
[[package]]
name = "anyio"
version = "4.12.1"
@@ -213,6 +232,7 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aioboto3", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "anthropic", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "apscheduler", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "beautifulsoup4", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "bilibili-api-python", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
@@ -227,6 +247,7 @@ dependencies = [
{ name = "glom", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "google-genai", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "httpx", extra = ["http2", "socks"], marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "httpx-aiohttp", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "httpx-curl-cffi", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "loguru", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "markdown", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
@@ -264,6 +285,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aioboto3", specifier = "==15.5.0" },
+ { name = "anthropic", specifier = "==0.84.0" },
{ name = "apscheduler", specifier = ">=3.11.0,<4.0.0" },
{ name = "beautifulsoup4", specifier = "==4.14.3" },
{ name = "bilibili-api-python", specifier = "==17.4.1" },
@@ -278,6 +300,7 @@ requires-dist = [
{ name = "glom", specifier = "==25.12.0" },
{ name = "google-genai", specifier = "==1.65.0" },
{ name = "httpx", extras = ["http2", "socks"], specifier = "==0.28.1" },
+ { name = "httpx-aiohttp", specifier = "==0.1.12" },
{ name = "httpx-curl-cffi", specifier = "==0.1.5" },
{ name = "loguru", specifier = "==0.7.3" },
{ name = "markdown", specifier = "==3.10.2" },
@@ -641,6 +664,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
+[[package]]
+name = "docstring-parser"
+version = "0.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
+]
+
[[package]]
name = "et-xmlfile"
version = "2.0.0"
@@ -875,6 +907,19 @@ socks = [
{ name = "socksio", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
]
+[[package]]
+name = "httpx-aiohttp"
+version = "0.1.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "httpx", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/2c/b894861cecf030fb45675ea24aa55b5722e97c602a163d872fca66c5a6d8/httpx_aiohttp-0.1.12.tar.gz", hash = "sha256:81feec51fd82c0ecfa0e9aaf1b1a6c2591260d5e2bcbeb7eb0277a78e610df2c", size = 275945, upload-time = "2025-12-12T10:12:15.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/8d/85c9701e9af72ca132a1783e2a54364a90c6da832304416a30fc11196ab2/httpx_aiohttp-0.1.12-py3-none-any.whl", hash = "sha256:5b0eac39a7f360fa7867a60bcb46bb1024eada9c01cbfecdb54dc1edb3fb7141", size = 6367, upload-time = "2025-12-12T10:12:14.018Z" },
+]
+
[[package]]
name = "httpx-curl-cffi"
version = "0.1.5"