Source code for rapidtools.models.claude

# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-22-2026

"""
Anthropic Claude API wrapper (raw HTTP, Messages API).

Example:
    >>> from rapidtools.models import ClaudeInference
    >>> model = ClaudeInference(api_key='sk-ant-...', model_id='claude-opus-5')
    >>> out = model.run_inference('roof.jpg', 'Describe the roof damage.')
    >>> print(out.text)
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any

from requests.exceptions import HTTPError, RetryError

from rapidtools.config import get_configured_session

from .api_base import (
    API_REQUEST_TIMEOUT,
    BaseAPIInferenceModel,
    api_session,
    catalog_ids,
    is_retryable_status,
    resolve_api_key,
)
from .base import GenerationConfig, ModelOutput
from .catalogs import (
    ANTHROPIC_DEFAULT_MODEL,
    ANTHROPIC_MODEL_CATALOG,
    ANTHROPIC_NO_TEMPERATURE_PREFIXES,
    PROVIDERS,
)

logger = logging.getLogger(__name__)

ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'
ANTHROPIC_API_VERSION = '2023-06-01'


[docs] class ClaudeInference(BaseAPIInferenceModel): """ Anthropic Claude Messages API client for image + text prompts. The key is read from ``api_key`` (string or key-file path) or the ``ANTHROPIC_API_KEY`` environment variable. Thinking blocks returned by current models are skipped; only text blocks are concatenated into :attr:`ModelOutput.text`. A ``refusal`` stop reason yields ``None``. Example: >>> from rapidtools.models import ClaudeInference >>> ClaudeInference.list_known_models()[:3] ['claude-fable-5-1', 'claude-fable-5', 'claude-opus-5'] >>> model = ClaudeInference(api_key='sk-ant-...') >>> out = model.run_inference( ... ['front.jpg', 'back.jpg'], ... 'Return JSON with keys damage_level and justification.', ... json_mode=True, ... ) >>> out.text '{"damage_level": 2, "justification": "minor roof damage"}' """ INFO = PROVIDERS['claude'] PROVIDER_NAME = 'Anthropic' API_KEY_ENV = ('ANTHROPIC_API_KEY',) MODEL_CATALOG = ANTHROPIC_MODEL_CATALOG # Anthropic specifically supports these image formats MIME_MAP = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', } def __init__( self, api_key: str | Path | None = None, model_id: str = ANTHROPIC_DEFAULT_MODEL, max_workers: int = 10, max_retries: int = 3, system_instruction: str | None = None, temperature: float = 0.4, max_tokens: int = 2048, # Anthropic REQUIRES this field in the payload timeout: float = API_REQUEST_TIMEOUT, ): """ Initialise the client and validate the chosen model. Args: api_key: Key string, key-file path, or ``None`` for ``ANTHROPIC_API_KEY``. model_id: Claude model ID. Defaults to ``'claude-opus-5'``. max_workers: Threads used by :meth:`run_batch`. max_retries: Retries for transient HTTP failures. system_instruction: Optional system prompt. temperature: Sampling temperature; only sent to models that accept it (Opus/Sonnet 4.6, Haiku 4.5 and older). max_tokens: Maximum tokens generated per response (required by the Messages API). timeout: Seconds to wait for each response (default 60). Raises: ValueError: If no API key can be resolved. """ super().__init__( max_retries=max_retries, max_workers=max_workers, timeout=timeout ) self.api_key = self._resolve_api_key(api_key) self.model_id = model_id self.system_instruction = system_instruction self.temperature = temperature self.max_tokens = max_tokens # Anthropic requires specific versioning headers self.session.headers.update( { 'x-api-key': self.api_key, 'anthropic-version': ANTHROPIC_API_VERSION, 'content-type': 'application/json', } ) self._validate_model() def _validate_model(self) -> None: """Warn (never fail) when the model is not in Anthropic's list.""" if not self.api_key or not self.model_id: return try: available_models = self.list_available_models(self.api_key) if available_models and self.model_id not in available_models: logger.warning( f"Model '{self.model_id}' is not in the Anthropic available " 'models list.' ) except Exception as e: # noqa: BLE001 - validation is best effort logger.debug(f'Transient error during Anthropic model validation: {e}')
[docs] @classmethod def list_available_models(cls, api_key: str | Path | None = None) -> list[str]: """ List the Claude model IDs accessible with ``api_key``. Without a key (or when the API cannot be reached) the curated catalogue is returned instead. Args: api_key: Key string, key-file path, or ``None`` for the env var. Returns: list[str]: Sorted model IDs. Example: >>> from rapidtools.models import ClaudeInference >>> ids = ClaudeInference.list_available_models('sk-ant-...') >>> 'claude-opus-5' in ids True """ try: key = resolve_api_key(api_key, cls.API_KEY_ENV, cls.PROVIDER_NAME) except (ValueError, FileNotFoundError): return catalog_ids(ANTHROPIC_MODEL_CATALOG) url = f'{ANTHROPIC_BASE_URL}/models' try: session = get_configured_session() response = session.get( url, headers={ 'x-api-key': key, 'anthropic-version': ANTHROPIC_API_VERSION, }, timeout=10, ) if response.status_code != 200: return catalog_ids(ANTHROPIC_MODEL_CATALOG) data = response.json() # Anthropic returns a list of models under the 'data' key ids = sorted(m['id'] for m in data.get('data', []) if 'id' in m) return ids or catalog_ids(ANTHROPIC_MODEL_CATALOG) except Exception: # noqa: BLE001 - network failures degrade gracefully return catalog_ids(ANTHROPIC_MODEL_CATALOG)
[docs] @staticmethod def supports_temperature(model_id: str) -> bool: """ Return whether ``model_id`` accepts the ``temperature`` parameter. Example: >>> from rapidtools.models import ClaudeInference >>> ClaudeInference.supports_temperature('claude-opus-5') False >>> ClaudeInference.supports_temperature('claude-haiku-4-5') True """ return not model_id.startswith(ANTHROPIC_NO_TEMPERATURE_PREFIXES)
def _build_payload( self, user_content: list[dict[str, Any]], temperature: float | None, max_tokens: int | None, json_mode: bool, system_instruction: str | None = None, ) -> dict[str, Any]: """Construct the Messages API request body.""" payload: dict[str, Any] = { 'model': self.model_id, 'messages': [{'role': 'user', 'content': user_content}], } if max_tokens is not None: payload['max_tokens'] = max_tokens if temperature is not None and self.supports_temperature(self.model_id): payload['temperature'] = temperature # Anthropic puts the system prompt at the root, not in messages. system = system_instruction or self.system_instruction if system: payload['system'] = system # Anthropic has no strict "response_format" flag; use a system hint. if json_mode: json_hint = ( 'You must respond with ONLY valid JSON and no other ' 'conversational text.' ) if 'system' in payload: payload['system'] = f'{payload["system"]}\n\n{json_hint}' else: payload['system'] = json_hint return payload @staticmethod def _extract_text(result_json: dict[str, Any]) -> str | None: """Join the text blocks of a Messages API response (skipping thinking).""" blocks = result_json.get('content') if not isinstance(blocks, list): return None texts = [ block.get('text', '') for block in blocks if isinstance(block, dict) and block.get('type') == 'text' ] if not texts: return None return ''.join(texts)
[docs] def run_inference( self, image_inputs: str | Path | list[str | Path], prompt: str | Path, json_mode: bool = False, max_retries: int | None = None, temperature: float | None = None, max_tokens: int | None = None, config: GenerationConfig | None = None, **kwargs: Any, ) -> ModelOutput | None: """ Send images and a prompt to the Messages API. Args: image_inputs: One path/URL or a list of them. Unreadable images are skipped with a logged error. prompt: Prompt text or path to a prompt file. json_mode: Add a system instruction demanding JSON-only output. max_retries: Per-call override of the session retry count. temperature: Per-call temperature override (ignored by models that do not accept it). max_tokens: Per-call output-token limit override. config: A :class:`~rapidtools.models.GenerationConfig`; explicit keyword arguments take precedence over it. **kwargs: Ignored; accepted for interface compatibility. Returns: ModelOutput | None: The response text and raw JSON, or ``None`` when the request failed, was refused, or nothing could be sent. Example: >>> from rapidtools.models import ClaudeInference >>> model = ClaudeInference(api_key='sk-ant-...') >>> model.run_inference('house.jpg', 'Is the house standing?').text 'Yes, the house is standing with visible roof damage.' """ prompt_str = self._resolve_prompt(prompt) log_ctx = f"[Prompt snippet: '{prompt_str[:30]}...']" if image_inputs is None: image_inputs = [] elif not isinstance(image_inputs, list): image_inputs = [image_inputs] # 1. Build the user message content block (images first, then text) user_content: list[dict[str, Any]] = [] for img_input in image_inputs: img_data = self._fetch_and_encode_image(img_input) if img_data: user_content.append( { 'type': 'image', 'source': { 'type': 'base64', 'media_type': img_data['mime_type'], 'data': img_data['data'], }, } ) if prompt_str: user_content.append({'type': 'text', 'text': prompt_str}) if not user_content: logger.error(f'{log_ctx} No valid text or images were loaded.') return None # 2. Resolve configs and build the payload gen = self._resolve_generation( config, temperature=temperature, max_tokens=max_tokens, json_mode=json_mode ) payload = self._build_payload( user_content, gen.temperature, gen.max_tokens, gen.json_mode, gen.system_instruction, ) url = f'{ANTHROPIC_BASE_URL}/messages' session_to_use = self.session should_close_session = False if max_retries is not None: session_to_use = api_session(max_retries) session_to_use.headers.update(self.session.headers) should_close_session = True # 3. Execute the HTTP request try: response = session_to_use.post(url, json=payload, timeout=self.timeout) if not response.ok: try: err_msg = ( response.json().get('error', {}).get('message', 'Unknown error') ) logger.error(f'{log_ctx} Anthropic API Error: {err_msg}') except Exception: # noqa: BLE001 - body may not be JSON pass response.raise_for_status() result_json = response.json() stop_reason = result_json.get('stop_reason') if stop_reason == 'refusal': details = result_json.get('stop_details') or {} reason = ( f'Request refused by Claude (category: {details.get("category")}).' ) logger.warning(f'{log_ctx} {reason}') return ModelOutput.failure(reason, raw_response=result_json) if stop_reason == 'max_tokens': logger.warning(f'{log_ctx} Response truncated due to max_tokens limit.') extracted_text = self._extract_text(result_json) if extracted_text is None: logger.error(f'{log_ctx} Unexpected response format: {result_json}') return None return ModelOutput(text=extracted_text, raw_response=result_json) except RetryError: logger.error(f'{log_ctx} Max retries exceeded.') except HTTPError as e: status = e.response.status_code if e.response is not None else None logger.error(f'{log_ctx} HTTP Error: {status}') if status is not None and not is_retryable_status(status): return ModelOutput.failure(f'HTTP {status}', retryable=False) except Exception as e: # noqa: BLE001 - surfaced as a failed asset logger.error(f'{log_ctx} Unexpected error: {e}') finally: if should_close_session: session_to_use.close() return None