Files
home-voice-assistant/satellite/llm_claude.py
Cosmo 05de9c284b feat(llm): direct Claude Haiku 4.5 backend with prompt caching
Adds a parallel LLM backend that bypasses OpenClaw and talks to
Anthropic Messages API directly. Selected via LLM_BACKEND=claude in
.env; default remains openclaw so nothing breaks for existing setup.

Why: OpenClaw gateway adds 500-1000ms overhead on every turn (auth,
memory fetch, routing). Direct Haiku 4.5 + prompt caching = faster
first token and -90% cost on cached chunks.

- satellite/llm_claude.py — Anthropic SDK streaming client, prompt
  caching on system prompt and all-but-last-2 history messages, per
  agent+date JSON history in HISTORY_DIR, reset_history() for the
  'сбрось' command, per-agent system prompts (Cosmo / Люся), fallback
  to error event if SDK/key missing.
- satellite/llm.py — dispatches to ask_claude_stream when backend=claude,
  exports LLM_BACKEND so modes.py can route reset too.
- satellite/modes.py — _handle_reset calls reset_history when backend
  is claude, keeps /new POST for openclaw.
- requirements.txt — anthropic >= 0.50.0
- .env.example — LLM_BACKEND, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
  HISTORY_DIR, MAX_HISTORY, HTTPS_PROXY block for non-RU egress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:12:39 +00:00

243 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Прямой клиент Claude Haiku 4.5 (Anthropic SDK) — альтернатива OpenClaw gateway.
Отличия от `llm.ask_agent_stream`:
* Сессия и история живут **локально** на клиенте (JSON в HISTORY_DIR/{agent}-{date}.json).
Смена даты = автосброс.
* Prompt caching через Anthropic cache_control: system prompt и старая часть истории
кешируются на 5 минут → latency first-token ниже, стоимость -90% на cached-tokens.
* Используется когда LLM_BACKEND=claude.
Если HTTPS_PROXY задан (напр. http://192.168.31.103:8888) — httpx подхватит автоматически,
Anthropic SDK пойдёт через прокси.
"""
import json
import os
import time
from datetime import date
from pathlib import Path
try:
import anthropic
except ImportError:
anthropic = None # SDK опциональный, активируется только при LLM_BACKEND=claude
from .config import log
from .text import clean_for_speech
from .tts import speak, play_error_sound
from . import notifier
from .llm import strip_fillers # переиспользуем чистку филлеров
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5")
HISTORY_DIR = Path(os.getenv("HISTORY_DIR", "data/history"))
MAX_TOKENS = int(os.getenv("VOICE_MAX_TOKENS", "300"))
MAX_HISTORY_MESSAGES = int(os.getenv("MAX_HISTORY", "40"))
# Граница кеша — все сообщения кроме последних N идут в cache block,
# что даёт prompt caching хит каждый турн.
CACHE_TAIL_UNCACHED = 2
COSMO_SYSTEM_PROMPT = """Ты — Cosmo, домашний голосовой ассистент Даниила (Санкт-Петербург).
Стиль:
- Короткие ответы: 1-2 предложения, редко 3. Это голосовой канал — многословность утомляет.
- Разговорный русский, без канцелярита, без формальных оборотов («здравствуйте», «уважаемый»).
- Обращение на «ты».
- Не предваряй ответ фразами-заполнителями («сейчас посмотрю», «минутку», «проверяю») — сразу отвечай.
- Без эмодзи, маркированных списков, код-блоков — всё будет зачитано.
- Если не знаешь — скажи коротко, не оправдывайся.
Контекст: Даниил — разработчик, живёт в СПб с женой Светой. Сегодня {today}."""
LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой ассистент Светы (Санкт-Петербург).
Стиль:
- Тёплый, заботливый, чуть эмоциональный, но лаконичный. 1-2 предложения.
- Обращение на «ты».
- Без эмодзи, списков, код-блоков — это голос.
- Если не знаешь — скажи коротко.
Сегодня {today}."""
_client: "anthropic.Anthropic | None" = None
def _get_client() -> "anthropic.Anthropic":
global _client
if anthropic is None:
raise RuntimeError(
"anthropic SDK не установлен. Запусти `pip install anthropic` "
"или оставь LLM_BACKEND=openclaw."
)
if not ANTHROPIC_API_KEY:
raise RuntimeError("ANTHROPIC_API_KEY не задан в .env")
if _client is None:
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
return _client
def _system_prompt(agent_id: str) -> str:
template = LUSYA_SYSTEM_PROMPT if agent_id == "lusya" else COSMO_SYSTEM_PROMPT
return template.format(today=date.today().isoformat())
def _history_path(agent_id: str) -> Path:
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
today = date.today().isoformat()
return HISTORY_DIR / f"{agent_id}-{today}.json"
def load_history(agent_id: str) -> list[dict]:
path = _history_path(agent_id)
if not path.exists():
return []
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
log.exception(f"Не смог прочитать историю {path}")
return []
def save_history(agent_id: str, history: list[dict]):
path = _history_path(agent_id)
try:
path.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
log.exception(f"Не смог сохранить историю {path}")
def reset_history(agent_id: str):
"""Удаляет историю диалога за текущий день."""
path = _history_path(agent_id)
if path.exists():
path.unlink()
log.info(f"История сброшена: {path}")
def _build_messages(history: list[dict]) -> list[dict]:
"""
Готовит messages array для Claude API с prompt caching.
Последние N=CACHE_TAIL_UNCACHED сообщений остаются динамическими (без кеша),
а всё что раньше — помечается cache_control на границе.
"""
if len(history) <= CACHE_TAIL_UNCACHED:
return [{"role": m["role"], "content": m["content"]} for m in history]
cache_boundary = len(history) - CACHE_TAIL_UNCACHED
messages = []
for i, msg in enumerate(history):
# Граница кеша — на последнем «старом» сообщении ставим cache_control.
if i == cache_boundary - 1:
messages.append({
"role": msg["role"],
"content": [{
"type": "text",
"text": msg["content"],
"cache_control": {"type": "ephemeral"},
}],
})
else:
messages.append({"role": msg["role"], "content": msg["content"]})
return messages
def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str:
"""Спросить Claude Haiku 4.5 напрямую. Возвращает cleaned text (без speak — это делается снаружи)."""
def _speak_if_local(t: str):
if t.strip() and notifier.speak_locally():
speak(t, agent_id)
try:
client = _get_client()
except RuntimeError as e:
log.error(str(e))
msg = "Клод не настроен, попробуй OpenClaw."
play_error_sound()
notifier.error(msg, agent_id)
_speak_if_local(msg)
return msg
history = load_history(agent_id)
history.append({"role": "user", "content": text})
# Обрезаем слишком длинную историю
if len(history) > MAX_HISTORY_MESSAGES:
history = history[-MAX_HISTORY_MESSAGES:]
system_blocks = [{
"type": "text",
"text": _system_prompt(agent_id),
"cache_control": {"type": "ephemeral"},
}]
messages = _build_messages(history)
start = time.time()
full_text = ""
try:
with client.messages.stream(
model=ANTHROPIC_MODEL,
max_tokens=MAX_TOKENS,
system=system_blocks,
messages=messages,
) as stream:
for chunk in stream.text_stream:
full_text += chunk
final = stream.get_final_message()
usage = final.usage
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
cache_write = getattr(usage, "cache_creation_input_tokens", 0) or 0
elapsed = time.time() - start
print(
f"🧠 Claude {ANTHROPIC_MODEL} {elapsed:.2f}s · "
f"in={usage.input_tokens} out={usage.output_tokens} "
f"cache_r={cache_read} cache_w={cache_write}"
)
except anthropic.APIConnectionError:
log.exception("Anthropic API connection error")
msg = "Не могу связаться с Клодом."
play_error_sound()
notifier.error(msg, agent_id)
_speak_if_local(msg)
return msg
except anthropic.APITimeoutError:
log.exception("Anthropic timeout")
msg = "Клод не ответил вовремя."
play_error_sound()
notifier.error(msg, agent_id)
_speak_if_local(msg)
return msg
except anthropic.APIStatusError as e:
status = getattr(e, "status_code", "?")
log.exception(f"Anthropic API status {status}")
msg = "Ошибка Клода."
play_error_sound()
notifier.error(msg, agent_id)
_speak_if_local(msg)
return msg
except Exception as e:
log.exception(f"Неожиданная ошибка Claude: {e}")
msg = "Что-то сломалось."
play_error_sound()
notifier.error(msg, agent_id)
_speak_if_local(msg)
return msg
if not full_text:
msg = "Не получил ответ."
notifier.error(msg, agent_id)
_speak_if_local(msg)
return msg
# Сохраняем реплику ассистента (до strip_fillers/clean — для верности истории)
history.append({"role": "assistant", "content": full_text})
save_history(agent_id, history)
result = clean_for_speech(strip_fillers(full_text))
_speak_if_local(result)
return result