- anthropic_client.py: all 4 methods (chat_structured, chat_plain, achat_structured, achat_plain) now use .messages.stream() context manager - coach_service.py: inline aclient.messages.create() replaced with async streaming via aclient.messages.stream() - _parse_response() updated to accept pre-extracted text string (since stream gives us text directly) - check_status() left as non-streaming (max_tokens=10, safe from the 10min rule)
402 lines
13 KiB
Python
402 lines
13 KiB
Python
"""
|
|
Commander AI Lab — Anthropic Claude Structured Output Client
|
|
════════════════════════════════════════════════════════════
|
|
Uses the anthropic SDK to call Claude's messages endpoint
|
|
with structured JSON output enforced via system prompt.
|
|
Supports both sync and native async operation.
|
|
|
|
All substantive calls use .messages.stream() to avoid the SDK's
|
|
10-minute non-streaming timeout restriction on large max_tokens.
|
|
"""
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Optional
|
|
|
|
import anthropic
|
|
|
|
logger = logging.getLogger("coach.anthropic")
|
|
|
|
# ── Anthropic Models ───────────────────────────────────────────
|
|
CLAUDE_OPUS = "claude-opus-4-0-20250514"
|
|
CLAUDE_SONNET = "claude-sonnet-4-5"
|
|
CLAUDE_HAIKU = "claude-haiku-4-5"
|
|
|
|
|
|
class AnthropicResponse:
|
|
"""Parsed response container from Anthropic API.
|
|
|
|
Drop-in replacement for PerplexityResponse — same attribute shape
|
|
so all existing call-sites work without changes.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
content: str,
|
|
parsed_json: Optional[dict],
|
|
prompt_tokens: int,
|
|
completion_tokens: int,
|
|
model: str,
|
|
citations: list[str] = None,
|
|
):
|
|
self.content = content
|
|
self.parsed_json = parsed_json
|
|
self.prompt_tokens = prompt_tokens
|
|
self.completion_tokens = completion_tokens
|
|
self.model = model
|
|
self.citations = citations or [] # Anthropic has no citations; kept for compat
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.parsed_json is not None
|
|
|
|
|
|
class AnthropicClient:
|
|
"""
|
|
Anthropic Claude API client.
|
|
|
|
Drop-in replacement for PerplexityClient.
|
|
All substantive calls use .messages.stream() so the SDK never
|
|
enforces the 10-minute non-streaming timeout.
|
|
|
|
Usage:
|
|
client = AnthropicClient(api_key="sk-ant-...")
|
|
resp = client.chat_structured(
|
|
system_prompt="You are a deck builder.",
|
|
user_prompt="Build a deck for...",
|
|
json_schema=deck_schema,
|
|
schema_name="DeckList",
|
|
)
|
|
if resp.ok:
|
|
deck = resp.parsed_json
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: str,
|
|
model: str = CLAUDE_OPUS,
|
|
timeout: int = 120,
|
|
base_url: str = None,
|
|
):
|
|
if not api_key:
|
|
raise ValueError("Anthropic API key is required")
|
|
self.api_key = api_key
|
|
self.model = model
|
|
self.timeout = timeout
|
|
self._client = anthropic.Anthropic(
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
timeout=float(timeout),
|
|
)
|
|
self._async_client = anthropic.AsyncAnthropic(
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
timeout=float(timeout),
|
|
)
|
|
|
|
def chat_structured(
|
|
self,
|
|
system_prompt: str,
|
|
user_prompt: str,
|
|
json_schema: dict = None,
|
|
schema_name: str = "response",
|
|
model: str = None,
|
|
temperature: float = 0.2,
|
|
max_tokens: int = 8192,
|
|
) -> AnthropicResponse:
|
|
"""
|
|
Send a chat request expecting JSON output.
|
|
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
|
|
"""
|
|
use_model = model or self.model
|
|
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
|
|
|
try:
|
|
with self._client.messages.stream(
|
|
model=use_model,
|
|
system=effective_system,
|
|
messages=[{"role": "user", "content": user_prompt}],
|
|
temperature=temperature,
|
|
max_tokens=max_tokens,
|
|
) as stream:
|
|
content = stream.get_final_text()
|
|
final_msg = stream.get_final_message()
|
|
return self._parse_streamed(content, final_msg, use_model)
|
|
except Exception as e:
|
|
logger.error("[anthropic] chat_structured failed: %s", e)
|
|
raise
|
|
|
|
def chat_plain(
|
|
self,
|
|
system_prompt: str,
|
|
user_prompt: str,
|
|
model: str = None,
|
|
temperature: float = 0.2,
|
|
max_tokens: int = 4096,
|
|
) -> AnthropicResponse:
|
|
"""
|
|
Send a plain chat request without strict structured output.
|
|
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
|
|
"""
|
|
use_model = model or self.model
|
|
|
|
try:
|
|
with self._client.messages.stream(
|
|
model=use_model,
|
|
system=system_prompt,
|
|
messages=[{"role": "user", "content": user_prompt}],
|
|
temperature=temperature,
|
|
max_tokens=max_tokens,
|
|
) as stream:
|
|
content = stream.get_final_text()
|
|
final_msg = stream.get_final_message()
|
|
return self._parse_streamed(content, final_msg, use_model)
|
|
except Exception as e:
|
|
logger.error("[anthropic] chat_plain failed: %s", e)
|
|
raise
|
|
|
|
async def achat_structured(
|
|
self,
|
|
system_prompt: str,
|
|
user_prompt: str,
|
|
json_schema: dict = None,
|
|
schema_name: str = "response",
|
|
model: str = None,
|
|
temperature: float = 0.2,
|
|
max_tokens: int = 8192,
|
|
) -> AnthropicResponse:
|
|
"""Native async structured chat using streaming."""
|
|
use_model = model or self.model
|
|
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
|
|
|
try:
|
|
async with self._async_client.messages.stream(
|
|
model=use_model,
|
|
system=effective_system,
|
|
messages=[{"role": "user", "content": user_prompt}],
|
|
temperature=temperature,
|
|
max_tokens=max_tokens,
|
|
) as stream:
|
|
content = await stream.get_final_text()
|
|
final_msg = await stream.get_final_message()
|
|
return self._parse_streamed(content, final_msg, use_model)
|
|
except Exception as e:
|
|
logger.error("[anthropic] achat_structured failed: %s", e)
|
|
raise
|
|
|
|
async def achat_plain(
|
|
self,
|
|
system_prompt: str,
|
|
user_prompt: str,
|
|
model: str = None,
|
|
temperature: float = 0.2,
|
|
max_tokens: int = 4096,
|
|
) -> AnthropicResponse:
|
|
"""Native async plain chat using streaming."""
|
|
use_model = model or self.model
|
|
|
|
try:
|
|
async with self._async_client.messages.stream(
|
|
model=use_model,
|
|
system=system_prompt,
|
|
messages=[{"role": "user", "content": user_prompt}],
|
|
temperature=temperature,
|
|
max_tokens=max_tokens,
|
|
) as stream:
|
|
content = await stream.get_final_text()
|
|
final_msg = await stream.get_final_message()
|
|
return self._parse_streamed(content, final_msg, use_model)
|
|
except Exception as e:
|
|
logger.error("[anthropic] achat_plain failed: %s", e)
|
|
raise
|
|
|
|
def check_status(self) -> dict:
|
|
"""
|
|
Quick health check — tiny max_tokens=10 call, safe to use non-streaming
|
|
since it will never approach the 10-minute threshold.
|
|
"""
|
|
try:
|
|
response = self._client.messages.create(
|
|
model=self.model,
|
|
system="Reply with the word OK.",
|
|
messages=[{"role": "user", "content": "Status check."}],
|
|
max_tokens=10,
|
|
)
|
|
content = response.content[0].text if response.content else ""
|
|
return {
|
|
"connected": True,
|
|
"model": self.model,
|
|
"response": content,
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"connected": False,
|
|
"model": self.model,
|
|
"error": str(e),
|
|
}
|
|
|
|
# ── Internal helpers ──────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _build_system(system_prompt: str, json_schema: dict, schema_name: str) -> str:
|
|
"""Append JSON schema hint to system prompt when a schema is provided."""
|
|
if not json_schema:
|
|
return system_prompt
|
|
schema_str = json.dumps(json_schema, indent=2)
|
|
return (
|
|
f"{system_prompt}\n\n"
|
|
f"You MUST respond with valid JSON only — no markdown fences, no prose.\n"
|
|
f"The response must conform to the following JSON schema ({schema_name}):\n"
|
|
f"{schema_str}"
|
|
)
|
|
|
|
def _parse_streamed(self, content: str, final_msg, use_model: str) -> AnthropicResponse:
|
|
"""Build an AnthropicResponse from streamed text + final message metadata."""
|
|
usage = getattr(final_msg, "usage", None)
|
|
prompt_tokens = usage.input_tokens if usage else 0
|
|
completion_tokens = usage.output_tokens if usage else 0
|
|
|
|
parsed = None
|
|
try:
|
|
parsed = json.loads(content)
|
|
except (json.JSONDecodeError, TypeError):
|
|
parsed = self._extract_json(content)
|
|
|
|
logger.info(
|
|
"[anthropic] model=%s tokens=(%d in, %d out) parsed=%s",
|
|
use_model,
|
|
prompt_tokens,
|
|
completion_tokens,
|
|
"yes" if parsed else "no",
|
|
)
|
|
|
|
return AnthropicResponse(
|
|
content=content,
|
|
parsed_json=parsed,
|
|
prompt_tokens=prompt_tokens,
|
|
completion_tokens=completion_tokens,
|
|
model=use_model,
|
|
citations=[],
|
|
)
|
|
|
|
@staticmethod
|
|
def _extract_json(text: str) -> Optional[dict]:
|
|
"""Best-effort JSON extraction from possibly noisy text."""
|
|
try:
|
|
return json.loads(text.strip())
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
# Markdown fences
|
|
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
|
|
if fence_match:
|
|
try:
|
|
return json.loads(fence_match.group(1).strip())
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
# Brace counting
|
|
start = text.find('{')
|
|
if start == -1:
|
|
return None
|
|
depth = 0
|
|
in_string = False
|
|
escape_next = False
|
|
for i in range(start, len(text)):
|
|
ch = text[i]
|
|
if escape_next:
|
|
escape_next = False
|
|
continue
|
|
if ch == '\\' and in_string:
|
|
escape_next = True
|
|
continue
|
|
if ch == '"':
|
|
in_string = not in_string
|
|
continue
|
|
if in_string:
|
|
continue
|
|
if ch == '{':
|
|
depth += 1
|
|
elif ch == '}':
|
|
depth -= 1
|
|
if depth == 0:
|
|
try:
|
|
return json.loads(text[start:i + 1])
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
|
|
# Truncated JSON — attempt repair
|
|
return AnthropicClient._repair_truncated_json(text[start:])
|
|
|
|
@staticmethod
|
|
def _repair_truncated_json(text: str) -> Optional[dict]:
|
|
"""
|
|
Attempt to repair truncated JSON from max_tokens cutoff.
|
|
Strategy: find the last complete nested object, then close
|
|
all open brackets/braces.
|
|
"""
|
|
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
|
|
last_good = -1
|
|
depth = 0
|
|
in_string = False
|
|
escape_next = False
|
|
for i in range(len(text)):
|
|
ch = text[i]
|
|
if escape_next:
|
|
escape_next = False
|
|
continue
|
|
if ch == '\\' and in_string:
|
|
escape_next = True
|
|
continue
|
|
if ch == '"':
|
|
in_string = not in_string
|
|
continue
|
|
if in_string:
|
|
continue
|
|
if ch == '{':
|
|
depth += 1
|
|
elif ch == '}':
|
|
depth -= 1
|
|
if depth >= 1:
|
|
last_good = i
|
|
|
|
if last_good == -1:
|
|
return None
|
|
|
|
truncated = text[:last_good + 1]
|
|
close_depth = 0
|
|
in_arr = 0
|
|
in_str = False
|
|
esc = False
|
|
for ch in truncated:
|
|
if esc:
|
|
esc = False
|
|
continue
|
|
if ch == '\\' and in_str:
|
|
esc = True
|
|
continue
|
|
if ch == '"':
|
|
in_str = not in_str
|
|
continue
|
|
if in_str:
|
|
continue
|
|
if ch == '{':
|
|
close_depth += 1
|
|
elif ch == '}':
|
|
close_depth -= 1
|
|
elif ch == '[':
|
|
in_arr += 1
|
|
elif ch == ']':
|
|
in_arr -= 1
|
|
|
|
suffix = ']' * in_arr + '}' * close_depth
|
|
repaired = truncated + suffix
|
|
try:
|
|
result = json.loads(repaired)
|
|
card_count = len(result.get('cards', []))
|
|
logger.info("JSON repair succeeded: recovered %d cards", card_count)
|
|
return result
|
|
except (json.JSONDecodeError, TypeError) as e:
|
|
logger.warning("JSON repair failed: %s", e)
|
|
return None
|