Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71bbefceb1 | ||
|
|
bdaa1b35e4 | ||
|
|
4c9ae63bcf | ||
|
|
c606ab9318 | ||
|
|
e0a1722fa4 | ||
|
|
9891aea1eb | ||
|
|
c2e70260c4 | ||
|
|
b4b5de889a | ||
|
|
d0b0284189 | ||
|
|
c0a87d77fc | ||
|
|
6e627bc2e0 | ||
|
|
ece660ae69 | ||
|
|
84268edf01 | ||
|
|
9136cca1ff | ||
|
|
6035b70ae5 | ||
|
|
e4c300bcfd |
+1
-1
@@ -56,7 +56,7 @@ ENV PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
COPY pyproject.toml README.md LICENSE /app/
|
||||
COPY nanobot/ /app/nanobot/
|
||||
RUN uv pip install --system --no-cache --reinstall /app
|
||||
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
|
||||
|
||||
ENTRYPOINT ["nanobot"]
|
||||
CMD ["gateway"]
|
||||
|
||||
+134
-5
@@ -2,6 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
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.message import MessageTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
@@ -76,6 +79,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:
|
||||
@@ -105,6 +110,7 @@ class AgentLoop:
|
||||
# Spawn tool (for subagents)
|
||||
spawn_tool = SpawnTool(manager=self.subagents)
|
||||
self.tools.register(spawn_tool)
|
||||
self.tools.register(WaitForSubagentsTool(manager=self.subagents))
|
||||
|
||||
# Cron tool (for scheduling)
|
||||
if self.cron_service:
|
||||
@@ -144,6 +150,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 +271,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:
|
||||
@@ -196,15 +293,41 @@ class AgentLoop:
|
||||
if isinstance(cron_tool, CronTool):
|
||||
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)
|
||||
messages = self.context.build_messages(
|
||||
history=session.get_history(),
|
||||
current_message=msg.content,
|
||||
current_message=current_message,
|
||||
media=msg.media if msg.media else None,
|
||||
channel=msg.channel,
|
||||
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 +337,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 +450,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 +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": "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("```"):
|
||||
|
||||
@@ -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.shell import ExecTool
|
||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||
|
||||
|
||||
class SubagentManager:
|
||||
@@ -40,11 +42,15 @@ class SubagentManager:
|
||||
self.provider = provider
|
||||
self.workspace = workspace
|
||||
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.exec_config = exec_config or ExecToolConfig()
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._task_results: dict[str, str] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
@@ -84,7 +90,7 @@ class SubagentManager:
|
||||
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
|
||||
|
||||
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(
|
||||
self,
|
||||
@@ -98,7 +104,7 @@ class SubagentManager:
|
||||
logger.info(f"Subagent [{task_id}] starting task: {label}")
|
||||
|
||||
try:
|
||||
# Build subagent tools (no message tool, no spawn tool)
|
||||
# Build subagent tools (no message tool)
|
||||
tools = ToolRegistry()
|
||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||
tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
||||
@@ -112,6 +118,10 @@ class SubagentManager:
|
||||
))
|
||||
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||
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
|
||||
system_prompt = self._build_subagent_prompt(task)
|
||||
@@ -121,7 +131,7 @@ class SubagentManager:
|
||||
]
|
||||
|
||||
# Run agent loop (limited iterations)
|
||||
max_iterations = 15
|
||||
max_iterations = 50
|
||||
iteration = 0
|
||||
final_result: str | None = None
|
||||
|
||||
@@ -191,6 +201,13 @@ class SubagentManager:
|
||||
"""Announce the subagent result to the main agent via the message bus."""
|
||||
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}]
|
||||
|
||||
Task: {task}
|
||||
@@ -239,7 +256,6 @@ You are a subagent spawned by the main agent to complete a specific task.
|
||||
|
||||
## What You Cannot Do
|
||||
- Send messages directly to users (no message tool available)
|
||||
- Spawn other subagents
|
||||
- Access the main agent's conversation history
|
||||
|
||||
## Workspace
|
||||
@@ -248,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."""
|
||||
|
||||
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:
|
||||
"""Return the number of currently running subagents."""
|
||||
return len(self._running_tasks)
|
||||
|
||||
@@ -53,7 +53,7 @@ class SpawnTool(Tool):
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional model override for the subagent (e.g. 'claude-sonnet-4-20250514'). Defaults to the main agent's model.",
|
||||
"description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -225,6 +225,7 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
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()
|
||||
@@ -236,14 +237,15 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
}
|
||||
|
||||
# Extended thinking: temperature must be 1 when enabled
|
||||
if self.thinking_budget > 0:
|
||||
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 <= self.thinking_budget:
|
||||
payload["max_tokens"] = self.thinking_budget + 4096
|
||||
if max_tokens <= effective_thinking:
|
||||
payload["max_tokens"] = effective_thinking + 4096
|
||||
payload["thinking"] = {
|
||||
"type": "enabled",
|
||||
"budget_tokens": self.thinking_budget,
|
||||
"budget_tokens": effective_thinking,
|
||||
}
|
||||
else:
|
||||
payload["temperature"] = temperature
|
||||
@@ -255,9 +257,10 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
payload["tools"] = tools
|
||||
|
||||
logger.info(
|
||||
"Anthropic request: model=%s max_tokens=%d thinking=%s",
|
||||
"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(
|
||||
@@ -266,6 +269,39 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
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}")
|
||||
@@ -279,6 +315,7 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
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
|
||||
@@ -293,6 +330,9 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
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,
|
||||
@@ -301,6 +341,7 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
tools=anthropic_tools,
|
||||
thinking_budget_override=effective_thinking,
|
||||
)
|
||||
return self._parse_response(response)
|
||||
except Exception as e:
|
||||
@@ -341,25 +382,19 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
),
|
||||
}
|
||||
|
||||
# Log usage and thinking info
|
||||
if thinking_blocks:
|
||||
thinking_chars = sum(len(b.get("thinking", "")) for b in thinking_blocks)
|
||||
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: %d thinking block(s) (%d chars), "
|
||||
"input=%d output=%d tokens",
|
||||
len(thinking_blocks), thinking_chars,
|
||||
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Anthropic response: no thinking blocks, input=%d output=%d tokens",
|
||||
"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=response.get("stop_reason", "end_turn"),
|
||||
finish_reason=stop_reason,
|
||||
usage=usage,
|
||||
reasoning_content=thinking_blocks or None,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user