Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71bbefceb1 | ||
|
|
bdaa1b35e4 | ||
|
|
4c9ae63bcf | ||
|
|
c606ab9318 | ||
|
|
e0a1722fa4 | ||
|
|
9891aea1eb | ||
|
|
c2e70260c4 | ||
|
|
b4b5de889a | ||
|
|
d0b0284189 | ||
|
|
c0a87d77fc | ||
|
|
6e627bc2e0 | ||
|
|
ece660ae69 | ||
|
|
84268edf01 | ||
|
|
9136cca1ff | ||
|
|
6035b70ae5 | ||
|
|
e4c300bcfd | ||
|
|
0c65efee06 | ||
|
|
c12d234ee8 | ||
|
|
2954933a55 | ||
|
|
a71cce08c1 | ||
|
|
c10544fc19 | ||
|
|
7c659bc0fe | ||
|
|
ea5bf4cf5d | ||
|
|
9f3e4089c2 | ||
|
|
c9880d4267 | ||
|
|
af436f5e6c | ||
|
|
2d3c94e609 | ||
|
|
88d2abc6c5 | ||
|
|
112212d3cd | ||
|
|
2ab51cb80b | ||
|
|
f71b3b3fea | ||
|
|
9990e80d61 | ||
|
|
9a131cb0ed | ||
|
|
c5ab4098ca | ||
|
|
5a8f3f772c | ||
|
|
e1a98d68ff | ||
|
|
92065dbb74 | ||
|
|
55ad41265f | ||
|
|
6d0d995b1b | ||
|
|
f444e94ff7 | ||
|
|
5f9af317c4 | ||
|
|
4b3bc89d06 | ||
|
|
3323b9d909 | ||
|
|
96a7abcda4 | ||
|
|
534a8344bd | ||
|
|
7b710116a4 | ||
|
|
fc9545c36a |
@@ -0,0 +1,83 @@
|
|||||||
|
name: Build Nanobot OAuth
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['main']
|
||||||
|
pull_request:
|
||||||
|
branches: ['main']
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.wylab.me
|
||||||
|
IMAGE_NAME: wylab/nanobot
|
||||||
|
BUILDKIT_PROGRESS: plain
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, linux-amd64]
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to the container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME || github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.oauth
|
||||||
|
provenance: false
|
||||||
|
platforms: linux/amd64
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
if: github.event_name == 'push' || github.event_name == 'schedule'
|
||||||
|
runs-on: [self-hosted, linux-amd64]
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Delete images older than 24h
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
cutoff=$(date -u -d '24 hours ago' +%s)
|
||||||
|
page=1
|
||||||
|
while true; do
|
||||||
|
versions=$(curl -sf -H "Authorization: token $TOKEN" \
|
||||||
|
"https://${{ env.REGISTRY }}/api/v1/packages/wylab?type=container&q=nanobot&limit=50&page=$page")
|
||||||
|
count=$(echo "$versions" | jq length)
|
||||||
|
[ "$count" = "0" ] && break
|
||||||
|
echo "$versions" | jq -c '.[]' | while read -r pkg; do
|
||||||
|
ver=$(echo "$pkg" | jq -r '.version')
|
||||||
|
# Keep latest and buildcache, only delete SHA tags
|
||||||
|
case "$ver" in latest|buildcache) continue ;; esac
|
||||||
|
created=$(echo "$pkg" | jq -r '.created_at')
|
||||||
|
ts=$(date -u -d "$created" +%s 2>/dev/null || echo 0)
|
||||||
|
if [ "$ts" -lt "$cutoff" ]; then
|
||||||
|
id=$(echo "$pkg" | jq -r '.id')
|
||||||
|
echo "Deleting nanobot:$ver (id=$id, created=$created)"
|
||||||
|
curl -sf -X DELETE -H "Authorization: token $TOKEN" \
|
||||||
|
"https://${{ env.REGISTRY }}/api/v1/packages/wylab/container/nanobot/$ver" || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$count" -lt 50 ] && break
|
||||||
|
page=$((page + 1))
|
||||||
|
done
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
FROM birdxs/nanobot:latest
|
||||||
|
|
||||||
|
# ── Skill dependencies ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# APT: ffmpeg (video-frames, whisper), jq, tmux, build-essential (for go)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg jq tmux build-essential procps && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# gh CLI via GitHub official apt repo
|
||||||
|
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||||
|
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \
|
||||||
|
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||||
|
> /etc/apt/sources.list.d/github-cli.list && \
|
||||||
|
apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Go toolchain
|
||||||
|
RUN curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xzf -
|
||||||
|
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
|
||||||
|
|
||||||
|
# Go tools: blogwatcher, blu (blucli), gifgrep, sonos (sonoscli), wacli, songsee
|
||||||
|
RUN go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest && \
|
||||||
|
go install github.com/steipete/blucli/cmd/blu@latest && \
|
||||||
|
go install github.com/steipete/gifgrep/cmd/gifgrep@latest && \
|
||||||
|
go install github.com/steipete/sonoscli/cmd/sonos@latest && \
|
||||||
|
go install github.com/steipete/wacli/cmd/wacli@latest && \
|
||||||
|
go install github.com/steipete/songsee/cmd/songsee@latest
|
||||||
|
|
||||||
|
# Pre-built binaries from GitHub releases
|
||||||
|
# gogcli (gog)
|
||||||
|
RUN curl -fsSL https://github.com/steipete/gogcli/releases/download/v0.9.0/gogcli_0.9.0_linux_amd64.tar.gz \
|
||||||
|
| tar -xzf - -C /usr/local/bin gog
|
||||||
|
|
||||||
|
# goplaces
|
||||||
|
RUN curl -fsSL https://github.com/steipete/goplaces/releases/download/v0.2.1/goplaces_0.2.1_linux_amd64.tar.gz \
|
||||||
|
| tar -xzf - -C /usr/local/bin goplaces
|
||||||
|
|
||||||
|
# himalaya (email CLI)
|
||||||
|
RUN curl -fsSL https://github.com/pimalaya/himalaya/releases/download/v1.1.0/himalaya.x86_64-linux.tgz \
|
||||||
|
| tar -xzf - -C /usr/local/bin himalaya
|
||||||
|
|
||||||
|
# obsidian-cli (release binary is named notesmd-cli, skill expects obsidian-cli)
|
||||||
|
RUN curl -fsSL -o /tmp/obsidian.tar.gz https://github.com/yakitrak/obsidian-cli/releases/download/v0.3.0/notesmd-cli_0.3.0_linux_amd64.tar.gz && \
|
||||||
|
tar -xzf /tmp/obsidian.tar.gz -C /tmp notesmd-cli && \
|
||||||
|
mv /tmp/notesmd-cli /usr/local/bin/obsidian-cli && \
|
||||||
|
rm /tmp/obsidian.tar.gz
|
||||||
|
|
||||||
|
# Node tools: oracle, gemini-cli, summarize
|
||||||
|
RUN npm install -g @steipete/oracle @google/gemini-cli @steipete/summarize
|
||||||
|
|
||||||
|
# Python tools: nano-pdf, openai-whisper
|
||||||
|
RUN uv tool install nano-pdf && \
|
||||||
|
uv tool install openai-whisper
|
||||||
|
ENV PATH="/root/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
# ── Nanobot source ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md LICENSE /app/
|
||||||
|
COPY nanobot/ /app/nanobot/
|
||||||
|
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
|
||||||
|
|
||||||
|
ENTRYPOINT ["nanobot"]
|
||||||
|
CMD ["gateway"]
|
||||||
@@ -71,7 +71,7 @@ Skills with available="false" need dependencies installed first - you can try in
|
|||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
def _get_identity(self) -> str:
|
def _get_identity(self) -> str:
|
||||||
"""Get the core identity section."""
|
"""Get the core identity section with runtime context."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import time as _time
|
import time as _time
|
||||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||||
@@ -79,10 +79,8 @@ Skills with available="false" need dependencies installed first - you can try in
|
|||||||
workspace_path = str(self.workspace.expanduser().resolve())
|
workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||||
|
|
||||||
return f"""# nanobot 🐈
|
|
||||||
|
|
||||||
You are nanobot, a helpful AI assistant. You have access to tools that allow you to:
|
return f"""You have access to tools that allow you to:
|
||||||
- Read, write, and edit files
|
- Read, write, and edit files
|
||||||
- Execute shell commands
|
- Execute shell commands
|
||||||
- Search the web and fetch web pages
|
- Search the web and fetch web pages
|
||||||
|
|||||||
+142
-13
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ from nanobot.agent.tools.shell import ExecTool
|
|||||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.spawn import SpawnTool
|
from nanobot.agent.tools.spawn import SpawnTool
|
||||||
|
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
@@ -74,8 +77,10 @@ class AgentLoop:
|
|||||||
exec_config=self.exec_config,
|
exec_config=self.exec_config,
|
||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._quota_cache: dict[str, Any] = {} # {model: str, cached_at: float}
|
||||||
|
self._quota_cache_ttl: float = 300.0 # 5 minutes
|
||||||
self._register_default_tools()
|
self._register_default_tools()
|
||||||
|
|
||||||
def _register_default_tools(self) -> None:
|
def _register_default_tools(self) -> None:
|
||||||
@@ -105,6 +110,7 @@ class AgentLoop:
|
|||||||
# Spawn tool (for subagents)
|
# Spawn tool (for subagents)
|
||||||
spawn_tool = SpawnTool(manager=self.subagents)
|
spawn_tool = SpawnTool(manager=self.subagents)
|
||||||
self.tools.register(spawn_tool)
|
self.tools.register(spawn_tool)
|
||||||
|
self.tools.register(WaitForSubagentsTool(manager=self.subagents))
|
||||||
|
|
||||||
# Cron tool (for scheduling)
|
# Cron tool (for scheduling)
|
||||||
if self.cron_service:
|
if self.cron_service:
|
||||||
@@ -143,7 +149,95 @@ class AgentLoop:
|
|||||||
"""Stop the agent loop."""
|
"""Stop the agent loop."""
|
||||||
self._running = False
|
self._running = False
|
||||||
logger.info("Agent loop stopping")
|
logger.info("Agent loop stopping")
|
||||||
|
|
||||||
|
def _select_model_based_on_quota(self) -> str:
|
||||||
|
"""Select Opus or Sonnet based on rolling weekly quota burn rate."""
|
||||||
|
# Check cache
|
||||||
|
now = time.time()
|
||||||
|
if self._quota_cache and (now - self._quota_cache.get("cached_at", 0)) < self._quota_cache_ttl:
|
||||||
|
return self._quota_cache["model"]
|
||||||
|
|
||||||
|
# Default models
|
||||||
|
OPUS = "claude-opus-4-6"
|
||||||
|
SONNET = "claude-sonnet-4-6"
|
||||||
|
TOLERANCE = 1.17 # 17% overage triggers downgrade
|
||||||
|
|
||||||
|
# Read rate limits
|
||||||
|
rate_limits_path = self.workspace / "memory" / "rate_limits.json"
|
||||||
|
if not rate_limits_path.exists():
|
||||||
|
logger.warning("rate_limits.json not found, defaulting to Sonnet")
|
||||||
|
return SONNET
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(rate_limits_path) as f:
|
||||||
|
limits = json.load(f)
|
||||||
|
|
||||||
|
actual_usage = limits.get("weekly_all_models")
|
||||||
|
weekly_reset = limits.get("weekly_reset")
|
||||||
|
|
||||||
|
if actual_usage is None or weekly_reset is None:
|
||||||
|
logger.warning("Rate limit data incomplete, defaulting to Sonnet")
|
||||||
|
return SONNET
|
||||||
|
|
||||||
|
# Calculate expected usage
|
||||||
|
actual_pct = actual_usage * 100
|
||||||
|
week_start = weekly_reset - (168 * 3600)
|
||||||
|
hours_elapsed = max(0, min((now - week_start) / 3600, 168))
|
||||||
|
expected_pct = (hours_elapsed / 168) * 100
|
||||||
|
threshold = expected_pct * TOLERANCE
|
||||||
|
|
||||||
|
# Decision logic
|
||||||
|
if actual_pct > threshold:
|
||||||
|
model = SONNET
|
||||||
|
logger.info(
|
||||||
|
f"Quota: {actual_pct:.1f}% used, expected {expected_pct:.1f}%, "
|
||||||
|
f"threshold {threshold:.1f}% → Sonnet"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
model = OPUS
|
||||||
|
logger.info(
|
||||||
|
f"Quota: {actual_pct:.1f}% used, expected {expected_pct:.1f}%, "
|
||||||
|
f"threshold {threshold:.1f}% → Opus"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache decision
|
||||||
|
self._quota_cache = {"model": model, "cached_at": now}
|
||||||
|
return model
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking quota: {e}, defaulting to Sonnet")
|
||||||
|
return SONNET
|
||||||
|
|
||||||
|
def _get_quota_status(self) -> str:
|
||||||
|
"""Return human-readable quota status."""
|
||||||
|
rate_limits_path = self.workspace / "memory" / "rate_limits.json"
|
||||||
|
if not rate_limits_path.exists():
|
||||||
|
return "⚠️ No quota data available yet."
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(rate_limits_path) as f:
|
||||||
|
limits = json.load(f)
|
||||||
|
|
||||||
|
actual_pct = limits.get("weekly_all_models", 0) * 100
|
||||||
|
reset_ts = limits.get("weekly_reset", 0)
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
hours_until_reset = (reset_ts - now) / 3600
|
||||||
|
week_start = reset_ts - (168 * 3600)
|
||||||
|
hours_elapsed = max(0, (now - week_start) / 3600)
|
||||||
|
expected_pct = (hours_elapsed / 168) * 100
|
||||||
|
|
||||||
|
model = self._select_model_based_on_quota()
|
||||||
|
|
||||||
|
return f"""📊 Quota Status:
|
||||||
|
• Used: {actual_pct:.1f}% (expected {expected_pct:.1f}%)
|
||||||
|
• Resets in: {hours_until_reset:.1f}h
|
||||||
|
• Current model: {model}
|
||||||
|
• Burn rate: {actual_pct / max(expected_pct, 0.01):.2f}x target"""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"⚠️ Error reading quota: {e}"
|
||||||
|
|
||||||
async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None:
|
async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None:
|
||||||
"""
|
"""
|
||||||
Process a single inbound message.
|
Process a single inbound message.
|
||||||
@@ -177,8 +271,11 @@ class AgentLoop:
|
|||||||
content="🐈 New session started. Memory consolidated.")
|
content="🐈 New session started. Memory consolidated.")
|
||||||
if cmd == "/help":
|
if cmd == "/help":
|
||||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
|
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status")
|
||||||
|
if cmd == "/quota":
|
||||||
|
status = self._get_quota_status()
|
||||||
|
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status)
|
||||||
|
|
||||||
# Consolidate memory before processing if session is too large
|
# Consolidate memory before processing if session is too large
|
||||||
if len(session.messages) > self.memory_window:
|
if len(session.messages) > self.memory_window:
|
||||||
await self._consolidate_memory(session)
|
await self._consolidate_memory(session)
|
||||||
@@ -196,28 +293,55 @@ class AgentLoop:
|
|||||||
if isinstance(cron_tool, CronTool):
|
if isinstance(cron_tool, CronTool):
|
||||||
cron_tool.set_context(msg.channel, msg.chat_id)
|
cron_tool.set_context(msg.channel, msg.chat_id)
|
||||||
|
|
||||||
|
# Prepend time-gap notice if >5 minutes since last user message
|
||||||
|
current_message = msg.content
|
||||||
|
last_user_ts = None
|
||||||
|
for m in reversed(session.messages):
|
||||||
|
if m.get("role") == "user":
|
||||||
|
last_user_ts = m.get("timestamp")
|
||||||
|
break
|
||||||
|
if last_user_ts:
|
||||||
|
try:
|
||||||
|
last_dt = datetime.fromisoformat(last_user_ts)
|
||||||
|
now_dt = datetime.now()
|
||||||
|
elapsed_seconds = (now_dt - last_dt).total_seconds()
|
||||||
|
if elapsed_seconds > 300: # 5 minutes
|
||||||
|
if elapsed_seconds < 3600:
|
||||||
|
gap_str = f"{int(elapsed_seconds // 60)} minutes"
|
||||||
|
elif elapsed_seconds < 86400:
|
||||||
|
gap_str = f"{int(elapsed_seconds // 3600)} hours"
|
||||||
|
else:
|
||||||
|
gap_str = f"{int(elapsed_seconds // 86400)} days"
|
||||||
|
current_message = f"[SYSTEM ANNOUNCEMENT: {gap_str} have elapsed since last user message; take this into account when replying to user]\n\n{msg.content}"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Malformed timestamp — skip silently
|
||||||
|
|
||||||
# Build initial messages (use get_history for LLM-formatted messages)
|
# Build initial messages (use get_history for LLM-formatted messages)
|
||||||
messages = self.context.build_messages(
|
messages = self.context.build_messages(
|
||||||
history=session.get_history(),
|
history=session.get_history(),
|
||||||
current_message=msg.content,
|
current_message=current_message,
|
||||||
media=msg.media if msg.media else None,
|
media=msg.media if msg.media else None,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Select model based on quota
|
||||||
|
selected_model = self._select_model_based_on_quota()
|
||||||
|
|
||||||
# Agent loop
|
# Agent loop
|
||||||
iteration = 0
|
iteration = 0
|
||||||
final_content = None
|
final_content = None
|
||||||
tools_used: list[str] = []
|
tools_used: list[str] = []
|
||||||
|
|
||||||
while iteration < self.max_iterations:
|
while iteration < self.max_iterations:
|
||||||
iteration += 1
|
iteration += 1
|
||||||
|
|
||||||
# Call LLM
|
# Call LLM
|
||||||
|
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
|
||||||
response = await self.provider.chat(
|
response = await self.provider.chat(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=self.tools.get_definitions(),
|
tools=self.tools.get_definitions(),
|
||||||
model=self.model
|
model=selected_model
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle tool calls
|
# Handle tool calls
|
||||||
@@ -325,14 +449,17 @@ class AgentLoop:
|
|||||||
# Agent loop (limited for announce handling)
|
# Agent loop (limited for announce handling)
|
||||||
iteration = 0
|
iteration = 0
|
||||||
final_content = None
|
final_content = None
|
||||||
|
|
||||||
|
# Select model based on quota
|
||||||
|
selected_model = self._select_model_based_on_quota()
|
||||||
|
|
||||||
while iteration < self.max_iterations:
|
while iteration < self.max_iterations:
|
||||||
iteration += 1
|
iteration += 1
|
||||||
|
|
||||||
response = await self.provider.chat(
|
response = await self.provider.chat(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=self.tools.get_definitions(),
|
tools=self.tools.get_definitions(),
|
||||||
model=self.model
|
model=selected_model
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.has_tool_calls:
|
if response.has_tool_calls:
|
||||||
@@ -424,7 +551,9 @@ Respond with ONLY valid JSON, no markdown fences."""
|
|||||||
{"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
|
{"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
|
||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": prompt},
|
||||||
],
|
],
|
||||||
model=self.model,
|
model="claude-haiku-4-5",
|
||||||
|
thinking_budget=0,
|
||||||
|
max_tokens=16384,
|
||||||
)
|
)
|
||||||
text = (response.content or "").strip()
|
text = (response.content or "").strip()
|
||||||
if text.startswith("```"):
|
if text.startswith("```"):
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ class SkillsLoader:
|
|||||||
"""Parse nanobot metadata JSON from frontmatter."""
|
"""Parse nanobot metadata JSON from frontmatter."""
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
return data.get("nanobot", {}) if isinstance(data, dict) else {}
|
return (data.get("nanobot") or data.get("clawdbot") or {}) if isinstance(data, dict) else {}
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
+48
-11
@@ -15,6 +15,8 @@ from nanobot.agent.tools.registry import ToolRegistry
|
|||||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
|
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
||||||
|
from nanobot.agent.tools.spawn import SpawnTool
|
||||||
|
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||||
|
|
||||||
|
|
||||||
class SubagentManager:
|
class SubagentManager:
|
||||||
@@ -40,16 +42,21 @@ class SubagentManager:
|
|||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.model = model or provider.get_default_model()
|
# Default to Sonnet, not the provider default (Opus).
|
||||||
|
# Quota switching only affects the main agent's own requests, not SubagentManager.
|
||||||
|
# Explicit model overrides (e.g. Haiku workers) still take precedence.
|
||||||
|
self.model = model or "claude-sonnet-4-6"
|
||||||
self.brave_api_key = brave_api_key
|
self.brave_api_key = brave_api_key
|
||||||
self.exec_config = exec_config or ExecToolConfig()
|
self.exec_config = exec_config or ExecToolConfig()
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
|
self._task_results: dict[str, str] = {}
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self,
|
self,
|
||||||
task: str,
|
task: str,
|
||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
origin_channel: str = "cli",
|
origin_channel: str = "cli",
|
||||||
origin_chat_id: str = "direct",
|
origin_chat_id: str = "direct",
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -75,7 +82,7 @@ class SubagentManager:
|
|||||||
|
|
||||||
# Create background task
|
# Create background task
|
||||||
bg_task = asyncio.create_task(
|
bg_task = asyncio.create_task(
|
||||||
self._run_subagent(task_id, task, display_label, origin)
|
self._run_subagent(task_id, task, display_label, origin, model=model)
|
||||||
)
|
)
|
||||||
self._running_tasks[task_id] = bg_task
|
self._running_tasks[task_id] = bg_task
|
||||||
|
|
||||||
@@ -83,7 +90,7 @@ class SubagentManager:
|
|||||||
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
|
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
|
||||||
|
|
||||||
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
|
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
|
||||||
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
return f"Subagent [{display_label}] started. Task ID: {task_id}"
|
||||||
|
|
||||||
async def _run_subagent(
|
async def _run_subagent(
|
||||||
self,
|
self,
|
||||||
@@ -91,12 +98,13 @@ class SubagentManager:
|
|||||||
task: str,
|
task: str,
|
||||||
label: str,
|
label: str,
|
||||||
origin: dict[str, str],
|
origin: dict[str, str],
|
||||||
|
model: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute the subagent task and announce the result."""
|
"""Execute the subagent task and announce the result."""
|
||||||
logger.info(f"Subagent [{task_id}] starting task: {label}")
|
logger.info(f"Subagent [{task_id}] starting task: {label}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Build subagent tools (no message tool, no spawn tool)
|
# Build subagent tools (no message tool)
|
||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||||
tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
||||||
@@ -110,7 +118,11 @@ class SubagentManager:
|
|||||||
))
|
))
|
||||||
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||||
tools.register(WebFetchTool())
|
tools.register(WebFetchTool())
|
||||||
|
spawn_tool = SpawnTool(manager=self)
|
||||||
|
spawn_tool.set_context("subagent", origin["chat_id"])
|
||||||
|
tools.register(spawn_tool)
|
||||||
|
tools.register(WaitForSubagentsTool(manager=self))
|
||||||
|
|
||||||
# Build messages with subagent-specific prompt
|
# Build messages with subagent-specific prompt
|
||||||
system_prompt = self._build_subagent_prompt(task)
|
system_prompt = self._build_subagent_prompt(task)
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
@@ -119,7 +131,7 @@ class SubagentManager:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Run agent loop (limited iterations)
|
# Run agent loop (limited iterations)
|
||||||
max_iterations = 15
|
max_iterations = 50
|
||||||
iteration = 0
|
iteration = 0
|
||||||
final_result: str | None = None
|
final_result: str | None = None
|
||||||
|
|
||||||
@@ -129,7 +141,7 @@ class SubagentManager:
|
|||||||
response = await self.provider.chat(
|
response = await self.provider.chat(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=tools.get_definitions(),
|
tools=tools.get_definitions(),
|
||||||
model=self.model,
|
model=model or self.model,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.has_tool_calls:
|
if response.has_tool_calls:
|
||||||
@@ -188,7 +200,14 @@ class SubagentManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Announce the subagent result to the main agent via the message bus."""
|
"""Announce the subagent result to the main agent via the message bus."""
|
||||||
status_text = "completed successfully" if status == "ok" else "failed"
|
status_text = "completed successfully" if status == "ok" else "failed"
|
||||||
|
|
||||||
|
# Child subagents (spawned by other subagents) store results silently.
|
||||||
|
# The parent orchestrator collects them via wait_for_subagents.
|
||||||
|
if origin["channel"] == "subagent":
|
||||||
|
self._task_results[task_id] = result
|
||||||
|
logger.debug(f"Subagent [{task_id}] stored result silently (child subagent)")
|
||||||
|
return
|
||||||
|
|
||||||
announce_content = f"""[Subagent '{label}' {status_text}]
|
announce_content = f"""[Subagent '{label}' {status_text}]
|
||||||
|
|
||||||
Task: {task}
|
Task: {task}
|
||||||
@@ -197,7 +216,7 @@ Result:
|
|||||||
{result}
|
{result}
|
||||||
|
|
||||||
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
|
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
|
||||||
|
|
||||||
# Inject as system message to trigger main agent
|
# Inject as system message to trigger main agent
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel="system",
|
channel="system",
|
||||||
@@ -205,7 +224,7 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
|
|||||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||||
content=announce_content,
|
content=announce_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
|
logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
|
||||||
|
|
||||||
@@ -237,7 +256,6 @@ You are a subagent spawned by the main agent to complete a specific task.
|
|||||||
|
|
||||||
## What You Cannot Do
|
## What You Cannot Do
|
||||||
- Send messages directly to users (no message tool available)
|
- Send messages directly to users (no message tool available)
|
||||||
- Spawn other subagents
|
|
||||||
- Access the main agent's conversation history
|
- Access the main agent's conversation history
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
@@ -246,6 +264,25 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed
|
|||||||
|
|
||||||
When you have completed the task, provide a clear summary of your findings or actions."""
|
When you have completed the task, provide a clear summary of your findings or actions."""
|
||||||
|
|
||||||
|
async def wait_for(self, task_ids: list[str]) -> str:
|
||||||
|
"""Wait for specified child subagents to complete and return their results."""
|
||||||
|
tasks_to_wait = [
|
||||||
|
self._running_tasks[tid]
|
||||||
|
for tid in task_ids
|
||||||
|
if tid in self._running_tasks
|
||||||
|
]
|
||||||
|
if tasks_to_wait:
|
||||||
|
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for tid in task_ids:
|
||||||
|
result = self._task_results.get(tid)
|
||||||
|
if result is not None:
|
||||||
|
results.append(f"[{tid}]:\n{result}")
|
||||||
|
else:
|
||||||
|
results.append(f"[{tid}]: No result found (invalid ID or task failed before storing)")
|
||||||
|
return "\n\n---\n\n".join(results)
|
||||||
|
|
||||||
def get_running_count(self) -> int:
|
def get_running_count(self) -> int:
|
||||||
"""Return the number of currently running subagents."""
|
"""Return the number of currently running subagents."""
|
||||||
return len(self._running_tasks)
|
return len(self._running_tasks)
|
||||||
|
|||||||
@@ -51,15 +51,20 @@ class SpawnTool(Tool):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional short label for the task (for display)",
|
"description": "Optional short label for the task (for display)",
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["task"],
|
"required": ["task"],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
async def execute(self, task: str, label: str | None = None, model: str | None = None, **kwargs: Any) -> str:
|
||||||
"""Spawn a subagent to execute the given task."""
|
"""Spawn a subagent to execute the given task."""
|
||||||
return await self._manager.spawn(
|
return await self._manager.spawn(
|
||||||
task=task,
|
task=task,
|
||||||
label=label,
|
label=label,
|
||||||
|
model=model,
|
||||||
origin_channel=self._origin_channel,
|
origin_channel=self._origin_channel,
|
||||||
origin_chat_id=self._origin_chat_id,
|
origin_chat_id=self._origin_chat_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Wait-for-subagents tool for orchestrator subagents."""
|
||||||
|
|
||||||
|
from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
|
from nanobot.agent.tools.base import Tool
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
|
||||||
|
|
||||||
|
class WaitForSubagentsTool(Tool):
|
||||||
|
"""
|
||||||
|
Tool to wait for child subagents to complete and collect their results.
|
||||||
|
|
||||||
|
Use this after spawning multiple subagents to wait for all of them
|
||||||
|
and get their results for synthesis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, manager: "SubagentManager"):
|
||||||
|
self._manager = manager
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "wait_for_subagents"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Wait for one or more child subagents to complete and return their results. "
|
||||||
|
"Use this after spawning subagents to collect all results before synthesizing. "
|
||||||
|
"Blocks until all specified subagents finish."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "List of task IDs to wait for (from spawn tool responses)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["task_ids"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, task_ids: list[str], **kwargs: Any) -> str:
|
||||||
|
"""Wait for the specified subagents and return their results."""
|
||||||
|
return await self._manager.wait_for(task_ids)
|
||||||
@@ -69,11 +69,15 @@ class BaseChannel(ABC):
|
|||||||
True if allowed, False otherwise.
|
True if allowed, False otherwise.
|
||||||
"""
|
"""
|
||||||
allow_list = getattr(self.config, "allow_from", [])
|
allow_list = getattr(self.config, "allow_from", [])
|
||||||
|
|
||||||
# If no allow list, allow everyone
|
# If no allow list, allow everyone
|
||||||
if not allow_list:
|
if not allow_list:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Wildcard allows everyone
|
||||||
|
if "*" in allow_list:
|
||||||
|
return True
|
||||||
|
|
||||||
sender_str = str(sender_id)
|
sender_str = str(sender_id)
|
||||||
if sender_str in allow_list:
|
if sender_str in allow_list:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
BotCommand("start", "Start the bot"),
|
BotCommand("start", "Start the bot"),
|
||||||
BotCommand("new", "Start a new conversation"),
|
BotCommand("new", "Start a new conversation"),
|
||||||
BotCommand("help", "Show available commands"),
|
BotCommand("help", "Show available commands"),
|
||||||
|
BotCommand("quota", "Show current quota status"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -127,6 +128,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
self._app.add_handler(CommandHandler("start", self._on_start))
|
self._app.add_handler(CommandHandler("start", self._on_start))
|
||||||
self._app.add_handler(CommandHandler("new", self._forward_command))
|
self._app.add_handler(CommandHandler("new", self._forward_command))
|
||||||
self._app.add_handler(CommandHandler("help", self._forward_command))
|
self._app.add_handler(CommandHandler("help", self._forward_command))
|
||||||
|
self._app.add_handler(CommandHandler("quota", self._forward_command))
|
||||||
|
|
||||||
# Add message handler for text, photos, voice, documents
|
# Add message handler for text, photos, voice, documents
|
||||||
self._app.add_handler(
|
self._app.add_handler(
|
||||||
|
|||||||
@@ -270,20 +270,21 @@ This file stores important information that should persist across sessions.
|
|||||||
|
|
||||||
|
|
||||||
def _make_provider(config):
|
def _make_provider(config):
|
||||||
"""Create LiteLLMProvider from config. Exits if no API key found."""
|
"""Create LLM provider from config. Uses OAuth for subscription tokens."""
|
||||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
from nanobot.providers import create_provider
|
||||||
p = config.get_provider()
|
p = config.get_provider()
|
||||||
model = config.agents.defaults.model
|
model = config.agents.defaults.model
|
||||||
if not (p and p.api_key) and not model.startswith("bedrock/"):
|
if not (p and p.api_key) and not model.startswith("bedrock/"):
|
||||||
console.print("[red]Error: No API key configured.[/red]")
|
console.print("[red]Error: No API key configured.[/red]")
|
||||||
console.print("Set one in ~/.nanobot/config.json under providers section")
|
console.print("Set one in ~/.nanobot/config.json under providers section")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
return LiteLLMProvider(
|
return create_provider(
|
||||||
api_key=p.api_key if p else None,
|
api_key=p.api_key if p else None,
|
||||||
|
model=model,
|
||||||
api_base=config.get_api_base(),
|
api_base=config.get_api_base(),
|
||||||
default_model=model,
|
|
||||||
extra_headers=p.extra_headers if p else None,
|
extra_headers=p.extra_headers if p else None,
|
||||||
provider_name=config.get_provider_name(),
|
provider_name=config.get_provider_name(),
|
||||||
|
thinking_budget=config.agents.defaults.thinking_budget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -507,6 +508,9 @@ def agent(
|
|||||||
channels_app = typer.Typer(help="Manage channels")
|
channels_app = typer.Typer(help="Manage channels")
|
||||||
app.add_typer(channels_app, name="channels")
|
app.add_typer(channels_app, name="channels")
|
||||||
|
|
||||||
|
from nanobot.cli.oauth import oauth_app
|
||||||
|
app.add_typer(oauth_app, name="oauth")
|
||||||
|
|
||||||
|
|
||||||
@channels_app.command("status")
|
@channels_app.command("status")
|
||||||
def channels_status():
|
def channels_status():
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""OAuth CLI commands for subscription authentication."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
oauth_app = typer.Typer(help="Manage OAuth authentication for subscription-based providers")
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
@oauth_app.command("login")
|
||||||
|
def login(
|
||||||
|
provider: str = typer.Argument("anthropic", help="Provider name"),
|
||||||
|
token: Optional[str] = typer.Option(None, "--token", "-t", help="OAuth token (from claude setup-token)"),
|
||||||
|
):
|
||||||
|
"""Login to a provider using OAuth.
|
||||||
|
|
||||||
|
For Anthropic Claude Max/Pro, run 'claude setup-token' and paste the token here.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
nanobot oauth login anthropic --token sk-ant-oat01-xxx
|
||||||
|
"""
|
||||||
|
from nanobot.config.oauth_store import OAuthStore
|
||||||
|
from nanobot.config.schema import OAuthCredentials
|
||||||
|
|
||||||
|
if provider != "anthropic":
|
||||||
|
console.print(f"[red]OAuth login for {provider} not yet supported[/red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
console.print("Please provide your OAuth token:")
|
||||||
|
console.print(" 1. Run: claude setup-token")
|
||||||
|
console.print(" 2. Copy the sk-ant-oat01-... token")
|
||||||
|
console.print(" 3. Run: nanobot oauth login anthropic --token <your-token>")
|
||||||
|
console.print()
|
||||||
|
token = typer.prompt("Token", hide_input=True)
|
||||||
|
|
||||||
|
if not token or "sk-ant-oat" not in token:
|
||||||
|
console.print("[red]Invalid token. Must contain sk-ant-oat[/red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
store = OAuthStore(Path.home() / ".nanobot")
|
||||||
|
creds = OAuthCredentials(
|
||||||
|
access_token=token,
|
||||||
|
token_type="token" # setup-token doesn't expire
|
||||||
|
)
|
||||||
|
store.save(provider, creds)
|
||||||
|
|
||||||
|
console.print(f"[green]Successfully saved {provider} OAuth credentials![/green]")
|
||||||
|
|
||||||
|
|
||||||
|
@oauth_app.command("status")
|
||||||
|
def status():
|
||||||
|
"""Show OAuth credential status."""
|
||||||
|
from nanobot.config.oauth_store import OAuthStore
|
||||||
|
|
||||||
|
store = OAuthStore(Path.home() / ".nanobot")
|
||||||
|
|
||||||
|
providers = ["anthropic"]
|
||||||
|
found_any = False
|
||||||
|
|
||||||
|
for provider in providers:
|
||||||
|
creds = store.load(provider)
|
||||||
|
if creds:
|
||||||
|
found_any = True
|
||||||
|
st = "valid"
|
||||||
|
if creds.is_expired:
|
||||||
|
st = "EXPIRED"
|
||||||
|
elif creds.expires_soon:
|
||||||
|
st = "expires soon"
|
||||||
|
|
||||||
|
token_preview = creds.access_token[:20] + "..."
|
||||||
|
console.print(f" {provider}: {token_preview} ({st})")
|
||||||
|
|
||||||
|
if not found_any:
|
||||||
|
console.print("No OAuth credentials configured.")
|
||||||
|
console.print("Run: nanobot oauth login anthropic --token <token>")
|
||||||
|
|
||||||
|
|
||||||
|
@oauth_app.command("logout")
|
||||||
|
def logout(
|
||||||
|
provider: str = typer.Argument("anthropic", help="Provider name"),
|
||||||
|
):
|
||||||
|
"""Remove OAuth credentials for a provider."""
|
||||||
|
from nanobot.config.oauth_store import OAuthStore
|
||||||
|
|
||||||
|
store = OAuthStore(Path.home() / ".nanobot")
|
||||||
|
if store.delete(provider):
|
||||||
|
console.print(f"[green]Removed {provider} OAuth credentials[/green]")
|
||||||
|
else:
|
||||||
|
console.print(f"No credentials found for {provider}")
|
||||||
@@ -12,35 +12,53 @@ def get_config_path() -> Path:
|
|||||||
return Path.home() / ".nanobot" / "config.json"
|
return Path.home() / ".nanobot" / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_oauth_store_dir() -> Path:
|
||||||
|
"""Get the OAuth store directory."""
|
||||||
|
return Path.home() / ".nanobot"
|
||||||
|
|
||||||
|
|
||||||
def get_data_dir() -> Path:
|
def get_data_dir() -> Path:
|
||||||
"""Get the nanobot data directory."""
|
"""Get the nanobot data directory."""
|
||||||
from nanobot.utils.helpers import get_data_path
|
from nanobot.utils.helpers import get_data_path
|
||||||
return get_data_path()
|
return get_data_path()
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_oauth_credentials(config: Config) -> Config:
|
||||||
|
"""Inject OAuth credentials from store into config if available."""
|
||||||
|
from nanobot.config.oauth_store import OAuthStore
|
||||||
|
|
||||||
|
store = OAuthStore(_get_oauth_store_dir())
|
||||||
|
creds = store.load("anthropic")
|
||||||
|
if creds and creds.access_token and not creds.is_expired:
|
||||||
|
config.providers.anthropic.api_key = creds.access_token
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: Path | None = None) -> Config:
|
def load_config(config_path: Path | None = None) -> Config:
|
||||||
"""
|
"""
|
||||||
Load configuration from file or create default.
|
Load configuration from file or create default.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_path: Optional path to config file. Uses default if not provided.
|
config_path: Optional path to config file. Uses default if not provided.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Loaded configuration object.
|
Loaded configuration object.
|
||||||
"""
|
"""
|
||||||
path = config_path or get_config_path()
|
path = config_path or get_config_path()
|
||||||
|
|
||||||
if path.exists():
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
data = _migrate_config(data)
|
data = _migrate_config(data)
|
||||||
return Config.model_validate(convert_keys(data))
|
config = Config.model_validate(convert_keys(data))
|
||||||
|
return _inject_oauth_credentials(config)
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
print(f"Warning: Failed to load config from {path}: {e}")
|
print(f"Warning: Failed to load config from {path}: {e}")
|
||||||
print("Using default configuration.")
|
print("Using default configuration.")
|
||||||
|
|
||||||
return Config()
|
return _inject_oauth_credentials(Config())
|
||||||
|
|
||||||
|
|
||||||
def save_config(config: Config, config_path: Path | None = None) -> None:
|
def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""OAuth credential storage."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.config.schema import OAuthCredentials
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthStore:
|
||||||
|
"""Stores OAuth credentials in a JSON file."""
|
||||||
|
|
||||||
|
FILENAME = "oauth-credentials.json"
|
||||||
|
|
||||||
|
def __init__(self, config_dir: Path):
|
||||||
|
self.config_dir = config_dir
|
||||||
|
self.file_path = config_dir / self.FILENAME
|
||||||
|
|
||||||
|
def _load_all(self) -> dict[str, Any]:
|
||||||
|
"""Load all credentials from file."""
|
||||||
|
if not self.file_path.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with open(self.file_path, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def _save_all(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Save all credentials to file."""
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(self.file_path, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
# Secure permissions
|
||||||
|
self.file_path.chmod(0o600)
|
||||||
|
|
||||||
|
def save(self, provider: str, credentials: OAuthCredentials) -> None:
|
||||||
|
"""Save credentials for a provider."""
|
||||||
|
data = self._load_all()
|
||||||
|
data[provider] = credentials.model_dump()
|
||||||
|
self._save_all(data)
|
||||||
|
|
||||||
|
def load(self, provider: str) -> OAuthCredentials | None:
|
||||||
|
"""Load credentials for a provider."""
|
||||||
|
data = self._load_all()
|
||||||
|
if provider not in data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return OAuthCredentials(**data[provider])
|
||||||
|
|
||||||
|
def delete(self, provider: str) -> bool:
|
||||||
|
"""Delete credentials for a provider."""
|
||||||
|
data = self._load_all()
|
||||||
|
if provider not in data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
del data[provider]
|
||||||
|
self._save_all(data)
|
||||||
|
return True
|
||||||
@@ -163,6 +163,7 @@ class AgentDefaults(BaseModel):
|
|||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
max_tool_iterations: int = 20
|
max_tool_iterations: int = 20
|
||||||
memory_window: int = 50
|
memory_window: int = 50
|
||||||
|
thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking
|
||||||
|
|
||||||
|
|
||||||
class AgentsConfig(BaseModel):
|
class AgentsConfig(BaseModel):
|
||||||
@@ -170,11 +171,41 @@ class AgentsConfig(BaseModel):
|
|||||||
defaults: AgentDefaults = Field(default_factory=AgentDefaults)
|
defaults: AgentDefaults = Field(default_factory=AgentDefaults)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthCredentials(BaseModel):
|
||||||
|
"""OAuth token credentials for subscription-based auth."""
|
||||||
|
access_token: str = ""
|
||||||
|
refresh_token: str = ""
|
||||||
|
expires_at: int = 0 # Unix timestamp
|
||||||
|
token_type: str = "oauth" # "oauth" or "token" (setup-token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_oauth_token(self) -> bool:
|
||||||
|
"""Check if this is an OAuth token (vs regular API key)."""
|
||||||
|
return "sk-ant-oat" in self.access_token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if token has expired."""
|
||||||
|
import time
|
||||||
|
if self.expires_at == 0:
|
||||||
|
return False # No expiry set (setup-token)
|
||||||
|
return time.time() > self.expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expires_soon(self) -> bool:
|
||||||
|
"""Check if token expires within 10 minutes."""
|
||||||
|
import time
|
||||||
|
if self.expires_at == 0:
|
||||||
|
return False
|
||||||
|
return time.time() > (self.expires_at - 600)
|
||||||
|
|
||||||
|
|
||||||
class ProviderConfig(BaseModel):
|
class ProviderConfig(BaseModel):
|
||||||
"""LLM provider configuration."""
|
"""LLM provider configuration."""
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
api_base: str | None = None
|
api_base: str | None = None
|
||||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||||
|
oauth_credentials: OAuthCredentials | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProvidersConfig(BaseModel):
|
class ProvidersConfig(BaseModel):
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
"""LLM provider abstraction module."""
|
"""Provider module exports."""
|
||||||
|
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||||
|
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||||
|
from nanobot.providers.registry import should_use_oauth_provider
|
||||||
|
|
||||||
__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider"]
|
__all__ = [
|
||||||
|
"LLMProvider",
|
||||||
|
"LLMResponse",
|
||||||
|
"ToolCallRequest",
|
||||||
|
"LiteLLMProvider",
|
||||||
|
"AnthropicOAuthProvider",
|
||||||
|
"create_provider",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_provider(
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
api_base: str | None = None,
|
||||||
|
extra_headers: dict[str, str] | None = None,
|
||||||
|
provider_name: str | None = None,
|
||||||
|
thinking_budget: int = 0,
|
||||||
|
) -> LLMProvider:
|
||||||
|
"""Factory function to create appropriate provider.
|
||||||
|
|
||||||
|
Automatically selects AnthropicOAuthProvider for OAuth tokens,
|
||||||
|
LiteLLMProvider for everything else.
|
||||||
|
"""
|
||||||
|
if should_use_oauth_provider(api_key, model):
|
||||||
|
return AnthropicOAuthProvider(
|
||||||
|
oauth_token=api_key,
|
||||||
|
default_model=model,
|
||||||
|
api_base=api_base,
|
||||||
|
thinking_budget=thinking_budget,
|
||||||
|
)
|
||||||
|
|
||||||
|
return LiteLLMProvider(
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=api_base,
|
||||||
|
default_model=model,
|
||||||
|
extra_headers=extra_headers,
|
||||||
|
provider_name=provider_name,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""Anthropic OAuth provider - direct API calls with Bearer auth.
|
||||||
|
|
||||||
|
This provider bypasses litellm to properly handle OAuth tokens
|
||||||
|
which require Authorization: Bearer header instead of x-api-key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
|
from nanobot.providers.oauth_utils import get_auth_headers
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicOAuthProvider(LLMProvider):
|
||||||
|
"""
|
||||||
|
Anthropic provider using OAuth token authentication.
|
||||||
|
|
||||||
|
Unlike the LiteLLM provider, this calls the Anthropic API directly
|
||||||
|
with proper Bearer token authentication for Claude Max/Pro subscriptions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
oauth_token: str,
|
||||||
|
default_model: str = "claude-opus-4-5",
|
||||||
|
api_base: str | None = None,
|
||||||
|
thinking_budget: int = 0,
|
||||||
|
):
|
||||||
|
super().__init__(api_key=None, api_base=api_base)
|
||||||
|
self.oauth_token = oauth_token
|
||||||
|
self.default_model = default_model
|
||||||
|
self.thinking_budget = thinking_budget
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict[str, str]:
|
||||||
|
"""Get request headers with Bearer auth."""
|
||||||
|
return get_auth_headers(self.oauth_token, is_oauth=True)
|
||||||
|
|
||||||
|
def _get_api_url(self) -> str:
|
||||||
|
"""Get API endpoint URL."""
|
||||||
|
if self.api_base:
|
||||||
|
return f"{self.api_base.rstrip('/')}/v1/messages"
|
||||||
|
return self.ANTHROPIC_API_URL
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_model(model: str) -> str:
|
||||||
|
"""Normalize model name for the Anthropic API.
|
||||||
|
|
||||||
|
Anthropic model IDs use hyphens (claude-sonnet-4-5), but users often
|
||||||
|
write dots (claude-sonnet-4.5). Normalize so both work.
|
||||||
|
"""
|
||||||
|
return model.replace(".", "-")
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Get or create async HTTP client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = httpx.AsyncClient(timeout=300.0)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _prepare_messages(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]]
|
||||||
|
) -> tuple[str | None, list[dict[str, Any]]]:
|
||||||
|
"""Prepare messages: extract system prompt and convert OpenAI format to Anthropic.
|
||||||
|
|
||||||
|
The agent loop produces messages in OpenAI format:
|
||||||
|
- assistant msgs with tool_calls [{type:"function", function:{name, arguments}}]
|
||||||
|
- tool role msgs with tool_call_id, name, content
|
||||||
|
|
||||||
|
Anthropic API expects:
|
||||||
|
- assistant msgs with content blocks [{type:"tool_use", id, name, input}]
|
||||||
|
- user msgs with content blocks [{type:"tool_result", tool_use_id, content}]
|
||||||
|
|
||||||
|
Returns (system_prompt, anthropic_messages)
|
||||||
|
"""
|
||||||
|
system_parts = []
|
||||||
|
converted: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role")
|
||||||
|
|
||||||
|
if role == "system":
|
||||||
|
system_parts.append(msg.get("content", ""))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "assistant" and msg.get("tool_calls"):
|
||||||
|
# Convert OpenAI tool_calls to Anthropic content blocks
|
||||||
|
content_blocks: list[dict[str, Any]] = []
|
||||||
|
# Preserve thinking blocks (list=raw API blocks with signatures, str=legacy)
|
||||||
|
rc = msg.get("reasoning_content")
|
||||||
|
if isinstance(rc, list):
|
||||||
|
content_blocks.extend(rc)
|
||||||
|
elif isinstance(rc, str) and rc:
|
||||||
|
content_blocks.append({"type": "thinking", "thinking": rc})
|
||||||
|
text = msg.get("content")
|
||||||
|
if text:
|
||||||
|
content_blocks.append({"type": "text", "text": text})
|
||||||
|
for tc in msg["tool_calls"]:
|
||||||
|
func = tc.get("function", {})
|
||||||
|
args = func.get("arguments", "{}")
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
args = {}
|
||||||
|
content_blocks.append({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.get("id", ""),
|
||||||
|
"name": func.get("name", ""),
|
||||||
|
"input": args,
|
||||||
|
})
|
||||||
|
converted.append({"role": "assistant", "content": content_blocks})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "assistant" and msg.get("reasoning_content"):
|
||||||
|
# Plain assistant message with thinking (no tool calls)
|
||||||
|
rc = msg["reasoning_content"]
|
||||||
|
if isinstance(rc, list):
|
||||||
|
content_blocks = list(rc)
|
||||||
|
else:
|
||||||
|
content_blocks = [{"type": "thinking", "thinking": rc}]
|
||||||
|
text = msg.get("content")
|
||||||
|
if text:
|
||||||
|
content_blocks.append({"type": "text", "text": text})
|
||||||
|
converted.append({"role": "assistant", "content": content_blocks})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "tool":
|
||||||
|
# Convert tool result to Anthropic user message with tool_result block
|
||||||
|
tool_result_block = {
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": msg.get("tool_call_id", ""),
|
||||||
|
"content": msg.get("content", ""),
|
||||||
|
}
|
||||||
|
# Merge into previous user message if it already has tool_result blocks
|
||||||
|
if converted and converted[-1].get("role") == "user":
|
||||||
|
prev_content = converted[-1].get("content")
|
||||||
|
if isinstance(prev_content, list):
|
||||||
|
prev_content.append(tool_result_block)
|
||||||
|
continue
|
||||||
|
converted.append({"role": "user", "content": [tool_result_block]})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
content = msg.get("content", "")
|
||||||
|
# Convert OpenAI image_url blocks to Anthropic image blocks
|
||||||
|
if isinstance(content, list):
|
||||||
|
content = self._convert_image_blocks(content)
|
||||||
|
# Merge text into previous user message if it has tool_result blocks
|
||||||
|
# (handles the "Reflect on the results" interleaved message)
|
||||||
|
if converted and converted[-1].get("role") == "user":
|
||||||
|
prev_content = converted[-1].get("content")
|
||||||
|
if isinstance(prev_content, list):
|
||||||
|
if isinstance(content, str):
|
||||||
|
prev_content.append({"type": "text", "text": content})
|
||||||
|
elif isinstance(content, list):
|
||||||
|
prev_content.extend(content)
|
||||||
|
continue
|
||||||
|
converted.append({"role": role, "content": content})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pass through other messages (assistant without tool_calls, etc.)
|
||||||
|
converted.append(msg)
|
||||||
|
|
||||||
|
system_prompt = "\n\n".join(system_parts)
|
||||||
|
return system_prompt, converted
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_image_blocks(content: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Convert OpenAI image_url blocks to Anthropic image blocks.
|
||||||
|
|
||||||
|
OpenAI format: {"type": "image_url", "image_url": {"url": "data:mime;base64,DATA"}}
|
||||||
|
Anthropic format: {"type": "image", "source": {"type": "base64", "media_type": "mime", "data": "DATA"}}
|
||||||
|
"""
|
||||||
|
converted = []
|
||||||
|
for block in content:
|
||||||
|
if block.get("type") == "image_url":
|
||||||
|
url = block.get("image_url", {}).get("url", "")
|
||||||
|
if url.startswith("data:") and ";base64," in url:
|
||||||
|
header, data = url.split(";base64,", 1)
|
||||||
|
media_type = header.removeprefix("data:")
|
||||||
|
converted.append({
|
||||||
|
"type": "image",
|
||||||
|
"source": {"type": "base64", "media_type": media_type, "data": data},
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
converted.append({
|
||||||
|
"type": "image",
|
||||||
|
"source": {"type": "url", "url": url},
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
converted.append(block)
|
||||||
|
return converted
|
||||||
|
|
||||||
|
def _convert_tools_to_anthropic(
|
||||||
|
self,
|
||||||
|
tools: list[dict[str, Any]] | None
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
"""Convert OpenAI-format tools to Anthropic format."""
|
||||||
|
if not tools:
|
||||||
|
return None
|
||||||
|
|
||||||
|
anthropic_tools = []
|
||||||
|
for tool in tools:
|
||||||
|
if tool.get("type") == "function":
|
||||||
|
func = tool["function"]
|
||||||
|
anthropic_tools.append({
|
||||||
|
"name": func["name"],
|
||||||
|
"description": func.get("description", ""),
|
||||||
|
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
|
||||||
|
})
|
||||||
|
|
||||||
|
return anthropic_tools if anthropic_tools else None
|
||||||
|
|
||||||
|
async def _make_request(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
system: str | None = None,
|
||||||
|
model: str = "claude-opus-4-5",
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
thinking_budget_override: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Make request to Anthropic API."""
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extended thinking: temperature must be 1 when enabled
|
||||||
|
effective_thinking = thinking_budget_override if thinking_budget_override is not None else self.thinking_budget
|
||||||
|
if effective_thinking > 0:
|
||||||
|
payload["temperature"] = 1
|
||||||
|
# max_tokens must exceed budget_tokens
|
||||||
|
if max_tokens <= effective_thinking:
|
||||||
|
payload["max_tokens"] = effective_thinking + 4096
|
||||||
|
payload["thinking"] = {
|
||||||
|
"type": "enabled",
|
||||||
|
"budget_tokens": effective_thinking,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
payload["temperature"] = temperature
|
||||||
|
|
||||||
|
if system:
|
||||||
|
payload["system"] = system
|
||||||
|
|
||||||
|
if tools:
|
||||||
|
payload["tools"] = tools
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Anthropic request: model={} max_tokens={} thinking={} tools={}",
|
||||||
|
payload.get("model"), payload.get("max_tokens"),
|
||||||
|
payload.get("thinking", "disabled"),
|
||||||
|
len(payload.get("tools", [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
self._get_api_url(),
|
||||||
|
headers=self._get_headers(),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dump rate limit headers for analysis
|
||||||
|
try:
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
header_dump = {
|
||||||
|
"timestamp": datetime.datetime.utcnow().isoformat(),
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"model": payload.get("model"),
|
||||||
|
"headers": dict(response.headers),
|
||||||
|
}
|
||||||
|
dump_path = "/root/.nanobot/workspace/api_headers.jsonl"
|
||||||
|
with open(dump_path, "a") as f:
|
||||||
|
f.write(json.dumps(header_dump) + "\n")
|
||||||
|
# Capture rate limit state for quota-based model switching
|
||||||
|
hdrs = response.headers
|
||||||
|
rate_limit_state = {
|
||||||
|
"updated_at": datetime.datetime.utcnow().isoformat(),
|
||||||
|
"model": payload.get("model"),
|
||||||
|
"weekly_all_models": float(hdrs["anthropic-ratelimit-unified-7d-utilization"]) if hdrs.get("anthropic-ratelimit-unified-7d-utilization") else None,
|
||||||
|
"weekly_sonnet": float(hdrs["anthropic-ratelimit-unified-7d_sonnet-utilization"]) if hdrs.get("anthropic-ratelimit-unified-7d_sonnet-utilization") else None,
|
||||||
|
"session_5h": float(hdrs["anthropic-ratelimit-unified-5h-utilization"]) if hdrs.get("anthropic-ratelimit-unified-5h-utilization") else None,
|
||||||
|
"weekly_reset": int(hdrs["anthropic-ratelimit-unified-7d-reset"]) if hdrs.get("anthropic-ratelimit-unified-7d-reset") else None,
|
||||||
|
"session_reset": int(hdrs["anthropic-ratelimit-unified-5h-reset"]) if hdrs.get("anthropic-ratelimit-unified-5h-reset") else None,
|
||||||
|
"binding_limit": hdrs.get("anthropic-ratelimit-unified-representative-claim"),
|
||||||
|
"sonnet_fallback": hdrs.get("anthropic-ratelimit-unified-fallback"),
|
||||||
|
}
|
||||||
|
state_path = "/root/.nanobot/workspace/memory/rate_limits.json"
|
||||||
|
os.makedirs(os.path.dirname(state_path), exist_ok=True)
|
||||||
|
with open(state_path, "w") as f:
|
||||||
|
json.dump(rate_limit_state, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Rate limit header capture failed: {}", e)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_text = response.text
|
||||||
|
raise Exception(f"Anthropic API error {response.status_code}: {error_text}")
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
thinking_budget: int | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
"""Send chat completion request to Anthropic API."""
|
||||||
|
model = model or self.default_model
|
||||||
|
|
||||||
|
# Strip provider prefix if present (e.g. "anthropic/claude-opus-4-5" -> "claude-opus-4-5")
|
||||||
|
if "/" in model:
|
||||||
|
model = model.split("/")[-1]
|
||||||
|
|
||||||
|
# Normalize dots to hyphens (claude-sonnet-4.5 -> claude-sonnet-4-5)
|
||||||
|
model = self._normalize_model(model)
|
||||||
|
|
||||||
|
system, prepared_messages = self._prepare_messages(messages)
|
||||||
|
anthropic_tools = self._convert_tools_to_anthropic(tools)
|
||||||
|
|
||||||
|
# Per-call thinking override (None = use instance default)
|
||||||
|
effective_thinking = self.thinking_budget if thinking_budget is None else thinking_budget
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._make_request(
|
||||||
|
messages=prepared_messages,
|
||||||
|
system=system,
|
||||||
|
model=model,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
tools=anthropic_tools,
|
||||||
|
thinking_budget_override=effective_thinking,
|
||||||
|
)
|
||||||
|
return self._parse_response(response)
|
||||||
|
except Exception as e:
|
||||||
|
return LLMResponse(
|
||||||
|
content=f"Error calling LLM: {str(e)}",
|
||||||
|
finish_reason="error",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_response(self, response: dict[str, Any]) -> LLMResponse:
|
||||||
|
"""Parse Anthropic API response."""
|
||||||
|
content_blocks = response.get("content", [])
|
||||||
|
|
||||||
|
text_content = ""
|
||||||
|
thinking_blocks: list[dict[str, Any]] = []
|
||||||
|
tool_calls = []
|
||||||
|
|
||||||
|
for block in content_blocks:
|
||||||
|
if block.get("type") == "thinking":
|
||||||
|
# Preserve full block including signature for multi-turn replay
|
||||||
|
thinking_blocks.append(block)
|
||||||
|
elif block.get("type") == "text":
|
||||||
|
text_content += block.get("text", "")
|
||||||
|
elif block.get("type") == "tool_use":
|
||||||
|
tool_calls.append(ToolCallRequest(
|
||||||
|
id=block.get("id", ""),
|
||||||
|
name=block.get("name", ""),
|
||||||
|
arguments=block.get("input", {}),
|
||||||
|
))
|
||||||
|
|
||||||
|
usage = {}
|
||||||
|
if "usage" in response:
|
||||||
|
usage = {
|
||||||
|
"prompt_tokens": response["usage"].get("input_tokens", 0),
|
||||||
|
"completion_tokens": response["usage"].get("output_tokens", 0),
|
||||||
|
"total_tokens": (
|
||||||
|
response["usage"].get("input_tokens", 0) +
|
||||||
|
response["usage"].get("output_tokens", 0)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_reason = response.get("stop_reason", "end_turn")
|
||||||
|
thinking_chars = sum(len(b.get("thinking", "")) for b in thinking_blocks) if thinking_blocks else 0
|
||||||
|
logger.info(
|
||||||
|
"Anthropic response: stop={} tool_calls={} thinking={} chars, "
|
||||||
|
"input={} output={} tokens",
|
||||||
|
stop_reason, len(tool_calls), thinking_chars,
|
||||||
|
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return LLMResponse(
|
||||||
|
content=text_content or None,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
finish_reason=stop_reason,
|
||||||
|
usage=usage,
|
||||||
|
reasoning_content=thinking_blocks or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
"""Get the default model."""
|
||||||
|
return self.default_model
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP client."""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
@@ -20,7 +20,7 @@ class LLMResponse:
|
|||||||
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
||||||
finish_reason: str = "stop"
|
finish_reason: str = "stop"
|
||||||
usage: dict[str, int] = field(default_factory=dict)
|
usage: dict[str, int] = field(default_factory=dict)
|
||||||
reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc.
|
reasoning_content: Any = None # str for Kimi/DeepSeek-R1; list[dict] for Anthropic thinking blocks
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_tool_calls(self) -> bool:
|
def has_tool_calls(self) -> bool:
|
||||||
@@ -48,6 +48,7 @@ class LLMProvider(ABC):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_budget: int | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""
|
"""
|
||||||
Send a chat completion request.
|
Send a chat completion request.
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_budget: int | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""
|
"""
|
||||||
Send a chat completion request via LiteLLM.
|
Send a chat completion request via LiteLLM.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""OAuth utility functions for Anthropic subscription auth."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def is_oauth_token(token: str | None) -> bool:
|
||||||
|
"""Check if token is an OAuth token (vs regular API key).
|
||||||
|
|
||||||
|
OAuth tokens from Claude Max/Pro contain 'sk-ant-oat' prefix.
|
||||||
|
Regular API keys use 'sk-ant-api03' or similar.
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
return "sk-ant-oat" in token
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_headers(token: str, is_oauth: bool = False) -> dict[str, str]:
|
||||||
|
"""Get authentication headers for Anthropic API.
|
||||||
|
|
||||||
|
OAuth tokens require Authorization: Bearer header.
|
||||||
|
Regular API keys use x-api-key header.
|
||||||
|
"""
|
||||||
|
headers: dict[str, str] = {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_oauth:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
# Required headers to mimic Claude Code client
|
||||||
|
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20"
|
||||||
|
headers["anthropic-dangerous-direct-browser-access"] = "true"
|
||||||
|
headers["user-agent"] = "claude-cli/2.1.2 (external, cli)"
|
||||||
|
headers["x-app"] = "cli"
|
||||||
|
else:
|
||||||
|
headers["x-api-key"] = token
|
||||||
|
|
||||||
|
return headers
|
||||||
@@ -357,3 +357,24 @@ def find_by_name(name: str) -> ProviderSpec | None:
|
|||||||
if spec.name == name:
|
if spec.name == name:
|
||||||
return spec
|
return spec
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def should_use_oauth_provider(api_key: str | None, model: str) -> bool:
|
||||||
|
"""Determine if OAuth provider should be used.
|
||||||
|
|
||||||
|
OAuth provider is used when:
|
||||||
|
1. API key is an OAuth token (contains 'sk-ant-oat')
|
||||||
|
2. Model is an Anthropic model (contains 'claude' or 'anthropic')
|
||||||
|
"""
|
||||||
|
if not api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "sk-ant-oat" not in api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
model_lower = model.lower()
|
||||||
|
anthropic_spec = find_by_name("anthropic")
|
||||||
|
if anthropic_spec:
|
||||||
|
return any(kw in model_lower for kw in anthropic_spec.keywords)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Test Anthropic OAuth provider."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider():
|
||||||
|
"""Create provider with test OAuth token."""
|
||||||
|
return AnthropicOAuthProvider(
|
||||||
|
oauth_token="sk-ant-oat01-test-token",
|
||||||
|
default_model="claude-opus-4-5"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_init(provider):
|
||||||
|
"""Provider should initialize with OAuth token."""
|
||||||
|
assert provider.oauth_token == "sk-ant-oat01-test-token"
|
||||||
|
assert provider.default_model == "claude-opus-4-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_uses_bearer_auth(provider):
|
||||||
|
"""Provider should use Bearer auth, not x-api-key."""
|
||||||
|
headers = provider._get_headers()
|
||||||
|
assert "Authorization" in headers
|
||||||
|
assert headers["Authorization"].startswith("Bearer ")
|
||||||
|
assert "x-api-key" not in headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_prepends_system_prompt(provider):
|
||||||
|
"""Chat should prepend Claude Code identity to system prompt."""
|
||||||
|
messages = [{"role": "user", "content": "Hello"}]
|
||||||
|
|
||||||
|
with patch.object(provider, "_make_request", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = {"content": [{"type": "text", "text": "Hi"}], "stop_reason": "end_turn"}
|
||||||
|
await provider.chat(messages)
|
||||||
|
|
||||||
|
call_args = mock.call_args
|
||||||
|
system = call_args[1]["system"]
|
||||||
|
assert "Claude Code" in system
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_response_text(provider):
|
||||||
|
"""Should parse text response correctly."""
|
||||||
|
response = {
|
||||||
|
"content": [{"type": "text", "text": "Hello world"}],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||||
|
}
|
||||||
|
result = provider._parse_response(response)
|
||||||
|
assert result.content == "Hello world"
|
||||||
|
assert result.finish_reason == "end_turn"
|
||||||
|
assert result.usage["prompt_tokens"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_response_tool_calls(provider):
|
||||||
|
"""Should parse tool call response correctly."""
|
||||||
|
response = {
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "/tmp/test"}}
|
||||||
|
],
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||||
|
}
|
||||||
|
result = provider._parse_response(response)
|
||||||
|
assert len(result.tool_calls) == 1
|
||||||
|
assert result.tool_calls[0].name == "read_file"
|
||||||
|
assert result.tool_calls[0].arguments == {"path": "/tmp/test"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_tools_to_anthropic(provider):
|
||||||
|
"""Should convert OpenAI-format tools to Anthropic format."""
|
||||||
|
openai_tools = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Read a file",
|
||||||
|
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
anthropic_tools = provider._convert_tools_to_anthropic(openai_tools)
|
||||||
|
assert len(anthropic_tools) == 1
|
||||||
|
assert anthropic_tools[0]["name"] == "read_file"
|
||||||
|
assert "input_schema" in anthropic_tools[0]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Test OAuth CLI commands."""
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from nanobot.cli.oauth import oauth_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner():
|
||||||
|
return CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_login_help(runner):
|
||||||
|
"""Login command should have help text."""
|
||||||
|
result = runner.invoke(oauth_app, ["login", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "token" in result.output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_status_no_credentials(runner, tmp_path, monkeypatch):
|
||||||
|
"""Status should show no credentials when none exist."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
result = runner.invoke(oauth_app, ["status"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No OAuth credentials" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_login_and_status(runner, tmp_path, monkeypatch):
|
||||||
|
"""Login should save credentials, status should show them."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
result = runner.invoke(oauth_app, ["login", "--token", "sk-ant-oat01-test-xxx"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Successfully saved" in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(oauth_app, ["status"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "sk-ant-oat01-test-x" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_logout(runner, tmp_path, monkeypatch):
|
||||||
|
"""Logout should remove credentials."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
runner.invoke(oauth_app, ["login", "--token", "sk-ant-oat01-test-xxx"])
|
||||||
|
result = runner.invoke(oauth_app, ["logout"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Removed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_login_invalid_token(runner, tmp_path, monkeypatch):
|
||||||
|
"""Login should reject non-OAuth tokens."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
result = runner.invoke(oauth_app, ["login", "--token", "sk-ant-api03-regular"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Invalid token" in result.output
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Test OAuth store integration with config loading."""
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
|
from nanobot.config.oauth_store import OAuthStore
|
||||||
|
from nanobot.config.schema import OAuthCredentials
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_token_injected_into_config(tmp_path, monkeypatch):
|
||||||
|
"""OAuth token from store should be injected into provider api_key."""
|
||||||
|
# Create a minimal config file (no api key set)
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps({
|
||||||
|
"agents": {"defaults": {"model": "anthropic/claude-opus-4-5"}},
|
||||||
|
"providers": {"anthropic": {"apiKey": ""}}
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Save OAuth credentials
|
||||||
|
store = OAuthStore(tmp_path)
|
||||||
|
creds = OAuthCredentials(access_token="sk-ant-oat01-test-inject")
|
||||||
|
store.save("anthropic", creds)
|
||||||
|
|
||||||
|
# Monkeypatch get_config_path to use our tmp dir
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
|
||||||
|
# Monkeypatch the OAuth store path
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path)
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
assert config.providers.anthropic.api_key == "sk-ant-oat01-test-inject"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_without_oauth_unchanged(tmp_path, monkeypatch):
|
||||||
|
"""Config without OAuth store should load normally."""
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps({
|
||||||
|
"providers": {"anthropic": {"apiKey": "sk-ant-api03-regular"}}
|
||||||
|
}))
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path / "nonexistent")
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
assert config.providers.anthropic.api_key == "sk-ant-api03-regular"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_does_not_overwrite_existing_key(tmp_path, monkeypatch):
|
||||||
|
"""If user already has an API key, OAuth should still override (OAuth takes priority)."""
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps({
|
||||||
|
"providers": {"anthropic": {"apiKey": "sk-ant-api03-existing"}}
|
||||||
|
}))
|
||||||
|
|
||||||
|
store = OAuthStore(tmp_path)
|
||||||
|
creds = OAuthCredentials(access_token="sk-ant-oat01-oauth-wins")
|
||||||
|
store.save("anthropic", creds)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path)
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
# OAuth token takes priority over existing API key
|
||||||
|
assert config.providers.anthropic.api_key == "sk-ant-oat01-oauth-wins"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Test OAuth configuration schema."""
|
||||||
|
import pytest
|
||||||
|
from nanobot.config.schema import ProviderConfig, OAuthCredentials
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_config_has_oauth_fields():
|
||||||
|
"""ProviderConfig should have oauth_credentials field."""
|
||||||
|
config = ProviderConfig(api_key="test")
|
||||||
|
assert hasattr(config, "oauth_credentials")
|
||||||
|
assert config.oauth_credentials is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_credentials_model():
|
||||||
|
"""OAuthCredentials should store token, refresh, expiry."""
|
||||||
|
creds = OAuthCredentials(
|
||||||
|
access_token="sk-ant-oat01-xxx",
|
||||||
|
refresh_token="rt_xxx",
|
||||||
|
expires_at=1234567890,
|
||||||
|
token_type="oauth"
|
||||||
|
)
|
||||||
|
assert creds.access_token.startswith("sk-ant-oat")
|
||||||
|
assert creds.is_oauth_token is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_credentials_expiry_check():
|
||||||
|
"""OAuthCredentials should detect expired tokens."""
|
||||||
|
import time
|
||||||
|
expired = OAuthCredentials(
|
||||||
|
access_token="sk-ant-oat01-xxx",
|
||||||
|
expires_at=int(time.time()) - 3600 # 1 hour ago
|
||||||
|
)
|
||||||
|
assert expired.is_expired is True
|
||||||
|
|
||||||
|
valid = OAuthCredentials(
|
||||||
|
access_token="sk-ant-oat01-xxx",
|
||||||
|
expires_at=int(time.time()) + 3600 # 1 hour from now
|
||||||
|
)
|
||||||
|
assert valid.is_expired is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_credentials_no_expiry():
|
||||||
|
"""Setup tokens with expires_at=0 should never be expired."""
|
||||||
|
creds = OAuthCredentials(
|
||||||
|
access_token="sk-ant-oat01-xxx",
|
||||||
|
expires_at=0 # No expiry (setup-token)
|
||||||
|
)
|
||||||
|
assert creds.is_expired is False
|
||||||
|
assert creds.expires_soon is False
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Test OAuth credential storage."""
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from nanobot.config.oauth_store import OAuthStore
|
||||||
|
from nanobot.config.schema import OAuthCredentials
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_store():
|
||||||
|
"""Create store with temp directory."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
yield OAuthStore(Path(tmpdir) / ".nanobot")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_credentials(temp_store):
|
||||||
|
"""Should save and load OAuth credentials."""
|
||||||
|
creds = OAuthCredentials(
|
||||||
|
access_token="sk-ant-oat01-xxx",
|
||||||
|
refresh_token="rt_xxx",
|
||||||
|
expires_at=1234567890
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_store.save("anthropic", creds)
|
||||||
|
loaded = temp_store.load("anthropic")
|
||||||
|
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.access_token == creds.access_token
|
||||||
|
assert loaded.refresh_token == creds.refresh_token
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_nonexistent_returns_none(temp_store):
|
||||||
|
"""Should return None for missing credentials."""
|
||||||
|
assert temp_store.load("nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_credentials(temp_store):
|
||||||
|
"""Should delete saved credentials."""
|
||||||
|
creds = OAuthCredentials(access_token="sk-ant-oat01-xxx")
|
||||||
|
temp_store.save("anthropic", creds)
|
||||||
|
assert temp_store.delete("anthropic") is True
|
||||||
|
assert temp_store.load("anthropic") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_nonexistent_returns_false(temp_store):
|
||||||
|
"""Should return False when deleting missing credentials."""
|
||||||
|
assert temp_store.delete("nonexistent") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_permissions(temp_store):
|
||||||
|
"""Credentials file should have restricted permissions."""
|
||||||
|
creds = OAuthCredentials(access_token="sk-ant-oat01-xxx")
|
||||||
|
temp_store.save("anthropic", creds)
|
||||||
|
perms = oct(temp_store.file_path.stat().st_mode)[-3:]
|
||||||
|
assert perms == "600"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Test OAuth utility functions."""
|
||||||
|
import pytest
|
||||||
|
from nanobot.providers.oauth_utils import is_oauth_token, get_auth_headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_oauth_token_detects_oat():
|
||||||
|
"""Should detect sk-ant-oat tokens as OAuth."""
|
||||||
|
assert is_oauth_token("sk-ant-oat01-buSdhCH2XEkebW7ZQZTvGqH5EwAFh4u52LrdJhAP") is True
|
||||||
|
assert is_oauth_token("sk-ant-api03-regularkey") is False
|
||||||
|
assert is_oauth_token("") is False
|
||||||
|
assert is_oauth_token(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_auth_headers_oauth():
|
||||||
|
"""OAuth tokens should use Authorization: Bearer."""
|
||||||
|
headers = get_auth_headers("sk-ant-oat01-xxx", is_oauth=True)
|
||||||
|
assert "Authorization" in headers
|
||||||
|
assert headers["Authorization"] == "Bearer sk-ant-oat01-xxx"
|
||||||
|
assert "x-api-key" not in headers
|
||||||
|
assert headers["anthropic-beta"] == "claude-code-20250219,oauth-2025-04-20"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_auth_headers_api_key():
|
||||||
|
"""Regular API keys should use x-api-key."""
|
||||||
|
headers = get_auth_headers("sk-ant-api03-xxx", is_oauth=False)
|
||||||
|
assert "x-api-key" in headers
|
||||||
|
assert headers["x-api-key"] == "sk-ant-api03-xxx"
|
||||||
|
assert "Authorization" not in headers
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Test provider factory with OAuth support."""
|
||||||
|
import pytest
|
||||||
|
from nanobot.providers import create_provider
|
||||||
|
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||||
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_provider_oauth_token():
|
||||||
|
"""OAuth tokens should create AnthropicOAuthProvider."""
|
||||||
|
provider = create_provider(
|
||||||
|
api_key="sk-ant-oat01-test-token",
|
||||||
|
model="anthropic/claude-opus-4-5"
|
||||||
|
)
|
||||||
|
assert isinstance(provider, AnthropicOAuthProvider)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_provider_regular_key():
|
||||||
|
"""Regular API keys should create LiteLLMProvider."""
|
||||||
|
provider = create_provider(
|
||||||
|
api_key="sk-ant-api03-regular-key",
|
||||||
|
model="anthropic/claude-opus-4-5"
|
||||||
|
)
|
||||||
|
assert isinstance(provider, LiteLLMProvider)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_provider_openrouter():
|
||||||
|
"""OpenRouter keys should create LiteLLMProvider."""
|
||||||
|
provider = create_provider(
|
||||||
|
api_key="sk-or-v1-xxx",
|
||||||
|
model="anthropic/claude-opus-4-5"
|
||||||
|
)
|
||||||
|
assert isinstance(provider, LiteLLMProvider)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Test OAuth detection in provider registry."""
|
||||||
|
import pytest
|
||||||
|
from nanobot.providers.registry import should_use_oauth_provider
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_use_oauth_for_oat_token():
|
||||||
|
"""OAuth provider should be used for sk-ant-oat tokens."""
|
||||||
|
assert should_use_oauth_provider("sk-ant-oat01-xxx", "anthropic/claude-opus-4-5") is True
|
||||||
|
assert should_use_oauth_provider("sk-ant-oat01-xxx", "claude-sonnet-4") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_not_use_oauth_for_regular_key():
|
||||||
|
"""Regular API keys should not use OAuth provider."""
|
||||||
|
assert should_use_oauth_provider("sk-ant-api03-xxx", "claude-opus-4-5") is False
|
||||||
|
assert should_use_oauth_provider("sk-or-v1-xxx", "anthropic/claude-opus-4-5") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_not_use_oauth_for_non_anthropic():
|
||||||
|
"""Non-Anthropic models should not use OAuth provider."""
|
||||||
|
assert should_use_oauth_provider("sk-ant-oat01-xxx", "gpt-4") is False
|
||||||
|
assert should_use_oauth_provider("sk-ant-oat01-xxx", "deepseek/deepseek-chat") is False
|
||||||
Reference in New Issue
Block a user