Commit 878bc55

benny-dou <60535774+benny-dou@users.noreply.github.com>
2026-03-03 10:18:39
feat(ai): add support for Anthropic API
1 parent b5cb1f8
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"