Merge branch 'main' into pr-820
This commit is contained in:
@@ -82,6 +82,12 @@ class ExecTool(Tool):
|
|||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
process.kill()
|
process.kill()
|
||||||
|
# Wait for the process to fully terminate so pipes are
|
||||||
|
# drained and file descriptors are released.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
return f"Error: Command timed out after {self.timeout} seconds"
|
return f"Error: Command timed out after {self.timeout} seconds"
|
||||||
|
|
||||||
output_parts = []
|
output_parts = []
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -17,6 +18,10 @@ from nanobot.config.schema import FeishuConfig
|
|||||||
try:
|
try:
|
||||||
import lark_oapi as lark
|
import lark_oapi as lark
|
||||||
from lark_oapi.api.im.v1 import (
|
from lark_oapi.api.im.v1 import (
|
||||||
|
CreateFileRequest,
|
||||||
|
CreateFileRequestBody,
|
||||||
|
CreateImageRequest,
|
||||||
|
CreateImageRequestBody,
|
||||||
CreateMessageRequest,
|
CreateMessageRequest,
|
||||||
CreateMessageRequestBody,
|
CreateMessageRequestBody,
|
||||||
CreateMessageReactionRequest,
|
CreateMessageReactionRequest,
|
||||||
@@ -263,7 +268,6 @@ class FeishuChannel(BaseChannel):
|
|||||||
before = protected[last_end:m.start()].strip()
|
before = protected[last_end:m.start()].strip()
|
||||||
if before:
|
if before:
|
||||||
elements.append({"tag": "markdown", "content": before})
|
elements.append({"tag": "markdown", "content": before})
|
||||||
level = len(m.group(1))
|
|
||||||
text = m.group(2).strip()
|
text = m.group(2).strip()
|
||||||
elements.append({
|
elements.append({
|
||||||
"tag": "div",
|
"tag": "div",
|
||||||
@@ -284,48 +288,126 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
return elements or [{"tag": "markdown", "content": content}]
|
return elements or [{"tag": "markdown", "content": content}]
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
||||||
"""Send a message through Feishu."""
|
_AUDIO_EXTS = {".opus"}
|
||||||
if not self._client:
|
_FILE_TYPE_MAP = {
|
||||||
logger.warning("Feishu client not initialized")
|
".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
|
||||||
return
|
".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _upload_image_sync(self, file_path: str) -> str | None:
|
||||||
|
"""Upload an image to Feishu and return the image_key."""
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
request = CreateImageRequest.builder() \
|
||||||
|
.request_body(
|
||||||
|
CreateImageRequestBody.builder()
|
||||||
|
.image_type("message")
|
||||||
|
.image(f)
|
||||||
|
.build()
|
||||||
|
).build()
|
||||||
|
response = self._client.im.v1.image.create(request)
|
||||||
|
if response.success():
|
||||||
|
image_key = response.data.image_key
|
||||||
|
logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}")
|
||||||
|
return image_key
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error uploading image {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _upload_file_sync(self, file_path: str) -> str | None:
|
||||||
|
"""Upload a file to Feishu and return the file_key."""
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
request = CreateFileRequest.builder() \
|
||||||
|
.request_body(
|
||||||
|
CreateFileRequestBody.builder()
|
||||||
|
.file_type(file_type)
|
||||||
|
.file_name(file_name)
|
||||||
|
.file(f)
|
||||||
|
.build()
|
||||||
|
).build()
|
||||||
|
response = self._client.im.v1.file.create(request)
|
||||||
|
if response.success():
|
||||||
|
file_key = response.data.file_key
|
||||||
|
logger.debug(f"Uploaded file {file_name}: {file_key}")
|
||||||
|
return file_key
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error uploading file {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
|
||||||
|
"""Send a single message (text/image/file/interactive) synchronously."""
|
||||||
try:
|
try:
|
||||||
# Determine receive_id_type based on chat_id format
|
|
||||||
# open_id starts with "ou_", chat_id starts with "oc_"
|
|
||||||
if msg.chat_id.startswith("oc_"):
|
|
||||||
receive_id_type = "chat_id"
|
|
||||||
else:
|
|
||||||
receive_id_type = "open_id"
|
|
||||||
|
|
||||||
# Build card with markdown + table support
|
|
||||||
elements = self._build_card_elements(msg.content)
|
|
||||||
card = {
|
|
||||||
"config": {"wide_screen_mode": True},
|
|
||||||
"elements": elements,
|
|
||||||
}
|
|
||||||
content = json.dumps(card, ensure_ascii=False)
|
|
||||||
|
|
||||||
request = CreateMessageRequest.builder() \
|
request = CreateMessageRequest.builder() \
|
||||||
.receive_id_type(receive_id_type) \
|
.receive_id_type(receive_id_type) \
|
||||||
.request_body(
|
.request_body(
|
||||||
CreateMessageRequestBody.builder()
|
CreateMessageRequestBody.builder()
|
||||||
.receive_id(msg.chat_id)
|
.receive_id(receive_id)
|
||||||
.msg_type("interactive")
|
.msg_type(msg_type)
|
||||||
.content(content)
|
.content(content)
|
||||||
.build()
|
.build()
|
||||||
).build()
|
).build()
|
||||||
|
|
||||||
response = self._client.im.v1.message.create(request)
|
response = self._client.im.v1.message.create(request)
|
||||||
|
|
||||||
if not response.success():
|
if not response.success():
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to send Feishu message: code={response.code}, "
|
f"Failed to send Feishu {msg_type} message: code={response.code}, "
|
||||||
f"msg={response.msg}, log_id={response.get_log_id()}"
|
f"msg={response.msg}, log_id={response.get_log_id()}"
|
||||||
)
|
)
|
||||||
else:
|
return False
|
||||||
logger.debug(f"Feishu message sent to {msg.chat_id}")
|
logger.debug(f"Feishu {msg_type} message sent to {receive_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending Feishu {msg_type} message: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
"""Send a message through Feishu, including media (images/files) if present."""
|
||||||
|
if not self._client:
|
||||||
|
logger.warning("Feishu client not initialized")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
for file_path in msg.media:
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
logger.warning(f"Media file not found: {file_path}")
|
||||||
|
continue
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
if ext in self._IMAGE_EXTS:
|
||||||
|
key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
|
||||||
|
if key:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync,
|
||||||
|
receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}),
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync,
|
||||||
|
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if msg.content and msg.content.strip():
|
||||||
|
card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
|
||||||
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error sending Feishu message: {e}")
|
logger.error(f"Error sending Feishu message: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -287,11 +287,25 @@ class Config(BaseSettings):
|
|||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
model_lower = (model or self.agents.defaults.model).lower()
|
model_lower = (model or self.agents.defaults.model).lower()
|
||||||
|
model_normalized = model_lower.replace("-", "_")
|
||||||
|
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||||
|
normalized_prefix = model_prefix.replace("-", "_")
|
||||||
|
|
||||||
|
def _kw_matches(kw: str) -> bool:
|
||||||
|
kw = kw.lower()
|
||||||
|
return kw in model_lower or kw.replace("-", "_") in model_normalized
|
||||||
|
|
||||||
|
# Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex.
|
||||||
|
for spec in PROVIDERS:
|
||||||
|
p = getattr(self.providers, spec.name, None)
|
||||||
|
if p and model_prefix and normalized_prefix == spec.name:
|
||||||
|
if spec.is_oauth or p.api_key:
|
||||||
|
return p, spec.name
|
||||||
|
|
||||||
# Match by keyword (order follows PROVIDERS registry)
|
# Match by keyword (order follows PROVIDERS registry)
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
p = getattr(self.providers, spec.name, None)
|
p = getattr(self.providers, spec.name, None)
|
||||||
if p and any(kw in model_lower for kw in spec.keywords):
|
if p and any(_kw_matches(kw) for kw in spec.keywords):
|
||||||
if spec.is_oauth or p.api_key:
|
if spec.is_oauth or p.api_key:
|
||||||
return p, spec.name
|
return p, spec.name
|
||||||
|
|
||||||
|
|||||||
@@ -88,10 +88,21 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
# Standard mode: auto-prefix for known providers
|
# Standard mode: auto-prefix for known providers
|
||||||
spec = find_by_model(model)
|
spec = find_by_model(model)
|
||||||
if spec and spec.litellm_prefix:
|
if spec and spec.litellm_prefix:
|
||||||
|
model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix)
|
||||||
if not any(model.startswith(s) for s in spec.skip_prefixes):
|
if not any(model.startswith(s) for s in spec.skip_prefixes):
|
||||||
model = f"{spec.litellm_prefix}/{model}"
|
model = f"{spec.litellm_prefix}/{model}"
|
||||||
|
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str:
|
||||||
|
"""Normalize explicit provider prefixes like `github-copilot/...`."""
|
||||||
|
if "/" not in model:
|
||||||
|
return model
|
||||||
|
prefix, remainder = model.split("/", 1)
|
||||||
|
if prefix.lower().replace("-", "_") != spec_name:
|
||||||
|
return model
|
||||||
|
return f"{canonical_prefix}/{remainder}"
|
||||||
|
|
||||||
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
|
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
|
||||||
"""Apply model-specific parameter overrides from the registry."""
|
"""Apply model-specific parameter overrides from the registry."""
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
|
|
||||||
|
|
||||||
def _strip_model_prefix(model: str) -> str:
|
def _strip_model_prefix(model: str) -> str:
|
||||||
if model.startswith("openai-codex/"):
|
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
|
||||||
return model.split("/", 1)[1]
|
return model.split("/", 1)[1]
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|||||||
@@ -384,10 +384,18 @@ def find_by_model(model: str) -> ProviderSpec | None:
|
|||||||
"""Match a standard provider by model-name keyword (case-insensitive).
|
"""Match a standard provider by model-name keyword (case-insensitive).
|
||||||
Skips gateways/local — those are matched by api_key/api_base instead."""
|
Skips gateways/local — those are matched by api_key/api_base instead."""
|
||||||
model_lower = model.lower()
|
model_lower = model.lower()
|
||||||
for spec in PROVIDERS:
|
model_normalized = model_lower.replace("-", "_")
|
||||||
if spec.is_gateway or spec.is_local:
|
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||||
continue
|
normalized_prefix = model_prefix.replace("-", "_")
|
||||||
if any(kw in model_lower for kw in spec.keywords):
|
std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local]
|
||||||
|
|
||||||
|
# Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex.
|
||||||
|
for spec in std_specs:
|
||||||
|
if model_prefix and normalized_prefix == spec.name:
|
||||||
|
return spec
|
||||||
|
|
||||||
|
for spec in std_specs:
|
||||||
|
if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords):
|
||||||
return spec
|
return spec
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -17,37 +17,37 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typer>=0.9.0",
|
"typer>=0.20.0,<1.0.0",
|
||||||
"litellm>=1.0.0",
|
"litellm>=1.81.5,<2.0.0",
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.12.0,<3.0.0",
|
||||||
"pydantic-settings>=2.0.0",
|
"pydantic-settings>=2.12.0,<3.0.0",
|
||||||
"websockets>=12.0",
|
"websockets>=16.0,<17.0",
|
||||||
"websocket-client>=1.6.0",
|
"websocket-client>=1.9.0,<2.0.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.28.0,<1.0.0",
|
||||||
"oauth-cli-kit>=0.1.1",
|
"oauth-cli-kit>=0.1.3,<1.0.0",
|
||||||
"loguru>=0.7.0",
|
"loguru>=0.7.3,<1.0.0",
|
||||||
"readability-lxml>=0.8.0",
|
"readability-lxml>=0.8.4,<1.0.0",
|
||||||
"rich>=13.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
"croniter>=2.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"dingtalk-stream>=0.4.0",
|
"dingtalk-stream>=0.24.0,<1.0.0",
|
||||||
"python-telegram-bot[socks]>=21.0",
|
"python-telegram-bot[socks]>=22.0,<23.0",
|
||||||
"lark-oapi>=1.0.0",
|
"lark-oapi>=1.5.0,<2.0.0",
|
||||||
"socksio>=1.0.0",
|
"socksio>=1.0.0,<2.0.0",
|
||||||
"python-socketio>=5.11.0",
|
"python-socketio>=5.16.0,<6.0.0",
|
||||||
"msgpack>=1.0.8",
|
"msgpack>=1.1.0,<2.0.0",
|
||||||
"slack-sdk>=3.26.0",
|
"slack-sdk>=3.39.0,<4.0.0",
|
||||||
"slackify-markdown>=0.2.0",
|
"slackify-markdown>=0.2.0,<1.0.0",
|
||||||
"qq-botpy>=1.0.0",
|
"qq-botpy>=1.2.0,<2.0.0",
|
||||||
"python-socks[asyncio]>=2.4.0",
|
"python-socks[asyncio]>=2.8.0,<3.0.0",
|
||||||
"prompt-toolkit>=3.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"mcp>=1.0.0",
|
"mcp>=1.26.0,<2.0.0",
|
||||||
"json-repair>=0.30.0",
|
"json-repair>=0.57.0,<1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0.0",
|
"pytest>=9.0.0,<10.0.0",
|
||||||
"pytest-asyncio>=0.21.0",
|
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||||
"ruff>=0.1.0",
|
"ruff>=0.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import pytest
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from nanobot.cli.commands import app
|
from nanobot.cli.commands import app
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||||
|
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||||
|
from nanobot.providers.registry import find_by_model
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
@@ -90,3 +94,37 @@ def test_onboard_existing_workspace_safe_create(mock_paths):
|
|||||||
assert "Created workspace" not in result.stdout
|
assert "Created workspace" not in result.stdout
|
||||||
assert "Created AGENTS.md" in result.stdout
|
assert "Created AGENTS.md" in result.stdout
|
||||||
assert (workspace_dir / "AGENTS.md").exists()
|
assert (workspace_dir / "AGENTS.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
|
||||||
|
|
||||||
|
assert config.get_provider_name() == "github_copilot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_matches_openai_codex_with_hyphen_prefix():
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.model = "openai-codex/gpt-5.1-codex"
|
||||||
|
|
||||||
|
assert config.get_provider_name() == "openai_codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword():
|
||||||
|
spec = find_by_model("github-copilot/gpt-5.3-codex")
|
||||||
|
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.name == "github_copilot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
|
||||||
|
provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex")
|
||||||
|
|
||||||
|
resolved = provider._resolve_model("github-copilot/gpt-5.3-codex")
|
||||||
|
|
||||||
|
assert resolved == "github_copilot/gpt-5.3-codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
||||||
|
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||||
|
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||||
|
|||||||
Reference in New Issue
Block a user