diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cae4894d1..ca151b4dd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,3 +44,13 @@ jobs: - if: matrix.pdfium == '4' run: pip install "pypdfium2<5" - run: python -m pytest -q + + 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 8d6aec6fd..19a2003ff 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -58,6 +58,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 — @@ -287,17 +339,23 @@ 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``, ``responses``), 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``, ``responses``; a value you + set also carries onto ``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. For a Claude model + used across both kinds of surface, the ``anthropic/`` + spelling means the same thing everywhere. 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 @@ -465,13 +523,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": @@ -493,11 +556,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 @@ -521,6 +589,18 @@ def _local_chat(self) -> bool: return bool(model.strip()) return model is not None + @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``.""" @@ -1036,7 +1116,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, @@ -1055,10 +1135,12 @@ def messages( Document QA over 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 @@ -1072,7 +1154,13 @@ def messages( 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 @@ -1104,9 +1192,11 @@ 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. """ if not self._local_chat: raise PageIndexAPIError( @@ -1114,9 +1204,14 @@ def messages( "client with chat_model=... (or a chat= model); the managed " "cloud chat serves chat_completions() only." ) + if not model and self._chat_model_stock: + raise _needs_model("messages()") from .local_chat import run_messages + wire, route = _claude_wire(model or self.chat_model, "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, system=system, temperature=temperature, top_p=top_p, top_k=top_k, stop_sequences=stop_sequences, max_turns=max_turns, @@ -1287,8 +1382,8 @@ def openai_agent_config( ``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. @@ -1370,7 +1465,7 @@ def as_anthropic_tools(self, include_management: bool = False, tools involved. Local: the in-process tools — the same set ``messages()`` runs internally. - Requires ``anthropic>=0.108.0`` + Requires the ``anthropic`` extra (``pip install 'pageindex[anthropic]'``), imported only when this method is called. @@ -1394,7 +1489,7 @@ def as_anthropic_tools(self, include_management: bool = False, def anthropic_runner_config( self, - model: str, + model: Optional[str] = None, doc_id: Optional[Union[str, list[str]]] = None, include_management: bool = False, asynchronous: bool = False, @@ -1423,8 +1518,15 @@ def anthropic_runner_config( 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``); also + resolves the ``max_tokens`` default. Unset: a + ``chat_model`` you set carries over; the stock default + raises rather than being sent. doc_id: Document ID or list of IDs to target, as in ``agent_instructions``. Local: also enforced at the tool layer, not just prompted. Cloud: prompt-level targeting. @@ -1443,6 +1545,14 @@ def anthropic_runner_config( from .agent_tools import build_agent_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, _ = _claude_wire(model, "anthropic_runner_config()") scope = self._local_doc_scope(doc_id) return { "model": model, @@ -1483,7 +1593,7 @@ def as_claude_mcp(self, include_management: bool = False, recommended channel: it is guaranteed delivery, carries ``doc_id`` targeting, and is 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( @@ -1502,6 +1612,7 @@ def claude_agent_config( doc_id: Optional[Union[str, list[str]]] = None, include_management: bool = False, server_name: str = "pageindex", + model: Optional[str] = None, ) -> dict[str, Any]: """ Document QA ``ClaudeAgentOptions`` kwargs in one call:: @@ -1512,8 +1623,11 @@ 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 customize (your own system prompt, extra - servers), switch to those methods directly. + 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 customize (your own system prompt, + extra servers), switch to those methods directly. Args: doc_id: Document ID or list of IDs to target, as in @@ -1523,8 +1637,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 build_agent_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()") scope = self._local_doc_scope(doc_id) return { "system_prompt": build_agent_instructions( @@ -1535,6 +1667,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( diff --git a/pageindex/integrations/anthropic_sdk.py b/pageindex/integrations/anthropic_sdk.py index 089b0809f..468eff26b 100644 --- a/pageindex/integrations/anthropic_sdk.py +++ b/pageindex/integrations/anthropic_sdk.py @@ -23,7 +23,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 ..agent_tools import _tool_specs diff --git a/pageindex/local_chat.py b/pageindex/local_chat.py index e32932d2b..4a7af58ea 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 @@ -248,8 +249,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. @@ -878,33 +880,50 @@ def _require_anthropic() -> None: from anthropic.lib.tools import ToolError # noqa: F401 except ImportError as exc: raise PageIndexAPIError( - "messages requires anthropic >= 0.108.0 (the tool " - "runner with ToolError) — pip install -U anthropic." + "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=None, route="anthropic"): """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: @@ -988,23 +1007,18 @@ 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.""" + 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: - 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 budget + 8192 return 4096 if model.startswith(_CLAUDE_4096_MODELS) else 8192 -def run_messages(client, messages, model: str, +def run_messages(client, messages, model: str, route: str = "anthropic", max_tokens: Optional[int] = None, stream: bool = False, doc_id=None, system=None, temperature: Optional[float] = None, @@ -1047,25 +1061,42 @@ def run_messages(client, messages, model: str, # network I/O, and a failure there must not strand the client below. tools = build_anthropic_tools(client, doc_ids=scope) 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, 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(model, 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 if stream: def events() -> Iterator[Any]: @@ -1075,14 +1106,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: @@ -1093,14 +1116,6 @@ def events() -> Iterator[Any]: turns = [turn for turn in 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: diff --git a/pyproject.toml b/pyproject.toml index f5bec8c9a..cd0b3ca2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,9 @@ python-dotenv = ">=1.0.0" pyyaml = ">=6.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 db400273b..091b6dec7 100644 --- a/tests/test_agent_tools.py +++ b/tests/test_agent_tools.py @@ -764,6 +764,74 @@ def test_claude_agent_config_local(client, store_path): 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, store_path): pytest.importorskip("agents") from agents import Agent @@ -903,6 +971,39 @@ 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" + assert config["max_tokens"] == 4096 # resolved on the stripped id + + +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" + assert config["max_tokens"] == 4096 + # 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, store_path): pytest.importorskip("anthropic") import anthropic @@ -2670,3 +2771,93 @@ def list_tools(self): cloud = PageIndexCloudClient(api_key="pi-test-key") with pytest.raises(PageIndexAPIError, match="no tools"): 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 diff --git a/tests/test_local_chat.py b/tests/test_local_chat.py index 64d09cdd6..ad05b82e1 100644 --- a/tests/test_local_chat.py +++ b/tests/test_local_chat.py @@ -710,7 +710,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 @@ -1470,7 +1470,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"): @@ -1568,6 +1568,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" + assert calls[0]["max_tokens"] == 4096 + + +@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") + local.messages("q") + assert calls[0]["model"] == "claude-3-opus-20240229" + assert calls[0]["max_tokens"] == 4096 + # The stock default was never chosen: nothing to send. + with pytest.raises(PageIndexAPIError, match="needs a model"): + PageIndexLocalClient(storage_path=store_path).messages("q") + + +@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 @@ -1822,8 +1878,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"} @@ -1871,7 +1927,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" @@ -1925,7 +1981,8 @@ 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 @@ -1945,7 +2002,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") @@ -2134,20 +2191,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) @@ -2218,20 +2274,23 @@ 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 + enabled10 = {"type": "enabled", "budget_tokens": 10000} assert lift("claude-opus-4-1", - {"type": "enabled", "budget_tokens": 10000}) == 18192 - assert lift("claude-test", - {"type": "enabled", "budget_tokens": 10000}) == 18192 + {"type": "enabled", "budget_tokens": 30000}) == 38192 + assert lift("claude-opus-4-1", enabled10) == 18192 + assert lift("claude-test", enabled10) == 18192 + assert lift("sonnet", enabled10) == 18192 + assert lift("us.anthropic.claude-3-7-sonnet-20250219-v1:0", + enabled10) == 18192 assert lift("claude-sonnet-4-5", {"type": "enabled", "budget_tokens": True}) == 8192 + assert capsys.readouterr().out == "" # ── own-model chat over cloud documents (the bridge) ── @@ -2389,7 +2448,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", @@ -2407,8 +2466,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 = [] @@ -2422,8 +2482,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): raise PageIndexAPIError("Could not reach the PageIndex MCP server") @@ -2432,7 +2492,37 @@ def boom(client, doc_ids=None): "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 @@ -2478,3 +2568,167 @@ def test_bridge_openai_agent_config_carries_configured_model(bridge_client): config = client.openai_agent_config() assert config["model"] == "fake-model" 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"}) + 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", {}) + 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 + + 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/gpt-4o") is None