diff --git a/README.md b/README.md index 1d69635..167ae22 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,43 @@ nanobot agent -m "What is 2+2?" That's it! You have a working AI assistant in 2 minutes. +## 🖥️ Local Models (vLLM) + +Run nanobot with your own local models using vLLM or any OpenAI-compatible server. + +**1. Start your vLLM server** + +```bash +vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000 +``` + +**2. Configure** (`~/.nanobot/config.json`) + +```json +{ + "providers": { + "vllm": { + "apiKey": "dummy", + "apiBase": "http://localhost:8000/v1" + } + }, + "agents": { + "defaults": { + "model": "meta-llama/Llama-3.1-8B-Instruct" + } + } +} +``` + +**3. Chat** + +```bash +nanobot agent -m "Hello from my local LLM!" +``` + +> [!TIP] +> The `apiKey` can be any non-empty string for local servers that don't require authentication. + ## 💬 Chat Apps Talk to your nanobot through Telegram or WhatsApp — anytime, anywhere. @@ -294,6 +331,24 @@ nanobot/ **Want to help?** Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)! +--- + +## ⭐ Star History + +*Community Growth Trajectory* + +
+ + + + + Star History Chart + + +
+ +--- + ## 🤝 Contribute PRs welcome! The codebase is intentionally small and readable. 🤗 diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 79837ab..a4053e7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -154,7 +154,7 @@ This file stores important information that should persist across sessions. @app.command() def gateway( - port: int = typer.Option(18789, "--port", "-p", help="Gateway port"), + port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): """Start the nanobot gateway.""" @@ -328,296 +328,61 @@ def agent( response = await agent_loop.process_direct(user_input, session_id) console.print(f"\n{__logo__} {response}\n") - except KeyboardInterrupt: - console.print("\nGoodbye!") + except (KeyboardInterrupt, EOFError): + console.print("\nExiting...") break asyncio.run(run_interactive()) # ============================================================================ -# Channel Commands -# ============================================================================ - - -channels_app = typer.Typer(help="Manage channels") -app.add_typer(channels_app, name="channels") - - -@channels_app.command("status") -def channels_status(): - """Show channel status.""" - from nanobot.config.loader import load_config - - config = load_config() - - table = Table(title="Channel Status") - table.add_column("Channel", style="cyan") - table.add_column("Enabled", style="green") - table.add_column("Bridge URL", style="yellow") - - wa = config.channels.whatsapp - table.add_row( - "WhatsApp", - "✓" if wa.enabled else "✗", - wa.bridge_url - ) - - console.print(table) - - -def _get_bridge_dir() -> Path: - """Get the bridge directory, setting it up if needed.""" - import shutil - import subprocess - - # User's bridge location - user_bridge = Path.home() / ".nanobot" / "bridge" - - # Check if already built - if (user_bridge / "dist" / "index.js").exists(): - return user_bridge - - # Check for npm - if not shutil.which("npm"): - console.print("[red]npm not found. Please install Node.js >= 18.[/red]") - raise typer.Exit(1) - - # Find source bridge: first check package data, then source dir - pkg_bridge = Path(__file__).parent / "bridge" # nanobot/bridge (installed) - src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) - - source = None - if (pkg_bridge / "package.json").exists(): - source = pkg_bridge - elif (src_bridge / "package.json").exists(): - source = src_bridge - - if not source: - console.print("[red]Bridge source not found.[/red]") - console.print("Try reinstalling: pip install --force-reinstall nanobot") - raise typer.Exit(1) - - console.print(f"{__logo__} Setting up bridge...") - - # Copy to user directory - user_bridge.parent.mkdir(parents=True, exist_ok=True) - if user_bridge.exists(): - shutil.rmtree(user_bridge) - shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) - - # Install and build - try: - console.print(" Installing dependencies...") - subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) - - console.print(" Building...") - subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) - - console.print("[green]✓[/green] Bridge ready\n") - except subprocess.CalledProcessError as e: - console.print(f"[red]Build failed: {e}[/red]") - if e.stderr: - console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") - raise typer.Exit(1) - - return user_bridge - - -@channels_app.command("login") -def channels_login(): - """Link device via QR code.""" - import subprocess - - bridge_dir = _get_bridge_dir() - - console.print(f"{__logo__} Starting bridge...") - console.print("Scan the QR code to connect.\n") - - try: - subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) - except subprocess.CalledProcessError as e: - console.print(f"[red]Bridge failed: {e}[/red]") - except FileNotFoundError: - console.print("[red]npm not found. Please install Node.js.[/red]") - - -# ============================================================================ -# Cron Commands -# ============================================================================ - -cron_app = typer.Typer(help="Manage scheduled tasks") -app.add_typer(cron_app, name="cron") - - -@cron_app.command("list") -def cron_list( - all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"), -): - """List scheduled jobs.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - jobs = service.list_jobs(include_disabled=all) - - if not jobs: - console.print("No scheduled jobs.") - return - - table = Table(title="Scheduled Jobs") - table.add_column("ID", style="cyan") - table.add_column("Name") - table.add_column("Schedule") - table.add_column("Status") - table.add_column("Next Run") - - import time - for job in jobs: - # Format schedule - if job.schedule.kind == "every": - sched = f"every {(job.schedule.every_ms or 0) // 1000}s" - elif job.schedule.kind == "cron": - sched = job.schedule.expr or "" - else: - sched = "one-time" - - # Format next run - next_run = "" - if job.state.next_run_at_ms: - next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)) - next_run = next_time - - status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - - table.add_row(job.id, job.name, sched, status, next_run) - - console.print(table) - - -@cron_app.command("add") -def cron_add( - name: str = typer.Option(..., "--name", "-n", help="Job name"), - message: str = typer.Option(..., "--message", "-m", help="Message for agent"), - every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), - cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), - at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), - deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), - to: str = typer.Option(None, "--to", help="Recipient for delivery"), -): - """Add a scheduled job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - from nanobot.cron.types import CronSchedule - - # Determine schedule type - if every: - schedule = CronSchedule(kind="every", every_ms=every * 1000) - elif cron_expr: - schedule = CronSchedule(kind="cron", expr=cron_expr) - elif at: - import datetime - dt = datetime.datetime.fromisoformat(at) - schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) - else: - console.print("[red]Error: Must specify --every, --cron, or --at[/red]") - raise typer.Exit(1) - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - job = service.add_job( - name=name, - schedule=schedule, - message=message, - deliver=deliver, - to=to, - ) - - console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") - - -@cron_app.command("remove") -def cron_remove( - job_id: str = typer.Argument(..., help="Job ID to remove"), -): - """Remove a scheduled job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - if service.remove_job(job_id): - console.print(f"[green]✓[/green] Removed job {job_id}") - else: - console.print(f"[red]Job {job_id} not found[/red]") - - -@cron_app.command("enable") -def cron_enable( - job_id: str = typer.Argument(..., help="Job ID"), - disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"), -): - """Enable or disable a job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - job = service.enable_job(job_id, enabled=not disable) - if job: - status = "disabled" if disable else "enabled" - console.print(f"[green]✓[/green] Job '{job.name}' {status}") - else: - console.print(f"[red]Job {job_id} not found[/red]") - - -@cron_app.command("run") -def cron_run( - job_id: str = typer.Argument(..., help="Job ID to run"), - force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), -): - """Manually run a job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - async def run(): - return await service.run_job(job_id, force=force) - - if asyncio.run(run()): - console.print(f"[green]✓[/green] Job executed") - else: - console.print(f"[red]Failed to run job {job_id}[/red]") - - -# ============================================================================ -# Status Commands +# System Commands # ============================================================================ @app.command() def status(): - """Show nanobot status.""" + """Check nanobot status and configuration.""" from nanobot.config.loader import load_config, get_config_path - from nanobot.utils.helpers import get_workspace_path config_path = get_config_path() - workspace = get_workspace_path() + if not config_path.exists(): + console.print("[red]Error: nanobot is not initialized.[/red]") + console.print("Run [cyan]nanobot onboard[/cyan] first.") + raise typer.Exit(1) - console.print(f"{__logo__} nanobot Status\n") + config = load_config() - console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}") - console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}") + console.print(f"{__logo__} [bold]nanobot status[/bold]") + console.print(f"Version: {__version__}") + console.print(f"Config: {config_path}") + console.print(f"Workspace: {config.workspace_path}") - if config_path.exists(): - config = load_config() + table = Table(title="Configuration Summary") + table.add_column("Category", style="cyan") + table.add_column("Status", style="green") + + # Channels + enabled_channels = [] + if config.channels.whatsapp.enabled: + enabled_channels.append("WhatsApp") + if config.channels.telegram.enabled: + enabled_channels.append("Telegram") + + table.add_row("Channels", ", ".join(enabled_channels) if enabled_channels else "[dim]none[/dim]") + + # Agent + table.add_row("Default Model", config.agents.defaults.model) + + # Tools + has_brave = bool(config.tools.web.search.api_key) + table.add_row("Web Search", "[green]enabled[/green]" if has_brave else "[dim]disabled[/dim]") + + console.print(table) + + # Detailed API check + with console.status("[bold blue]Checking API providers..."): + console.print(f"\n[bold]API Providers:[/bold]") console.print(f"Model: {config.agents.defaults.model}") # Check API keys @@ -625,11 +390,14 @@ def status(): has_anthropic = bool(config.providers.anthropic.api_key) has_openai = bool(config.providers.openai.api_key) has_zhipu = bool(config.providers.zhipu.api_key) + has_vllm = bool(config.providers.vllm.api_base) console.print(f"OpenRouter API: {'[green]✓[/green]' if has_openrouter else '[dim]not set[/dim]'}") console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}") console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}") console.print(f"Zhipu AI API: {'[green]✓[/green]' if has_zhipu else '[dim]not set[/dim]'}") + vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]" + console.print(f"vLLM/Local: {vllm_status}") if __name__ == "__main__": diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 58089a4..df0fc5e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -51,12 +51,13 @@ class ProvidersConfig(BaseModel): openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) + vllm: ProviderConfig = Field(default_factory=ProviderConfig) class GatewayConfig(BaseModel): """Gateway/server configuration.""" host: str = "0.0.0.0" - port: int = 18789 + port: int = 18790 class WebSearchConfig(BaseModel): @@ -89,21 +90,24 @@ class Config(BaseSettings): return Path(self.agents.defaults.workspace).expanduser() def get_api_key(self) -> str | None: - """Get API key in priority order: OpenRouter > Anthropic > OpenAI > Zhipu.""" + """Get API key in priority order: OpenRouter > Anthropic > OpenAI > Zhipu > vLLM.""" return ( self.providers.openrouter.api_key or self.providers.anthropic.api_key or self.providers.openai.api_key or self.providers.zhipu.api_key or + self.providers.vllm.api_key or None ) def get_api_base(self) -> str | None: - """Get API base URL if using OpenRouter or Zhipu.""" + """Get API base URL if using OpenRouter, Zhipu or vLLM.""" if self.providers.openrouter.api_key: return self.providers.openrouter.api_base or "https://openrouter.ai/api/v1" if self.providers.zhipu.api_key: return self.providers.zhipu.api_base + if self.providers.vllm.api_base: + return self.providers.vllm.api_base return None class Config: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index c71e776..07e84cd 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -32,11 +32,17 @@ class LiteLLMProvider(LLMProvider): (api_base and "openrouter" in api_base) ) + # Track if using custom endpoint (vLLM, etc.) + self.is_vllm = bool(api_base) and not self.is_openrouter + # Configure LiteLLM based on provider if api_key: if self.is_openrouter: # OpenRouter mode - set key os.environ["OPENROUTER_API_KEY"] = api_key + elif self.is_vllm: + # vLLM/custom endpoint - uses OpenAI-compatible API + os.environ["OPENAI_API_KEY"] = api_key elif "anthropic" in default_model: os.environ.setdefault("ANTHROPIC_API_KEY", api_key) elif "openai" in default_model or "gpt" in default_model: @@ -86,6 +92,11 @@ class LiteLLMProvider(LLMProvider): ): model = f"zhipu/{model}" + # For vLLM, use hosted_vllm/ prefix per LiteLLM docs + # Convert openai/ prefix to hosted_vllm/ if user specified it + if self.is_vllm: + model = f"hosted_vllm/{model}" + kwargs: dict[str, Any] = { "model": model, "messages": messages, @@ -93,6 +104,10 @@ class LiteLLMProvider(LLMProvider): "temperature": temperature, } + # Pass api_base directly for custom endpoints (vLLM, etc.) + if self.api_base: + kwargs["api_base"] = self.api_base + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = "auto"