Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,13 @@ jobs:
- run: python -m pytest -q
env:
PAGEINDEX_API_KEY: ${{ secrets.PAGEINDEX_API_KEY }}

gate:
needs: tests
# always(), because GitHub counts a SKIPPED required check as
# passing: skipping on cancel would green-light a commit with zero
# legs run. A cancelled run must go red here, not vanish.
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- run: test "${{ needs.tests.result }}" = "success"
236 changes: 186 additions & 50 deletions pageindex/client.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pageindex/integrations/anthropic_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def build_anthropic_tools(client, include_management: bool = False,
except ImportError as exc:
raise PageIndexAPIError(
"as_anthropic_tools requires the Anthropic SDK tool runner "
"(anthropic>=0.108.0) — pip install -U anthropic (or pip install "
"— pip install -U anthropic (or pip install "
"'pageindex[anthropic]')."
) from exc
from mcp.types import CallToolResult
Expand Down
162 changes: 91 additions & 71 deletions pageindex/local_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,10 @@ def _reported_model(model_name: str) -> str:
def _litellm_claude_marks(wire: str) -> Optional[dict]:
"""Claude's prompt caching is opt-in per request: on Claude models
routed through LiteLLM (Anthropic direct, Bedrock, Vertex — each
channel live-verified), mark the managed system prefix and the newest
message via LiteLLM's injection param so the loop's later turns and a
conversation's next calls read them instead of repaying full price.
live-verified — and Foundry, same injection), mark the managed system
prefix and the newest message via LiteLLM's injection param so the
loop's later turns and a conversation's next calls read them instead
of repaying full price.
``wire`` is the name LiteLLM itself resolves — each lane strips its
own routing prefixes first, because the lanes normalize differently
(the chat wire treats bare names as OpenAI shorthand; the Agents SDK
Expand All @@ -250,8 +251,9 @@ def _litellm_claude_marks(wire: str) -> Optional[dict]:
model, provider, _, _ = get_llm_provider(model=wire)
except Exception:
return None
if provider == "anthropic" or (provider in ("bedrock", "vertex_ai")
and "claude" in model.lower()):
if provider == "anthropic" or (
provider in ("bedrock", "vertex_ai", "azure_ai")
and "claude" in model.lower()):
# The stable prefix plus the newest message, so each turn re-reads
# the turns before it. LiteLLM seeds nothing unprompted, so this
# pair is the marks' sole source.
Expand Down Expand Up @@ -1228,33 +1230,50 @@ def _require_anthropic() -> None:
from anthropic.lib.tools import ToolError # noqa: F401
except ImportError as exc:
raise PageIndexAPIError(
"chat(protocol='messages') requires anthropic >= 0.108.0 (the "
"tool runner with ToolError) — pip install -U anthropic."
"chat(protocol='messages') requires the anthropic SDK tool "
"runner (with ToolError) — pip install -U anthropic."
) from exc


_ANTHROPIC_CLIENTS: dict = {} # backend key -> client, kept open for reuse
_ANTHROPIC_CLIENTS: dict = {} # (route, backend) key -> client, kept open

# The transport class per routing prefix — the anthropic SDK ships one
# client per channel, so a row here is what makes a route reachable.
_ROUTE_CLIENTS = {"anthropic": "Anthropic", "bedrock": "AnthropicBedrock",
"vertex_ai": "AnthropicVertex",
"azure_ai": "AnthropicFoundry"}

def _anthropic_client(backend=None):

def _anthropic_client(backend, route):
"""The backend client — the seam tests replace with a fake transport.
One client per backend: each construction pays ~45 ms of SSL-context
build and a cold connection pool. A backend whose values defeat
hashing constructs per call, as before."""
``route`` (declared by the model's prefix) picks the SDK client
class. One client per (route, backend): each construction pays
~45 ms of SSL-context build and a cold connection pool. A backend
whose values defeat hashing constructs per call, as before."""
import anthropic
kwargs = _sdk_backend(backend)
try:
key = tuple(sorted(
key = (route, tuple(sorted(
(k, tuple(sorted(v.items())) if isinstance(v, dict) else v)
for k, v in kwargs.items()))
for k, v in kwargs.items())))
hash(key)
except TypeError:
key = None
if key in _ANTHROPIC_CLIENTS:
return _ANTHROPIC_CLIENTS[key]
cls = getattr(anthropic, _ROUTE_CLIENTS[route], None)
if cls is None:
# A build predating this route's client class: same contract as
# the tool-runner probe, one step earlier.
raise PageIndexAPIError(
f"messages on this route needs the anthropic SDK's "
f"{_ROUTE_CLIENTS[route]} client, which this anthropic build "
"lacks — pip install -U anthropic.")
try:
client = anthropic.Anthropic(**kwargs)
except TypeError as exc:
client = cls(**kwargs)
except (anthropic.AnthropicError, ValueError, TypeError) as exc:
# Vertex/Foundry refuse a missing region or credential right at
# construction, each with its own type; same contract for all.
raise PageIndexAPIError(
f"The Anthropic backend is not configured: {exc}") from exc
if key is not None and len(_ANTHROPIC_CLIENTS) < 8:
Expand Down Expand Up @@ -1327,32 +1346,21 @@ def _anthropic_usage(turns, final_usage: dict) -> dict:
return totals


_CLAUDE_4096_MODELS = ("claude-3-opus", "claude-3-sonnet", "claude-3-haiku",
"claude-3-5-sonnet-20240620")


def _default_max_tokens(model: str, thinking=None) -> int:
"""The wire-required per-turn budget when the caller sets none: 8192,
except the claude-3 generation whose output ceiling is 4096. The wire
also requires max_tokens > thinking.budget_tokens, so an enabled
budget lifts the default above itself — clamped to the model's output
ceiling where LiteLLM's capability map knows it."""
def _default_max_tokens(thinking=None) -> int:
"""The wire-required per-turn budget when the caller sets none: 8192.
The wire also requires max_tokens > thinking.budget_tokens, so an
enabled budget lifts the default above itself. Pure arithmetic on the
caller's own inputs — whether the sum fits the model's output ceiling
is the API's own ruling (its 400 names both numbers), never a lookup
here."""
budget = (thinking.get("budget_tokens")
if isinstance(thinking, dict) else None)
if isinstance(budget, int) and not isinstance(budget, bool):
want = budget + 8192
try:
from . import utils # noqa: F401 — must precede litellm's import
import litellm
ceiling = (litellm.model_cost.get(model)
or {}).get("max_output_tokens")
except Exception:
ceiling = None
return min(want, ceiling) if ceiling else want
return 4096 if model.startswith(_CLAUDE_4096_MODELS) else 8192
return budget + 8192
return 8192


def run_messages(client, messages, model: str,
def run_messages(client, messages, model: str, route: str,
max_tokens: Optional[int] = None,
stream: bool = False, doc_id=None, system=None,
temperature: Optional[float] = None,
Expand Down Expand Up @@ -1392,42 +1400,68 @@ def run_messages(client, messages, model: str,
# Top-level cache_control: the server re-marks the newest block each
# turn, so the loop re-reads the growing conversation from cache.
# Counts toward the 4-breakpoint limit (live-verified 400 past it).
# Bedrock's InvokeModel integration rejects the field for Opus 4.6 and
# earlier: there each turn's tool results carry the breakpoint instead.
marks_fit = _cache_marks(system_blocks, prepared) < 4
cached: dict[str, Any] = (
{"cache_control": {"type": "ephemeral"}}
if _cache_marks(system_blocks, prepared) < 4 else {})
if marks_fit and route != "bedrock" else {})
# Tools before the transport: on a bridge client building them is
# network I/O, and a failure there must not strand the client below.
failures: list = []
tools = build_anthropic_tools(client, doc_ids=scope, failures=failures)
merged = _merged_backend(client, backend)
backend_client = _anthropic_client(merged)
backend_client = _anthropic_client(merged, route)
# Close only a per-call construction: cached clients stay open for
# reuse; a caller-owned http_client survives regardless.
# list(): an atomic snapshot — a bare .values() scan breaks under a
# concurrent setdefault.
owns_transport = ("http_client" not in (merged or {})
and backend_client not in _ANTHROPIC_CLIENTS.values())
if max_tokens is None:
max_tokens = _default_max_tokens(
model, (extra_body or {}).get("thinking", thinking))
runner = backend_client.beta.messages.tool_runner(
max_tokens=max_tokens,
messages=prepared,
model=model,
tools=tools,
system=system_blocks,
stream=stream,
# Bounded like the OpenAI surfaces (their framework default is 10).
max_iterations=max_turns if max_turns is not None else 10,
**passthrough,
**cached,
)
and backend_client
not in list(_ANTHROPIC_CLIENTS.values()))
try:
if not hasattr(backend_client.beta.messages, "tool_runner"):
# An anthropic build predating this route's tool runner passes
# _require_anthropic (its probes are older): name the gap here.
raise PageIndexAPIError(
"messages on this route needs the anthropic SDK's tool "
"runner, which this anthropic build lacks — "
"pip install -U anthropic.")
if max_tokens is None:
max_tokens = _default_max_tokens(
(extra_body or {}).get("thinking", thinking))
runner = backend_client.beta.messages.tool_runner(
max_tokens=max_tokens,
messages=prepared,
model=model,
tools=tools,
system=system_blocks,
stream=stream,
# Bounded like the OpenAI surfaces (their framework default is 10).
max_iterations=max_turns if max_turns is not None else 10,
**passthrough,
**cached,
)
except BaseException:
# A failure before the runner handoff must not strand the transport
# the branches below would have closed.
if owns_transport:
backend_client.close()
raise
# Older Anthropic versions also execute tools on max_tokens turns, newer
# ones skip them: check right after the runner's own tool step.
generate_tool_response = runner.generate_tool_call_response
moved: list = [] # the block holding the bedrock breakpoint

def checked_tool_response():
response = generate_tool_response()
if failures:
raise failures[0]
if response and marks_fit and route == "bedrock":
for block in moved:
block.pop("cache_control", None)
moved[:] = [response["content"][-1]]
moved[0]["cache_control"] = {"type": "ephemeral"}
return response

runner.generate_tool_call_response = checked_tool_response
Expand All @@ -1440,14 +1474,6 @@ def events() -> Iterator[Any]:
yield event
except anthropic.AnthropicError as exc:
raise _model_backend_error(exc, "messages", client) from exc
except TypeError as exc:
# the SDK's request-time credential-resolution failure
if "authentication" not in str(exc).lower():
raise
raise PageIndexAPIError(
"The Anthropic backend is not configured: set the "
"ANTHROPIC_API_KEY environment variable, or pass an "
f"api_key in chat_backend / backend. ({exc})") from exc
finally:
# runs on exhaustion and abandonment (GeneratorExit) alike
if owns_transport:
Expand All @@ -1458,20 +1484,14 @@ def events() -> Iterator[Any]:
turns = list(runner)
except anthropic.AnthropicError as exc:
raise _model_backend_error(exc, "messages", client) from exc
except TypeError as exc:
# the SDK's request-time credential-resolution failure
if "authentication" not in str(exc).lower():
raise
raise PageIndexAPIError(
"The Anthropic backend is not configured: set the "
"ANTHROPIC_API_KEY environment variable, or pass an "
f"api_key in chat_backend / backend. ({exc})") from exc
finally:
# safe here: the params read-back below does no HTTP
if owns_transport:
backend_client.close()
if not turns:
raise PageIndexAPIError("The model returned no response.")
for block in moved:
block.pop("cache_control", None) # a request's breakpoint, not history
captured: dict = {}

def capture(params):
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ pyyaml = ">=6.0"
Pillow = ">=9.0"
# Older releases break string prompts with SDK MCP servers (#597, #780).
claude-agent-sdk = { version = ">=0.1.53", optional = true }
# Older releases execute a refusal turn's tool_use blocks.
anthropic = { version = ">=0.108.0", optional = true }
# Pre-0.122 lacks the Bedrock/Vertex tool runner; pre-0.108 executes a
# refusal turn's tool_use blocks.
anthropic = { version = ">=0.122.0", optional = true }

[tool.poetry.extras]
claude = ["claude-agent-sdk"]
Expand Down
Loading
Loading