Files
doczyai-pipelines/src/utils/llm_utils.py
T
Venkatakrishna Reddy Avula 2da639e59b Merged in feature/DAIP2-2314-DAIP2-1687-hybrid (pull request #993)
Feature/DAIP2-2314 DAIP2 1687 hybrid

* remove -files from s3 prefix requirements

* Resolve input paths

* fix: VendorProcessor.process_file returns (df, None) tuple

runner.safe_process_file unpacks the result as (cc_df, dashboard_df), so
returning a single DataFrame caused every vendor/generic file to fail with
"too many values to unpack (expected 2)" — Python iterates DataFrame columns
during unpacking. Vendor pipelines have no dashboard variant; second slot is
None and the existing `dashboard_result is not None` guard in runner.py
already handles it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* DAIP2-2314 + DAIP2-1687: pad DYNAMIC_PRIMARY + DYNAMIC_PRIMARY_ENTITY_CLASSIFICATION over 1024-token cache floor

- Pad DYNAMIC_PRIMARY_INSTRUCTION with three new sections: [SCOPE BOUNDARIES], [SOURCE TEXT INTERPRETATION], [REASONING DISCIPLINE], plus a [WORKED EXAMPLES] block. Estimated tokens: 447 -> 1117 (Sonnet 4.5 1024-min, +93 margin). All additions reinforce existing rules (sibling-field separation, alias mapping, pricing-vs-LOB distinction, contrastive-clause exclusion, exhibit-header binding) — no new directives that could bias extraction.
- Pad DYNAMIC_PRIMARY_ENTITY_CLASSIFICATION_INSTRUCTION with a [FINAL CHECKLIST BEFORE OUTPUT] block. Estimated tokens: 956 -> 1101 (Sonnet 4.5 1024-min, +77 margin). Reinforces the existing 4-step anti-duplication protocol and JSON shape requirements.
- Register both new entries in cache_registry: DYNAMIC_PRIMARY_ENTITY_CLASSIFICATION as INSTRUCTION_PLUS_CONTEXT (caches at warm-up), DYNAMIC_PRIMARY_ENTITIES as CONTEXT (instruction is intentionally short; CONTEXT c…
* black format fix

* Merged dev into feature/DAIP2-2314-DAIP2-1687-hybrid

* fixed raw lob values in base lob field mapping and composite entities fix

* black format fix

* fixed LOB Program output issues

* issue fixes

* remove debugging code

* Updated prompts

* updated additional instructions

* Update Program-->LOB

* LLM-based AD-Program/Product mapping to LOB even when there is a crosswalk

* black format fix

* Merged dev into feature/DAIP2-2314-DAIP2-1687-hybrid

* added logging in prompt call tracking

* added updated logging in prompt call tracking

* aaded min cache token per usage label

* added cache registry for dynamic primary mapping prompt calls

* reolved mapping prompts ambiguities

* black format fix

* Phase 2 modifications added

* reverted phase 2 modifications


Approved-by: Katon Minhas
2026-05-08 15:52:32 +00:00

1499 lines
57 KiB
Python

import base64
import hashlib
import inspect
import json
import logging
import os
import random
import threading
import time
from collections import OrderedDict
import anthropic
import src.utils.string_utils as string_utils
import src.utils.prompt_call_tracking as prompt_call_tracking
import src.utils.usage_tracking as usage_tracking
from botocore.exceptions import ClientError, ReadTimeoutError
from src import config
# Cache warming state
_cache_warmed = {}
_cache_warming_lock = threading.Lock()
# ============================================================================
# MODEL SUPPORT CONFIGURATION
# ============================================================================
# Centralized configuration for supported and deprecated models.
# Model IDs are defined in config.py - update there first, then add to these lists.
# Supported models (from config.py)
# Add new models here when they're added to config.py
_SUPPORTED_MODELS = {
config.MODEL_ID_CLAUDE45_HAIKU,
config.MODEL_ID_CLAUDE35_SONNET,
config.MODEL_ID_CLAUDE37_SONNET,
config.MODEL_ID_CLAUDE4_SONNET,
config.MODEL_ID_CLAUDE45_SONNET,
}
# Deprecated model patterns (fuzzy matching)
# Add patterns here to deprecate model families (e.g., "claude-2" matches any claude-2 variant)
# Patterns are case-insensitive and match if found anywhere in the model ID
_DEPRECATED_PATTERNS = [
"claude-instant", # Deprecates all Claude Instant/Claude 2 models
"claude-2",
]
def _validate_model_support(
model_id: str, resolved_model_id: str, context: str = ""
) -> None:
"""Validate that a model is supported and not deprecated.
Checks both exact model IDs and pattern matching for deprecated models.
Raises ValueError with warning if model is deprecated or unsupported.
Args:
model_id: Original model ID (may be alias)
resolved_model_id: Resolved model ID from config.resolve_model_id()
context: Additional context for error message (e.g., "for image array")
"""
model_id_lower = resolved_model_id.lower()
# Check for deprecated patterns (fuzzy match)
for pattern in _DEPRECATED_PATTERNS:
if pattern in model_id_lower:
# Determine appropriate error message based on pattern
if "claude-2" in pattern or "claude-instant" in pattern:
deprecation_msg = (
"Claude 2 is no longer supported. Please use Claude 3+ models."
)
supported_models = "Haiku, Sonnet 3.5/3.7/4/4.5"
else:
deprecation_msg = (
"This model is no longer supported. Please use Claude 3+ models."
)
supported_models = "Haiku, Sonnet 3.5/3.7/4/4.5"
warning_msg = (
f"Deprecated model detected: {model_id} (resolved: {resolved_model_id}). "
f"{deprecation_msg}"
)
if context:
warning_msg = (
f"Deprecated model detected {context}: {model_id} (resolved: {resolved_model_id}). "
f"{deprecation_msg}"
)
logging.warning(warning_msg)
raise ValueError(
f"Unsupported model_id{(' ' + context) if context else ''}: {model_id}. "
f"{deprecation_msg} Supported models: {supported_models}."
)
# Check if supported (exact match)
supported_set = {m for m in _SUPPORTED_MODELS if m is not None}
if resolved_model_id not in supported_set:
raise ValueError(
f"Unsupported model_id{(' ' + context) if context else ''}: {model_id} "
f"(resolved: {resolved_model_id}). "
f"Only Claude 3+ models are supported (Haiku, Sonnet 3.5/3.7/4/4.5)."
)
def _supports_prompt_cache(model_id: str) -> bool:
"""Return True if the given model supports prompt caching via system cache_control.
Enable for models that are known to support prompt caching:
- Claude 3.5 Sonnet v2+ (20241022-v2:0)
- Claude 3.7 Sonnet
- Claude 4 Sonnet
- Claude 4.5 Sonnet
- Claude 4.5 Haiku
"""
supported_ids = {
getattr(config, "MODEL_ID_CLAUDE35_SONNET_V2", None),
getattr(config, "MODEL_ID_CLAUDE37_SONNET", None),
getattr(config, "MODEL_ID_CLAUDE4_SONNET", None),
getattr(config, "MODEL_ID_CLAUDE45_SONNET", None),
getattr(config, "MODEL_ID_CLAUDE45_HAIKU", None),
}
# Filter out None values in case any model IDs are not configured
return model_id in {m for m in supported_ids if m is not None}
# Per Anthropic docs (https://docs.anthropic.com/en/docs/prompt-caching),
# the minimum cacheable prompt length differs by model family:
# 1024 tokens — Sonnet 3.7 / 4 / 4.5, Opus 4 / 4.1
# 2048 tokens — Sonnet 4.6, Haiku 3.5 (deprecated)
# 4096 tokens — Sonnet/Opus Mythos, Opus 4.5/4.6/4.7, Haiku 4.5
# Caches against blocks below the threshold are silently dropped — the
# request still succeeds and returns content, but `cache_creation_tokens`
# and `cache_read_tokens` will both be 0 forever. That is the
# `no_cache_event` signature surfaced in runtime tracking.
_MODEL_MIN_CACHE_TOKENS_DEFAULT = 1024
_MODEL_MIN_CACHE_TOKENS: dict[str, int] = {
# Sonnet 4.5 — 1024
getattr(config, "MODEL_ID_CLAUDE45_SONNET", "_unset_45_sonnet"): 1024,
# Sonnet 4 / 3.7 / 3.5 v2 — 1024
getattr(config, "MODEL_ID_CLAUDE4_SONNET", "_unset_4_sonnet"): 1024,
getattr(config, "MODEL_ID_CLAUDE37_SONNET", "_unset_37_sonnet"): 1024,
getattr(config, "MODEL_ID_CLAUDE35_SONNET_V2", "_unset_35_sonnet_v2"): 1024,
# Haiku 4.5 — 4096
getattr(config, "MODEL_ID_CLAUDE45_HAIKU", "_unset_45_haiku"): 4096,
}
def _min_cache_tokens(model_id: str) -> int:
"""Return the minimum prompt-cache block size honored by the model.
Falls back to 1024 (the historical Sonnet minimum) when the model is
unknown — that is the most permissive choice; callers only get a
surprise `no_cache_event` if the actual minimum is higher.
"""
return _MODEL_MIN_CACHE_TOKENS.get(model_id, _MODEL_MIN_CACHE_TOKENS_DEFAULT)
def _classify_cache_outcome(
*,
has_context_cache: bool,
cache_read_tokens: int,
cache_creation_tokens: int,
context_chars: int,
cache_key: str | None,
) -> str:
"""Classify why a cache-attempting call did or didn't hit the Anthropic cache.
Values: hit, first_call, ttl_expired, under_min_tokens, silent_miss, not_attempted.
Anthropic's minimum cacheable block is 1024 tokens; we approximate as
context_chars / 3.5. Updates _cache_key_seen on every classify for future calls.
"""
if not has_context_cache:
return "not_attempted"
if cache_read_tokens > 0:
if cache_key is not None:
with _cache_key_seen_lock:
_cache_key_seen[cache_key] = time.monotonic()
return "hit"
if cache_creation_tokens > 0:
# Fresh write — either this is the first call with this key, or a prior
# entry expired (TTL ~5 min) and had to be re-written.
if cache_key is not None:
with _cache_key_seen_lock:
prior_seen = cache_key in _cache_key_seen
_cache_key_seen[cache_key] = time.monotonic()
return "ttl_expired" if prior_seen else "first_call"
return "first_call"
# has_context_cache=True but both token counts are 0 — silent failure mode.
approx_tokens = int(context_chars / 3.5) if context_chars else 0
if approx_tokens and approx_tokens < 1024:
return "under_min_tokens"
return "silent_miss"
def _ctx_minus_explicit_keys(ctx: dict, *explicit_keys: str) -> dict:
"""Filter a ctx dict so explicit kwargs aren't duplicated via **ctx splat."""
skip = {"_usage_label", "segment", *explicit_keys}
return {k: v for k, v in ctx.items() if not k.startswith("_") and k not in skip}
def _log_llm_retry(
filename: str,
model_id: str,
attempt: int,
error_class: str,
error_msg: str,
backoff_sec: float,
) -> None:
"""Emit one llm_retry instrumentation event (no-op when disabled)."""
from src.utils import instrumentation, instrumentation_context
if not instrumentation.is_enabled():
return
ctx = dict(instrumentation_context.get_ctx())
usage_label = ctx.get("_usage_label")
ctx_segment = ctx.get("segment")
instrumentation.log(
"llm_retry",
filename=filename,
model=model_id,
usage_label=usage_label,
segment=instrumentation.resolve_segment(usage_label, ctx_segment),
attempt=attempt,
error_class=error_class,
error_msg=(error_msg or "")[:200],
backoff_sec=round(backoff_sec, 4),
**_ctx_minus_explicit_keys(ctx, "filename", "model", "attempt"),
)
def _log_llm_error(
filename: str,
model_id: str,
attempt: int,
error_class: str,
error_msg: str,
) -> None:
"""Emit one llm_error instrumentation event (no-op when disabled).
Fires when retries are exhausted and the call is about to raise.
"""
from src.utils import instrumentation, instrumentation_context
if not instrumentation.is_enabled():
return
ctx = dict(instrumentation_context.get_ctx())
usage_label = ctx.get("_usage_label")
ctx_segment = ctx.get("segment")
instrumentation.log(
"llm_error",
filename=filename,
model=model_id,
usage_label=usage_label,
segment=instrumentation.resolve_segment(usage_label, ctx_segment),
attempt=attempt,
error_class=error_class,
error_msg=(error_msg or "")[:200],
**_ctx_minus_explicit_keys(ctx, "filename", "model", "attempt"),
)
def _track_usage_from_response(
response_body: dict, filename: str, model_id: str
) -> None:
"""Extract and track usage from API response.
Args:
response_body: Parsed JSON response from Bedrock API
filename: Name of file being processed
model_id: Model ID used for the API call
"""
if not filename:
return
(
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
) = usage_tracking.extract_usage_from_response(response_body)
if (
input_tokens > 0
or output_tokens > 0
or cache_creation_tokens > 0
or cache_read_tokens > 0
):
usage_tracking.track_usage(
filename,
model_id,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
)
# One llm_call event per Bedrock response with non-zero tokens.
from src.utils import instrumentation, instrumentation_context
if instrumentation.is_enabled():
from src.utils.usage_tracking import get_cost_per_token
input_cpt, output_cpt, cache_read_cpt = get_cost_per_token(model_id)
if "sonnet" in model_id.lower():
cache_write_cpt = input_cpt * 1.25
elif "haiku" in model_id.lower():
cache_write_cpt = input_cpt * 1.20
else:
cache_write_cpt = input_cpt
cost = (
input_tokens * input_cpt
+ output_tokens * output_cpt
+ cache_read_tokens * cache_read_cpt
+ cache_creation_tokens * cache_write_cpt
)
ctx = dict(instrumentation_context.get_ctx())
usage_label = ctx.pop("_usage_label", None)
has_context_cache = ctx.pop("_has_context_cache", False)
context_chars = ctx.pop("_context_chars", 0)
prompt_chars = ctx.pop("_prompt_chars", 0)
cache_key = ctx.pop("_cache_key", None)
segment = instrumentation.resolve_segment(
usage_label, ctx.pop("segment", None)
)
cache_miss_reason = _classify_cache_outcome(
has_context_cache=has_context_cache,
cache_read_tokens=cache_read_tokens,
cache_creation_tokens=cache_creation_tokens,
context_chars=context_chars,
cache_key=cache_key,
)
instrumentation.log(
"llm_call",
filename=filename,
model=model_id,
usage_label=usage_label,
segment=segment,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_tokens=cache_creation_tokens,
cache_read_tokens=cache_read_tokens,
total_tokens=(
input_tokens
+ output_tokens
+ cache_creation_tokens
+ cache_read_tokens
),
cost_usd=cost,
cache_hit_inmem=False,
cache_miss_reason=cache_miss_reason,
has_context_cache=has_context_cache,
context_chars=context_chars,
prompt_chars=prompt_chars,
**_ctx_minus_explicit_keys(
ctx,
"filename",
"model",
"usage_label",
"input_tokens",
"output_tokens",
"cache_creation_tokens",
"cache_read_tokens",
"total_tokens",
"cost_usd",
"cache_hit_inmem",
"cache_miss_reason",
"has_context_cache",
"context_chars",
"prompt_chars",
),
)
def _extract_text_from_claude_response(response_body: dict) -> str:
"""Extract text from a Bedrock Claude response body.
Supports the messages API content blocks and includes a completion fallback
for older payload formats. Raises ValueError when no text is available.
"""
if not isinstance(response_body, dict):
raise ValueError("Claude response body is not a dictionary")
content = response_body.get("content")
if isinstance(content, list):
text_blocks = []
for block in content:
if isinstance(block, dict) and isinstance(block.get("text"), str):
text_blocks.append(block["text"])
if text_blocks:
return "".join(text_blocks)
# Fallback for legacy response shape.
completion = response_body.get("completion")
if isinstance(completion, str) and completion:
return completion
stop_reason = response_body.get("stop_reason", "unknown")
content_len = len(content) if isinstance(content, list) else "n/a"
raise ValueError(
f"Claude response missing text content (stop_reason={stop_reason}, content_len={content_len})"
)
def _build_claude_3_request_body(
prompt: str,
max_tokens: int,
instruction: str = None,
cache: bool = False,
model_id: str = None,
context_for_caching: str = None,
) -> dict:
"""Build request body for Claude 3+ API calls.
Args:
prompt: Input text prompt
max_tokens: Maximum tokens to generate
instruction: Optional system instruction
cache: Whether to enable prompt caching
model_id: Model ID to check for cache support
context_for_caching: Optional context text to cache separately (placed before prompt)
Returns:
Dictionary ready for JSON serialization
"""
request_body = {
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": max_tokens,
"temperature": 0.0,
}
if instruction:
if cache and model_id and _supports_prompt_cache(model_id):
request_body["system"] = [
{
"type": "text",
"text": instruction,
"cache_control": {"type": "ephemeral"},
}
]
else:
request_body["system"] = instruction
# Build user message content with optional cached context
user_content = []
# Add cacheable context first (if provided)
if context_for_caching:
if cache and model_id and _supports_prompt_cache(model_id):
token_estimate = len(context_for_caching.split()) * 1.3
min_tokens = _min_cache_tokens(model_id)
if token_estimate < min_tokens:
logging.warning(
f"context_for_caching is ~{token_estimate:.0f} tokens, below the "
f"Anthropic {min_tokens}-token minimum for prompt caching on {model_id}; "
"context will be sent but the cache_control block may not actually be cached."
)
user_content.append(
{
"type": "text",
"text": context_for_caching,
"cache_control": {"type": "ephemeral"},
}
)
else:
user_content.append(
{
"type": "text",
"text": context_for_caching,
}
)
# Add main prompt (not cached)
user_content.append({"type": "text", "text": prompt})
request_body["messages"] = [{"role": "user", "content": user_content}]
return request_body
def _store_in_cache(cache_key: str, response: str) -> None:
"""Thread-safe cache storage with LRU eviction.
Args:
cache_key: Unique cache key for this response
response: Response text to cache
"""
with _cache_lock:
if len(claude_cache) >= CACHE_LIMIT:
claude_cache.popitem(last=False) # Remove oldest (least recently used)
claude_cache[cache_key] = response
claude_cache.move_to_end(cache_key) # Mark as recently used
# In-memory cache
claude_cache = OrderedDict()
CACHE_LIMIT = 20000
_cache_lock = threading.Lock() # Thread safety for cache operations
# Tracks which Anthropic cache-key hashes have been seen in this process and
# when (monotonic seconds). Used to classify cache_miss_reason=first_call vs
# ttl_expired on llm_call events. O(distinct cache keys) — tiny.
_cache_key_seen: dict[str, float] = {}
_cache_key_seen_lock = threading.Lock()
# Model routing set (derived from _SUPPORTED_MODELS for consistency)
_CLAUDE_3_AND_UP_MODELS = {m for m in _SUPPORTED_MODELS if m is not None}
def get_cache_key(
prompt,
model_id,
instruction=None,
max_tokens=None,
cache=False,
context_for_caching=None,
):
"""Generates a unique hash key for caching.
Args:
prompt (str): The input prompt.
model_id (str): The model identifier.
instruction (str, optional): System instruction for caching. Defaults to None.
max_tokens (int, optional): Maximum tokens to generate. Defaults to None.
cache (bool, optional): Whether caching is enabled. Defaults to False.
context_for_caching (str, optional): Context text for caching. Defaults to None.
Returns:
str: SHA256 hash of the cache key components.
"""
# Build cache key from all relevant parameters
key_parts = [model_id, prompt]
if instruction is not None:
key_parts.append(str(instruction))
if max_tokens is not None:
key_parts.append(str(max_tokens))
if cache:
key_parts.append("cache_enabled")
if context_for_caching:
context_hash = hashlib.sha256(context_for_caching.encode()).hexdigest()
key_parts.append(f"context_sha256={context_hash}")
return hashlib.sha256(":".join(key_parts).encode()).hexdigest()
def invoke_claude(
prompt: str,
model_id: str,
filename: str,
max_tokens: int = 4096,
cache: bool = False,
instruction: str = None,
usage_label: str = None,
context_for_caching: str = None,
) -> str:
"""Invokes the Claude model with the given parameters.
Args:
prompt (str): The input prompt to send to the model.
model_id (str): The ID of the model to use. Supports both direct model IDs
(e.g. 'anthropic.claude-3-5-sonnet-20240620-v1:0') as well as aliases (e.g.
'sonnet_latest', 'haiku_latest', etc.).
filename (str): The name of the file being processed, for cost logging purposes.
max_tokens (int, optional): The maximum number of tokens to generate. Defaults to 4096.
cache (bool, optional): Whether to enable prompt caching. Defaults to False.
instruction (str, optional): System instruction to use with caching. Defaults to None.
context_for_caching (str, optional): Context text to cache separately before prompt.
Useful for caching large exhibit text that's reused across multiple field extractions.
When provided with cache=True, this context will be cached at the Anthropic API level.
Raises:
ValueError: If the model_id is not supported after alias resolution
Returns:
str: The model's response.
Note:
- Responses are cached using LRU eviction (limit: 20,000 entries)
- Includes automatic retry logic with exponential backoff for throttling
- Cost logging is performed automatically for all successful requests
- Model aliases are resolved via config.resolve_model_id()
- Prompt caching can be enabled to reduce costs for large repeated prompts
- Context caching allows caching exhibit text separately from field questions
"""
if usage_label is None:
try:
usage_label = inspect.stack()[1].function
except Exception:
usage_label = "UNLABELED"
cache_key = get_cache_key(
prompt,
model_id,
instruction=instruction,
max_tokens=max_tokens,
cache=cache,
context_for_caching=context_for_caching,
)
# Resolve alias up-front so cache-policy classification in tracking
# logs uses the durable id, not whatever alias the caller happened to
# pass.
resolved_model_id = config.resolve_model_id(model_id)
model_supports_cache = _supports_prompt_cache(resolved_model_id)
# Check cache first - thread-safe access
with _cache_lock:
if cache_key in claude_cache:
claude_cache.move_to_end(cache_key) # Mark as recently used
prompt_call_tracking.log_prompt_call(
contract_filename=filename,
usage_label=usage_label,
model_id=model_id,
cache_enabled=cache,
max_tokens=max_tokens,
prompt=prompt,
instruction=instruction,
context_for_caching=context_for_caching,
status="success",
from_local_memory_cache=True,
resolved_model_id=resolved_model_id,
model_supports_cache=model_supports_cache,
)
# One llm_call_inmem_hit event per in-memory cache return.
from src.utils import instrumentation, instrumentation_context
if instrumentation.is_enabled():
ctx = instrumentation_context.get_ctx()
segment = instrumentation.resolve_segment(
usage_label, ctx.get("segment")
)
instrumentation.log(
"llm_call_inmem_hit",
filename=filename,
usage_label=usage_label,
segment=segment,
model=model_id,
prompt_chars=len(prompt) if prompt else 0,
context_chars=(
len(context_for_caching) if context_for_caching else 0
),
has_context_cache=bool(context_for_caching),
cache_hit_inmem=True,
**_ctx_minus_explicit_keys(
dict(ctx),
"filename",
"usage_label",
"model",
"prompt_chars",
"context_chars",
"has_context_cache",
"cache_hit_inmem",
),
)
return claude_cache[cache_key]
# Validate model support (checks for deprecated and unsupported models)
_validate_model_support(model_id, resolved_model_id)
# Push call metadata into context so _track_usage_from_response can read it.
from src.utils.instrumentation_context import instr_scope
try:
with instr_scope(
_usage_label=usage_label,
_has_context_cache=bool(context_for_caching),
_context_chars=len(context_for_caching) if context_for_caching else 0,
_prompt_chars=len(prompt) if prompt else 0,
_cache_key=cache_key,
):
if config.RUN_MODE == "local":
response = local_claude_3_and_up(
prompt,
resolved_model_id,
filename,
max_tokens,
cache=cache,
instruction=instruction,
usage_label=usage_label,
context_for_caching=context_for_caching,
)
elif config.RUN_MODE == "ec2":
response = ec2_claude_3_and_up(
prompt,
resolved_model_id,
filename,
max_tokens,
cache=cache,
instruction=instruction,
usage_label=usage_label,
context_for_caching=context_for_caching,
)
else:
logging.error(
"Usage: python local_main.py <-local> OR python main.py <ec2>"
)
raise
except Exception as e:
prompt_call_tracking.log_prompt_call(
contract_filename=filename,
usage_label=usage_label,
model_id=resolved_model_id,
cache_enabled=cache,
max_tokens=max_tokens,
prompt=prompt,
instruction=instruction,
context_for_caching=context_for_caching,
status="error",
error=str(e),
resolved_model_id=resolved_model_id,
model_supports_cache=model_supports_cache,
)
raise
# Store response in cache with LRU eviction - thread-safe
_store_in_cache(cache_key, response)
return response
def get_cache_key_with_image_array(
prompt, model_id, image_base64_list, instruction=None, max_tokens=None, cache=False
):
"""Generates a unique hash key for caching with a list of base64 images.
Args:
prompt (str): The input prompt.
model_id (str): The model identifier.
image_base64_list (list): List of base64-encoded images.
instruction (str, optional): System instruction for caching. Defaults to None.
max_tokens (int, optional): Maximum tokens to generate. Defaults to None.
cache (bool, optional): Whether caching is enabled. Defaults to False.
Returns:
str: SHA256 hash of the cache key components.
"""
combined_images = ":".join(
image_base64_list
) # Combine all base64 images into a single string
# Build cache key from all relevant parameters
key_parts = [model_id, prompt, combined_images]
if instruction is not None:
key_parts.append(str(instruction))
if max_tokens is not None:
key_parts.append(str(max_tokens))
if cache:
key_parts.append("cache_enabled")
return hashlib.sha256(":".join(key_parts).encode()).hexdigest()
def invoke_claude_with_image_array(
prompt, model_id, filename, base64_images, max_tokens=4096
):
usage_label = inspect.stack()[1].function
cache_key = get_cache_key_with_image_array(
prompt, model_id, base64_images, max_tokens=max_tokens
)
# Check cache first - thread-safe access
with _cache_lock:
if cache_key in claude_cache:
claude_cache.move_to_end(cache_key) # Mark as recently used
return claude_cache[cache_key]
# Resolve alias to actual model ID
resolved_model_id = config.resolve_model_id(model_id)
# Validate model support (checks for deprecated and unsupported models)
_validate_model_support(model_id, resolved_model_id, context="for image array")
if config.RUN_MODE == "local":
if resolved_model_id == config.MODEL_ID_CLAUDE35_SONNET:
response = local_claude_3_and_up_with_image_array(
prompt,
resolved_model_id,
filename,
max_tokens,
base64_images,
usage_label=usage_label,
)
logging.debug(
f"Effective Date Response from local Claude 3 for {filename}: {response}"
)
else:
raise ValueError(
f"Unsupported model_id for image array: {resolved_model_id}"
)
elif config.RUN_MODE == "ec2":
# EC2 mode with image arrays - currently only Claude 3.5 Sonnet is supported for image arrays
if resolved_model_id == config.MODEL_ID_CLAUDE35_SONNET:
# For EC2, we need to use the image array function, but it's only implemented for local mode
# For now, raise an error suggesting to use local mode or a different approach
raise ValueError(
f"Image arrays with EC2 mode are currently only supported in local mode. "
f"Please use RUN_MODE='local' or use a text-only prompt."
)
else:
raise ValueError(
f"Unsupported model_id for image array in EC2 mode: {resolved_model_id}. "
f"Only Claude 3.5 Sonnet is supported for image arrays."
)
else:
logging.error("Usage: python local_main.py <-local> OR python main.py <ec2>")
raise
# Store response in cache with LRU eviction - thread-safe
_store_in_cache(cache_key, response)
return response
# Claude calls
# Note: local_claude_2() and ec2_claude2() have been removed as Claude 2 is no longer supported
def local_claude_3_and_up(
prompt: str,
model_id: str,
filename: str,
max_tokens: int,
max_retries: int = 5,
initial_delay: int = 1,
cache: bool = False,
instruction: str = None,
usage_label: str = None,
context_for_caching: str = None,
):
"""
Handles local invocation of Claude 3 and above models (3.x, 3.5, 4.x) via AWS Bedrock.
This function provides a unified interface for all modern Claude models using the
messages API format. Includes exponential backoff retry logic for throttling errors.
Args:
prompt (str): Input text prompt to send to the model
model_id (str): AWS Bedrock model identifier (e.g., 'anthropic.claude-3-5-sonnet-20241022-v2:0')
filename (str): Name of file being processed (for logging/tracking purposes)
max_tokens (int): Maximum number of tokens to generate in the response
max_retries (int, optional): Maximum number of retry attempts for throttling errors. Defaults to 5.
initial_delay (int, optional): Initial delay in seconds for exponential backoff. Defaults to 1.
cache (bool, optional): Whether to enable prompt caching. Defaults to False.
instruction (str, optional): System instruction to use with caching. Defaults to None.
Returns:
str: Generated text response from the Claude model
Raises:
ClientError: For AWS Bedrock API errors (non-throttling)
Exception: When max retries are exceeded or other unexpected errors occur
Note:
- Uses the modern messages API format (anthropic_version: "bedrock-2023-05-31")
- Supports answer tracking via config.ENABLE_ANSWER_TRACKING for caching/debugging
- Temperature is set to 0.0 for deterministic responses
- Automatically handles throttling with exponential backoff retry logic
- Prompt caching can be enabled to reduce costs for large repeated prompts
"""
if config.ENABLE_ANSWER_TRACKING and prompt in config.ANSWER_TRACKING_DICT.keys():
return config.ANSWER_TRACKING_DICT[prompt]
# Build request body with caching support
request_body = _build_claude_3_request_body(
prompt,
max_tokens,
instruction=instruction,
cache=cache,
model_id=model_id,
context_for_caching=context_for_caching,
)
body = json.dumps(request_body)
for attempt in range(max_retries):
try:
response = config.BEDROCK_RUNTIME.invoke_model(
body=body,
modelId=model_id,
accept="application/json",
contentType="application/json",
)
response_body = json.loads(response.get("body").read())
response_text = _extract_text_from_claude_response(response_body)
(
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
) = usage_tracking.extract_usage_from_response(response_body)
# Track usage and costs from API response (including cache tokens)
_track_usage_from_response(response_body, filename, model_id)
prompt_call_tracking.log_prompt_call(
contract_filename=filename,
usage_label=usage_label or "UNLABELED",
model_id=model_id,
cache_enabled=cache,
max_tokens=max_tokens,
prompt=prompt,
instruction=instruction,
context_for_caching=context_for_caching,
status="success",
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_tokens=cache_creation_tokens,
cache_read_tokens=cache_read_tokens,
resolved_model_id=model_id,
model_supports_cache=_supports_prompt_cache(model_id),
)
if config.ENABLE_ANSWER_TRACKING:
config.ANSWER_TRACKING_DICT[prompt] = response_text
return response_text
except ValueError as e:
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
_log_llm_retry(
filename, model_id, attempt + 1, "ValueError", str(e), delay
)
logging.warning(
f"Attempt {attempt + 1} failed due to malformed Claude response. "
f"Retrying in {delay:.2f} seconds. Error: {e}"
)
time.sleep(delay)
else:
_log_llm_error(filename, model_id, attempt + 1, "ValueError", str(e))
raise
except ClientError as e:
error_code = e.response["Error"]["Code"]
if error_code in [
"ThrottlingException",
"TooManyRequestsException",
]:
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
_log_llm_retry(
filename, model_id, attempt + 1, error_code, str(e), delay
)
time.sleep(delay)
else:
_log_llm_error(filename, model_id, attempt + 1, error_code, str(e))
raise
else:
_log_llm_error(filename, model_id, attempt + 1, error_code, str(e))
raise
raise Exception("Max retries exceeded")
def local_claude_3_and_up_with_image_array(
prompt,
model_id,
filename,
max_tokens,
base64_images,
max_retries=5,
initial_delay=1,
image_media_type="image/png",
usage_label: str = None,
):
"""
Processes a prompt along with an image in base64 format using the Claude 3 model via AWS Bedrock.
Args:
prompt (str): Input prompt to LLM.
model_id (str): Model ID to use.
filename (str): Name of the file being operated upon.
max_tokens (int): Maximum tokens to sample.
image_base64 (str): Base64 encoded image data.
max_retries (int): Maximum number of retries for the request.
initial_delay (int): Initial delay for exponential backoff.
image_media_type (str): Media type of the image (default: "image/jpeg").
Returns:
str: Response text from the model.
"""
if config.ENABLE_ANSWER_TRACKING and prompt in config.ANSWER_TRACKING_DICT.keys():
return config.ANSWER_TRACKING_DICT[prompt]
# Create the request body with the new message format
body = json.dumps(
{
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": max_tokens,
"temperature": 0.0,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Images:"},
*[
{
"type": "image",
"source": {
"type": "base64",
"media_type": image_media_type,
"data": image_base64,
},
}
for image_base64 in base64_images
],
{"type": "text", "text": prompt},
],
}
],
}
)
for attempt in range(max_retries):
try:
response = config.BEDROCK_RUNTIME.invoke_model(
body=body,
modelId=model_id,
accept="application/json",
contentType="application/json",
)
response_body = json.loads(response.get("body").read())
response_text = _extract_text_from_claude_response(response_body)
# Track usage and costs from API response (including cache tokens)
_track_usage_from_response(response_body, filename, model_id)
if config.ENABLE_ANSWER_TRACKING:
config.ANSWER_TRACKING_DICT[prompt] = response_text
return response_text
except ValueError as e:
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
logging.warning(
f"Attempt {attempt + 1} failed due to malformed Claude response. "
f"Retrying in {delay:.2f} seconds. Error: {e}"
)
time.sleep(delay)
else:
raise
except ClientError as e:
if e.response["Error"]["Code"] in [
"ThrottlingException",
"TooManyRequestsException",
]:
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
time.sleep(delay)
else:
raise
else:
raise
raise Exception("Max retries exceeded")
def ec2_claude_3_and_up(
prompt: str,
model_id: str,
filename: str,
max_tokens: int,
max_retries: int = 5,
initial_delay: int = 1,
cache: bool = False,
instruction: str = None,
usage_label: str = None,
context_for_caching: str = None,
):
"""
Handles EC2 invocation of Claude 3 and above models.
When ENABLE_RUNTIME_ROTATION is True:
Provides multi-runtime failover (UAT -> DEV -> PROD) with exponential backoff.
When ENABLE_RUNTIME_ROTATION is False:
Uses single runtime with exponential backoff retry logic.
Both options include comprehensive usage tracking via usage_tracking.py.
Args:
prompt (str): Input text prompt to send to the model
model_id (str): AWS Bedrock model identifier (e.g., 'anthropic.claude-3-5-sonnet-20241022-v2:0')
filename (str): Name of file being processed (for usage tracking and logging)
max_tokens (int): Maximum number of tokens to generate in the response
max_retries (int, optional): Maximum retry attempts per runtime environment. Defaults to 5.
initial_delay (int, optional): Initial delay in seconds for exponential backoff. Defaults to 1.
cache (bool, optional): Whether to enable prompt caching. Defaults to False.
instruction (str, optional): System instruction to use with caching. Defaults to None.
usage_label (str, optional): Label to surface in usage diagnostics. Defaults to None.
Returns:
str: Generated text response from the Claude model
Raises:
ClientError: For non-recoverable AWS Bedrock API errors
ReadTimeoutError: For network timeout issues
Exception: When all runtimes (when applicable) and retries are exhausted
Runtime Failover Order (when ENABLE_RUNTIME_ROTATION is True):
1. UAT (primary runtime from config.BEDROCK_RUNTIME)
2. DEV (fallback via role assumption)
3. PROD (final fallback via role assumption)
Usage Tracking:
- All token usage is tracked via usage_tracking.track_usage()
- Uses exact token counts from API responses (not estimates)
- Tracks all 4 token types: input, output, cache_creation, cache_read
Note:
- Uses messages API format for all Claude 3+ models
- When rotation is enabled, runtime gets full retry cycle before switching to next
- Temperature set to 0.0 for consistent, deterministic outputs
- Prompt caching can be enabled to reduce costs for large repeated prompts
"""
# Build request body with caching support
request_body = _build_claude_3_request_body(
prompt,
max_tokens,
instruction=instruction,
cache=cache,
model_id=model_id,
context_for_caching=context_for_caching,
)
body = json.dumps(request_body)
if config.ENABLE_RUNTIME_ROTATION:
dev_runtime = config.assume_role_and_get_runtime(
config.dev_account_id, config.dev_role_name
)
prod_runtime = config.assume_role_and_get_runtime(
config.prod_account_id, config.prod_role_name
)
runtimes = {
"UAT": config.BEDROCK_RUNTIME, # This is the default runtime (UAT)
"DEV": dev_runtime,
"PROD": prod_runtime,
}
for runtime_key in runtimes.keys():
current_runtime = runtimes[runtime_key]
for attempt in range(max_retries):
try:
response = current_runtime.invoke_model(
body=body,
modelId=model_id,
accept="application/json",
contentType="application/json",
)
response_body = json.loads(response.get("body").read())
response_text = _extract_text_from_claude_response(response_body)
(
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
) = usage_tracking.extract_usage_from_response(response_body)
# Track usage and costs from API response (including cache tokens)
_track_usage_from_response(response_body, filename, model_id)
prompt_call_tracking.log_prompt_call(
contract_filename=filename,
usage_label=usage_label or "UNLABELED",
model_id=model_id,
cache_enabled=cache,
max_tokens=max_tokens,
prompt=prompt,
instruction=instruction,
context_for_caching=context_for_caching,
status="success",
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_tokens=cache_creation_tokens,
cache_read_tokens=cache_read_tokens,
resolved_model_id=model_id,
model_supports_cache=_supports_prompt_cache(model_id),
)
return response_text
except ValueError as e:
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
_log_llm_retry(
filename, model_id, attempt + 1, "ValueError", str(e), delay
)
logging.warning(
f"Attempt {attempt + 1} failed due to malformed Claude response. "
f"Retrying in {delay:.2f} seconds... Error: {e}"
)
time.sleep(delay)
else:
_log_llm_retry(
filename,
model_id,
attempt + 1,
f"runtime_switch:{runtime_key}:ValueError",
str(e),
0.0,
)
logging.warning(
f"Switching to next runtime due to malformed Claude response: {e}."
)
break # Switch to the next runtime
except (ReadTimeoutError, ClientError) as e:
error_code = e.response["Error"]["Code"]
if error_code in [
"ThrottlingException",
"TooManyRequestsException",
]:
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
_log_llm_retry(
filename,
model_id,
attempt + 1,
error_code,
str(e),
delay,
)
logging.warning(
f"Attempt {attempt + 1} failed. Retrying in {delay:.2f} seconds..."
)
time.sleep(delay)
else:
_log_llm_retry(
filename,
model_id,
attempt + 1,
f"runtime_switch:{runtime_key}:{error_code}",
str(e),
0.0,
)
logging.warning(
f"Switching to next runtime due to {error_code}: {str(e)}."
)
break # Switch to the next runtime
else:
_log_llm_error(
filename, model_id, attempt + 1, error_code, str(e)
)
logging.error(f"Unexpected error: {str(e)}")
raise # Reraise other exceptions
except Exception as e:
_log_llm_error(
filename,
model_id,
attempt + 1,
type(e).__name__,
str(e),
)
logging.error(f"Unexpected error: {str(e)}")
raise
else: # Runtime rotation is disabled
for attempt in range(max_retries):
try:
response = config.BEDROCK_RUNTIME.invoke_model(
body=body,
modelId=model_id,
accept="application/json",
contentType="application/json",
)
response_body = json.loads(response.get("body").read())
response_text = _extract_text_from_claude_response(response_body)
(
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
) = usage_tracking.extract_usage_from_response(response_body)
# Track usage and costs from API response (including cache tokens)
_track_usage_from_response(response_body, filename, model_id)
prompt_call_tracking.log_prompt_call(
contract_filename=filename,
usage_label=usage_label or "UNLABELED",
model_id=model_id,
cache_enabled=cache,
max_tokens=max_tokens,
prompt=prompt,
instruction=instruction,
context_for_caching=context_for_caching,
status="success",
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_tokens=cache_creation_tokens,
cache_read_tokens=cache_read_tokens,
resolved_model_id=model_id,
model_supports_cache=_supports_prompt_cache(model_id),
)
return response_text
except ValueError as e:
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
_log_llm_retry(
filename, model_id, attempt + 1, "ValueError", str(e), delay
)
logging.warning(
f"Attempt {attempt + 1} failed due to malformed Claude response. "
f"Retrying in {delay:.2f} seconds... Error: {e}"
)
time.sleep(delay)
else:
_log_llm_error(
filename, model_id, attempt + 1, "ValueError", str(e)
)
raise
except ReadTimeoutError as e:
# Timeout errors should probably retry
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
_log_llm_retry(
filename,
model_id,
attempt + 1,
"ReadTimeoutError",
str(e),
delay,
)
logging.warning(
f"Attempt {attempt + 1} failed due to timeout. Retrying in {delay:.2f} seconds..."
)
time.sleep(delay)
else:
_log_llm_error(
filename,
model_id,
attempt + 1,
"ReadTimeoutError",
str(e),
)
raise
except ClientError as e:
error_code = e.response["Error"]["Code"]
if error_code in ["ThrottlingException", "TooManyRequestsException"]:
if attempt < max_retries - 1:
delay = (2**attempt + random.uniform(0, 1)) * initial_delay
_log_llm_retry(
filename,
model_id,
attempt + 1,
error_code,
str(e),
delay,
)
logging.warning(
f"Attempt {attempt + 1} failed due to rate limiting. Retrying in {delay:.2f} seconds..."
)
time.sleep(delay)
else:
_log_llm_error(
filename, model_id, attempt + 1, error_code, str(e)
)
raise
else:
_log_llm_error(filename, model_id, attempt + 1, error_code, str(e))
logging.error(f"Unexpected error: {str(e)}")
raise # Non-throttling client errors should fail immediately
except Exception as e:
_log_llm_error(
filename, model_id, attempt + 1, type(e).__name__, str(e)
)
logging.error(f"Unexpected error: {str(e)}")
raise
raise Exception(f"Max retries ({max_retries}) exceeded for model {model_id}.")
def get_image_array_answer(prompt, filename, base64_images):
response_text = invoke_claude_with_image_array(
prompt, config.MODEL_ID_CLAUDE35_SONNET, filename, base64_images
)
response_dict = json.loads(response_text)
return response_dict
def warm_prompt_cache(
instruction: str,
cache_key: str,
model_id: str = "sonnet_latest",
max_tokens: int = 10,
):
"""
Warms the prompt cache by sending a minimal request with the instruction.
This should be called once before parallel processing to ensure the cache
is registered.
Cache entries are model-specific in Bedrock — a cache created against
`sonnet_latest` is NOT readable from a runtime call invoked against
`haiku_latest`. The warmed-state map is therefore keyed by
(cache_key, resolved_model_id) so repeated calls with different models
each get warmed exactly once.
Args:
instruction (str): The instruction text to cache
cache_key (str): A unique identifier for this cache (e.g., "REIMBURSEMENT_PRIMARY")
model_id (str): The model ID or alias to use. Defaults to "sonnet_latest"
max_tokens (int): Minimal tokens for cache warming. Defaults to 10
Returns:
bool: True if cache was warmed, False if it was already warm or
the resolved model does not support prompt caching.
"""
global _cache_warmed
# Resolve model ID up-front so the warmed-state key matches what the
# runtime call will use after its own resolve_model_id().
resolved_model_id = config.resolve_model_id(model_id)
# Hard skip on models that do not support prompt caching in this
# Bedrock setup. Without this, warming spends create-tokens against a
# cache the runtime call can never read.
if not _supports_prompt_cache(resolved_model_id):
logging.info(
"Skipping cache warming for %s on %s (resolved %s): model does "
"not support prompt caching.",
cache_key,
model_id,
resolved_model_id,
)
return False
warmed_key = (cache_key, resolved_model_id)
# Check if already warmed (thread-safe)
with _cache_warming_lock:
if _cache_warmed.get(warmed_key, False):
logging.debug(
f"Cache already warmed for {cache_key} on {resolved_model_id}"
)
return False
# Mark as being warmed
_cache_warmed[warmed_key] = True
logging.info(f"Warming prompt cache for {cache_key} on {resolved_model_id}...")
# Create minimal prompt with the instruction to register the cache
minimal_prompt = "Respond with OK."
try:
# Build request with caching enabled
request_body = _build_claude_3_request_body(
minimal_prompt,
max_tokens,
instruction=instruction,
cache=True,
model_id=resolved_model_id,
)
body = json.dumps(request_body)
# Send the request to register the cache
if config.RUN_MODE == "local":
response = config.BEDROCK_RUNTIME.invoke_model(
body=body,
modelId=resolved_model_id,
accept="application/json",
contentType="application/json",
)
elif config.RUN_MODE == "ec2":
response = config.BEDROCK_RUNTIME.invoke_model(
body=body,
modelId=resolved_model_id,
accept="application/json",
contentType="application/json",
)
else:
logging.error("Invalid RUN_MODE for cache warming")
return False
response_body = json.loads(response.get("body").read())
logging.info(f"Successfully warmed cache for {cache_key}")
# Track usage for cache warming call (use cache_key as filename identifier)
_track_usage_from_response(
response_body, f"cache_warming_{cache_key}", resolved_model_id
)
# Log cache usage info if available
if "usage" in response_body:
usage = response_body["usage"]
if "cache_creation_input_tokens" in usage:
logging.info(
f"Cache created with {usage.get('cache_creation_input_tokens', 0)} tokens for {cache_key}"
)
return True
except Exception as e:
logging.error(f"Error warming cache for {cache_key}: {str(e)}")
# Mark as not warmed on error so it can be retried
with _cache_warming_lock:
_cache_warmed[warmed_key] = False
return False