chore: update nanobot to 0.1.4.post6

This commit is contained in:
qixinbo
2026-03-28 01:01:13 +08:00
parent b24aff956a
commit dbbc7fdafc
166 changed files with 23622 additions and 4497 deletions
+4 -4
View File
@@ -11,7 +11,7 @@ NANOBOT_ROOT = PROJECT_ROOT / "nanobot"
if str(NANOBOT_ROOT) not in sys.path:
sys.path.append(str(NANOBOT_ROOT))
from nanobot.providers.litellm_provider import LiteLLMProvider
from app.core.llm_provider import build_llm_provider
from app.schemas.chart import ChartGenerationResponse
from app.services.llm_cache import get_active_llm_config
@@ -150,12 +150,12 @@ async def generate_chart(data: List[Dict[str, Any]], query: str) -> ChartGenerat
)
try:
provider = LiteLLMProvider(
provider = build_llm_provider(
model=active_config.get("model"),
provider=active_config.get("provider"),
api_key=active_config.get("api_key"),
api_base=active_config.get("api_base"),
default_model=active_config.get("model"),
extra_headers=active_config.get("extra_headers") or {},
provider_name=active_config.get("provider")
)
except Exception as e:
return ChartGenerationResponse(
+4 -6
View File
@@ -19,7 +19,7 @@ NANOBOT_ROOT = PROJECT_ROOT / "nanobot"
if str(NANOBOT_ROOT) not in sys.path:
sys.path.append(str(NANOBOT_ROOT))
from nanobot.providers.litellm_provider import LiteLLMProvider
from app.core.llm_provider import build_llm_provider
from app.connectors.postgres import postgres_connector
from app.connectors.clickhouse import clickhouse_connector
from app.connectors.factory import get_connector
@@ -358,12 +358,12 @@ async def process_nl2sql(
# 3. Initialize Provider
try:
provider = LiteLLMProvider(
provider = build_llm_provider(
model=active_config.get("model"),
provider=active_config.get("provider"),
api_key=active_config.get("api_key"),
api_base=active_config.get("api_base"),
default_model=active_config.get("model"),
extra_headers=active_config.get("extra_headers") or {},
provider_name=active_config.get("provider")
)
except Exception as e:
return NL2SQLResponse(sql="", result=[], error=f"Failed to initialize LLM provider: {e}")
@@ -410,8 +410,6 @@ Language: Chinese (Simplified)
max_tokens=NL2SQL_MAX_TOKENS,
temperature=NL2SQL_TEMPERATURE,
reasoning_effort=NL2SQL_REASONING_EFFORT,
request_timeout=NL2SQL_LLM_REQUEST_TIMEOUT_SECONDS,
num_retries=0,
),
timeout=NL2SQL_LLM_TIMEOUT_SECONDS,
)
+24 -46
View File
@@ -7,7 +7,7 @@ from jose import jwt, JWTError
from pydantic import BaseModel, Field
from app.core.security import SECRET_KEY, ALGORITHM
from app.core.data_root import get_data_root
from litellm import completion
from app.core.llm_provider import build_llm_provider
router = APIRouter()
security = HTTPBearer()
@@ -154,52 +154,30 @@ def delete_llm_config(config_id: str, _: CurrentUser = Depends(get_admin_user)):
return {"message": "LLM configuration deleted successfully"}
@router.post("/llm/test")
def test_connection(request: TestConnectionRequest, _: CurrentUser = Depends(get_admin_user)):
async def test_connection(request: TestConnectionRequest, _: CurrentUser = Depends(get_admin_user)):
try:
# Use litellm to test connection
# litellm handles many providers
kwargs = {
"model": request.model,
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 5
provider = build_llm_provider(
model=request.model.strip(),
provider=request.provider,
api_key=request.api_key,
api_base=request.api_base,
extra_headers=request.extra_headers,
)
response = await provider.chat(
messages=[{"role": "user", "content": "Hello"}],
max_tokens=5,
temperature=0,
)
if response.finish_reason == "error":
raise ValueError(response.content or "Unknown provider error")
return {
"success": True,
"message": "Connection successful",
"details": {
"content": response.content,
"finish_reason": response.finish_reason,
"usage": response.usage,
},
}
if request.api_key:
kwargs["api_key"] = request.api_key
if request.api_base:
kwargs["api_base"] = request.api_base
if request.extra_headers:
kwargs["extra_headers"] = request.extra_headers
# For OpenAI-compatible endpoints that are not standard OpenAI (like Local, vLLM etc)
# usually user sets provider to "openai" and api_base to their custom URL.
# litellm usually works well if we pass custom_llm_provider="openai" if provider is openai but custom url
# If provider is "local" or "openai", we generally use "openai" format
if request.provider == "local":
kwargs["custom_llm_provider"] = "openai"
elif request.provider:
kwargs["custom_llm_provider"] = request.provider
# If user explicitly selected provider in UI, we might want to respect that
# But litellm completion main arg is 'model'.
# If the UI 'model' input doesn't have prefix, we might need to add it or pass custom_llm_provider.
# Simple heuristic: if provider is set, try to pass it if litellm supports it or just rely on env vars/args
# For this simple test, we just try to call it.
try:
response = completion(**kwargs)
except Exception as first_error:
error_text = str(first_error)
if request.provider and "Provider NOT provided" in error_text and "/" not in request.model:
retry_kwargs = kwargs.copy()
retry_kwargs["model"] = f"{request.provider}/{request.model}"
response = completion(**retry_kwargs)
else:
raise first_error
return {"success": True, "message": "Connection successful", "details": str(response)}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Connection failed: {str(e)}")
+20 -3
View File
@@ -114,6 +114,22 @@ def _save_data(data: List[Dict[str, Any]]):
with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def _dedupe_skills(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
deduped: Dict[str, Dict[str, Any]] = {}
for item in data:
skill_id = str(item.get("id") or "").strip()
if not skill_id:
continue
existing = deduped.get(skill_id)
if existing is None:
deduped[skill_id] = item
continue
existing_project = existing.get("project_id")
incoming_project = item.get("project_id")
if existing_project is None and incoming_project is not None:
deduped[skill_id] = item
return list(deduped.values())
def _safe_skill_dir_name(value: str) -> str:
safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', value or "").lower()
return safe or "skill"
@@ -183,9 +199,10 @@ def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]:
data.append(new_skill)
registered_paths.add(skill_dir)
deduped = _dedupe_skills(data)
if project_id is not None:
return [item for item in data if item.get("project_id") == project_id or item.get("project_id") is None]
return data
return [item for item in deduped if item.get("project_id") == project_id or item.get("project_id") is None]
return deduped
@router.get("/skills", response_model=List[Skill])
def list_skills(project_id: Optional[int] = None):
@@ -384,7 +401,7 @@ def delete_skill(skill_id: str, project_id: Optional[int] = None):
if item["id"] == skill_id:
if item.get("is_builtin"):
raise HTTPException(status_code=400, detail="Builtin skills cannot be deleted")
if project_id is not None and item.get("project_id") != project_id:
if project_id is not None and item.get("project_id") not in (project_id, None):
new_data.append(item)
continue
found = True
+106
View File
@@ -0,0 +1,106 @@
from typing import List
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.subagent import Subagent
from app.models.project import Project
from app.schemas.subagent import SubagentCreate, SubagentUpdate, Subagent as SubagentSchema
from app.core.security import get_current_user, CurrentUser
router = APIRouter()
@router.get("/projects/{project_id}/subagents", response_model=List[SubagentSchema])
def list_subagents(
project_id: int,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if not current_user.is_admin and project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
subagents = db.query(Subagent).filter(Subagent.project_id == project_id).offset(skip).limit(limit).all()
return subagents
@router.post("/projects/{project_id}/subagents", response_model=SubagentSchema)
def create_subagent(
project_id: int,
subagent: SubagentCreate,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if not current_user.is_admin and project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
db_subagent = Subagent(**subagent.dict(), project_id=project_id)
db.add(db_subagent)
db.commit()
db.refresh(db_subagent)
return db_subagent
@router.get("/subagents/{subagent_id}", response_model=SubagentSchema)
def read_subagent(
subagent_id: int,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
db_subagent = db.query(Subagent).filter(Subagent.id == subagent_id).first()
if db_subagent is None:
raise HTTPException(status_code=404, detail="Subagent not found")
project = db.query(Project).filter(Project.id == db_subagent.project_id).first()
if not current_user.is_admin and project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
return db_subagent
@router.put("/subagents/{subagent_id}", response_model=SubagentSchema)
def update_subagent(
subagent_id: int,
subagent: SubagentUpdate,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
db_subagent = db.query(Subagent).filter(Subagent.id == subagent_id).first()
if db_subagent is None:
raise HTTPException(status_code=404, detail="Subagent not found")
project = db.query(Project).filter(Project.id == db_subagent.project_id).first()
if not current_user.is_admin and project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
subagent_data = subagent.dict(exclude_unset=True)
for key, value in subagent_data.items():
setattr(db_subagent, key, value)
db.add(db_subagent)
db.commit()
db.refresh(db_subagent)
return db_subagent
@router.delete("/subagents/{subagent_id}")
def delete_subagent(
subagent_id: int,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
db_subagent = db.query(Subagent).filter(Subagent.id == subagent_id).first()
if db_subagent is None:
raise HTTPException(status_code=404, detail="Subagent not found")
project = db.query(Project).filter(Project.id == db_subagent.project_id).first()
if not current_user.is_admin and project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
db.delete(db_subagent)
db.commit()
return {"status": "success"}
+20 -2
View File
@@ -2,14 +2,32 @@ import os
from pathlib import Path
from typing import Optional
from app.core.data_root import get_data_root, get_reports_root, get_uploads_root, get_workspace_root
from app.core.data_root import (
BACKEND_ROOT,
LEGACY_DATA_ROOT,
get_data_root,
get_reports_root,
get_uploads_root,
get_workspace_root,
)
data_root = get_data_root()
workspace_root = get_workspace_root()
uploads_root = get_uploads_root()
reports_root = get_reports_root()
allowed_artifact_roots = (workspace_root, uploads_root, reports_root)
legacy_workspace_root = LEGACY_DATA_ROOT / "workspace"
legacy_uploads_root = LEGACY_DATA_ROOT / "uploads"
legacy_reports_root = LEGACY_DATA_ROOT / "data"
backend_root = BACKEND_ROOT
allowed_artifact_roots = (
workspace_root,
uploads_root,
reports_root,
legacy_workspace_root,
legacy_uploads_root,
legacy_reports_root,
)
def resolve_upload_file_path(file_url: Optional[str]) -> Path:
+60
View File
@@ -0,0 +1,60 @@
from typing import Optional, Dict
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import find_by_name
def normalize_provider_name(provider: Optional[str]) -> Optional[str]:
if not provider:
return None
normalized = provider.strip().lower()
alias_map = {
"azure": "azure_openai",
"local": "vllm",
}
return alias_map.get(normalized, normalized)
def build_llm_provider(
*,
model: str,
provider: Optional[str] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
extra_headers: Optional[Dict[str, str]] = None,
):
provider_name = normalize_provider_name(provider)
spec = find_by_name(provider_name) if provider_name else None
backend = spec.backend if spec else "openai_compat"
if backend == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
if backend == "azure_openai":
if not api_key or not api_base:
raise ValueError("Azure OpenAI requires api_key and api_base.")
return AzureOpenAIProvider(
api_key=api_key,
api_base=api_base,
default_model=model,
)
if backend == "anthropic":
from nanobot.providers.anthropic_provider import AnthropicProvider
return AnthropicProvider(
api_key=api_key,
api_base=api_base,
default_model=model,
extra_headers=extra_headers,
)
return OpenAICompatProvider(
api_key=api_key,
api_base=api_base,
default_model=model,
extra_headers=extra_headers,
spec=spec,
)
+175 -77
View File
@@ -15,14 +15,14 @@ if str(PROJECT_ROOT / "nanobot") not in sys.path:
sys.path.append(str(PROJECT_ROOT / "nanobot"))
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config
from nanobot.config.paths import get_cron_dir
from nanobot.cron.service import CronService
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.custom_provider import CustomProvider
from nanobot.providers.base import GenerationSettings
from nanobot.providers.registry import find_by_name
from nanobot.session.manager import SessionManager
from nanobot.config.schema import Config
@@ -32,10 +32,9 @@ from nanobot.config.schema import Config
# or just import here if we are confident.
# Given the structure, importing here should be fine as long as skills.py doesn't import nanobot.py.
from app.api.skills import load_skills
from app.services.llm_cache import get_llm_configs
from app.services.llm_cache import get_llm_configs, get_active_llm_config
from app.core.data_root import get_workspace_root
from app.core.streaming_provider import StreamingLiteLLMProvider
class NanobotIntegration:
def __init__(self):
@@ -47,6 +46,75 @@ class NanobotIntegration:
self._model_agent_cache: Dict[tuple[str | None, int | None], AgentLoop] = {}
self._model_agent_lock = asyncio.Lock()
@staticmethod
def _normalize_config_value(value: Any) -> Any:
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return value
@staticmethod
def _normalize_model_id(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return str(value)
@staticmethod
def _extract_response_text(response: Any) -> str:
if response is None:
return ""
if isinstance(response, str):
return response
if isinstance(response, OutboundMessage):
return response.content or ""
if isinstance(response, dict):
content = response.get("content")
if isinstance(content, str):
return content
return str(content or "")
content = getattr(response, "content", None)
if isinstance(content, str):
return content
return str(response)
def _need_custom_agent_for_target(self, target_config: Dict[str, Any]) -> bool:
if not self.agent:
return False
provider = self.agent.provider
target_model = self._normalize_config_value(target_config.get("model"))
current_model = self._normalize_config_value(
getattr(self.agent, "model", None) or getattr(provider, "default_model", None)
)
if target_model != current_model:
return True
target_provider = self._normalize_config_value(target_config.get("provider"))
current_provider = self._normalize_config_value(getattr(provider, "_provider_name_override", None))
if not current_provider:
current_provider = self._normalize_config_value(getattr(getattr(provider, "_spec", None), "name", None))
if not current_provider and current_model and self.config:
current_provider = self._normalize_config_value(self.config.get_provider_name(current_model))
if target_provider != current_provider:
return True
target_api_base = self._normalize_config_value(target_config.get("api_base"))
current_api_base = self._normalize_config_value(getattr(provider, "api_base", None))
if target_api_base != current_api_base:
return True
target_api_key = self._normalize_config_value(target_config.get("api_key"))
current_api_key = self._normalize_config_value(getattr(provider, "api_key", None))
if target_api_key != current_api_key:
return True
target_headers = target_config.get("extra_headers") or {}
current_headers = getattr(provider, "extra_headers", None) or {}
return target_headers != current_headers
def initialize(self):
workspace_path = get_workspace_root()
workspace_path.mkdir(parents=True, exist_ok=True)
@@ -74,12 +142,9 @@ class NanobotIntegration:
provider=provider,
workspace=self.config.workspace_path,
model=self.config.agents.defaults.model,
temperature=self.config.agents.defaults.temperature,
max_tokens=self.config.agents.defaults.max_tokens,
max_iterations=self.config.agents.defaults.max_tool_iterations,
memory_window=self.config.agents.defaults.memory_window,
reasoning_effort=self.config.agents.defaults.reasoning_effort,
brave_api_key=self.config.tools.web.search.api_key or None,
context_window_tokens=self.config.agents.defaults.context_window_tokens,
web_search_config=self.config.tools.web.search,
web_proxy=self.config.tools.web.proxy or None,
exec_config=self.config.tools.exec,
cron_service=self.cron,
@@ -87,6 +152,7 @@ class NanobotIntegration:
session_manager=session_manager,
mcp_servers=self.config.tools.mcp_servers,
channels_config=self.config.channels,
timezone=self.config.agents.defaults.timezone,
)
self._register_custom_tools(self.agent)
@@ -105,68 +171,94 @@ class NanobotIntegration:
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_skill_file, target_dir / "SKILL.md")
def _register_custom_tools(self, agent: AgentLoop):
def _register_custom_tools(self, agent: AgentLoop, project_id: int | None = None):
from app.tools.nl2sql import NL2SQLTool
from app.tools.visualization import VisualizationTool
from app.tools.get_schema import GetDatabaseSchemaTool
from app.tools.subagent import ListSubagentsTool, InvokeSubagentTool
agent.tools.register(NL2SQLTool())
agent.tools.register(VisualizationTool())
agent.tools.register(GetDatabaseSchemaTool())
if project_id is not None:
agent.tools.register(ListSubagentsTool(project_id=project_id))
agent.tools.register(InvokeSubagentTool(project_id=project_id))
def _build_provider(
self,
model: str,
provider_name: str | None,
api_key: str | None,
api_base: str | None,
extra_headers: dict[str, Any] | None = None,
):
spec = find_by_name(provider_name) if provider_name else None
backend = spec.backend if spec else "openai_compat"
if backend == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
if backend == "azure_openai":
if not api_key or not api_base:
raise ValueError("Azure OpenAI requires api_key and api_base.")
return AzureOpenAIProvider(
api_key=api_key,
api_base=api_base,
default_model=model,
)
if backend == "anthropic":
from nanobot.providers.anthropic_provider import AnthropicProvider
return AnthropicProvider(
api_key=api_key,
api_base=api_base,
default_model=model,
extra_headers=extra_headers,
)
return OpenAICompatProvider(
api_key=api_key,
api_base=api_base,
default_model=model,
extra_headers=extra_headers,
spec=spec,
)
def _make_provider(self, config: Config):
# Logic adapted from nanobot/cli/commands.py
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
# Check if model is using an ID from our database configuration
# This requires accessing the database or a cache of LLM configs
# Since we are inside NanobotIntegration, we can try to load from the JSON file directly for simplicity
# or rely on the caller to have injected the right config if they used environment variables.
# But here we need to support dynamic loading based on the `model` string if it matches a stored config ID.
# However, typically the `model` passed here comes from `config.agents.defaults.model`.
# If we want to support dynamic switching per request, we should look at `agent.process_direct` arguments.
# The `AgentLoop` initializes with a provider, but `LiteLLMProvider` might be able to handle dynamic models if we pass them.
# BUT `LiteLLMProvider` is initialized with a specific `default_model`.
# To support per-request model changes, we need to ensure the `provider` object or the `agent` can accept a model override.
# `AgentLoop` methods like `process_direct` don't typically take a `model` argument to override the provider's default.
# We might need to reinstantiate the provider or use a "DynamicProvider" that delegates based on context.
# For now, let's assume standard initialization.
# If the user provides a `model_id` in `process_message`, we will handle it there by creating a temporary provider/agent or updating the current one.
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
if provider_name == "custom":
return CustomProvider(
api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model,
)
if provider_name == "azure_openai":
if not p or not p.api_key or not p.api_base:
raise ValueError("Azure OpenAI requires api_key and api_base.")
return AzureOpenAIProvider(
api_key=p.api_key,
api_base=p.api_base,
default_model=model,
)
spec = find_by_name(provider_name)
# Skip API key check for now to allow initialization without full config
return StreamingLiteLLMProvider(
provider = self._build_provider(
model=model,
provider_name=provider_name,
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
provider_name=provider_name,
)
provider.generation = GenerationSettings(
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
reasoning_effort=config.agents.defaults.reasoning_effort,
)
return provider
def _make_provider_from_target(self, target_config: Dict[str, Any]):
model = self._normalize_config_value(target_config.get("model")) or self.config.agents.defaults.model
provider_name = self._normalize_config_value(target_config.get("provider"))
if not provider_name and model and self.config:
provider_name = self._normalize_config_value(self.config.get_provider_name(model))
provider = self._build_provider(
model=model,
provider_name=provider_name,
api_key=self._normalize_config_value(target_config.get("api_key")),
api_base=self._normalize_config_value(target_config.get("api_base")),
extra_headers=target_config.get("extra_headers"),
)
provider.generation = GenerationSettings(
temperature=self.config.agents.defaults.temperature,
max_tokens=self.config.agents.defaults.max_tokens,
reasoning_effort=self.config.agents.defaults.reasoning_effort,
)
return provider
async def start(self):
if self._started:
@@ -195,12 +287,9 @@ class NanobotIntegration:
provider=provider,
workspace=self.config.workspace_path,
model=provider.default_model,
temperature=self.config.agents.defaults.temperature,
max_tokens=self.config.agents.defaults.max_tokens,
max_iterations=self.config.agents.defaults.max_tool_iterations,
memory_window=self.config.agents.defaults.memory_window,
reasoning_effort=self.config.agents.defaults.reasoning_effort,
brave_api_key=self.config.tools.web.search.api_key or None,
context_window_tokens=self.config.agents.defaults.context_window_tokens,
web_search_config=self.config.tools.web.search,
web_proxy=self.config.tools.web.proxy or None,
exec_config=self.config.tools.exec,
cron_service=self.cron,
@@ -208,23 +297,19 @@ class NanobotIntegration:
session_manager=self.agent.sessions if self.agent else None,
mcp_servers=mcp_servers if mcp_servers is not None else self.config.tools.mcp_servers,
channels_config=self.config.channels,
timezone=self.config.agents.defaults.timezone,
)
async def _get_or_create_model_agent(self, model_id: str | None, target_config: Dict[str, Any] | None, project_id: int | None = None) -> AgentLoop:
cache_key = (model_id, project_id)
normalized_model_id = self._normalize_model_id(model_id)
cache_key = (normalized_model_id, project_id)
async with self._model_agent_lock:
cached = self._model_agent_cache.get(cache_key)
if cached:
return cached
if target_config:
provider = StreamingLiteLLMProvider(
api_key=target_config.get("api_key"),
api_base=target_config.get("api_base"),
default_model=target_config.get("model"),
extra_headers=target_config.get("extra_headers"),
provider_name=target_config.get("provider"),
)
provider = self._make_provider_from_target(target_config)
else:
provider = self._make_provider(self.config)
@@ -245,7 +330,7 @@ class NanobotIntegration:
mcp_servers_dict[s["name"]] = cfg
agent = self._build_agent_for_provider(provider, mcp_servers=mcp_servers_dict)
self._register_custom_tools(agent)
self._register_custom_tools(agent, project_id=project_id)
self._model_agent_cache[cache_key] = agent
return agent
@@ -257,6 +342,7 @@ class NanobotIntegration:
model_id: str | None = None,
project_id: int | None = None,
on_progress: Callable[[str], Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None,
):
if not self.agent:
self.initialize()
@@ -273,17 +359,28 @@ class NanobotIntegration:
need_custom_agent = False
target_config = None
if model_id:
selected_model_id = self._normalize_model_id(model_id)
if selected_model_id:
llm_configs = get_llm_configs()
target_config = next((item for item in llm_configs if item.get("id") == model_id), None)
if target_config and target_config.get("model") != self.agent.model:
need_custom_agent = True
target_config = next(
(item for item in llm_configs if self._normalize_model_id(item.get("id")) == selected_model_id),
None,
)
if target_config is None:
active_config = get_active_llm_config()
if active_config and active_config.get("id"):
selected_model_id = self._normalize_model_id(active_config.get("id"))
target_config = active_config
if target_config and self._need_custom_agent_for_target(target_config):
need_custom_agent = True
if project_id is not None:
need_custom_agent = True
if need_custom_agent:
agent_to_use = await self._get_or_create_model_agent(model_id, target_config, project_id)
agent_to_use = await self._get_or_create_model_agent(selected_model_id, target_config, project_id)
full_message = message
# We no longer inject the full skill content into the user's message here,
@@ -303,8 +400,9 @@ class NanobotIntegration:
channel="api",
chat_id=session_id,
on_progress=on_progress,
on_stream=on_stream,
)
return response
return self._extract_response_text(response)
def _normalize_session_messages(self, messages: List[Any]) -> List[dict[str, Any]]:
normalized: List[dict[str, Any]] = []
-162
View File
@@ -1,162 +0,0 @@
import contextvars
import json
from typing import Any, Dict, List, Optional
from loguru import logger
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.base import LLMResponse
from litellm import acompletion, stream_chunk_builder
streaming_queue_var = contextvars.ContextVar("streaming_queue", default=None)
class StreamingLiteLLMProvider(LiteLLMProvider):
def __init__(self, *args, **kwargs):
self._provider_name_override = kwargs.get("provider_name")
super().__init__(*args, **kwargs)
def _get_active_spec(self, model: str):
from nanobot.providers.registry import find_by_model, find_by_name
spec = None
if self._provider_name_override:
spec = find_by_name(self._provider_name_override)
if not spec:
spec = find_by_model(model)
return spec
def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None:
"""Set environment variables based on detected provider."""
import os
spec = self._gateway or self._get_active_spec(model)
if not spec:
return
if not spec.env_key:
return
if self._gateway:
os.environ[spec.env_key] = api_key
else:
# os.environ.setdefault 会在已存在且为空字符串时保留空字符串,导致 litellm 无法识别
# 因此强制更新
os.environ[spec.env_key] = api_key
effective_base = api_base or spec.default_api_base
for env_name, env_val in spec.env_extras:
resolved = env_val.replace("{api_key}", api_key)
resolved = resolved.replace("{api_base}", effective_base)
os.environ[env_name] = resolved
def _resolve_model(self, model: str) -> str:
"""Resolve model name by applying provider/gateway prefixes, using override if available."""
if self._gateway:
prefix = self._gateway.litellm_prefix
if self._gateway.strip_model_prefix:
model = model.split("/")[-1]
if prefix and not model.startswith(f"{prefix}/"):
model = f"{prefix}/{model}"
return model
spec = self._get_active_spec(model)
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):
model = f"{spec.litellm_prefix}/{model}"
elif spec and not spec.litellm_prefix and "/" not in model:
# For standard providers like openai, anthropic, litellm requires the prefix for unknown models
# but registry sets litellm_prefix="" to rely on native matching.
# If native matching fails (e.g. non-standard model name), we should force prefix.
# We only force prefix if provider was explicitly set and model has no prefix.
if self._provider_name_override:
model = f"{spec.name}/{model}"
return model
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
"""Apply model-specific parameter overrides from the registry."""
model_lower = model.lower()
spec = self._get_active_spec(model)
if spec:
for pattern, overrides in spec.model_overrides:
if pattern in model_lower:
kwargs.update(overrides)
return
def _extra_msg_keys(self, original_model: str, resolved_model: str) -> frozenset[str]:
"""Return provider-specific extra keys to preserve in request messages."""
spec = self._get_active_spec(original_model) or self._get_active_spec(resolved_model)
if (spec and spec.name == "anthropic") or "claude" in original_model.lower() or resolved_model.startswith("anthropic/"):
# _ANTHROPIC_EXTRA_KEYS is defined in nanobot.providers.litellm_provider, let's just use the string
return frozenset({"thinking_blocks"})
return frozenset()
async def chat(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 4000,
reasoning_effort: Optional[str] = None,
request_timeout: Optional[int] = None,
num_retries: Optional[int] = None,
) -> LLMResponse:
original_model = model or self.default_model
model_name = self._resolve_model(original_model)
extra_msg_keys = self._extra_msg_keys(original_model, model_name)
if self._supports_cache_control(original_model):
messages, tools = self._apply_cache_control(messages, tools)
kwargs: Dict[str, Any] = {
"model": model_name,
"messages": self._sanitize_messages(self._sanitize_empty_content(messages), extra_keys=extra_msg_keys),
"temperature": temperature,
"max_tokens": max(1, max_tokens),
"stream": True, # 强制开启流式
}
self._apply_model_overrides(model_name, kwargs)
if self.api_key and self.api_key != "no-key":
kwargs["api_key"] = self.api_key
if self.api_base:
kwargs["api_base"] = self.api_base
if self.extra_headers:
kwargs["extra_headers"] = self.extra_headers
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
if request_timeout is not None:
kwargs["timeout"] = request_timeout
if num_retries is not None:
kwargs["num_retries"] = max(0, int(num_retries))
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
kwargs["drop_params"] = True
try:
response_stream = await acompletion(**kwargs)
chunks = []
queue = streaming_queue_var.get()
async for chunk in response_stream:
chunks.append(chunk)
if queue is not None:
# 提取普通内容或 think 内容
delta = chunk.choices[0].delta if chunk.choices else None
if delta:
content = getattr(delta, "content", None)
reasoning_content = getattr(delta, "reasoning_content", None)
if content:
await queue.put({"type": "delta", "content": content})
if reasoning_content:
await queue.put({"type": "progress", "content": reasoning_content, "is_reasoning": True})
# 还原为完整的 response 对象供 nanobot 处理
full_response = stream_chunk_builder(chunks, messages=messages)
return self._parse_response(full_response)
except Exception as e:
logger.error("StreamingLiteLLMProvider failed: {}", e)
raise
+1
View File
@@ -14,3 +14,4 @@ class Project(Base):
owner = relationship("User", back_populates="projects")
data_sources = relationship("DataSource", back_populates="project", cascade="all, delete-orphan")
subagents = relationship("Subagent", back_populates="project", cascade="all, delete-orphan")
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.database import Base
class Subagent(Base):
__tablename__ = "subagents"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
name = Column(String, nullable=False)
description = Column(String, nullable=True)
instructions = Column(String, nullable=True)
model = Column(String, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
project = relationship("Project", back_populates="subagents")
+27
View File
@@ -0,0 +1,27 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class SubagentBase(BaseModel):
name: str
description: Optional[str] = None
instructions: Optional[str] = None
model: Optional[str] = None
class SubagentCreate(SubagentBase):
pass
class SubagentUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
instructions: Optional[str] = None
model: Optional[str] = None
class Subagent(SubagentBase):
id: int
project_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
+149
View File
@@ -0,0 +1,149 @@
from typing import Any, Optional
import json
from nanobot.agent.tools.base import Tool
from app.database import SessionLocal
from app.models.subagent import Subagent
from app.core.nanobot import nanobot_service
from app.services.llm_cache import get_llm_configs, get_active_llm_config
class ListSubagentsTool(Tool):
"""
Tool to list available subagents for the current project.
"""
def __init__(self, project_id: Optional[int] = None):
super().__init__()
self.project_id = project_id
@property
def name(self) -> str:
return "list_subagents"
@property
def description(self) -> str:
return "List all available subagents in the current project, including their names and descriptions."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {},
"required": [],
}
async def execute(self, **kwargs: Any) -> str:
if not self.project_id:
return "Error: No project context available to list subagents."
with SessionLocal() as db:
subagents = db.query(Subagent).filter(Subagent.project_id == self.project_id).all()
if not subagents:
return "No subagents found in the current project."
result = []
for sa in subagents:
result.append({
"id": sa.id,
"name": sa.name,
"description": sa.description,
})
return json.dumps(result, ensure_ascii=False, indent=2)
class InvokeSubagentTool(Tool):
"""
Tool to invoke a specific subagent to perform a task.
"""
def __init__(self, project_id: Optional[int] = None):
super().__init__()
self.project_id = project_id
@property
def name(self) -> str:
return "invoke_subagent"
@property
def description(self) -> str:
return (
"Invoke a subagent by name to perform a specific task. "
"You should first use list_subagents to find the correct subagent name."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"subagent_name": {
"type": "string",
"description": "The name of the subagent to invoke.",
},
"task": {
"type": "string",
"description": "The specific task or query to send to the subagent.",
}
},
"required": ["subagent_name", "task"],
}
async def execute(self, **kwargs: Any) -> str:
subagent_name = kwargs.get("subagent_name")
task = kwargs.get("task")
if not self.project_id:
return "Error: No project context available to invoke subagent."
if not subagent_name or not task:
return "Error: subagent_name and task are required."
with SessionLocal() as db:
subagent = db.query(Subagent).filter(
Subagent.project_id == self.project_id,
Subagent.name == subagent_name
).first()
if not subagent:
return f"Error: Subagent '{subagent_name}' not found."
# Construct the message with subagent instructions
instructions = subagent.instructions or "You are a helpful assistant."
message = f"[System: You are acting as subagent '{subagent.name}'. Instructions: {instructions}]\n\nTask: {task}"
resolved_model_id = None
llm_configs = get_llm_configs()
target = None
raw_model = (getattr(subagent, "model", None) or "").strip()
if raw_model:
target = next((item for item in llm_configs if item.get("id") == raw_model), None)
if target is None:
normalized = raw_model.lower()
target = next(
(
item for item in llm_configs
if (
str(item.get("model") or "").strip().lower() == normalized
or str(item.get("name") or "").strip().lower() == normalized
)
),
None,
)
if target is None:
target = get_active_llm_config()
if target and target.get("id"):
resolved_model_id = target.get("id")
try:
from app.context import current_session_id
parent_session_id = current_session_id.get() or "default"
subagent_session_id = f"{parent_session_id}:subagent:{subagent.id}"
response = await nanobot_service.process_message(
message=message,
session_id=subagent_session_id,
project_id=self.project_id,
model_id=resolved_model_id,
)
return f"Subagent '{subagent.name}' completed the task.\nResult:\n{response}"
except Exception as e:
return f"Error invoking subagent '{subagent.name}': {str(e)}"