diff --git a/README.md b/README.md index 61f38f4..3866241 100644 --- a/README.md +++ b/README.md @@ -664,6 +664,7 @@ Config file: `~/.nanobot/config.json` > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config. +> - **Alibaba Cloud Coding Plan**: If you're on the Alibaba Cloud Coding Plan (BaiLian), set `"apiBase": "https://coding.dashscope.aliyuncs.com/v1"` in your dashscope provider config. | Provider | Purpose | Get API Key | |----------|---------|-------------| diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index df4825f..27511fa 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -10,6 +10,7 @@ from typing import Any from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader +from nanobot.utils.helpers import detect_image_mime class ContextBuilder: @@ -136,10 +137,14 @@ Reply directly with text for conversations. Only use the 'message' tool to send images = [] for path in media: p = Path(path) - mime, _ = mimetypes.guess_type(path) - if not p.is_file() or not mime or not mime.startswith("image/"): + if not p.is_file(): continue - b64 = base64.b64encode(p.read_bytes()).decode() + raw = p.read_bytes() + # Detect real MIME type from magic bytes; fallback to filename guess + mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] + if not mime or not mime.startswith("image/"): + continue + b64 = base64.b64encode(raw).decode() images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) if not images: diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 93c1825..21fe77d 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -128,6 +128,13 @@ class MemoryStore: # Some providers return arguments as a JSON string instead of dict if isinstance(args, str): args = json.loads(args) + # Some providers return arguments as a list (handle edge case) + if isinstance(args, list): + if args and isinstance(args[0], dict): + args = args[0] + else: + logger.warning("Memory consolidation: unexpected arguments as empty or non-dict list") + return False if not isinstance(args, dict): logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__) return False diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 900c17b..c868bbf 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -13,34 +13,13 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import DiscordConfig +from nanobot.utils.helpers import split_message DISCORD_API_BASE = "https://discord.com/api/v10" MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB MAX_MESSAGE_LEN = 2000 # Discord message character limit -def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: - """Split content into chunks within max_len, preferring line breaks.""" - if not content: - return [] - if len(content) <= max_len: - return [content] - chunks: list[str] = [] - while content: - if len(content) <= max_len: - chunks.append(content) - break - cut = content[:max_len] - pos = cut.rfind('\n') - if pos <= 0: - pos = cut.rfind(' ') - if pos <= 0: - pos = max_len - chunks.append(content[:pos]) - content = content[pos:].lstrip() - return chunks - - class DiscordChannel(BaseChannel): """Discord channel using Gateway websocket.""" @@ -105,7 +84,7 @@ class DiscordChannel(BaseChannel): headers = {"Authorization": f"Bot {self.config.token}"} try: - chunks = _split_message(msg.content or "") + chunks = split_message(msg.content or "", MAX_MESSAGE_LEN) if not chunks: return diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index e6f0049..8f69c09 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -472,8 +472,124 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] + # ── Smart format detection ────────────────────────────────────────── + # Patterns that indicate "complex" markdown needing card rendering + _COMPLEX_MD_RE = re.compile( + r"```" # fenced code block + r"|^\|.+\|.*\n\s*\|[-:\s|]+\|" # markdown table (header + separator) + r"|^#{1,6}\s+" # headings + , re.MULTILINE, + ) + + # Simple markdown patterns (bold, italic, strikethrough) + _SIMPLE_MD_RE = re.compile( + r"\*\*.+?\*\*" # **bold** + r"|__.+?__" # __bold__ + r"|(? str: + """Determine the optimal Feishu message format for *content*. + + Returns one of: + - ``"text"`` – plain text, short and no markdown + - ``"post"`` – rich text (links only, moderate length) + - ``"interactive"`` – card with full markdown rendering + """ + stripped = content.strip() + + # Complex markdown (code blocks, tables, headings) → always card + if cls._COMPLEX_MD_RE.search(stripped): + return "interactive" + + # Long content → card (better readability with card layout) + if len(stripped) > cls._POST_MAX_LEN: + return "interactive" + + # Has bold/italic/strikethrough → card (post format can't render these) + if cls._SIMPLE_MD_RE.search(stripped): + return "interactive" + + # Has list items → card (post format can't render list bullets well) + if cls._LIST_RE.search(stripped) or cls._OLIST_RE.search(stripped): + return "interactive" + + # Has links → post format (supports tags) + if cls._MD_LINK_RE.search(stripped): + return "post" + + # Short plain text → text format + if len(stripped) <= cls._TEXT_MAX_LEN: + return "text" + + # Medium plain text without any formatting → post format + return "post" + + @classmethod + def _markdown_to_post(cls, content: str) -> str: + """Convert markdown content to Feishu post message JSON. + + Handles links ``[text](url)`` as ``a`` tags; everything else as ``text`` tags. + Each line becomes a paragraph (row) in the post body. + """ + lines = content.strip().split("\n") + paragraphs: list[list[dict]] = [] + + for line in lines: + elements: list[dict] = [] + last_end = 0 + + for m in cls._MD_LINK_RE.finditer(line): + # Text before this link + before = line[last_end:m.start()] + if before: + elements.append({"tag": "text", "text": before}) + elements.append({ + "tag": "a", + "text": m.group(1), + "href": m.group(2), + }) + last_end = m.end() + + # Remaining text after last link + remaining = line[last_end:] + if remaining: + elements.append({"tag": "text", "text": remaining}) + + # Empty line → empty paragraph for spacing + if not elements: + elements.append({"tag": "text", "text": ""}) + + paragraphs.append(elements) + + post_body = { + "zh_cn": { + "content": paragraphs, + } + } + return json.dumps(post_body, ensure_ascii=False) + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} _AUDIO_EXTS = {".opus"} + _VIDEO_EXTS = {".mp4", ".mov", ".avi"} _FILE_TYPE_MAP = { ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", @@ -682,21 +798,46 @@ class FeishuChannel(BaseChannel): else: key = await loop.run_in_executor(None, self._upload_file_sync, file_path) if key: - media_type = "audio" if ext in self._AUDIO_EXTS else "file" + # Use msg_type "media" for audio/video so users can play inline; + # "file" for everything else (documents, archives, etc.) + if ext in self._AUDIO_EXTS or ext in self._VIDEO_EXTS: + media_type = "media" + else: + media_type = "file" await loop.run_in_executor( None, self._send_message_sync, receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False), ) if msg.content and msg.content.strip(): - elements = self._build_card_elements(msg.content) - for chunk in self._split_elements_by_table_limit(elements): - card = {"config": {"wide_screen_mode": True}, "elements": chunk} + fmt = self._detect_msg_format(msg.content) + + if fmt == "text": + # Short plain text – send as simple text message + text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False) await loop.run_in_executor( None, self._send_message_sync, - receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + receive_id_type, msg.chat_id, "text", text_body, ) + elif fmt == "post": + # Medium content with links – send as rich-text post + post_body = self._markdown_to_post(msg.content) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "post", post_body, + ) + + else: + # Complex / long content – send as interactive card + elements = self._build_card_elements(msg.content) + for chunk in self._split_elements_by_table_limit(elements): + card = {"config": {"wide_screen_mode": True}, "elements": chunk} + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + ) + except Exception as e: logger.error("Error sending Feishu message: {}", e) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 884b2d0..9097496 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -14,6 +14,9 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import TelegramConfig +from nanobot.utils.helpers import split_message + +TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit def _markdown_to_telegram_html(text: str) -> str: @@ -79,26 +82,6 @@ def _markdown_to_telegram_html(text: str) -> str: return text -def _split_message(content: str, max_len: int = 4000) -> list[str]: - """Split content into chunks within max_len, preferring line breaks.""" - if len(content) <= max_len: - return [content] - chunks: list[str] = [] - while content: - if len(content) <= max_len: - chunks.append(content) - break - cut = content[:max_len] - pos = cut.rfind('\n') - if pos == -1: - pos = cut.rfind(' ') - if pos == -1: - pos = max_len - chunks.append(content[:pos]) - content = content[pos:].lstrip() - return chunks - - class TelegramChannel(BaseChannel): """ Telegram channel using long polling. @@ -273,8 +256,8 @@ class TelegramChannel(BaseChannel): if msg.content and msg.content != "[empty message]": is_progress = msg.metadata.get("_progress", False) draft_id = msg.metadata.get("message_id") - - for chunk in _split_message(msg.content): + + for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN): try: html = _markdown_to_telegram_html(chunk) if is_progress and draft_id: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2d0f3c2..ca5d8d7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -7,6 +7,18 @@ import signal import sys from pathlib import Path +# Force UTF-8 encoding for Windows console +if sys.platform == "win32": + import locale + if sys.stdout.encoding != "utf-8": + os.environ["PYTHONIOENCODING"] = "utf-8" + # Re-open stdout/stderr with UTF-8 encoding + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + import typer from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import HTML @@ -200,8 +212,6 @@ def onboard(): def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" - from nanobot.providers.custom_provider import CustomProvider - from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider from nanobot.providers.azure_openai_provider import AzureOpenAIProvider @@ -214,6 +224,7 @@ def _make_provider(config: Config): return OpenAICodexProvider(default_model=model) # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM + from nanobot.providers.custom_provider import CustomProvider if provider_name == "custom": return CustomProvider( api_key=p.api_key if p else "no-key", @@ -235,6 +246,7 @@ def _make_provider(config: Config): default_model=model, ) + from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.registry import find_by_name spec = find_by_name(provider_name) if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): @@ -540,9 +552,13 @@ def agent( signal.signal(signal.SIGINT, _handle_signal) signal.signal(signal.SIGTERM, _handle_signal) - signal.signal(signal.SIGHUP, _handle_signal) + # SIGHUP is not available on Windows + if hasattr(signal, 'SIGHUP'): + signal.signal(signal.SIGHUP, _handle_signal) # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes - signal.signal(signal.SIGPIPE, signal.SIG_IGN) + # SIGPIPE is not available on Windows + if hasattr(signal, 'SIGPIPE'): + signal.signal(signal.SIGPIPE, signal.SIG_IGN) async def run_interactive(): bus_task = asyncio.create_task(agent_loop.run()) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 44c7446..803cb61 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -199,21 +199,6 @@ class QQConfig(Base): ) # Allowed user openids (empty = public access) -class MatrixConfig(Base): - """Matrix (Element) channel configuration.""" - - enabled: bool = False - homeserver: str = "https://matrix.org" - access_token: str = "" - user_id: str = "" # e.g. @bot:matrix.org - device_id: str = "" - e2ee_enabled: bool = True # end-to-end encryption support - sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout - max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit - allow_from: list[str] = Field(default_factory=list) - group_policy: Literal["open", "mention", "allowlist"] = "open" - group_allow_from: list[str] = Field(default_factory=list) - allow_room_mentions: bool = False class ChannelsConfig(Base): @@ -279,12 +264,8 @@ class ProvidersConfig(Base): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field( - default_factory=ProviderConfig - ) # SiliconFlow (硅基流动) API gateway - volcengine: ProviderConfig = Field( - default_factory=ProviderConfig - ) # VolcEngine (火山引擎) API gateway + siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) + volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index d8d8ace..620424e 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -8,6 +8,7 @@ from typing import Any import json_repair import litellm from litellm import acompletion +from loguru import logger from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.registry import find_by_model, find_gateway @@ -255,20 +256,37 @@ class LiteLLMProvider(LLMProvider): """Parse LiteLLM response into our standard format.""" choice = response.choices[0] message = choice.message + content = message.content + finish_reason = choice.finish_reason + + # Some providers (e.g. GitHub Copilot) split content and tool_calls + # across multiple choices. Merge them so tool_calls are not lost. + raw_tool_calls = [] + for ch in response.choices: + msg = ch.message + if hasattr(msg, "tool_calls") and msg.tool_calls: + raw_tool_calls.extend(msg.tool_calls) + if ch.finish_reason in ("tool_calls", "stop"): + finish_reason = ch.finish_reason + if not content and msg.content: + content = msg.content + + if len(response.choices) > 1: + logger.debug("LiteLLM response has {} choices, merged {} tool_calls", + len(response.choices), len(raw_tool_calls)) tool_calls = [] - if hasattr(message, "tool_calls") and message.tool_calls: - for tc in message.tool_calls: - # Parse arguments from JSON string if needed - args = tc.function.arguments - if isinstance(args, str): - args = json_repair.loads(args) + for tc in raw_tool_calls: + # Parse arguments from JSON string if needed + args = tc.function.arguments + if isinstance(args, str): + args = json_repair.loads(args) - tool_calls.append(ToolCallRequest( - id=_short_tool_id(), - name=tc.function.name, - arguments=args, - )) + tool_calls.append(ToolCallRequest( + id=_short_tool_id(), + name=tc.function.name, + arguments=args, + )) usage = {} if hasattr(response, "usage") and response.usage: @@ -280,11 +298,11 @@ class LiteLLMProvider(LLMProvider): reasoning_content = getattr(message, "reasoning_content", None) or None thinking_blocks = getattr(message, "thinking_blocks", None) or None - + return LLMResponse( - content=message.content, + content=content, tool_calls=tool_calls, - finish_reason=choice.finish_reason or "stop", + finish_reason=finish_reason or "stop", usage=usage, reasoning_content=reasoning_content, thinking_blocks=thinking_blocks, diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 93a1138..3ba1a0e 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -26,33 +26,33 @@ class ProviderSpec: """ # identity - name: str # config field name, e.g. "dashscope" - keywords: tuple[str, ...] # model-name keywords for matching (lowercase) - env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY" - display_name: str = "" # shown in `nanobot status` + name: str # config field name, e.g. "dashscope" + keywords: tuple[str, ...] # model-name keywords for matching (lowercase) + env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY" + display_name: str = "" # shown in `nanobot status` # model prefixing - litellm_prefix: str = "" # "dashscope" → model becomes "dashscope/{model}" - skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these + litellm_prefix: str = "" # "dashscope" → model becomes "dashscope/{model}" + skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) env_extras: tuple[tuple[str, str], ...] = () # gateway / local detection - is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) - is_local: bool = False # local deployment (vLLM, Ollama) - detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" - detect_by_base_keyword: str = "" # match substring in api_base URL - default_api_base: str = "" # fallback base URL + is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) + is_local: bool = False # local deployment (vLLM, Ollama) + detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" + detect_by_base_keyword: str = "" # match substring in api_base URL + default_api_base: str = "" # fallback base URL # gateway behavior - strip_model_prefix: bool = False # strip "provider/" before re-prefixing + strip_model_prefix: bool = False # strip "provider/" before re-prefixing # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () # OAuth-based providers (e.g., OpenAI Codex) don't use API keys - is_oauth: bool = False # if True, uses OAuth flow instead of API key + is_oauth: bool = False # if True, uses OAuth flow instead of API key # Direct providers bypass LiteLLM entirely (e.g., CustomProvider) is_direct: bool = False @@ -70,7 +70,6 @@ class ProviderSpec: # --------------------------------------------------------------------------- PROVIDERS: tuple[ProviderSpec, ...] = ( - # === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ====== ProviderSpec( name="custom", @@ -90,17 +89,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( litellm_prefix="", is_direct=True, ), - # === Gateways (detected by api_key / api_base, not model name) ========= # Gateways can route any model, so they win in fallback. - # OpenRouter: global gateway, keys start with "sk-or-" ProviderSpec( name="openrouter", keywords=("openrouter",), env_key="OPENROUTER_API_KEY", display_name="OpenRouter", - litellm_prefix="openrouter", # claude-3 → openrouter/claude-3 + litellm_prefix="openrouter", # claude-3 → openrouter/claude-3 skip_prefixes=(), env_extras=(), is_gateway=True, @@ -112,16 +109,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), supports_prompt_caching=True, ), - # AiHubMix: global gateway, OpenAI-compatible interface. # strip_model_prefix=True: it doesn't understand "anthropic/claude-3", # so we strip to bare "claude-3" then re-prefix as "openai/claude-3". ProviderSpec( name="aihubmix", keywords=("aihubmix",), - env_key="OPENAI_API_KEY", # OpenAI-compatible + env_key="OPENAI_API_KEY", # OpenAI-compatible display_name="AiHubMix", - litellm_prefix="openai", # → openai/{model} + litellm_prefix="openai", # → openai/{model} skip_prefixes=(), env_extras=(), is_gateway=True, @@ -129,10 +125,9 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( detect_by_key_prefix="", detect_by_base_keyword="aihubmix", default_api_base="https://aihubmix.com/v1", - strip_model_prefix=True, # anthropic/claude-3 → claude-3 → openai/claude-3 + strip_model_prefix=True, # anthropic/claude-3 → claude-3 → openai/claude-3 model_overrides=(), ), - # SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix ProviderSpec( name="siliconflow", @@ -150,7 +145,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # VolcEngine (火山引擎): OpenAI-compatible gateway ProviderSpec( name="volcengine", @@ -168,9 +162,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # === Standard providers (matched by model-name keywords) =============== - # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. ProviderSpec( name="anthropic", @@ -189,7 +181,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), supports_prompt_caching=True, ), - # OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed. ProviderSpec( name="openai", @@ -207,14 +198,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # OpenAI Codex: uses OAuth, not API key. ProviderSpec( name="openai_codex", keywords=("openai-codex",), - env_key="", # OAuth-based, no API key + env_key="", # OAuth-based, no API key display_name="OpenAI Codex", - litellm_prefix="", # Not routed through LiteLLM + litellm_prefix="", # Not routed through LiteLLM skip_prefixes=(), env_extras=(), is_gateway=False, @@ -224,16 +214,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://chatgpt.com/backend-api", strip_model_prefix=False, model_overrides=(), - is_oauth=True, # OAuth-based authentication + is_oauth=True, # OAuth-based authentication ), - # Github Copilot: uses OAuth, not API key. ProviderSpec( name="github_copilot", keywords=("github_copilot", "copilot"), - env_key="", # OAuth-based, no API key + env_key="", # OAuth-based, no API key display_name="Github Copilot", - litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model + litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model skip_prefixes=("github_copilot/",), env_extras=(), is_gateway=False, @@ -243,17 +232,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="", strip_model_prefix=False, model_overrides=(), - is_oauth=True, # OAuth-based authentication + is_oauth=True, # OAuth-based authentication ), - # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. ProviderSpec( name="deepseek", keywords=("deepseek",), env_key="DEEPSEEK_API_KEY", display_name="DeepSeek", - litellm_prefix="deepseek", # deepseek-chat → deepseek/deepseek-chat - skip_prefixes=("deepseek/",), # avoid double-prefix + litellm_prefix="deepseek", # deepseek-chat → deepseek/deepseek-chat + skip_prefixes=("deepseek/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -263,15 +251,14 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Gemini: needs "gemini/" prefix for LiteLLM. ProviderSpec( name="gemini", keywords=("gemini",), env_key="GEMINI_API_KEY", display_name="Gemini", - litellm_prefix="gemini", # gemini-pro → gemini/gemini-pro - skip_prefixes=("gemini/",), # avoid double-prefix + litellm_prefix="gemini", # gemini-pro → gemini/gemini-pro + skip_prefixes=("gemini/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -281,7 +268,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Zhipu: LiteLLM uses "zai/" prefix. # Also mirrors key to ZHIPUAI_API_KEY (some LiteLLM paths check that). # skip_prefixes: don't add "zai/" when already routed via gateway. @@ -290,11 +276,9 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("zhipu", "glm", "zai"), env_key="ZAI_API_KEY", display_name="Zhipu AI", - litellm_prefix="zai", # glm-4 → zai/glm-4 + litellm_prefix="zai", # glm-4 → zai/glm-4 skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"), - env_extras=( - ("ZHIPUAI_API_KEY", "{api_key}"), - ), + env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),), is_gateway=False, is_local=False, detect_by_key_prefix="", @@ -303,14 +287,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # DashScope: Qwen models, needs "dashscope/" prefix. ProviderSpec( name="dashscope", keywords=("qwen", "dashscope"), env_key="DASHSCOPE_API_KEY", display_name="DashScope", - litellm_prefix="dashscope", # qwen-max → dashscope/qwen-max + litellm_prefix="dashscope", # qwen-max → dashscope/qwen-max skip_prefixes=("dashscope/", "openrouter/"), env_extras=(), is_gateway=False, @@ -321,7 +304,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Moonshot: Kimi models, needs "moonshot/" prefix. # LiteLLM requires MOONSHOT_API_BASE env var to find the endpoint. # Kimi K2.5 API enforces temperature >= 1.0. @@ -330,22 +312,17 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("moonshot", "kimi"), env_key="MOONSHOT_API_KEY", display_name="Moonshot", - litellm_prefix="moonshot", # kimi-k2.5 → moonshot/kimi-k2.5 + litellm_prefix="moonshot", # kimi-k2.5 → moonshot/kimi-k2.5 skip_prefixes=("moonshot/", "openrouter/"), - env_extras=( - ("MOONSHOT_API_BASE", "{api_base}"), - ), + env_extras=(("MOONSHOT_API_BASE", "{api_base}"),), is_gateway=False, is_local=False, detect_by_key_prefix="", detect_by_base_keyword="", - default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China + default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China strip_model_prefix=False, - model_overrides=( - ("kimi-k2.5", {"temperature": 1.0}), - ), + model_overrides=(("kimi-k2.5", {"temperature": 1.0}),), ), - # MiniMax: needs "minimax/" prefix for LiteLLM routing. # Uses OpenAI-compatible API at api.minimax.io/v1. ProviderSpec( @@ -353,7 +330,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("minimax",), env_key="MINIMAX_API_KEY", display_name="MiniMax", - litellm_prefix="minimax", # MiniMax-M2.1 → minimax/MiniMax-M2.1 + litellm_prefix="minimax", # MiniMax-M2.1 → minimax/MiniMax-M2.1 skip_prefixes=("minimax/", "openrouter/"), env_extras=(), is_gateway=False, @@ -364,9 +341,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # === Local deployment (matched by config key, NOT by api_base) ========= - # vLLM / any OpenAI-compatible local server. # Detected when config key is "vllm" (provider_name="vllm"). ProviderSpec( @@ -374,20 +349,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("vllm",), env_key="HOSTED_VLLM_API_KEY", display_name="vLLM/Local", - litellm_prefix="hosted_vllm", # Llama-3-8B → hosted_vllm/Llama-3-8B + litellm_prefix="hosted_vllm", # Llama-3-8B → hosted_vllm/Llama-3-8B skip_prefixes=(), env_extras=(), is_gateway=False, is_local=True, detect_by_key_prefix="", detect_by_base_keyword="", - default_api_base="", # user must provide in config + default_api_base="", # user must provide in config strip_model_prefix=False, model_overrides=(), ), - # === Auxiliary (not a primary LLM provider) ============================ - # Groq: mainly used for Whisper voice transcription, also usable for LLM. # Needs "groq/" prefix for LiteLLM routing. Placed last — it rarely wins fallback. ProviderSpec( @@ -395,8 +368,8 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("groq",), env_key="GROQ_API_KEY", display_name="Groq", - litellm_prefix="groq", # llama3-8b-8192 → groq/llama3-8b-8192 - skip_prefixes=("groq/",), # avoid double-prefix + litellm_prefix="groq", # llama3-8b-8192 → groq/llama3-8b-8192 + skip_prefixes=("groq/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -413,6 +386,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( # Lookup helpers # --------------------------------------------------------------------------- + def find_by_model(model: str) -> ProviderSpec | None: """Match a standard provider by model-name keyword (case-insensitive). Skips gateways/local — those are matched by api_key/api_base instead.""" @@ -428,7 +402,9 @@ def find_by_model(model: str) -> ProviderSpec | None: return spec for spec in std_specs: - if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords): + if any( + kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords + ): return spec return None diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 3a8c802..c57c365 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -5,6 +5,19 @@ from datetime import datetime from pathlib import Path +def detect_image_mime(data: bytes) -> str | None: + """Detect image MIME type from magic bytes, ignoring file extension.""" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if data[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if data[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return None + + def ensure_dir(path: Path) -> Path: """Ensure directory exists, return it.""" path.mkdir(parents=True, exist_ok=True) @@ -34,6 +47,38 @@ def safe_filename(name: str) -> str: return _UNSAFE_CHARS.sub("_", name).strip() +def split_message(content: str, max_len: int = 2000) -> list[str]: + """ + Split content into chunks within max_len, preferring line breaks. + + Args: + content: The text content to split. + max_len: Maximum length per chunk (default 2000 for Discord compatibility). + + Returns: + List of message chunks, each within max_len. + """ + if not content: + return [] + if len(content) <= max_len: + return [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + # Try to break at newline first, then space, then hard break + pos = cut.rfind('\n') + if pos <= 0: + pos = cut.rfind(' ') + if pos <= 0: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks + + def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: """Sync bundled templates to workspace. Only creates missing files.""" from importlib.resources import files as pkg_files diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py index 375c802..ff15584 100644 --- a/tests/test_memory_consolidation_types.py +++ b/tests/test_memory_consolidation_types.py @@ -145,3 +145,78 @@ class TestMemoryConsolidationTypeHandling: assert result is True provider.chat.assert_not_called() + + @pytest.mark.asyncio + async def test_list_arguments_extracts_first_dict(self, tmp_path: Path) -> None: + """Some providers return arguments as a list - extract first element if it's a dict.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + # Simulate arguments being a list containing a dict + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=[{ + "history_entry": "[2026-01-01] User discussed testing.", + "memory_update": "# Memory\nUser likes testing.", + }], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is True + assert "User discussed testing." in store.history_file.read_text() + assert "User likes testing." in store.memory_file.read_text() + + @pytest.mark.asyncio + async def test_list_arguments_empty_list_returns_false(self, tmp_path: Path) -> None: + """Empty list arguments should return False.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=[], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False + + @pytest.mark.asyncio + async def test_list_arguments_non_dict_content_returns_false(self, tmp_path: Path) -> None: + """List with non-dict content should return False.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=["string", "content"], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False