Compare commits

...
Author SHA1 Message Date
wylabandClaude Sonnet 4.5 48ee7e1fd0 feat: capture Anthropic rate limit headers for quota-based model switching
- Writes rate_limits.json after every API call with weekly/5h utilization
- Writes api_headers.jsonl with raw headers for analysis
- Upgrades quota fallback model from Sonnet 4.5 to 4.6

Deploy to site-packages (gateway loads from there, not /app/):
  docker cp to /usr/local/lib/python3.12/site-packages/nanobot/providers/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 20:18:39 +00:00
wylab 01d5ce9f1b Revert "feat: capture Anthropic rate limit headers for heartbeat throttling"
This reverts commit f58d967487.
2026-02-18 19:03:20 +00:00
wylab b6d96d926a Revert "fix: log rate limit header capture exceptions instead of silently ignoring"
This reverts commit 684233240b.
2026-02-18 19:03:20 +00:00
wylabandClaude Sonnet 4.5 684233240b fix: log rate limit header capture exceptions instead of silently ignoring
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 18:52:55 +00:00
nanobotandwylab f58d967487 feat: capture Anthropic rate limit headers for heartbeat throttling
Adds rate limit state capture to _make_request():
- Parses 7 Anthropic rate limit headers from every API response
- Writes to memory/rate_limits.json with utilization, reset times, binding limit
- Enables HEARTBEAT.md to throttle based on weekly_all_models budget

Part of rate limit monitoring implementation plan.
2026-02-18 18:38:10 +00:00
wylabandClaude Opus 4.6 c0a87d77fc fix: loguru format strings and consolidate response logging
Build Nanobot OAuth / build (pull_request) Successful in 5m58s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
- Changed printf-style (%s/%d) to loguru format ({}) in 3 log statements
- Consolidated response logging into a single line showing stop_reason,
  tool_calls count, thinking chars, and token usage
- Added tool count to request logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:47:38 +00:00
wylabandClaude Opus 4.6 6e627bc2e0 fix: use quota-selected model in system handler
The system message handler was using self.model instead of the
quota-selected model, bypassing the Opus/Sonnet switching logic.
Also added debug logging for model selection and thinking_budget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:47:32 +00:00
wylabandClaude Sonnet 4.5 ece660ae69 feat: dynamic Opus/Sonnet model switching based on rolling quota
Build Nanobot OAuth / build (pull_request) Successful in 5m34s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
Implement intelligent model selection to manage 7-day Opus quota burn rate:

- Add _select_model_based_on_quota() method to AgentLoop
  - Reads rate limit data from memory/rate_limits.json
  - Calculates expected vs actual quota usage (100%/168h = 0.595% per hour)
  - If actual > expected × 1.17 (17% overage), downgrades to Sonnet
  - If actual ≤ expected, uses Opus
  - Caches decision for 5 minutes to minimize file I/O

- Add /quota slash command to display real-time quota status
  - Shows current usage vs expected usage
  - Shows hours until weekly reset
  - Shows selected model and burn rate multiplier

- Main agent now calls _select_model_based_on_quota() before each conversation
  - Heartbeat subagent unaffected (explicitly uses claude-sonnet-4-20250514)

This replaces the wrong approach from PR #5 which throttled heartbeat
frequency instead of switching the main agent's model.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 00:37:00 +01:00
wylabandClaude Opus 4.6 84268edf01 Fix memory consolidation truncation: set max_tokens=16384
Build Nanobot OAuth / build (push) Successful in 5m52s
Build Nanobot OAuth / cleanup (push) Successful in 10s
Consolidation was failing because max_tokens defaulted to 4096,
causing Haiku's response to be truncated mid-JSON (finish_reason=max_tokens).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:45:35 +01:00
wylabandClaude Opus 4.6 9136cca1ff Fix memory consolidation timeout: use Haiku without thinking
Build Nanobot OAuth / build (push) Successful in 5m51s
Build Nanobot OAuth / cleanup (push) Successful in 3s
Root cause: consolidation was calling Opus 4.6 with 10k thinking budget
on 50-80 message prompts. The 300s httpx timeout killed every request
(all failures were exactly 5 minutes after start). Consolidation is just
summarization — Haiku with no thinking handles it in seconds.

Also adds per-call thinking_budget override to the provider interface
so callers can disable thinking for lightweight tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:30:48 +01:00
wylabandClaude Opus 4.6 6035b70ae5 Add psycopg2-binary to Docker image for PostgreSQL access
Build Nanobot OAuth / build (push) Successful in 5m29s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:25:02 +01:00
nanobotandcode-server e4c300bcfd Increase subagent max_iterations from 15 to 50 (#3)
Build Nanobot OAuth / build (push) Successful in 47s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Co-authored-by: nanobot <nanobot@wylab.me>
Co-committed-by: nanobot <nanobot@wylab.me>
2026-02-14 11:40:50 +01:00
wylabandClaude Opus 4.6 0c65efee06 ci: remove deploy workflow, replaced by Watchtower
Build Nanobot OAuth / build (push) Successful in 42s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Auto-deploy is now handled by Watchtower on Unraid, which polls
for new images every 5 minutes for labeled containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:49:26 +01:00
wylabandClaude Opus 4.6 c12d234ee8 ci: add self-deploy workflow via workflow_dispatch
Build Nanobot OAuth / build (push) Successful in 41s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Allows triggering a deploy via Gitea API. SSHes to Unraid to pull
latest image and restart the nanobot container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:40:43 +01:00
wylabandClaude Opus 4.6 2954933a55 fix(ci): add https:// to cleanup API URLs
Build Nanobot OAuth / build (push) Successful in 40s
Build Nanobot OAuth / cleanup (push) Successful in 1s
REGISTRY env var is just the hostname without scheme. Docker actions
handle this automatically, but curl needs the full URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:25:43 +01:00
wylabandClaude Opus 4.6 a71cce08c1 ci: auto-cleanup SHA-tagged images older than 24h
Build Nanobot OAuth / build (push) Successful in 5m30s
Build Nanobot OAuth / cleanup (push) Failing after 0s
Runs after push builds and daily at 03:00 UTC. Keeps :latest and
:buildcache, deletes old SHA-tagged images via Gitea packages API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:18:26 +01:00
wylabandClaude Opus 4.6 c10544fc19 ci: let PR builds write to registry cache
Build Nanobot OAuth / build (push) Has been cancelled
Makes merge builds near-instant since PR already cached all layers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:17:05 +01:00
nanobotandcode-server 7c659bc0fe feat: add optional model override for spawn subagents (#1)
Build Nanobot OAuth / build (push) Has been cancelled
Co-authored-by: Nanobot Agent <nanobot@wylab.me>
Co-committed-by: Nanobot Agent <nanobot@wylab.me>
2026-02-14 03:13:47 +01:00
wylabandClaude Opus 4.6 ea5bf4cf5d ci: require build pass before PR merge
Build Nanobot OAuth / build (push) Successful in 50s
- Add pull_request trigger to build workflow
- Skip push and cache-to on PRs (build-only validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 02:59:01 +01:00
wylabandClaude Opus 4.6 9f3e4089c2 Translate OpenAI image_url blocks to Anthropic image format
Build Nanobot OAuth / build (push) Successful in 5m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 01:40:47 +01:00
wylabandClaude Opus 4.6 c9880d4267 Add summarize to Docker image
Build Nanobot OAuth / build (push) Successful in 15m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:41:39 +01:00
wylabandClaude Opus 4.6 af436f5e6c Remove hardcoded identity strings from system prompt
Build Nanobot OAuth / build (push) Successful in 6m13s
- Remove "# nanobot" branding and "You are nanobot" from context.py
- Remove "You are a helpful AI assistant" personality line
- Remove fake "required" Claude Code system prefix from OAuth provider
- Identity is now fully customizable via IDENTITY.md in workspace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:10:54 +01:00
wylabandClaude Opus 4.6 2d3c94e609 fix: replace Homebrew with direct installs in Dockerfile
Build Nanobot OAuth / build (push) Successful in 25m40s
Homebrew refuses to run as root in Docker containers.
Replace all brew installs with:
- GitHub release binaries (gogcli, goplaces, himalaya, obsidian-cli)
- go install (songsee)
- npm (gemini-cli)
- uv tool (openai-whisper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:23:11 +01:00
wylabandClaude Opus 4.6 88d2abc6c5 Port OpenClaw skills: add clawdbot metadata support + deps
Build Nanobot OAuth / build (push) Failing after 6m51s
- skills.py: recognize "clawdbot" metadata key alongside "nanobot"
  so OpenClaw SKILL.md files work without rewriting
- Dockerfile.oauth: add skill binary dependencies
  - APT: ffmpeg, jq, tmux, gh
  - Go: blogwatcher, blucli, gifgrep, sonoscli, wacli
  - Brew: gogcli, goplaces, songsee, gemini-cli, obsidian-cli,
    himalaya, openai-whisper
  - npm: @steipete/oracle
  - uv: nano-pdf

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:01:19 +01:00
wylabandClaude Opus 4.6 112212d3cd fix: use loguru for provider logging
Build Nanobot OAuth / build (push) Successful in 1m58s
Nanobot uses loguru, not stdlib logging. Switch to loguru so
thinking/usage logs actually appear in container output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:12:55 +01:00
wylabandClaude Opus 4.6 2ab51cb80b Add debug logging to Anthropic OAuth provider
Build Nanobot OAuth / build (push) Successful in 1m59s
Logs thinking block presence, character count, and token usage
in API responses. Also logs request parameters including thinking
budget configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:59:32 +01:00
wylabandClaude Opus 4.6 f71b3b3fea Preserve thinking block signatures for multi-turn conversations
Build Nanobot OAuth / build (push) Successful in 1m59s
The Anthropic API returns a signature field in thinking blocks that
must be replayed in subsequent turns. Store full thinking blocks
(including signatures) instead of just the text content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:51:15 +01:00
wylabandClaude Opus 4.6 9990e80d61 Replace hardcoded model aliases with dot-to-hyphen normalization
Build Nanobot OAuth / build (push) Successful in 1m52s
Instead of maintaining a brittle alias dict mapping model names to
dated API IDs, simply normalize dots to hyphens. The Anthropic API
accepts both claude-sonnet-4-5 and dated variants like
claude-sonnet-4-5-20250929, so no alias table is needed. This lets
users write "claude-sonnet-4.5" or "claude-sonnet-4-5" interchangeably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:46:10 +01:00
wylabandClaude Opus 4.6 9a131cb0ed Add extended thinking support for Anthropic API
Build Nanobot OAuth / build (push) Successful in 1m57s
Adds configurable thinking_budget in agent defaults. When >0, sends
the thinking parameter to the API with the specified token budget.
Handles API constraints: forces temperature=1, auto-bumps max_tokens
if it's below the thinking budget, preserves thinking blocks in
message history for multi-turn conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:38:58 +01:00
wylabandClaude Opus 4.6 c5ab4098ca Fix tool_use message format for Anthropic API
Build Nanobot OAuth / build (push) Successful in 1m59s
The agent loop produces messages in OpenAI format (role:tool, tool_calls
array) but the Anthropic API expects its own format (tool_use content
blocks in assistant messages, tool_result blocks in user messages).

This caused 400 errors whenever the bot tried to use tools like
web_search, because the follow-up message with tool results was
malformed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:29:55 +01:00
wylabandClaude Opus 4.6 5a8f3f772c Support wildcard "*" in allowFrom channel config
Build Nanobot OAuth / build (push) Successful in 1m57s
Allow "*" in the allowFrom list to explicitly permit all senders,
as an alternative to the empty-list-means-allow-all behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:11:13 +01:00
wylabandClaude Opus 4.6 e1a98d68ff ci: add Docker build workflow and fix gateway CMD
Build Nanobot OAuth / build (push) Successful in 2m45s
- Add .github/workflows/build.yml to auto-build and push to Gitea registry
- Change Dockerfile.oauth CMD from "status" to "gateway" for persistent container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:25:32 +01:00
wylab 92065dbb74 Merge remote-tracking branch 'origin/main' 2026-02-13 14:16:24 +01:00
wylabandClaude Opus 4.6 55ad41265f feat(oauth): add model alias resolution and Dockerfile.oauth
Add MODEL_ALIASES dict to resolve short model names (e.g. claude-sonnet-4)
to dated API IDs (e.g. claude-sonnet-4-20250514). Includes claude-opus-4-6.

Add Dockerfile.oauth overlay extending birdxs/nanobot:latest for fast builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:12:00 +01:00
wylabandClaude Opus 4.6 6d0d995b1b feat(config): integrate OAuth store with config loading
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:19:51 +01:00
wylabandClaude Opus 4.6 f444e94ff7 feat(cli): add OAuth login/status/logout commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:16:23 +01:00
wylabandClaude Opus 4.6 5f9af317c4 feat(config): add OAuth credential storage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:10:17 +01:00
wylabandClaude Opus 4.6 4b3bc89d06 refactor(agent): use provider factory for OAuth support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:07:38 +01:00
wylabandClaude Opus 4.6 3323b9d909 feat(providers): add create_provider factory with OAuth detection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:05:51 +01:00
wylabandClaude Opus 4.6 96a7abcda4 feat(registry): add OAuth provider detection logic
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:03:37 +01:00
wylabandClaude Opus 4.6 534a8344bd feat(providers): add AnthropicOAuthProvider with Bearer auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:58:36 +01:00
wylabandClaude Opus 4.6 7b710116a4 feat(providers): add OAuth token detection and header utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:57:21 +01:00
wylabandClaude Opus 4.6 fc9545c36a feat(config): add OAuthCredentials model for subscription auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:56:48 +01:00
28 changed files with 1403 additions and 37 deletions
+83
View File
@@ -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
+62
View File
@@ -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"]
+2 -4
View File
@@ -71,7 +71,7 @@ Skills with available="false" need dependencies installed first - you can try in
return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str:
"""Get the core identity section."""
"""Get the core identity section with runtime context."""
from datetime import datetime
import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
@@ -80,9 +80,7 @@ Skills with available="false" need dependencies installed first - you can try in
system = platform.system()
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
- Execute shell commands
- Search the web and fetch web pages
+107 -4
View File
@@ -2,6 +2,7 @@
import asyncio
import json
import time
from pathlib import Path
from typing import Any
@@ -76,6 +77,8 @@ class AgentLoop:
)
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()
def _register_default_tools(self) -> None:
@@ -144,6 +147,94 @@ class AgentLoop:
self._running = False
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:
"""
Process a single inbound message.
@@ -177,7 +268,10 @@ class AgentLoop:
content="🐈 New session started. Memory consolidated.")
if cmd == "/help":
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
if len(session.messages) > self.memory_window:
@@ -205,6 +299,9 @@ class AgentLoop:
chat_id=msg.chat_id,
)
# Select model based on quota
selected_model = self._select_model_based_on_quota()
# Agent loop
iteration = 0
final_content = None
@@ -214,10 +311,11 @@ class AgentLoop:
iteration += 1
# Call LLM
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
model=self.model
model=selected_model
)
# Handle tool calls
@@ -326,13 +424,16 @@ class AgentLoop:
iteration = 0
final_content = None
# Select model based on quota
selected_model = self._select_model_based_on_quota()
while iteration < self.max_iterations:
iteration += 1
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
model=self.model
model=selected_model
)
if response.has_tool_calls:
@@ -424,7 +525,9 @@ Respond with ONLY valid JSON, no markdown fences."""
{"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
{"role": "user", "content": prompt},
],
model=self.model,
model="claude-haiku-4-5",
thinking_budget=0,
max_tokens=16384,
)
text = (response.content or "").strip()
if text.startswith("```"):
+1 -1
View File
@@ -170,7 +170,7 @@ class SkillsLoader:
"""Parse nanobot metadata JSON from frontmatter."""
try:
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):
return {}
+5 -3
View File
@@ -50,6 +50,7 @@ class SubagentManager:
self,
task: str,
label: str | None = None,
model: str | None = None,
origin_channel: str = "cli",
origin_chat_id: str = "direct",
) -> str:
@@ -75,7 +76,7 @@ class SubagentManager:
# Create background 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
@@ -91,6 +92,7 @@ class SubagentManager:
task: str,
label: str,
origin: dict[str, str],
model: str | None = None,
) -> None:
"""Execute the subagent task and announce the result."""
logger.info(f"Subagent [{task_id}] starting task: {label}")
@@ -119,7 +121,7 @@ class SubagentManager:
]
# Run agent loop (limited iterations)
max_iterations = 15
max_iterations = 50
iteration = 0
final_result: str | None = None
@@ -129,7 +131,7 @@ class SubagentManager:
response = await self.provider.chat(
messages=messages,
tools=tools.get_definitions(),
model=self.model,
model=model or self.model,
)
if response.has_tool_calls:
+6 -1
View File
@@ -51,15 +51,20 @@ class SpawnTool(Tool):
"type": "string",
"description": "Optional short label for the task (for display)",
},
"model": {
"type": "string",
"description": "Optional model override for the subagent (e.g. 'claude-sonnet-4-20250514'). Defaults to the main agent's model.",
},
},
"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."""
return await self._manager.spawn(
task=task,
label=label,
model=model,
origin_channel=self._origin_channel,
origin_chat_id=self._origin_chat_id,
)
+4
View File
@@ -74,6 +74,10 @@ class BaseChannel(ABC):
if not allow_list:
return True
# Wildcard allows everyone
if "*" in allow_list:
return True
sender_str = str(sender_id)
if sender_str in allow_list:
return True
+2
View File
@@ -92,6 +92,7 @@ class TelegramChannel(BaseChannel):
BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"),
BotCommand("help", "Show available commands"),
BotCommand("quota", "Show current quota status"),
]
def __init__(
@@ -127,6 +128,7 @@ class TelegramChannel(BaseChannel):
self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", 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
self._app.add_handler(
+8 -4
View File
@@ -270,20 +270,21 @@ This file stores important information that should persist across sessions.
def _make_provider(config):
"""Create LiteLLMProvider from config. Exits if no API key found."""
from nanobot.providers.litellm_provider import LiteLLMProvider
"""Create LLM provider from config. Uses OAuth for subscription tokens."""
from nanobot.providers import create_provider
p = config.get_provider()
model = config.agents.defaults.model
if not (p and p.api_key) and not model.startswith("bedrock/"):
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1)
return LiteLLMProvider(
return create_provider(
api_key=p.api_key if p else None,
model=model,
api_base=config.get_api_base(),
default_model=model,
extra_headers=p.extra_headers if p else None,
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")
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")
def channels_status():
+93
View File
@@ -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}")
+20 -2
View File
@@ -12,12 +12,29 @@ def get_config_path() -> Path:
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:
"""Get the nanobot data directory."""
from nanobot.utils.helpers import 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:
"""
Load configuration from file or create default.
@@ -35,12 +52,13 @@ def load_config(config_path: Path | None = None) -> Config:
with open(path) as f:
data = json.load(f)
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:
print(f"Warning: Failed to load config from {path}: {e}")
print("Using default configuration.")
return Config()
return _inject_oauth_credentials(Config())
def save_config(config: Config, config_path: Path | None = None) -> None:
+59
View File
@@ -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
+31
View File
@@ -163,6 +163,7 @@ class AgentDefaults(BaseModel):
temperature: float = 0.7
max_tool_iterations: int = 20
memory_window: int = 50
thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking
class AgentsConfig(BaseModel):
@@ -170,11 +171,41 @@ class AgentsConfig(BaseModel):
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):
"""LLM provider configuration."""
api_key: str = ""
api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
oauth_credentials: OAuthCredentials | None = None
class ProvidersConfig(BaseModel):
+42 -3
View File
@@ -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.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,
)
+410
View File
@@ -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
+2 -1
View File
@@ -20,7 +20,7 @@ class LLMResponse:
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
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
def has_tool_calls(self) -> bool:
@@ -48,6 +48,7 @@ class LLMProvider(ABC):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_budget: int | None = None,
) -> LLMResponse:
"""
Send a chat completion request.
+1
View File
@@ -106,6 +106,7 @@ class LiteLLMProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_budget: int | None = None,
) -> LLMResponse:
"""
Send a chat completion request via LiteLLM.
+38
View File
@@ -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
+21
View File
@@ -357,3 +357,24 @@ def find_by_name(name: str) -> ProviderSpec | None:
if spec.name == name:
return spec
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
+88
View File
@@ -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]
+55
View File
@@ -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
+65
View File
@@ -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"
+48
View File
@@ -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
+55
View File
@@ -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"
+28
View File
@@ -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
+32
View File
@@ -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)
+21
View File
@@ -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