|
| 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]}") |
0 commit comments