Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion examples/nl_arm_controller/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ CW_CAMERA_WIDTH=1280
CW_CAMERA_HEIGHT=720
CW_CAMERA_JPEG_QUALITY=80

# Anthropic — LLM motion planner
# LLM motion planner — set ONE of these:
# OPENROUTER_API_KEY takes priority when set: planning is routed through
# OpenRouter via the OpenAI Agents SDK (AnyLLMModel), so OPENROUTER_MODEL
# can be any model OpenRouter serves (e.g. "openai/gpt-5.4-mini",
# "anthropic/claude-4.5-sonnet"). Vision mode (--vision) still requires
# ANTHROPIC_API_KEY, since the image-description path calls Claude directly.
OPENROUTER_API_KEY=
# Optional: defaults to openai/gpt-5.4-mini when unset. Do not set this blank.
# OPENROUTER_MODEL=openai/gpt-5.4-mini

ANTHROPIC_API_KEY=
# Override the model if needed
# ANTHROPIC_MODEL=claude-sonnet-4-5
Expand Down
12 changes: 11 additions & 1 deletion examples/nl_arm_controller/motion.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@
RAMP_HZ: int = 20


def _sdk_joint_name(joint: str) -> str:
"""Map our canonical joint names ("1".."6") to the SDK's controllable names.

The so101 twin's universal schema exposes joints as "_1".."_6" (leading
underscore), while the rest of this app — plans, prompts, limits — uses
the bare "1".."6" convention. Translate only at this SDK boundary.
"""
return joint if joint.startswith("_") else f"_{joint}"


class _RobotJoints(Protocol):
def set( # noqa: D401 — match SDK signature
self,
Expand Down Expand Up @@ -279,7 +289,7 @@ def _ramp_to(self, target_pose: dict[str, float], duration: float) -> None:
def _snap_to(self, pose: dict[str, float]) -> None:
for joint, angle in pose.items():
if not self.dry_run:
self.robot.joints.set(joint, angle, degrees=True)
self.robot.joints.set(_sdk_joint_name(joint), angle, degrees=True)
self._current_pose[joint] = angle

def _format_pose(self) -> str:
Expand Down
32 changes: 26 additions & 6 deletions examples/nl_arm_controller/nl_arm_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from pathlib import Path

from dotenv import load_dotenv
from planner_config import get_openrouter_model

load_dotenv(Path(__file__).parent / ".env", override=False)
load_dotenv(override=False)
Expand All @@ -46,6 +47,7 @@

CYBERWAVE_API_KEY = os.environ.get("CYBERWAVE_API_KEY")
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY")

CW_MODE = os.environ.get("CW_MODE", "live")
Expand Down Expand Up @@ -74,32 +76,50 @@ def _check_secret(name: str, value: str | None) -> tuple[str, bool]:
return f" {name:<24} ❌ not set", False
return f" {name:<24} ✅ {value[:8]}… (len {len(value)})", True

def _check_secret_any(*names: str) -> tuple[str, bool]:
for name in names:
value = os.environ.get(name)
if value:
return f" {name:<24} ✅ {value[:8]}… (len {len(value)})", True
return f" {' or '.join(names):<24} ❌ none set", False


def _active_planner_label() -> str:
"""Which LLM/provider `planner.py` will actually use, for banner/self-check display."""
if OPENROUTER_API_KEY:
return f"{get_openrouter_model()} (via OpenRouter / agents SDK)"
if ANTHROPIC_API_KEY:
return f"{ANTHROPIC_MODEL} (via Anthropic)"
return "(no ANTHROPIC_API_KEY or OPENROUTER_API_KEY set)"



def run_self_check() -> int:
print("─" * 64)
print(" NL → SO-101 Controller — environment self-check")
print("─" * 64)

rows = [
_check_secret("CYBERWAVE_API_KEY", CYBERWAVE_API_KEY),
_check_secret("ANTHROPIC_API_KEY", ANTHROPIC_API_KEY),
_check_secret_any("OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"),
_check_secret("MISTRAL_API_KEY", MISTRAL_API_KEY),
]
for line, _ in rows:
print(line)
keys_ok = all(ok for _, ok in rows)

print()
print(f" active planner = {_active_planner_label()}")
print(f" CW_MODE = {CW_MODE}")
print(f" CYBERWAVE_TWIN_ID = {CW_TWIN_ID or '(unset)'}")
print(f" CYBERWAVE_ENVIRONMENT_ID = {CW_ENV_ID or '(unset)'}")
print(f" ANTHROPIC_MODEL = {ANTHROPIC_MODEL}")
print(f" OPENROUTER_MODEL = {get_openrouter_model()}")
print(f" MISTRAL_STT_MODEL = {MISTRAL_STT_MODEL}")
print(f" VOICE_ENABLED = {VOICE_ENABLED}")

print()
deps_ok = True
for mod_name in ("cyberwave", "anthropic", "httpx", "sounddevice", "soundfile", "pynput"):
for mod_name in ("cyberwave", "anthropic", "agents", "httpx", "sounddevice", "soundfile", "pynput"):
try:
__import__(mod_name)
print(f" import {mod_name:<14} ✅")
Expand Down Expand Up @@ -138,7 +158,7 @@ def _print_banner(
print(f" NL → SO-101 controller ({' + '.join(inputs)})")
print("─" * 64)
print(f" mode: {'DRY-RUN (no arm)' if dry_run else CW_MODE}")
print(f" planner: {ANTHROPIC_MODEL}")
print(f" planner: {_active_planner_label()}")
if voice:
print(f" STT model: {MISTRAL_STT_MODEL}")
if vision and camera_info:
Expand Down Expand Up @@ -179,8 +199,8 @@ def _read_voice() -> str | None:


def run_agent(dry_run: bool, voice: bool, vision: bool) -> int:
if not ANTHROPIC_API_KEY:
print("❌ ANTHROPIC_API_KEY not set in .env")
if not (OPENROUTER_API_KEY or ANTHROPIC_API_KEY):
print("❌ Need either OPENROUTER_API_KEY or ANTHROPIC_API_KEY set in .env")
return 1

if voice and not MISTRAL_API_KEY:
Expand Down
82 changes: 73 additions & 9 deletions examples/nl_arm_controller/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,18 @@

from __future__ import annotations

import asyncio
import json
import os
import re
from dataclasses import dataclass

from motion import MotionPlan, validate_plan
from planner_config import get_openrouter_model
from agents import Agent, ModelSettings, Runner, function_tool, set_tracing_disabled
from agents.extensions.models.any_llm_model import AnyLLMModel

set_tracing_disabled(disabled=True)


SYSTEM_PROMPT = """You are the motion planner for an SO-101 6-axis robot arm.
Expand Down Expand Up @@ -148,10 +154,68 @@ def plan_from_utterance(
max_tokens: int = 400,
temperature: float = 0.2,
) -> PlanResult:
"""Call Claude with `utterance` and return a `PlanResult`.
"""Plan from text.

Picks up `ANTHROPIC_API_KEY` from the environment via the SDK default.
Prefers OpenRouter (via the OpenAI Agents SDK's `AnyLLMModel`) when
`OPENROUTER_API_KEY` is set; otherwise falls back to calling Claude
directly with `ANTHROPIC_API_KEY`.
"""
if os.environ.get("OPENROUTER_API_KEY"):
chosen_model = _openrouter_model_name(model)
raw = _run_any_llm_agent(
utterance,
system_prompt=SYSTEM_PROMPT,
model=chosen_model,
max_tokens=max_tokens,
temperature=temperature,
)
plan, err = parse_plan_json(raw)
return PlanResult(plan=plan, raw_response=raw, error=err, model=chosen_model)

raw, chosen_model = _call_anthropic(
utterance,
system_prompt=SYSTEM_PROMPT,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
plan, err = parse_plan_json(raw)
return PlanResult(plan=plan, raw_response=raw, error=err, model=chosen_model)


def _openrouter_model_name(model: str | None) -> str:
chosen = model or get_openrouter_model()
return chosen if chosen.startswith("openrouter/") else f"openrouter/{chosen}"


def _run_any_llm_agent(
utterance: str,
*,
system_prompt: str,
model: str,
max_tokens: int,
temperature: float,
) -> str:
"""Run a single-turn agent via `AnyLLMModel` (OpenAI Agents SDK) and return its raw text."""
agent = Agent(
name="MotionPlanner",
instructions=system_prompt,
model=AnyLLMModel(model=model, api_key=os.environ["OPENROUTER_API_KEY"]),
model_settings=ModelSettings(max_tokens=max_tokens, temperature=temperature),
)
result = asyncio.run(Runner.run(agent, utterance))
return str(result.final_output or "")


def _call_anthropic(
utterance: str,
*,
system_prompt: str,
model: str | None,
max_tokens: int,
temperature: float,
) -> tuple[str, str]:
"""Call Claude directly with `utterance`, picking up `ANTHROPIC_API_KEY` via the SDK default."""
import anthropic

client = anthropic.Anthropic()
Expand All @@ -169,9 +233,7 @@ def plan_from_utterance(
getattr(block, "text", "") for block in response.content
if getattr(block, "type", None) == "text"
)

plan, err = parse_plan_json(raw)
return PlanResult(plan=plan, raw_response=raw, error=err, model=chosen_model)
return raw, chosen_model


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -261,11 +323,13 @@ def plan_from_utterance_with_image(
) -> PlanResult:
"""Call Claude Vision with `utterance` + the image, return a `PlanResult`.

If `frame_b64_jpeg` is None (no fresh frame available), this falls back to
the text-only planner so the agent stays usable when the camera publisher
is down.
Vision uses Claude's multimodal API directly (`ANTHROPIC_API_KEY`), since
that's the format wired up here. If no fresh frame is available, or only
`OPENROUTER_API_KEY` is configured (no vision path for it yet), this
falls back to the text-only planner via `plan_from_utterance` so the
agent stays usable.
"""
if frame_b64_jpeg is None:
if frame_b64_jpeg is None or not os.environ.get("ANTHROPIC_API_KEY"):
return plan_from_utterance(utterance, model=model, max_tokens=max_tokens, temperature=temperature)

import anthropic
Expand Down
13 changes: 13 additions & 0 deletions examples/nl_arm_controller/planner_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Shared LLM planner configuration."""

from __future__ import annotations

import os


DEFAULT_OPENROUTER_MODEL = "openai/gpt-5.4-mini"


def get_openrouter_model() -> str:
"""Return the OpenRouter model selected by the environment or its default."""
return os.environ.get("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL)
6 changes: 6 additions & 0 deletions examples/nl_arm_controller/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
# new changes: pip install -e cyberwave-sdks/cyberwave-python

anthropic>=0.40.0
# OpenAI Agents SDK — used to route planning through OpenRouter (any provider/
# model reachable via OpenRouter) when OPENROUTER_API_KEY is set, via
# AnyLLMModel. The `any-llm` extra pulls in `any-llm-sdk`, which AnyLLMModel
# requires. Falls back to calling Claude directly when only
# ANTHROPIC_API_KEY is set.
openai-agents[any-llm]>=0.1.0
# Mistral STT is called via direct HTTP (httpx) — the official `mistralai`
# v2.x SDK on PyPI is currently unstable for our use case.
httpx>=0.28.1
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,9 @@ markers = [
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[dependency-groups]
dev = [
"cyberwave>=0.5.3",
"openai-agents>=0.18.2",
]
Loading
Loading