Skip to content

Commit 3ce142a

Browse files
authored
Merge pull request #87 from zestones/17-m31-anthropic-client-wrapper
17 m31 anthropic client wrapper
2 parents 21f6930 + 6ad24e2 commit 3ce142a

4 files changed

Lines changed: 718 additions & 0 deletions

File tree

backend/agents/anthropic_client.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Anthropic SDK singleton + helpers shared across all ARIA agents.
2+
3+
The module exposes:
4+
5+
- ``anthropic`` — process-wide ``AsyncAnthropic`` client. Built once at import
6+
time so a missing ``ANTHROPIC_API_KEY`` raises a clear startup error via
7+
Pydantic settings (acceptance criterion of M3.1).
8+
- ``model_for(use_case)`` — maps a use case to the correct Claude model slug.
9+
Investigator/vision use Opus, everything else uses Sonnet.
10+
- ``parse_json_response(message)`` — best-effort JSON extraction from Claude's
11+
free-text responses (handles raw JSON, ``json`` fences, and JSON preceded by
12+
preamble text).
13+
14+
Note: callers needing streaming (``M4.5`` Investigator extended thinking) must
15+
hit ``anthropic.messages.create(stream=True)`` directly on the singleton — do
16+
not introduce a wrapper that forces ``stream=False``.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import logging
23+
import re
24+
from typing import Any, Literal
25+
26+
from anthropic import AsyncAnthropic
27+
from anthropic.types import Message, TextBlock
28+
from core.config import get_settings
29+
30+
log = logging.getLogger("aria.anthropic")
31+
32+
_settings = get_settings()
33+
34+
# Singleton — safe to share across coroutines (httpx.AsyncClient under the hood).
35+
anthropic = AsyncAnthropic(
36+
api_key=_settings.anthropic_api_key,
37+
timeout=60.0,
38+
max_retries=2,
39+
)
40+
41+
_SONNET = "claude-sonnet-4-5"
42+
_OPUS = "claude-opus-4-7"
43+
44+
45+
# TODO: decide wether using only opus 4.7 or not even for simple extraction and chat
46+
def model_for(use_case: Literal["extraction", "vision", "reasoning", "chat"]) -> str:
47+
"""Return the model slug for a given use case, respecting ``ARIA_MODEL``.
48+
49+
Routing table:
50+
51+
| use_case | ARIA_MODEL=sonnet | ARIA_MODEL=opus |
52+
|-------------|-------------------|-----------------|
53+
| vision | Sonnet | Opus |
54+
| reasoning | Sonnet | Opus |
55+
| extraction | Sonnet | Sonnet |
56+
| chat | Sonnet | Sonnet |
57+
58+
``extraction`` and ``chat`` are ALWAYS Sonnet regardless of ``ARIA_MODEL``
59+
— there is no quality gain from Opus for free-text patch extraction or
60+
KB Builder Q&A, only ~10x cost.
61+
62+
Switch for demo day: ``ARIA_MODEL=opus`` in ``.env`` or Docker env.
63+
Revert for dev: ``ARIA_MODEL=sonnet`` (default).
64+
"""
65+
if use_case in ("vision", "reasoning") and _settings.aria_model == "opus":
66+
return _OPUS
67+
return _SONNET
68+
69+
70+
_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)```")
71+
_BRACE_RE = re.compile(r"\{[\s\S]*\}")
72+
73+
74+
def parse_json_response(response: Message) -> dict[str, Any]:
75+
"""Extract the first JSON object from a Claude ``Message``.
76+
77+
Handles three shapes Claude tends to emit:
78+
79+
1. Raw JSON.
80+
2. JSON wrapped in ``json`` (or bare `` ``) fences.
81+
3. JSON preceded by preamble text (``Here's the answer:\\n\\n{...}``).
82+
83+
Raises ``ValueError`` if no valid JSON is found.
84+
"""
85+
text = next(
86+
(block.text for block in response.content if isinstance(block, TextBlock)),
87+
None,
88+
)
89+
if not text:
90+
raise ValueError("no text block in response")
91+
92+
# 1. Direct parse.
93+
try:
94+
return json.loads(text)
95+
except json.JSONDecodeError:
96+
pass
97+
98+
# 2. Strip ```json ... ``` fences.
99+
fence = _FENCE_RE.search(text)
100+
if fence:
101+
try:
102+
return json.loads(fence.group(1).strip())
103+
except json.JSONDecodeError:
104+
pass
105+
106+
# 3. Find the first ``{...}`` block (greedy — matches outermost braces).
107+
brace = _BRACE_RE.search(text)
108+
if brace:
109+
try:
110+
return json.loads(brace.group(0))
111+
except json.JSONDecodeError:
112+
pass
113+
114+
raise ValueError(f"could not parse JSON from response: {text[:200]}")

backend/core/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ class Settings(BaseSettings):
2626
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
2727
mcp_api_key: str = "change-me-mcp-api-key"
2828

29+
# ── Anthropic ───────────────────────────────────────
30+
# ANTHROPIC_API_KEY is required at startup — Pydantic raises if missing.
31+
anthropic_api_key: str
32+
# Advisory toggle reserved for the final-day demo polish.
33+
# `model_for()` currently ignores this and routes per use case.
34+
aria_model: str = "sonnet" # "sonnet" | "opus"
35+
2936
@property
3037
def database_dsn(self) -> str:
3138
return (

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ PyJWT==2.10.1
77
werkzeug==3.1.3
88
python-multipart==0.0.20
99
fastmcp==3.2.4
10+
anthropic==0.96.0

0 commit comments

Comments
 (0)