diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a3977ba21..0b4671aae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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" diff --git a/pageindex/client.py b/pageindex/client.py index b6b1b36d0..bd925de8e 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -98,6 +98,58 @@ def _agents_sdk_model_name(model: str) -> str: return f"litellm/{model}" +# The Anthropic-stack routes this SDK wires a transport for, each with +# its Claude Code env switch; a row is an inventory fact, not a model +# judgment. Growth rule: a row per route LiteLLM names and the anthropic +# SDK ships a client for (Mantle clears both bars, no one has asked; +# Claude-on-AWS/GoogleCloud wait on LiteLLM prefix names). +_ROUTE_ENV = {"bedrock": "CLAUDE_CODE_USE_BEDROCK", + "vertex_ai": "CLAUDE_CODE_USE_VERTEX", + "azure_ai": "CLAUDE_CODE_USE_FOUNDRY"} +_CLAUDE_ROUTES = tuple(_ROUTE_ENV) + + +def _claude_wire(model, surface: str) -> "tuple[str, str]": + """(wire id, route) for an Anthropic-native surface. The name is sent + as written — the destination judges the id; only the routing prefix + is read: ``litellm/`` drops, ``bedrock/`` / ``vertex_ai/`` / + ``azure_ai/`` select that transport, and ``anthropic/`` is the + direct route's own prefix. + Anything else — bare ids, aliases, gateway names — ships verbatim on + the direct route. A prefix with nothing after it names a route and + no model: refused, so no surface ships model='' or switches a + transport with no model chosen.""" + if not isinstance(model, str): + raise PageIndexAPIError( + f"{surface} model must be a str, got {type(model).__name__}.") + wire = model.removeprefix("litellm/") + for route in _CLAUDE_ROUTES: + if wire.startswith(route + "/"): + wire = wire[len(route) + 1:] + break + else: + wire, route = wire.removeprefix("anthropic/"), "anthropic" + if not wire: + raise PageIndexAPIError( + f"{surface} model {model!r} names a route but no model id.") + return wire, route + + +def _yaml_names_chat(loader) -> bool: + # config.yaml is a third way to name a chat model. Blank values mean + # "absent", exactly like the flat arguments (_resolve_models agrees). + return any(loader._default_dict.get(key) + for key in ("chat_model", "retrieve_model", "model")) + + +def _needs_model(surface: str) -> PageIndexAPIError: + # The stock chat_model default is not the user's choice: never send + # it on an Anthropic-native surface as if it were one. + return PageIndexAPIError( + f"{surface} needs a model — pass a Claude model=..., or " + "configure chat_model on the client.") + + _LOCAL_INDEX_KEYS = ("model", "summary_model", "backend", "storage_path") # Near-synonyms of "cloud" that would otherwise parse as model names — @@ -327,17 +379,21 @@ class PageIndexClient: documents (structure and summaries). Defaults to the SDK default (fast and cheap). chat_model (str, optional): Your own model for the chat surfaces - (``chat``, ``chat_completions``), exposed as - ``client.chat_model`` — on a cloud client, setting it runs - the document-QA agent in your process over the cloud - documents (page content then flows through your process to - your model provider). Chat names route through LiteLLM and - mean what LiteLLM says they mean; bare names are - OpenAI-compatible shorthand, and ``openai/Qwen/...`` is the - form for an OpenAI-compatible server that itself serves - slashed model ids (vLLM, TGI). Defaults to the SDK default - (strong); reads ``None`` on a cloud client where the managed - chat answers. + (``chat``, ``chat_completions``; a value you set also + carries onto ``chat(protocol="messages")`` and the two + Anthropic agent configs), exposed as ``client.chat_model`` — + on a cloud client, setting it runs the document-QA agent in + your process over the cloud documents (page content then + flows through your process to your model provider). Chat + names route through LiteLLM and mean what LiteLLM says they + mean; bare names are OpenAI-compatible shorthand, and + ``openai/Qwen/...`` is the form for an OpenAI-compatible + server that itself serves slashed model ids (vLLM, TGI). The + Anthropic-native surfaces read the name by its routing + prefix instead — bare names are Anthropic's own — and treat + the untouched stock default as no choice. Defaults to the + SDK default (strong); reads ``None`` on a cloud client where + the managed chat answers. model (str, optional): Local mode only — one model for both roles: sets the default for ``index_model`` and ``chat_model`` at once. The role-specific arguments win over it. (Also the @@ -521,13 +577,18 @@ def __init__( overrides = {name: value for name, value in chat_conf.items() if name in ("chat_model", "retrieve_model") and value} - opt = ConfigLoader().load(overrides or None) - self.chat_model = opt.chat_model + loader = ConfigLoader() + opt = loader.load(overrides or None) + self._chat_model = opt.chat_model + # chat="local" alone names no model: the stock default. + self._chat_model_stock = (not overrides + and not _yaml_names_chat(loader)) self.chat_backend = chat_conf.get("chat_backend") _preload_litellm() else: # Managed chat: the endpoint selects its own model. - self.chat_model = None + self._chat_model = None + self._chat_model_stock = True self.chat_backend = None else: if chat_mode == "managed": @@ -549,11 +610,16 @@ def __init__( if name in ("model", "index_model", "summary_model", "chat_model", "retrieve_model") and value} - opt = ConfigLoader().load(overrides or None) + loader = ConfigLoader() + opt = loader.load(overrides or None) self.model = opt.model self.index_model = opt.index_model self.summary_model = opt.summary_model - self.chat_model = opt.chat_model + self._chat_model = opt.chat_model + self._chat_model_stock = (not (overrides.get("chat_model") + or overrides.get("retrieve_model") + or overrides.get("model")) + and not _yaml_names_chat(loader)) self.chat_backend = chat_conf.get("chat_backend") self.storage_path = index_conf.get("storage_path") or ".pageindex" from .local_api import LocalAPI @@ -609,6 +675,18 @@ def __getattr__(self, name): raise AttributeError( f"{type(self).__name__!r} object has no attribute {name!r}") + @property + def chat_model(self): + """Your own chat model; None on a managed-chat client.""" + return self._chat_model + + @chat_model.setter + def chat_model(self, value): + # Any assignment is a choice; only the untouched stock default + # is not one. + self._chat_model = value + self._chat_model_stock = False + @property def retrieve_model(self): """Legacy name for ``chat_model``.""" @@ -1120,8 +1198,9 @@ def chat( clipped). One run serves one view. Protocol lanes: the protocol's own event stream. model: Own-model chat only — backend model name (defaults - to ``chat_model``). ``protocol="messages"`` needs it named - — a Claude model; there is no cross-vendor default. + to ``chat_model``). ``protocol="messages"`` takes a Claude + model — a ``chat_model`` you set carries over; the stock + default is never sent. reasoning_effort: Own-model chat only — how hard the model thinks (``"low"`` / ``"medium"`` / ``"high"``; what a backend accepts is its own). Each lane sends its native @@ -1296,11 +1375,6 @@ def chat( instructions=cast(Optional[str], instructions), max_turns=max_turns, extra_body=body, extra_headers=extra_headers, backend=backend) - if not model: - raise PageIndexAPIError( - "protocol=\"messages\" drives Anthropic's Messages API " - "with the Anthropic SDK — name the Claude model with " - "model=... (there is no cross-vendor default to guess).") body = extra_body if reasoning_effort: # Anthropic's own effort field, beside the caller's other @@ -1650,7 +1724,7 @@ def _responses( def _messages( self, messages: Union[str, list[dict[str, Any]]], - model: str, + model: Optional[str] = None, max_tokens: Optional[int] = None, stream: bool = False, doc_id: Optional[Union[str, list[str]]] = None, @@ -1672,10 +1746,12 @@ def _messages( the Anthropic Messages protocol, Claude-native. Own-model chat only — local mode, or a cloud client constructed - with ``chat_model=``/``chat=``. Drives Anthropic's /v1/messages - via the Anthropic SDK's own tool runner (requires - ``pageindex[anthropic]``; ANTHROPIC_API_KEY selects the - backend). ``tool_use``/``tool_result`` round-trip is the + with ``chat_model=``/``chat=``. Drives the Messages API via the + Anthropic SDK's own tool runner (requires + ``pageindex[anthropic]``); the model's routing prefix picks the + transport — Anthropic directly by default (ANTHROPIC_API_KEY + selects the backend), or that channel's own SDK client. + ``tool_use``/``tool_result`` round-trip is the format's native behavior: the response is the final message envelope with cross-turn aggregated ``usage`` plus a ``messages`` field — the full new turn sequence, valid for verbatim @@ -1683,17 +1759,25 @@ def _messages( ``cache_control`` breakpoint, and the request sets the top-level ``cache_control`` so each turn re-reads the growing conversation from cache — skipped when your own blocks already use the three - remaining breakpoints (the managed prompt holds the fourth). + remaining breakpoints (the managed prompt holds the fourth). On + ``bedrock/``, whose InvokeModel integration rejects the top-level + field for Opus 4.6 and earlier, an explicit breakpoint moves onto + each turn's tool results instead. Args: messages: Native Messages-format history (including prior tool_use/tool_result blocks on round-trip), or a bare query string (it becomes a single user message). - model: Required — there is no cross-vendor default to guess. + model: Model for the Messages wire, sent as written — a + ``bedrock/``, ``vertex_ai/``, or ``azure_ai/`` prefix + selects that channel's SDK client, anything else goes to + Anthropic directly (``litellm/`` and ``anthropic/`` + prefixes are stripped). Unset: a ``chat_model`` you set + carries over; the stock default raises rather than being + sent. max_tokens: Per-turn output budget the Messages API requires on - the wire; the default is resolved per model (8192, or 4096 - for the claude-3 generation whose ceiling is lower) so the - simple call needs only a question, and rises to + the wire; the default is 8192 so the simple call needs + only a question, and rises to budget_tokens + 8192 when ``thinking`` is enabled (the wire requires max_tokens above the budget). Passed through. stream: Yield the Anthropic SDK's event stream across turns @@ -1725,14 +1809,20 @@ def _messages( win over defaults. backend: Connection overrides for this call's backend client, merged over the client's ``chat_backend`` (per-call keys - win). Keys are the anthropic SDK's client params — - ``api_key``, ``base_url``, ``auth_token``, … — passed - verbatim; unknown keys raise. + win). Keys are the selected route's anthropic SDK client + params, passed verbatim — direct: ``api_key``, + ``base_url``, ``auth_token``, …; the cloud routes take + their own client's (``aws_region``, ``project_id``, …) — + and unknown keys raise. """ self._require_own_chat("chat(protocol='messages')") + if not model and self._chat_model_stock: + raise _needs_model("chat(protocol='messages')") from .local_chat import run_messages + wire, route = _claude_wire(model or self.chat_model, + "chat(protocol='messages')") return run_messages( - self, messages, model=model, max_tokens=max_tokens, + self, messages, model=wire, route=route, max_tokens=max_tokens, stream=stream, doc_id=doc_id, folder_id=folder_id, system=system, temperature=temperature, top_p=top_p, top_k=top_k, stop_sequences=stop_sequences, max_turns=max_turns, @@ -1930,8 +2020,8 @@ def openai_agent_config( there — ``chat_backend`` does not travel with it. Prompt caching: OpenAI models cache server-side on their own; - LiteLLM-routed Claude (Anthropic, Bedrock, Vertex) gets its - cache marks from the bundled ``model_settings``. Pass + LiteLLM-routed Claude (Anthropic, Bedrock, Vertex, Foundry) gets + its cache marks from the bundled ``model_settings``. Pass ``model_settings`` here to layer your own on top — your fields win and ``extra_args`` merge. Replacing the returned key wholesale drops the marks instead. @@ -2010,7 +2100,7 @@ def as_anthropic_tools(self, include_management: bool = False, tools involved. Local: the in-process tools — the same set ``chat(protocol="messages")`` runs internally. - Requires ``anthropic>=0.108.0`` + Requires the ``anthropic`` extra (``pip install 'pageindex[anthropic]'``), imported only when this method is called. @@ -2030,7 +2120,7 @@ def as_anthropic_tools(self, include_management: bool = False, def anthropic_runner_config( self, - model: str, + model: Optional[str] = None, *, include_management: bool = False, asynchronous: bool = False, @@ -2053,7 +2143,9 @@ def anthropic_runner_config( ``chat(protocol="messages")`` uses, and a top-level ``cache_control`` so each loop turn re-reads the growing prompt from cache (pop the key if you place your own - breakpoints — the API allows four). Unlike the chat lane, + breakpoints — the API allows four; a ``bedrock/`` model omits it, + as Bedrock's InvokeModel integration rejects it for Opus 4.6 and + earlier). Unlike the chat lane, ``system`` here is the bare instructions string, without the chat header or its block-level breakpoint. To target a folder or documents, prepend ``folder_context(folder_id)`` / @@ -2061,14 +2153,19 @@ def anthropic_runner_config( customize further, switch to those methods directly. Args: - model: Backend model name (also resolves the ``max_tokens`` - default). + model: Model name, routing prefixes (``litellm/``, + ``anthropic/``, ``bedrock/``, ``vertex_ai/``, + ``azure_ai/``) stripped — your client is the transport + and judges the id, so pair a routed prefix with that + channel's own client class (``AnthropicBedrock``, + ``AnthropicVertex``, ``AnthropicFoundry``). Unset: a + ``chat_model`` you set carries over; the stock default + raises rather than being sent. include_management (bool): Also expose tools that modify the library. asynchronous (bool): Build async runnables for ``AsyncAnthropic``. - max_tokens: Per-turn output budget; default resolved per - model. + max_tokens: Per-turn output budget; default 8192. max_turns: Agent-loop bound; default 10. thinking: Anthropic ``thinking`` config, included in the kwargs; an enabled budget also lifts the ``max_tokens`` @@ -2078,15 +2175,24 @@ def anthropic_runner_config( from .agent_tools import _base_instructions from .local_chat import _default_max_tokens, _validate_max_turns _validate_max_turns(max_turns) + if not model and self._chat_model_stock: + raise _needs_model("anthropic_runner_config()") + model = model or self.chat_model + if not model: + # A cleared chat_model ('' or None) configures nothing — + # same refusal as the stock default, never a {'model': ''}. + raise _needs_model("anthropic_runner_config()") + model, route = _claude_wire(model, "anthropic_runner_config()") return { "model": model, "max_tokens": (max_tokens if max_tokens is not None - else _default_max_tokens(model, thinking)), + else _default_max_tokens(thinking)), "system": _base_instructions(self, include_management), "tools": self.as_anthropic_tools(include_management, asynchronous), "max_iterations": max_turns if max_turns is not None else 10, **({"thinking": thinking} if thinking is not None else {}), - "cache_control": {"type": "ephemeral"}, + **({} if route == "bedrock" + else {"cache_control": {"type": "ephemeral"}}), } def as_claude_mcp(self, include_management: bool = False, *, @@ -2111,7 +2217,7 @@ def as_claude_mcp(self, include_management: bool = False, *, (harmless). ``system_prompt`` stays the recommended channel: it is guaranteed delivery, and the only channel local mode has. - Usage (or ``claude_agent_config()`` for all three slots in one + Usage (or ``claude_agent_config()`` for the whole bundle in one call):: options = ClaudeAgentOptions( @@ -2130,6 +2236,7 @@ def claude_agent_config( *, include_management: bool = False, server_name: str = "pageindex", + model: Optional[str] = None, ) -> dict[str, Any]: """ Document QA ``ClaudeAgentOptions`` kwargs in one call:: @@ -2140,7 +2247,10 @@ def claude_agent_config( (``agent_instructions``) and the server entry (``as_claude_mcp``, itself the tool gate) with its ``allowed_tools`` pre-approval, one ``include_management`` and ``server_name`` applied - everywhere. To target a folder or documents, prepend + everywhere; a chosen model adds ``model`` (and its route's + ``env`` switch) — those keys are then taken, so pop them from + the result before passing your own ``model=`` or ``env=`` + alongside the unpack. To target a folder or documents, prepend ``folder_context(folder_id)`` / ``document_context(doc_id)`` to your prompt; to customize (your own system prompt, extra servers), switch to those methods directly. @@ -2150,8 +2260,26 @@ def claude_agent_config( library. server_name (str): Key the server is registered under; locally also the name the SDK server declares. + model (str, optional): Claude model, in the client's spelling + or the SDK's own (aliases included) — routing prefixes + are stripped and the SDK judges the id. A ``bedrock/``, + ``vertex_ai/``, or ``azure_ai/`` prefix also rides along + as that channel's ``CLAUDE_CODE_USE_*`` env switch, set + to ``"1"`` — only that one: other switches in your + environment stay yours, weighed by the CLI's own rules. + ``anthropic/`` and bare spellings name a model, not a + channel, and leave ``env`` out. Unset: a ``chat_model`` + you set is forwarded as written; the stock default, like + a managed-chat client, leaves the SDK's own default in + place. """ from .agent_tools import _base_instructions + # The stock default is not a choice: leave the SDK's own model. + if not model and not self._chat_model_stock: + model = self.chat_model + route = None + if model: + model, route = _claude_wire(model, "claude_agent_config()") return { "system_prompt": _base_instructions(self, include_management), "mcp_servers": {server_name: self.as_claude_mcp( @@ -2159,6 +2287,14 @@ def claude_agent_config( # Pre-approval only — the server itself is already gated (the # read-only endpoint on cloud, the registered set locally). "allowed_tools": [f"mcp__{server_name}"], + **({"model": model} if model else {}), + # Claude Code picks its transport from env switches; a route + # prefix rides along as that one switch (the SDK merges env + # over the inherited environment). Only the chosen switch: + # the rest of the caller's environment is the caller's, and + # how the CLI weighs its own switches is the CLI's business. + **({"env": {_ROUTE_ENV[route]: "1"}} + if route in _ROUTE_ENV else {}), } def agent_instructions(self, *, include_management: bool = False) -> str: diff --git a/pageindex/integrations/anthropic_sdk.py b/pageindex/integrations/anthropic_sdk.py index 9a940892f..a7a1a0ca3 100644 --- a/pageindex/integrations/anthropic_sdk.py +++ b/pageindex/integrations/anthropic_sdk.py @@ -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 diff --git a/pageindex/local_chat.py b/pageindex/local_chat.py index d2da77307..42c7f5176 100644 --- a/pageindex/local_chat.py +++ b/pageindex/local_chat.py @@ -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 @@ -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. @@ -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: @@ -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, @@ -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 @@ -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: @@ -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): diff --git a/pyproject.toml b/pyproject.toml index f1f66e1cd..ef21a2ea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py index 9ea12069b..6fa04f6f3 100644 --- a/tests/test_agent_tools.py +++ b/tests/test_agent_tools.py @@ -750,6 +750,74 @@ def test_claude_agent_config_local(client): assert renamed["allowed_tools"] == ["mcp__docs"] +def test_claude_agent_config_forwards_a_claude_chat_model(cloud_with_fake_bridge): + # chat_model says who answers; the Claude Agent SDK takes Anthropic's + # own name, so LiteLLM's routing prefix is stripped. + for name in ("anthropic/claude-sonnet-4-6", "claude-sonnet-4-6", + "litellm/anthropic/claude-sonnet-4-6"): + cloud = PageIndexCloudClient(api_key="pi-test-key", chat_model=name) + assert cloud.claude_agent_config()["model"] == "claude-sonnet-4-6" + # Names are sent as written — no model map gates them. + for name in ("anthropic/claude-3-5-sonnet-latest", + "claude-3-5-sonnet-latest"): + cloud = PageIndexCloudClient(api_key="pi-test-key", chat_model=name) + assert cloud.claude_agent_config()["model"] == "claude-3-5-sonnet-latest" + + +def test_claude_agent_config_carries_any_chosen_chat_model( + cloud_with_fake_bridge): + # A model you set is sent as written — the destination judges the id. + for name in ("gpt-4.1", "openrouter/anthropic/claude-sonnet-4-6"): + cloud = PageIndexCloudClient(api_key="pi-test-key", chat_model=name) + assert cloud.claude_agent_config()["model"] == name + # An explicit model may be the SDK's own name (an alias included)... + assert cloud.claude_agent_config(model="sonnet")["model"] == "sonnet" + # ... or the client's spelling, read exactly like chat_model. + for spelling in ("anthropic/claude-sonnet-4-6", + "litellm/anthropic/claude-sonnet-4-6"): + assert (cloud.claude_agent_config(model=spelling)["model"] + == "claude-sonnet-4-6") + # Explicitly writing the stock value is a choice too: it carries. + cloud = PageIndexCloudClient(api_key="pi-test-key", chat_model="gpt-5.6-sol") + assert cloud.claude_agent_config()["model"] == "gpt-5.6-sol" + + +def test_claude_agent_config_managed_chat_sets_no_model(cloud_with_fake_bridge): + cloud, _ = cloud_with_fake_bridge + assert "model" not in cloud.claude_agent_config() + + +def test_claude_agent_config_local_chat_model(store_path): + pytest.importorskip("claude_agent_sdk") + local = PageIndexLocalClient(storage_path=store_path, + chat_model="anthropic/claude-sonnet-4-6") + assert local.claude_agent_config()["model"] == "claude-sonnet-4-6" + assert "model" not in PageIndexLocalClient( + storage_path=store_path).claude_agent_config() + + +def test_claude_agent_config_route_prefix_sets_the_channel_switch( + cloud_with_fake_bridge): + # Claude Code picks its transport from env switches, not a client + # class — a routing prefix rides along as that one switch, set to + # "1", and nothing else: the rest of the caller's environment is the + # caller's, and how the CLI weighs its own switches is the CLI's + # business, not this SDK's. + cloud, _ = cloud_with_fake_bridge + for prefix, switch in (("bedrock", "CLAUDE_CODE_USE_BEDROCK"), + ("vertex_ai", "CLAUDE_CODE_USE_VERTEX"), + ("azure_ai", "CLAUDE_CODE_USE_FOUNDRY")): + cloud.chat_model = f"{prefix}/claude-opus-4-6" + config = cloud.claude_agent_config() + assert config["model"] == "claude-opus-4-6" + assert config["env"] == {switch: "1"} + # anthropic/ and bare spellings name a model, not a channel: env + # stays out, so the same name means the same thing on every surface. + for spelling in ("anthropic/claude-opus-4-6", "claude-opus-4-6"): + cloud.chat_model = spelling + assert "env" not in cloud.claude_agent_config() + + def test_openai_agent_config_local(client): pytest.importorskip("agents") from agents import Agent @@ -889,12 +957,43 @@ def test_openai_agent_config_cloud_omits_model(cloud_with_fake_bridge): "get_document"] +def test_anthropic_runner_config_accepts_the_litellm_spelling(client): + pytest.importorskip("anthropic") + config = client.anthropic_runner_config( + model="anthropic/claude-3-opus-20240229") + assert config["model"] == "claude-3-opus-20240229" + + +def test_anthropic_runner_config_carries_a_claude_chat_model(client, store_path): + pytest.importorskip("anthropic") + local = PageIndexLocalClient(storage_path=store_path, + chat_model="anthropic/claude-3-opus-20240229") + config = local.anthropic_runner_config() + assert config["model"] == "claude-3-opus-20240229" + # The stock default was never chosen: nothing to send. + with pytest.raises(PageIndexAPIError, match="needs a model"): + client.anthropic_runner_config() + + +def test_anthropic_runner_config_cleared_chat_model_needs_a_model(store_path): + """'' and None both mean "configures nothing": a cleared chat_model + must get the crafted refusal, never a {'model': ''} config or a + NoneType crash.""" + pytest.importorskip("anthropic") + local = PageIndexLocalClient(storage_path=store_path, + chat_model="claude-sonnet-4-5") + for cleared in ("", None): + local.chat_model = cleared + with pytest.raises(PageIndexAPIError, match="needs a model"): + local.anthropic_runner_config() + + def test_anthropic_runner_config_shapes(client): pytest.importorskip("anthropic") import anthropic from anthropic.lib.tools import BetaAsyncFunctionTool config = client.anthropic_runner_config(model="claude-3-opus-20240229") - assert config["max_tokens"] == 4096 + assert config["max_tokens"] == 8192 assert config["max_iterations"] == 10 assert config["cache_control"] == {"type": "ephemeral"} assert config["system"] == AGENT_INSTRUCTIONS @@ -2932,6 +3031,98 @@ def list_tools(self): cloud.as_openai_tools() +def test_claude_wire_reads_only_the_routing_prefix(): + from pageindex.client import _claude_wire + assert _claude_wire("claude-sonnet-4-6", "t()") == ( + "claude-sonnet-4-6", "anthropic") + assert _claude_wire("anthropic/claude-sonnet-4-6", "t()") == ( + "claude-sonnet-4-6", "anthropic") + assert _claude_wire( + "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0", "t()") == ( + "anthropic.claude-3-5-sonnet-20241022-v2:0", "bedrock") + assert _claude_wire( + "litellm/bedrock/us.anthropic.claude-sonnet-4-6-v1:0", "t()") == ( + "us.anthropic.claude-sonnet-4-6-v1:0", "bedrock") + assert _claude_wire("vertex_ai/claude-sonnet-4@20250514", "t()") == ( + "claude-sonnet-4@20250514", "vertex_ai") + assert _claude_wire("azure_ai/claude-opus-4-6", "t()") == ( + "claude-opus-4-6", "azure_ai") + # Unknown prefixes and bare names ship verbatim on the direct route. + assert _claude_wire("team/claude-prod", "t()") == ( + "team/claude-prod", "anthropic") + assert _claude_wire("sonnet", "t()") == ("sonnet", "anthropic") + with pytest.raises(PageIndexAPIError, match="must be a str"): + _claude_wire(123, "t()") + + +def test_stock_chat_model_never_impersonates_a_choice(store_path): + pytest.importorskip("anthropic") + from pageindex import PageIndexClient + # Flagged at construction; any spelling that names a model clears it. + stock = PageIndexLocalClient(storage_path=store_path) + assert stock._chat_model_stock + with pytest.raises(PageIndexAPIError, match="needs a model"): + stock.anthropic_runner_config() + for kwargs in ({"chat_model": "gpt-5.6-sol"}, {"retrieve_model": "gpt-4o"}, + {"model": "gpt-4.1-mini"}): + assert not PageIndexLocalClient( + storage_path=store_path, **kwargs)._chat_model_stock + # chat="local" alone names no model; managed chat has none at all. + assert PageIndexClient(api_key="pi-test-key", + chat="local")._chat_model_stock + assert PageIndexCloudClient(api_key="pi-test-key")._chat_model_stock + # Assignment is a choice: the flag follows the write paths. + stock.chat_model = "anthropic/claude-opus-4-1" + assert not stock._chat_model_stock + assert stock.anthropic_runner_config()["model"] == "claude-opus-4-1" + + +def test_config_yaml_chat_model_is_a_choice(store_path, monkeypatch, + cloud_with_fake_bridge): + # config.yaml is the third way to name a chat model; a key set there + # must read as chosen, exactly like the constructor spellings. + pytest.importorskip("anthropic") + import pageindex.utils + real = pageindex.utils.ConfigLoader._load_yaml + + def with_chat_model(path): + loaded = dict(real(path)) + loaded["chat_model"] = "anthropic/claude-sonnet-4-6" + return loaded + + monkeypatch.setattr(pageindex.utils.ConfigLoader, "_load_yaml", + staticmethod(with_chat_model)) + local = PageIndexLocalClient(storage_path=store_path) + assert not local._chat_model_stock + assert local.anthropic_runner_config()["model"] == "claude-sonnet-4-6" + cloud = PageIndexCloudClient(api_key="pi-test-key", chat="local") + assert not cloud._chat_model_stock + assert cloud.claude_agent_config()["model"] == "claude-sonnet-4-6" + + def with_blank_keys(path): + loaded = dict(real(path)) + loaded["chat_model"] = None # a bare "chat_model:" line + loaded["model"] = "" + return loaded + + # Blank values mean "absent", exactly like the flat arguments. + monkeypatch.setattr(pageindex.utils.ConfigLoader, "_load_yaml", + staticmethod(with_blank_keys)) + assert PageIndexLocalClient(storage_path=store_path)._chat_model_stock + + +def test_anthropic_runner_config_strips_the_route_prefix(store_path): + pytest.importorskip("anthropic") + local = PageIndexLocalClient( + storage_path=store_path, + chat_model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0") + config = local.anthropic_runner_config() + assert config["model"] == "anthropic.claude-3-5-sonnet-20241022-v2:0" + assert config["max_tokens"] == 8192 + # Bedrock's InvokeModel integration rejects the top-level field. + assert "cache_control" not in config + + # ── tool-path rate limits: retried below the tool layer, then fail fast ── class _McpStub: diff --git a/tests/test_local_chat.py b/tests/test_local_chat.py index 0ce0e1627..8ad5fd735 100644 --- a/tests/test_local_chat.py +++ b/tests/test_local_chat.py @@ -1212,7 +1212,7 @@ def handler(request): http_client=anthropic_httpx.Client( transport=anthropic_httpx.MockTransport(handler))) monkeypatch.setattr(local_chat, "_anthropic_client", - lambda backend=None: fake) + lambda backend=None, route="anthropic": fake) return state["calls"] return install @@ -2078,7 +2078,7 @@ def handler(request): http_client=anthropic_httpx.Client( transport=anthropic_httpx.MockTransport(handler))) monkeypatch.setattr(local_chat, "_anthropic_client", - lambda backend=None: fake) + lambda backend=None, route="anthropic": fake) with pytest.raises(PageIndexAPIError, match="model backend failed"): client._messages("q", model="claude-test") with pytest.raises(PageIndexAPIError, match="model backend failed"): @@ -2203,13 +2203,13 @@ async def close(self): @needs_anthropic -def test_messages_max_tokens_default_resolves_per_model(client, fake_anthropic): - """The wire-required budget must not exceed the model's ceiling: the - claude-3 generation caps output at 4096.""" +def test_messages_max_tokens_default(client, fake_anthropic): + """The wire-required budget defaults to 8192 whatever the model names; + an explicit value passes through.""" calls = fake_anthropic([ _anthropic_message([{"type": "text", "text": "ok"}], "end_turn")]) client._messages("q", model="claude-3-opus-20240229") - assert calls[0]["max_tokens"] == 4096 + assert calls[0]["max_tokens"] == 8192 calls = fake_anthropic([ _anthropic_message([{"type": "text", "text": "ok"}], "end_turn")]) client._messages("q", model="claude-sonnet-4-5") @@ -2220,6 +2220,62 @@ def test_messages_max_tokens_default_resolves_per_model(client, fake_anthropic): assert calls[0]["max_tokens"] == 1234 +@needs_anthropic +def test_messages_accepts_the_litellm_spelling(client, fake_anthropic): + calls = fake_anthropic([ + _anthropic_message([{"type": "text", "text": "ok"}], "end_turn")]) + client._messages("q", model="anthropic/claude-3-opus-20240229") + assert calls[0]["model"] == "claude-3-opus-20240229" + + +@needs_anthropic +def test_messages_carries_a_claude_chat_model(store_path, fake_anthropic): + calls = fake_anthropic([ + _anthropic_message([{"type": "text", "text": "ok"}], "end_turn")]) + local = PageIndexLocalClient(storage_path=store_path, + chat_model="anthropic/claude-3-opus-20240229") + # Through the public door: chat() must not demand model= itself. + local.chat("q", protocol="messages") + assert calls[0]["model"] == "claude-3-opus-20240229" + # The stock default was never chosen: nothing to send. + with pytest.raises(PageIndexAPIError, match="needs a model"): + PageIndexLocalClient(storage_path=store_path).chat( + "q", protocol="messages") + + +@needs_anthropic +def test_messages_cleared_chat_model_gets_the_own_model_refusal( + store_path, fake_anthropic): + """'' and None both mean "configures nothing": clearing chat_model + drops the client back to no-own-chat, and messages() refuses in its + own voice — never model='' on the wire, never a NoneType crash.""" + calls = fake_anthropic([ + _anthropic_message([{"type": "text", "text": "never"}], "end_turn")]) + local = PageIndexLocalClient(storage_path=store_path, + chat_model="claude-sonnet-4-5") + for cleared in ("", None): + local.chat_model = cleared + with pytest.raises(PageIndexAPIError, match="chat_model="): + local._messages("q") + assert calls == [] + + +@needs_anthropic +def test_messages_route_prefix_needs_a_model_id(store_path, fake_anthropic): + """A prefix-only name selects a channel and names nothing — sending + model='' (or switching transports with no model chosen) is the worst + of both; refuse it in this SDK's own voice.""" + calls = fake_anthropic([ + _anthropic_message([{"type": "text", "text": "never"}], "end_turn")]) + local = PageIndexLocalClient(storage_path=store_path, + chat_model="claude-sonnet-4-5") + for name in ("bedrock/", "vertex_ai/", "azure_ai/", "anthropic/", + "litellm/"): + with pytest.raises(PageIndexAPIError, match="no model id"): + local._messages("q", model=name) + assert calls == [] + + @needs_anthropic def test_messages_thinking_passes_through(client, fake_anthropic): """Anthropic-native thinking config, forwarded verbatim; unset sends @@ -2449,6 +2505,37 @@ def test_messages_top_level_cache_control(client, store_path, fake_anthropic): assert "cache_control" not in calls[0] +@needs_anthropic +@pytest.mark.parametrize("stream", [False, True]) +def test_messages_bedrock_moves_an_explicit_breakpoint(client, store_path, + fake_anthropic, stream): + """Bedrock's InvokeModel integration rejects the top-level field (Opus + 4.6 and earlier): each turn's newest tool result carries the one moving + breakpoint instead, and none leaks into the returned history.""" + seed_doc(store_path, "pi-a", "report.pdf") + replies = [_anthropic_message([_anthropic_tool_use(tool_id)], "tool_use") + for tool_id in ("tu_1", "tu_2")] + replies.append(_anthropic_message([{"type": "text", "text": "Done"}], + "end_turn")) + calls = fake_anthropic([_anthropic_sse(reply) for reply in replies] + if stream else replies) + result = client._messages("q", model="bedrock/anthropic.claude-sonnet-4-6", + max_tokens=50, stream=stream) + if stream: + list(result) + + def marked(call): + return [block.get("tool_use_id") + for message in call["messages"] + if isinstance(message["content"], list) + for block in message["content"] if block.get("cache_control")] + + assert all("cache_control" not in call for call in calls) + assert [marked(call) for call in calls] == [[], ["tu_1"], ["tu_2"]] + if not stream: + assert "cache_control" not in json.dumps(result["messages"]) + + def test_merged_backend_precedence(): from types import SimpleNamespace stub = SimpleNamespace(chat_backend={"api_key": "a", "api_version": "v1"}) @@ -2461,11 +2548,11 @@ def test_merged_backend_precedence(): def test_messages_backend_merges_and_reaches_the_client(client, fake_anthropic, monkeypatch): real = local_chat._anthropic_client({"api_key": "kk", - "base_url": "http://x"}) + "base_url": "http://x"}, "anthropic") assert real.api_key == "kk" assert str(real.base_url).rstrip("/") == "http://x" real = local_chat._anthropic_client({"api_key": "kk", - "api_base": "http://y"}) + "api_base": "http://y"}, "anthropic") assert str(real.base_url).rstrip("/") == "http://y" calls = fake_anthropic([ @@ -2474,8 +2561,8 @@ def test_messages_backend_merges_and_reaches_the_client(client, fake_anthropic, seen = {} monkeypatch.setattr( local_chat, "_anthropic_client", - lambda backend=None: (seen.setdefault("backend", backend), - fixture_client())[1]) + lambda backend=None, route="anthropic": ( + seen.setdefault("backend", backend), fixture_client())[1]) client.chat_backend = {"base_url": "http://cb"} client._messages("q", model="claude-sonnet-4-5", backend={"api_key": "z"}) assert seen["backend"] == {"base_url": "http://cb", "api_key": "z"} @@ -2485,7 +2572,7 @@ def test_messages_backend_merges_and_reaches_the_client(client, fake_anthropic, def test_messages_bad_backend_wraps_like_the_other_doors(): with pytest.raises(PageIndexAPIError, match="Anthropic backend is not configured"): - local_chat._anthropic_client({"no_such_param": 1}) + local_chat._anthropic_client({"no_such_param": 1}, "anthropic") @needs_agents @@ -2523,7 +2610,7 @@ def handler(request): api_key="t", http_client=anthropic_httpx.Client( transport=anthropic_httpx.MockTransport(handler))) monkeypatch.setattr(local_chat, "_anthropic_client", - lambda backend=None: fake) + lambda backend=None, route="anthropic": fake) client._messages("q", model="claude-sonnet-4-5", extra_headers={"anthropic-beta": "context-1m-2025"}) assert seen["beta"] == "context-1m-2025" @@ -2573,9 +2660,21 @@ def test_anthropic_client_cached_per_backend(monkeypatch): """One real client per backend: construction pays ~45ms of SSL-context build and a cold connection pool each call otherwise.""" monkeypatch.setattr(local_chat, "_ANTHROPIC_CLIENTS", {}) - a = local_chat._anthropic_client({"api_key": "k"}) - assert local_chat._anthropic_client({"api_key": "k"}) is a - assert local_chat._anthropic_client({"api_key": "k2"}) is not a + a = local_chat._anthropic_client({"api_key": "k"}, "anthropic") + assert local_chat._anthropic_client({"api_key": "k"}, "anthropic") is a + assert local_chat._anthropic_client({"api_key": "k2"}, "anthropic") is not a + + +@needs_anthropic +def test_anthropic_client_cache_keys_on_the_route(monkeypatch): + """Same backend, another route: a cached direct client must never + carry a Bedrock id to api.anthropic.com.""" + monkeypatch.setattr(local_chat, "_ANTHROPIC_CLIENTS", {}) + monkeypatch.setenv("AWS_REGION", "us-east-1") + direct = local_chat._anthropic_client({"api_key": "k"}, "anthropic") + bedrock = local_chat._anthropic_client({"api_key": "k"}, "bedrock") + assert isinstance(bedrock, anthropic.AnthropicBedrock) + assert not isinstance(direct, anthropic.AnthropicBedrock) @needs_anthropic @@ -2587,10 +2686,11 @@ def test_anthropic_client_construction_race_keeps_first(monkeypatch): real = anthropic.Anthropic def racing(**kwargs): - local_chat._ANTHROPIC_CLIENTS[(("api_key", "k"),)] = winner + local_chat._ANTHROPIC_CLIENTS[ + ("anthropic", (("api_key", "k"),))] = winner return real(**kwargs) monkeypatch.setattr(anthropic, "Anthropic", racing) - assert local_chat._anthropic_client({"api_key": "k"}) is winner + assert local_chat._anthropic_client({"api_key": "k"}, "anthropic") is winner @needs_anthropic @@ -2607,7 +2707,7 @@ def handler(request): http_client=anthropic_httpx.Client( transport=anthropic_httpx.MockTransport(handler))) monkeypatch.setattr(local_chat, "_ANTHROPIC_CLIENTS", - {(("api_key", "test"),): cached}) + {("anthropic", (("api_key", "test"),)): cached}) def boom(**kwargs): raise AssertionError("cache hit expected — no new construction") @@ -2853,20 +2953,19 @@ def test_messages_keeps_caller_owned_http_client_open(client): @needs_anthropic -def test_messages_without_credentials_raises_contract_error(client, - monkeypatch, - tmp_path): - """No pre-check: the SDK's own request-time credential-resolution - failure is translated into the contract's PageIndexAPIError — for a - bare call, a credential-less backend dict, and the unset-env-var - shape ({"api_key": None}) alike.""" +def test_messages_without_credentials_raises_the_sdks_own_error(client, + monkeypatch, + tmp_path): + """No pre-check and no translation: the SDK's own request-time + credential-resolution failure propagates as itself (its text already + names api_key / auth_token) — for a bare call, a credential-less + backend dict, and the unset-env-var shape ({"api_key": None}) alike.""" monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) monkeypatch.delenv("ANTHROPIC_PROFILE", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) # no ant-auth profile fallback for backend in (None, {"timeout": 30}, {"api_key": None}): - with pytest.raises(PageIndexAPIError, - match="Anthropic backend is not configured"): + with pytest.raises(TypeError, match="authentication"): client._messages("q", model="claude-test", backend=backend) @@ -2937,20 +3036,17 @@ def test_dump_block_omits_unset_response_defaults(): "id": "tu_1", "input": {}, "name": "t", "type": "tool_use"} -def test_default_max_tokens_respects_output_ceilings(): - """A lifted thinking default must not overshoot the model's output - ceiling — the wire rejects max_tokens above it; bool is not a budget.""" +def test_default_max_tokens_is_pure_arithmetic(capsys): + """The thinking default is budget + 8192, full stop: the model's real + ceiling is the API's to enforce (its 400 names both numbers), so no + third-party lookup runs — no I/O, no hang, nothing printed, for any + spelling. bool is not a budget.""" lift = local_chat._default_max_tokens - enabled = {"type": "enabled", "budget_tokens": 30000} - assert lift("claude-opus-4-1", enabled) == 32000 - assert lift("claude-sonnet-4-5-20250929", - {"type": "enabled", "budget_tokens": 60000}) == 64000 - assert lift("claude-opus-4-1", - {"type": "enabled", "budget_tokens": 10000}) == 18192 - assert lift("claude-test", - {"type": "enabled", "budget_tokens": 10000}) == 18192 - assert lift("claude-sonnet-4-5", - {"type": "enabled", "budget_tokens": True}) == 8192 + assert lift({"type": "enabled", "budget_tokens": 30000}) == 38192 + assert lift({"type": "enabled", "budget_tokens": 10000}) == 18192 + assert lift({"type": "enabled", "budget_tokens": True}) == 8192 + assert lift() == 8192 + assert capsys.readouterr().out == "" # ── own-model chat over cloud documents (the bridge) ── @@ -3202,7 +3298,7 @@ def handler(request): "error": {"type": "authentication_error", "message": "invalid x-api-key"}}) - def fresh_fake(backend=None): + def fresh_fake(backend=None, route="anthropic"): # per call: run_messages closes a per-call transport it owns return anthropic.Anthropic( api_key="test", @@ -3220,8 +3316,9 @@ def fresh_fake(backend=None): @needs_anthropic def test_messages_no_backend_leak_when_tool_build_fails(bridge_client, monkeypatch): - """build_anthropic_tools is network I/O on a bridge client — a - failure there must not strand an opened per-call transport.""" + """build_anthropic_tools is network I/O on a bridge client, and it + runs before the transport exists — a failure there must construct no + transport to strand.""" client, _ = bridge_client made = [] @@ -3235,8 +3332,8 @@ def close(self): self.closed = True monkeypatch.setattr(local_chat, "_anthropic_client", - lambda backend=None: made.append(FakeAnthropic()) - or made[-1]) + lambda backend=None, route="anthropic": + made.append(FakeAnthropic()) or made[-1]) def boom(client, doc_ids=None, **kwargs): raise PageIndexAPIError("Could not reach the PageIndex MCP server") @@ -3245,7 +3342,37 @@ def boom(client, doc_ids=None, **kwargs): "pageindex.integrations.anthropic_sdk.build_anthropic_tools", boom) with pytest.raises(PageIndexAPIError, match="MCP server"): client._messages("q", model="claude-test", max_tokens=100) - assert all(fake.closed for fake in made) + # Tools are deliberately built BEFORE the transport, so a tool-build + # failure must find no transport constructed at all — if this list is + # ever non-empty, that ordering (and its no-leak guarantee) broke. + assert not made + + +@needs_anthropic +def test_messages_no_backend_leak_when_runner_build_fails(client, monkeypatch): + """A failure between transport construction and the runner handoff + (the runner rejecting a passthrough kwarg, say) must not strand the + per-call transport either.""" + made = [] + + class FakeAnthropic: + def __init__(self): + self.closed = False + + def explode(**kw): + raise TypeError("unexpected keyword argument 'thinking'") + self.beta = types.SimpleNamespace( + messages=types.SimpleNamespace(tool_runner=explode)) + + def close(self): + self.closed = True + + monkeypatch.setattr(local_chat, "_anthropic_client", + lambda backend=None, route="anthropic": + made.append(FakeAnthropic()) or made[-1]) + with pytest.raises(TypeError, match="thinking"): + client._messages("q", model="claude-test", max_tokens=100) + assert made and all(fake.closed for fake in made) @needs_agents @@ -3293,6 +3420,176 @@ def test_bridge_openai_agent_config_carries_configured_model(bridge_client): assert "CLOUD LIVE INSTRUCTIONS" in config["instructions"] +@needs_anthropic +def test_messages_routes_by_model_prefix(client, fake_anthropic, monkeypatch): + calls = fake_anthropic([ + _anthropic_message([{"type": "text", "text": "ok"}], "end_turn")]) + fixture_client = local_chat._anthropic_client + seen = {} + monkeypatch.setattr( + local_chat, "_anthropic_client", + lambda backend=None, route="anthropic": ( + seen.setdefault("route", route), fixture_client())[1]) + client._messages( + "q", model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0") + assert seen["route"] == "bedrock" + assert calls[0]["model"] == "anthropic.claude-3-5-sonnet-20241022-v2:0" + + +@needs_anthropic +def test_anthropic_client_route_picks_the_transport_class(monkeypatch): + """The model's routing prefix selects the SDK client class — the + bedrock/vertex ids only mean something to their own transports.""" + monkeypatch.setattr(local_chat, "_ANTHROPIC_CLIENTS", {}) + # AnthropicBedrock defaults api_key from this env var, and refuses + # api_key alongside AWS credential kwargs — a developer machine that + # exports it must not fail this construction. + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + bedrock = local_chat._anthropic_client( + {"aws_region": "us-east-1", "aws_access_key": "a", + "aws_secret_key": "s"}, "bedrock") + assert isinstance(bedrock, anthropic.AnthropicBedrock) + direct = local_chat._anthropic_client({"api_key": "k"}, "anthropic") + assert isinstance(direct, anthropic.Anthropic) + assert not isinstance(direct, anthropic.AnthropicBedrock) + + +@needs_anthropic +def test_anthropic_client_vertex_route(monkeypatch): + monkeypatch.setattr(local_chat, "_ANTHROPIC_CLIENTS", {}) + vertex = local_chat._anthropic_client( + {"region": "us-east5", "project_id": "p", "access_token": "t"}, + "vertex_ai") + assert isinstance(vertex, anthropic.AnthropicVertex) + + +@needs_anthropic +def test_anthropic_client_foundry_route(monkeypatch): + if not hasattr(anthropic, "AnthropicFoundry"): + pytest.skip("this anthropic build has no Foundry client") + monkeypatch.setattr(local_chat, "_ANTHROPIC_CLIENTS", {}) + # AnthropicFoundry fills base_url from this and refuses it beside resource=. + monkeypatch.delenv("ANTHROPIC_FOUNDRY_BASE_URL", raising=False) + foundry = local_chat._anthropic_client( + {"resource": "r", "api_key": "k"}, "azure_ai") + assert isinstance(foundry, anthropic.AnthropicFoundry) + + +@needs_anthropic +def test_anthropic_client_unconfigured_route_keeps_the_error_contract( + monkeypatch): + """Vertex and Foundry fail at construction (region, credentials) — + those failures must wrap the constructors' documented refusals.""" + # skip decided first: a skip after assertions would discard the + # vertex coverage those assertions already ran. + if not hasattr(anthropic, "AnthropicFoundry"): + pytest.skip("this anthropic build has no Foundry client") + monkeypatch.setattr(local_chat, "_ANTHROPIC_CLIENTS", {}) + for name in ("CLOUD_ML_REGION", "GOOGLE_CLOUD_PROJECT", + "ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_FOUNDRY_BASE_URL", + "ANTHROPIC_FOUNDRY_RESOURCE"): + monkeypatch.delenv(name, raising=False) + with pytest.raises(PageIndexAPIError, + match="Anthropic backend is not configured"): + local_chat._anthropic_client(None, "vertex_ai") + with pytest.raises(PageIndexAPIError, + match="Anthropic backend is not configured"): + local_chat._anthropic_client(None, "azure_ai") + + +@needs_anthropic +def test_missing_route_client_class_names_the_upgrade(monkeypatch): + # A build predating a route's client class gets the tool-runner + # probe's contract, not a bare AttributeError. + import anthropic + monkeypatch.setattr(local_chat, "_ANTHROPIC_CLIENTS", {}) + monkeypatch.delattr(anthropic, "AnthropicFoundry", raising=False) + with pytest.raises(PageIndexAPIError, + match="AnthropicFoundry.*pip install -U anthropic"): + local_chat._anthropic_client(None, "azure_ai") + + +@needs_anthropic +def test_messages_names_the_missing_tool_runner(client, monkeypatch): + # anthropic 0.108–0.121 constructs Bedrock/Vertex clients whose beta + # surface has no tool runner: name the gap, not an AttributeError. + class _Runnerless: + class beta: + class messages: ... + + def close(self): + pass + + monkeypatch.setattr(local_chat, "_anthropic_client", + lambda backend=None, route="anthropic": _Runnerless()) + with pytest.raises(PageIndexAPIError, match="tool runner"): + client._messages("q", model="bedrock/anthropic.claude-sonnet-4-6-v1:0") + + +def _failing_runner_client(monkeypatch, exc): + # A transport whose runner dies on first turn — the shape of a + # request-time credential failure (auth resolves per request). + class _FailingRunner: + def __iter__(self): + return self + + def __next__(self): + raise exc + + def generate_tool_call_response(self): + return None + + class _Fake: + class beta: + class messages: + @staticmethod + def tool_runner(**kwargs): + return _FailingRunner() + + def close(self): + pass + + monkeypatch.setattr(local_chat, "_anthropic_client", + lambda backend=None, route="anthropic": _Fake()) + + +@needs_anthropic +def test_messages_request_failures_propagate_raw(client, monkeypatch): + """No second-guessing the transports: a request-time failure that is + not the anthropic SDK's own error type propagates as itself — the + stacks' original text and traceback are the diagnostic, on every + route and on both paths. (Construction-time misconfiguration still + wraps, in _anthropic_client — that is the constructors' documented + contract.)""" + cases = (RuntimeError("could not resolve credentials from session"), + TypeError("Could not resolve authentication method"), + ModuleNotFoundError("No module named 'botocore'", + name="botocore")) + for exc in cases: + _failing_runner_client(monkeypatch, exc) + with pytest.raises(type(exc)): + client._messages( + "q", model="bedrock/anthropic.claude-sonnet-4-6-v1:0") + _failing_runner_client(monkeypatch, exc) + with pytest.raises(type(exc)): + list(client._messages("q", stream=True, + model="claude-sonnet-4-5")) + + +def test_route_tables_and_marks_agree(): + """One route concept, three tables — the client's route/env map, this + module's client classes, and the marks predicate. A new route must + land in every one; a miss is a silent env no-op or full-price turns.""" + from pageindex.client import _CLAUDE_ROUTES + assert set(local_chat._ROUTE_CLIENTS) == {"anthropic", *_CLAUDE_ROUTES} + for wire in ("anthropic/claude-opus-4-6", + *(f"{route}/claude-opus-4-6" for route in _CLAUDE_ROUTES)): + assert local_chat._litellm_claude_marks(wire), wire + # Claude-gated on the cloud routes: other models get no marks. + assert local_chat._litellm_claude_marks( + "azure_ai/Meta-Llama-3-70B-Instruct") is None + + # ── chat(protocol=): the protocol doors behind the front door ── def test_old_door_names_point_at_chat_protocol(client): @@ -3495,7 +3792,7 @@ def test_messages_extra_body_refuses_skeleton_before_transport(client, lambda backend=None: made.append(1)) with pytest.raises(PageIndexAPIError, match="instructions="): local_chat.run_messages(client, "q", model="claude-x", - extra_body={"system": "x"}) + route="anthropic", extra_body={"system": "x"}) assert made == [] diff --git a/tests/test_package_surface.py b/tests/test_package_surface.py index 7e49ccb2b..8ac7b2faf 100644 --- a/tests/test_package_surface.py +++ b/tests/test_package_surface.py @@ -140,21 +140,6 @@ def test_import_leaves_litellm_env_untouched(tmp_path): assert out.stdout.strip() == "ok" -def test_chat_module_stamps_before_it_imports_litellm(): - """local_chat imports litellm without utils on its import path; the - stamp has to be in place by then anyway.""" - probe = ("import os\n" - "from pageindex import local_chat\n" - "local_chat._default_max_tokens('claude-sonnet-4-5'," - " {'budget_tokens': 4096})\n" - "print(os.environ.get('LITELLM_LOCAL_MODEL_COST_MAP'))\n") - env = {k: v for k, v in os.environ.items() - if k != "LITELLM_LOCAL_MODEL_COST_MAP"} - out = subprocess.run([sys.executable, "-c", probe], env=env, - capture_output=True, text=True, check=True) - assert out.stdout.strip() == "True" - - def test_utils_import_keeps_litellm_off_the_network(): """utils' import sets litellm's no-fetch default; an explicit choice wins.""" probe = ("import os, pageindex.utils; "