Skip to content
16 changes: 15 additions & 1 deletion hindsight-api-slim/hindsight_api/engine/llm_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
"""

from abc import ABC, abstractmethod
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any, Self
from typing import Any, Callable, Self

from .response_models import LLMToolCallResult

Expand Down Expand Up @@ -112,6 +113,7 @@ async def call(
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
Expand All @@ -133,6 +135,11 @@ async def call(
cached_prefix: Opaque handle from ``get_or_create_cached_prefix`` for the
cacheable system prefix, or None. Providers without explicit prompt
caching ignore it (and the wrapper only forwards it when set).
attempt_context: Factory for an async context manager holding the shared
concurrency permits. Passed only when the provider declares
``supports_attempt_scoped_concurrency()``; the provider must enter it
around each individual upstream request so retry backoff never
occupies a permit.

Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
Expand All @@ -158,6 +165,7 @@ async def call_with_tools(
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
cached_prefix: str | None = None,
cached_prefix_message_count: int = 0,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Expand All @@ -172,6 +180,8 @@ async def call_with_tools(
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: Canonical tool-selection policy.
attempt_context: Factory for an async context manager holding the shared
concurrency permits — see ``call``.

Returns:
LLMToolCallResult with content and/or tool_calls.
Expand All @@ -187,6 +197,10 @@ async def supports_batch_api(self) -> bool:
"""
return False

def supports_attempt_scoped_concurrency(self) -> bool:
"""Whether retries can acquire concurrency permits per upstream attempt."""
return False

# ── Prompt prefix caching (optional, per-provider) ─────────────────────────

def supports_prompt_caching(self) -> bool:
Expand Down
52 changes: 45 additions & 7 deletions hindsight-api-slim/hindsight_api/engine/llm_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import re
import time
import uuid
from contextlib import AsyncExitStack
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, Any

from json_repair import repair_json
Expand Down Expand Up @@ -114,6 +114,27 @@ def _semaphores_for_scope(scope: str) -> list[asyncio.Semaphore]:
return [per_op, _global_llm_semaphore]


@asynccontextmanager
async def _attempt_permits(scope: str):
"""Hold configured LLM concurrency permits for one upstream attempt."""
from ..worker.stage import get_stage, set_stage

async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
try:
yield
except BaseException:
# A failed attempt exits here with its permits released while the
# provider classifies the error and sleeps out its backoff. Suffix
# the stage so `attempt=N` always means "permits held, request in
# flight" (#3002); the next attempt re-stamps after re-acquiring.
stage = get_stage()
if stage is not None and not stage.endswith(".backoff"):
set_stage(f"{stage}.backoff")
raise


def _request_params(
*,
max_completion_tokens: int | None = None,
Expand Down Expand Up @@ -971,10 +992,18 @@ async def call(
# hand so the error path below can attach it if parsing/validation fails.
usage_token = set_response_usage(None)
try:
# Providers that own retry loops acquire the shared permits for each
# upstream attempt so backoff never occupies request capacity.
attempt_gated = self._provider_impl.supports_attempt_scoped_concurrency()
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
set_stage(base_stage)
if not attempt_gated:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Permits in hand — only now leave `.queued`. Attempt-gated
# providers acquire permits per attempt instead, so they keep
# `.queued` until their first `attempt=N` stamp lands after
# the permit acquire inside attempt_context (#3002).
set_stage(base_stage)

# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix() (e.g. Gemini); it's None for
Expand All @@ -983,6 +1012,7 @@ async def call(
cache_kwarg = {"cached_prefix": cached_prefix} if cached_prefix is not None else {}
try:
# Delegate to provider implementation
attempt_kwarg = {"attempt_context": lambda: _attempt_permits(scope)} if attempt_gated else {}
result = await self._provider_impl.call(
messages=messages,
response_format=response_format,
Expand All @@ -996,6 +1026,7 @@ async def call(
strict_schema=strict_schema,
return_usage=return_usage,
**cache_kwarg,
**attempt_kwarg,
)
except Exception as e:
# The provider call may have succeeded (and incurred token
Expand Down Expand Up @@ -1107,10 +1138,15 @@ async def call_with_tools(
# hand so the error path below can attach it if parsing/validation fails.
usage_token = set_response_usage(None)
try:
attempt_gated = self._provider_impl.supports_attempt_scoped_concurrency()
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
set_stage(base_stage)
if not attempt_gated:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Permits in hand — only now leave `.queued`; attempt-gated
# providers stay `.queued` until their first post-acquire
# `attempt=N` stamp (see call() above, #3002).
set_stage(base_stage)

# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix() / create_incremental_cache();
Expand All @@ -1123,6 +1159,7 @@ async def call_with_tools(
)
try:
# Delegate to provider implementation
attempt_kwarg = {"attempt_context": lambda: _attempt_permits(scope)} if attempt_gated else {}
result = await self._provider_impl.call_with_tools(
messages=messages,
tools=tools,
Expand All @@ -1134,6 +1171,7 @@ async def call_with_tools(
max_backoff=max_backoff,
tool_choice=tool_choice,
**cache_kwarg,
**attempt_kwarg,
)
except Exception as e:
# The provider call may have succeeded (and incurred token
Expand Down
17 changes: 14 additions & 3 deletions hindsight-api-slim/hindsight_api/engine/providers/anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
import json
import logging
import time
from typing import Any
from contextlib import AbstractAsyncContextManager, nullcontext
from typing import Any, Callable

from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.providers.llm_debug import dump_request_on_4xx
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -173,6 +175,7 @@ async def call(
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
Expand Down Expand Up @@ -263,7 +266,9 @@ async def call(

for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.{self.provider}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
response = await self._client.messages.create(**call_params)
# Stash usage before parse/validate, which may raise locally
# even though the provider charged for these tokens (#2387).
stash_response_usage(_usage_from_anthropic_response(response))
Expand Down Expand Up @@ -424,6 +429,7 @@ async def call_with_tools(
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
attempt_context: Callable[[], AbstractAsyncContextManager[None]] | None = None,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Expand Down Expand Up @@ -513,7 +519,9 @@ async def call_with_tools(
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
async with attempt_context() if attempt_context is not None else nullcontext():
set_stage(f"llm.{self.provider}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
response = await self._client.messages.create(**call_params)
stash_response_usage(_usage_from_anthropic_response(response))

# Extract content and tool calls
Expand Down Expand Up @@ -808,3 +816,6 @@ async def cleanup(self) -> None:
"""Clean up resources (close Anthropic client connections)."""
if hasattr(self, "_client") and self._client:
await self._client.close()

def supports_attempt_scoped_concurrency(self) -> bool:
return True
Loading
Loading