diff --git a/README.md b/README.md index de11eda..c19cf40 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,8 @@ edit both products' core copies, that is evidence it belongs here. bus/fan-in orchestration, resource management, task budgets, and the backend-injected DAG scheduler. Product tool assembly, persistence backends, and workflow policy remain downstream. -9. **Provider substrate** (next): converge the shared provider transports, - fallback engine, prompt cache, usage metering, and non-blocking streaming +9. **Provider substrate** (complete in AgentCore): shared provider transports, + fallback engine, prompt cache, usage metadata normalization, and non-blocking streaming behind product configuration and session-affinity adapters. 10. Remove product compatibility facades after downstream imports have moved to `agent_core`. diff --git a/agent_core/llm.py b/agent_core/llm.py index acd7630..e9250d0 100644 --- a/agent_core/llm.py +++ b/agent_core/llm.py @@ -46,6 +46,21 @@ class StreamDelta: # consumer read an empty vendor, which split one model's usage across # a ``provider=""`` bucket and a named bucket. provider: str = "" + # VERBATIM provider content blocks for the assembled turn, carried only by + # providers whose replay contract needs the structured block list rather + # than flattened text — currently Anthropic extended thinking, where each + # ``thinking`` block's cryptographic ``signature`` (and any opaque + # ``redacted_thinking`` payload) MUST be echoed back unmodified on the next + # turn or the model cannot continue the signed reasoning state. + # + # ``content``/``reasoning_content`` above are the flattened text channels; + # they cannot represent a signature, so without this field the streaming + # path silently produced reasoning that ``thinking_format="content_block"`` + # could not replay. Providers that need no block list leave it empty and + # the stream assembler keeps using the flattened string. + reasoning_blocks: list[dict[str, Any]] = field( + default_factory=list[dict[str, Any]], + ) @runtime_checkable diff --git a/agent_core/llm_utils.py b/agent_core/llm_utils.py new file mode 100644 index 0000000..1067418 --- /dev/null +++ b/agent_core/llm_utils.py @@ -0,0 +1,27 @@ +"""Small product-neutral LLM helpers.""" + +from __future__ import annotations + + +def extract_boxed_content(text: str) -> str: + r"""Return the content of the last balanced ``\boxed{...}``.""" + if not text: + return "" + matches: list[str] = [] + offset = 0 + while (start := text.find(r"\boxed{", offset)) >= 0: + content_start = start + 7 + depth = 1 + pos = content_start + while pos < len(text) and depth: + depth += (text[pos] == "{") - (text[pos] == "}") + pos += 1 + if depth == 0: + matches.append(text[content_start : pos - 1]) + offset = pos + else: + offset = content_start + return matches[-1] if matches else "" + + +__all__ = ["extract_boxed_content"] diff --git a/agent_core/providers/__init__.py b/agent_core/providers/__init__.py new file mode 100644 index 0000000..a87bda3 --- /dev/null +++ b/agent_core/providers/__init__.py @@ -0,0 +1,43 @@ +"""Provider transports and provider-neutral client wrappers.""" + +from agent_core.providers.anthropic import AnthropicClient +from agent_core.providers.fallback import FallbackEntry, LLMFallbackChain +from agent_core.providers.nonblocking_stream import NonBlockingStream, nonblocking_stderr +from agent_core.providers.openai_chat import ( + OpenAIClient, + SessionQueryResolver, + SessionScopeResolver, + configure_session_query_resolver, + configure_session_scope_resolver, +) +from agent_core.providers.openai_responses import OpenAIResponsesClient +from agent_core.providers.prompt_cache import ( + AnthropicPromptCacheAdapter, + maybe_wrap_for_prompt_cache, +) +from agent_core.providers.protocol_client import ( + build_protocol_client, + protocol_of, + provider_label, + thinking_format_for_protocol, +) + +__all__ = [ + "AnthropicClient", + "AnthropicPromptCacheAdapter", + "FallbackEntry", + "LLMFallbackChain", + "NonBlockingStream", + "OpenAIClient", + "OpenAIResponsesClient", + "SessionQueryResolver", + "SessionScopeResolver", + "build_protocol_client", + "configure_session_query_resolver", + "configure_session_scope_resolver", + "maybe_wrap_for_prompt_cache", + "nonblocking_stderr", + "protocol_of", + "provider_label", + "thinking_format_for_protocol", +] diff --git a/agent_core/providers/anthropic.py b/agent_core/providers/anthropic.py new file mode 100644 index 0000000..c1237bf --- /dev/null +++ b/agent_core/providers/anthropic.py @@ -0,0 +1,721 @@ +"""Anthropic LLMClient — wraps :class:`anthropic.AsyncAnthropic`. + +Translates between OpenAI Chat Completions message format (used everywhere +else in the runtime) and Anthropic's block-based messages API. + +Replaces ``langchain_anthropic.ChatAnthropic``. Anthropic gotchas handled here: + +- ``cache_read_input_tokens`` / ``cache_creation_input_tokens`` map to the + shared cache-read/cache-write usage fields, including Anthropic's optional + nested 1-hour cache-creation count. +- Thinking-block ``signature``: the *response* parser preserves it on the + structured ``thinking`` block AND the *outbound* assistant re-send + (:func:`_to_anthropic_msg`) echoes signed ``thinking`` / + ``redacted_thinking`` blocks back verbatim, so multi-turn Claude *extended + thinking* with tool use continues the signed reasoning state. This only + engages when ``thinking=`` is requested (opt-in via the client ctor / a + profile ``protocol: anthropic``); with thinking off the branch is inert + and the assistant re-send is text + tool_use only, as before. +""" + +from __future__ import annotations + +# pyright: basic, reportPrivateImportUsage=false +import json +import logging +import os +from collections.abc import AsyncIterator +from typing import Any + +from agent_core.llm import LLMClient, LLMResponse, StreamDelta +from agent_core.messages import Message, ToolCall, text_of + +logger = logging.getLogger(__name__) + + +class AnthropicClient(LLMClient): + """Non-streaming-first Anthropic adapter.""" + + def __init__( + self, + model: str, + *, + api_key: str | None = None, + base_url: str | None = None, + temperature: float | None = None, + max_tokens: int | None = 4096, + timeout: float | None = 300.0, + thinking: dict[str, Any] | None = None, + effort: str = "", + bedrock: bool = False, + default_headers: dict[str, str] | None = None, + ) -> None: + self.model = model + self.default_temperature = temperature + self.default_max_tokens = max_tokens + self.default_timeout = timeout + # Extended thinking: when set (e.g. ``{"type": "adaptive", "display": + # "summarized"}``) the request carries ``thinking=`` so responses return + # thinking + signature blocks; the response parser keeps them verbatim + # (content_block) for faithful multi-turn replay. ``temperature`` is + # dropped when thinking is on (Anthropic 400s on the combo). ``effort`` + # (low|medium|high|xhigh|max) → ``output_config.effort`` via extra_body. + self._thinking = thinking or None + self._effort = (effort or "").strip() + # Transport: ``bedrock`` swaps AsyncAnthropic (``/v1/messages`` + + # ``x-api-key``) for the AWS Bedrock runtime (``/model/{id}/invoke`` + + # ``anthropic_version`` body stamp) authenticated with a Bedrock API Key + # (``Authorization: Bearer``) instead of IAM SigV4. Everything downstream + # (_build_kwargs / _to_llm_response / thinking replay) is transport- + # agnostic and reused unchanged. + if bedrock: + self._client = _build_bedrock_client( + api_key, + base_url, + timeout, + default_headers, + ) + else: + from anthropic import AsyncAnthropic + self._client = AsyncAnthropic( + api_key=api_key, base_url=base_url, timeout=timeout, max_retries=0, + default_headers=default_headers, + ) + + def _build_kwargs( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None, + temperature: float | None, + max_tokens: int | None, + extra_headers: dict[str, str] | None, + timeout: float | None, + ) -> dict[str, Any]: + """Shared request-shape builder for :meth:`chat` and :meth:`stream`.""" + system, msgs = _split_system(messages) + kwargs: dict[str, Any] = { + "model": self.model, + # ``_to_anthropic_msg`` returns None for a message with nothing + # sendable (a contentless assistant turn); those are dropped. + "messages": [ + converted + for converted in (_to_anthropic_msg(m) for m in msgs) + if converted is not None + ], + "max_tokens": max_tokens or self.default_max_tokens or 4096, + } + if system: + kwargs["system"] = system + if self._thinking: + # Anthropic rejects ``temperature`` together with thinking, so it is + # OMITTED here regardless of the configured default. ``effort`` rides + # on ``extra_body.output_config`` so any value (incl. ``xhigh``) + # reaches ``messages.create`` without the SDK's stricter validation. + kwargs["thinking"] = self._thinking + if self._effort: + kwargs["extra_body"] = {"output_config": {"effort": self._effort}} + else: + eff_temp = temperature if temperature is not None else self.default_temperature + if eff_temp is not None: + kwargs["temperature"] = eff_temp + if tools: + kwargs["tools"] = [_to_anthropic_tool(t) for t in tools] + if extra_headers: + kwargs["extra_headers"] = extra_headers + if timeout is not None: + kwargs["timeout"] = timeout + elif self.default_timeout is not None: + kwargs["timeout"] = self.default_timeout + _add_prompt_cache(kwargs) + return kwargs + + async def chat( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> LLMResponse: + kwargs = self._build_kwargs( + messages, tools=tools, temperature=temperature, + max_tokens=max_tokens, extra_headers=extra_headers, timeout=timeout, + ) + raw = await self._client.messages.create(**kwargs) + return _to_llm_response(raw) + + async def stream( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> AsyncIterator[StreamDelta]: + # Real token-by-token streaming over Anthropic's raw event stream. + # Each event maps to the same ``StreamDelta`` shape the kernel + # assembler consumes for OpenAI (content / reasoning_content / + # tool_call_deltas), and the terminal delta carries usage/finish/model + # just like the OpenAI ``include_usage`` chunk. + kwargs = self._build_kwargs( + messages, tools=tools, temperature=temperature, + max_tokens=max_tokens, extra_headers=extra_headers, timeout=timeout, + ) + kwargs["stream"] = True + input_tokens: int | None = None + output_tokens: int | None = None + cache_read: int | None = None + cache_write: int | None = None + reasoning_tokens: int | None = None + model = "" + stop_reason = "" + # Verbatim block list, in the provider's own emission order, rebuilt + # from the event stream so the streamed turn replays exactly like the + # non-streaming one (``_to_llm_response``). Keyed by the stream's block + # ``index`` while open; ``_ordered_blocks`` flattens it at the end. + # + # This exists for extended thinking: a ``thinking`` block's + # ``signature`` arrives as its own ``signature_delta`` event AFTER the + # ``thinking_delta`` text, and ``redacted_thinking`` blocks carry their + # opaque payload on ``content_block_start`` with no deltas at all. + # Neither can be expressed in the flattened ``reasoning_content`` + # string, so a streamed thinking turn used to yield reasoning that + # ``thinking_format="content_block"`` could not replay. + blocks: dict[int, dict[str, Any]] = {} + stream = await self._client.messages.create(**kwargs) + async for event in stream: + etype = getattr(event, "type", "") + if etype == "message_start": + msg = getattr(event, "message", None) + model = getattr(msg, "model", "") or model + u = getattr(msg, "usage", None) + if u is not None: + input_tokens = getattr(u, "input_tokens", input_tokens) + cr = getattr(u, "cache_read_input_tokens", None) + if cr is not None: + cache_read = cr + cw = _anthropic_cache_write_tokens(u) + if cw is not None: + cache_write = cw + elif etype == "content_block_start": + cb = getattr(event, "content_block", None) + idx = getattr(event, "index", 0) + cbtype = getattr(cb, "type", "") + if cbtype == "tool_use": + # Open a tool-call slot: id + name set once; arguments + # arrive as ``input_json_delta`` partial-JSON fragments. + # Deliberately NOT recorded in ``blocks``: tool calls ride + # the ``tool_call_deltas`` channel and are re-emitted from + # ``Message.tool_calls`` on replay, exactly as the + # non-streaming ``_to_llm_response`` does. + yield StreamDelta(tool_call_deltas=[{ + "index": idx, + "id": getattr(cb, "id", "") or "", + "name": getattr(cb, "name", "") or "", + "arguments": "", + }]) + elif cbtype == "text": + blocks[idx] = { + "type": "text", + "text": getattr(cb, "text", "") or "", + } + elif cbtype == "thinking": + blocks[idx] = { + "type": "thinking", + "thinking": getattr(cb, "thinking", "") or "", + "signature": getattr(cb, "signature", "") or "", + } + elif cbtype == "redacted_thinking": + # Whole payload lands on the start event — no deltas follow. + blocks[idx] = { + "type": "redacted_thinking", + "data": getattr(cb, "data", "") or "", + } + elif etype == "content_block_delta": + d = getattr(event, "delta", None) + dtype = getattr(d, "type", "") + idx = getattr(event, "index", 0) + if dtype == "text_delta": + chunk = getattr(d, "text", "") or "" + blk = blocks.get(idx) + if blk is not None and blk.get("type") == "text": + blk["text"] += chunk + yield StreamDelta(content=chunk) + elif dtype == "thinking_delta": + chunk = getattr(d, "thinking", "") or "" + blk = blocks.get(idx) + if blk is not None and blk.get("type") == "thinking": + blk["thinking"] += chunk + yield StreamDelta(reasoning_content=chunk) + elif dtype == "signature_delta": + # The signature is a single opaque token, not an + # incremental text stream, but it is appended rather than + # assigned so a provider that ever chunks it still + # reassembles correctly. + blk = blocks.get(idx) + if blk is not None and blk.get("type") == "thinking": + blk["signature"] += getattr(d, "signature", "") or "" + elif dtype == "input_json_delta": + yield StreamDelta(tool_call_deltas=[{ + "index": idx, + "id": None, + "name": None, + "arguments": getattr(d, "partial_json", "") or "", + }]) + elif etype == "message_delta": + d = getattr(event, "delta", None) + stop_reason = getattr(d, "stop_reason", "") or stop_reason + u = getattr(event, "usage", None) + if u is not None: + ot = getattr(u, "output_tokens", None) + if ot is not None: + output_tokens = ot + # ``output_tokens_details.thinking_tokens`` when the payload + # carries it; without this the streamed path reported no + # ``reasoning_tokens`` at all while the non-streaming path + # did, splitting one model's thinking spend across two + # differently-shaped usage dicts. + rt = _anthropic_reasoning_tokens(u) + if rt is not None: + reasoning_tokens = rt + # Terminal delta: fold the accumulated usage/finish/model onto the + # assembled ``LLMResponse`` (mirrors OpenAI's empty-choices chunk). + # ``reasoning_blocks`` is sent ONLY for a thinking turn — for a plain + # text turn the flattened ``content`` string is the faithful shape and + # the assembler should keep using it. + ordered = _ordered_blocks(blocks) + yield StreamDelta( + usage=_anthropic_usage_dict( + input_tokens, + output_tokens, + cache_read, + cache_write, + reasoning_tokens, + ), + finish_reason=stop_reason, + model=model, + reasoning_blocks=ordered if _has_thinking(ordered) else [], + ) + + +# ── Bedrock transport ──────────────────────────────────────────────────── + + +def _bedrock_region_from_url(base_url: str | None) -> str: + """Best-effort region from a bedrock-runtime base_url. + + ``https://bedrock-runtime.us-east-1.amazonaws.com`` → ``us-east-1``; + defaults to ``us-east-1`` when it can't be parsed (the region only labels + the SDK client — the endpoint is ``base_url`` verbatim).""" + host = (base_url or "").split("//", 1)[-1].split("/", 1)[0] + parts = host.split(".") + if len(parts) >= 3 and parts[0].startswith("bedrock-runtime"): + return parts[1] + return "us-east-1" + + +def _build_bedrock_client( + api_key: str | None, + base_url: str | None, + timeout: float | None, + default_headers: dict[str, str] | None = None, +): + """AsyncAnthropicBedrock that authenticates with a Bedrock API Key + (``Authorization: Bearer``) instead of IAM SigV4. + + Mirrors the proven reporter pattern (``report_llm._build_bedrock_raw``): + the stock ``AsyncAnthropicBedrock._prepare_request`` SigV4-signs via boto3 + (needs AWS creds); we override it to inject the Bearer header. Everything + else the Bedrock client gives for free is what we want — the + ``/v1/messages`` → ``/model/{id}/invoke`` URL rewrite and the + ``anthropic_version: bedrock-2023-05-31`` body stamp. + + Because the override REPLACES SigV4 outright, a missing key cannot fall back + to the AWS credential chain the stock method would have consulted — it would + send a bare ``Authorization: Bearer`` and get a 401 that looks like a bad + key rather than a bypassed auth path. So the key is resolved from + ``api_key`` then ``AWS_BEARER_TOKEN_BEDROCK`` (the env var the Bedrock API-key + flow documents), and a still-empty value raises instead of building a client + that cannot authenticate.""" + import httpx + from anthropic import AsyncAnthropicBedrock + + bearer = api_key or os.getenv("AWS_BEARER_TOKEN_BEDROCK", "") + if not bearer: + raise ValueError( + "Bedrock transport needs a Bedrock API key: pass api_key= or set " + "AWS_BEARER_TOKEN_BEDROCK. This client overrides SigV4 request " + "signing with Bearer auth, so ambient AWS credentials are NOT used.", + ) + + class _BearerBedrock(AsyncAnthropicBedrock): + async def _prepare_request(self, request: httpx.Request) -> None: + request.headers["Authorization"] = f"Bearer {bearer}" + + return _BearerBedrock( + aws_region=_bedrock_region_from_url(base_url), + base_url=base_url or None, + timeout=timeout, + max_retries=0, + default_headers=default_headers, + ) + + +# ── Conversion helpers ─────────────────────────────────────────────────── + + +def _split_system(messages: list[Message]) -> tuple[str, list[Message]]: + """Pull out the (single) leading system message; Anthropic takes it + as a top-level kwarg, not as a message.""" + if messages and messages[0].get("role") == "system": + return text_of(messages[0].get("content", "")), messages[1:] + return "", list(messages) + + +def _add_prompt_cache(kwargs: dict[str, Any]) -> None: + """Set Anthropic prompt-cache breakpoints on ``kwargs`` in place. + + Anthropic caching is opt-in per content block (unlike OpenAI's automatic + caching), so without breakpoints the full growing prompt is re-billed every + turn (``cached_tokens=0``). Place two ``ephemeral`` breakpoints — the system + prefix (static across the run) and the last message's final block (a rolling + breakpoint that caches the growing conversation prefix). Anthropic allows up + to 4 and serves the longest matching cached prefix, so these two cover the + static head and the moving tail. This lives inside ``AnthropicClient`` so it + only ever touches Anthropic requests. Disable with ``ANTHROPIC_PROMPT_CACHE=0``. + """ + if os.getenv("ANTHROPIC_PROMPT_CACHE", "1") == "0": + return + # System prefix (a plain string) -> one cache-controlled text block. + system = kwargs.get("system") + if isinstance(system, str) and system: + kwargs["system"] = [{ + "type": "text", + "text": system, + "cache_control": {"type": "ephemeral"}, + }] + # Rolling tail: mark the last message's final content block. + msgs = kwargs.get("messages") + if not msgs: + return + last = msgs[-1] + content = last.get("content") + if isinstance(content, str): + if content: + last["content"] = [{ + "type": "text", + "text": content, + "cache_control": {"type": "ephemeral"}, + }] + elif isinstance(content, list) and content and isinstance(content[-1], dict): + content[-1] = {**content[-1], "cache_control": {"type": "ephemeral"}} + + +def _to_anthropic_msg(m: Message) -> dict[str, Any] | None: + role = m.get("role") + if role == "tool": + return { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": m.get("tool_call_id", ""), + "content": text_of(m.get("content", "")), + }], + } + if role == "assistant": + blocks: list[dict[str, Any]] = [] + raw = m.get("content") + if isinstance(raw, list): + # Extended-thinking continuation: history kept the VERBATIM block + # list (via model_profile.to_history for content_block). Re-send the + # signed ``thinking`` / ``redacted_thinking`` blocks UNMODIFIED so + # Anthropic can validate the signature server-side and continue the + # signed reasoning state, then append the visible text + tool_use. + for block in raw: + if not isinstance(block, dict): + continue + bt = block.get("type") + if bt == "thinking": + tb: dict[str, Any] = { + "type": "thinking", + "thinking": block.get("thinking", "") or "", + } + sig = block.get("signature") + if sig: + tb["signature"] = sig + blocks.append(tb) + elif bt == "redacted_thinking": + blocks.append({ + "type": "redacted_thinking", + "data": block.get("data", "") or "", + }) + elif bt == "text": + txt = block.get("text", "") or "" + if txt: + blocks.append({"type": "text", "text": txt}) + else: + body = text_of(raw or "") + if body: + blocks.append({"type": "text", "text": body}) + for tc in m.get("tool_calls", []) or []: + block = _to_anthropic_tool_use(tc) + if block is not None: + blocks.append(block) + if not blocks: + # Nothing to say and nothing to call. An empty ``text`` block is + # NOT a usable placeholder — Anthropic rejects zero-length text + # ("text content blocks must be non-empty"), and once such a turn + # lands in durable history EVERY later request replaying it fails + # in conversion, before a request is even sent. Whitespace-only is + # no safer: as the final message it trips the trailing-whitespace + # check instead. Reachable in practice from + # ``finish_reason="length"`` with empty content, which + # ``_to_llm_response`` maps to ``content=""``. + # + # Dropping the turn is the faithful shape — there was no assistant + # output to replay — and it is safe because the Messages API + # combines consecutive same-role messages rather than requiring + # strict alternation. The caller filters the ``None``. + logger.debug( + "dropping contentless assistant message from Anthropic request", + ) + return None + return {"role": "assistant", "content": blocks} + return {"role": "user", "content": text_of(m.get("content", ""))} + + +def _to_anthropic_tool_use(tc: Any) -> dict[str, Any] | None: + """One OpenAI ``tool_calls`` entry → an Anthropic ``tool_use`` block. + + Returns ``None`` for a call this conversion cannot express, rather than + raising. Everything here runs while REPLAYING durable history, so an + exception is not a one-request failure: the malformed call is already + recorded, and every subsequent turn in the session re-converts it and dies + the same way. A partial or truncated tool call must degrade, not wedge the + session. + + - Missing ``id`` or function ``name`` → dropped. Anthropic requires both, + and a call with no id can have no matching ``tool_result`` to orphan. + - Unparseable or non-object ``arguments`` → ``{}``. Anthropic's ``input`` + must be a JSON object, and on replay the arguments are historical detail + (the tool already ran); the block's *identity* is what the following + ``tool_result`` needs to validate against. The OpenAI path passes the raw + string through, so parity here means surviving the same input. + """ + if not isinstance(tc, dict): + logger.warning("skipping non-dict tool_call in Anthropic conversion") + return None + fn = tc.get("function") + fn = fn if isinstance(fn, dict) else {} + call_id = tc.get("id") or "" + name = fn.get("name") or "" + if not call_id or not name: + logger.warning( + "skipping malformed tool_call in Anthropic conversion " + "(id=%r, name=%r)", call_id, name, + ) + return None + raw_args = fn.get("arguments") or "{}" + try: + parsed = json.loads(raw_args) + except (TypeError, ValueError): + logger.warning( + "tool_call %s (%s) has unparseable arguments; replaying with an " + "empty input object", call_id, name, + ) + parsed = {} + if not isinstance(parsed, dict): + logger.warning( + "tool_call %s (%s) arguments parsed to %s, not an object; " + "replaying with an empty input object", + call_id, name, type(parsed).__name__, + ) + parsed = {} + return {"type": "tool_use", "id": call_id, "name": name, "input": parsed} + + +def _to_anthropic_tool(t: dict[str, Any]) -> dict[str, Any]: + """OpenAI ``{type:function, function:{name,description,parameters}}`` → + Anthropic ``{name, description, input_schema}``.""" + fn = t.get("function") or t + return { + "name": fn.get("name", ""), + "description": fn.get("description", ""), + "input_schema": fn.get("parameters", {}), + } + + +def _anthropic_usage_dict( + input_tokens: int | None, + output_tokens: int | None, + cache_read: int | None, + cache_write: int | None, + reasoning: int | None = None, +) -> dict[str, int]: + """Normalise Anthropic token counts into the wire-shape usage dict shared + by the non-streaming ``_to_llm_response`` and the streaming assembler. + Cache reads and writes are kept separate for billing and also summed into + the backward-compatible ``cached_tokens`` field. ``reasoning`` + (extended-thinking tokens, part of ``output_tokens``) is surfaced + separately when the payload reported it — ``None`` means "not reported" and + omits the key, while ``0`` is recorded as a real zero.""" + out: dict[str, int] = {} + if input_tokens is not None: + out["prompt_tokens"] = int(input_tokens) + if output_tokens is not None: + out["completion_tokens"] = int(output_tokens) + if cache_read is not None or cache_write is not None: + read = int(cache_read or 0) + write = int(cache_write or 0) + out["cache_read_tokens"] = read + out["cache_write_tokens"] = write + out["cached_tokens"] = read + write + out["cache_creation_tokens"] = write + if reasoning is not None: + out["reasoning_tokens"] = int(reasoning) + if out.get("prompt_tokens") or out.get("completion_tokens"): + out["total_tokens"] = ( + out.get("prompt_tokens", 0) + out.get("completion_tokens", 0) + ) + return out + + +def _anthropic_cache_write_tokens(usage: Any) -> int | None: + """Return Anthropic cache-creation tokens, including the 1-hour extension.""" + if usage is None: + return None + raw = getattr(usage, "cache_creation_input_tokens", None) + if raw is None and isinstance(usage, dict): + raw = usage.get("cache_creation_input_tokens") + nested = getattr(usage, "cache_creation", None) + if nested is None and isinstance(usage, dict): + nested = usage.get("cache_creation") + extension = getattr(nested, "ephemeral_1h_input_tokens", None) + if extension is None and isinstance(nested, dict): + extension = nested.get("ephemeral_1h_input_tokens") + if raw is None and extension is None: + return None + return max(0, int(raw or 0)) + max(0, int(extension or 0)) + + +def _anthropic_reasoning_tokens(usage: Any) -> int | None: + """Best-effort extended-thinking token count off an Anthropic usage object. + + Newer usage payloads may expose ``output_tokens_details.thinking_tokens``; + absent that the count is folded into ``output_tokens`` and unrecoverable. + + Returns ``None`` for "the payload didn't say", distinct from ``0`` for "the + payload said zero" — the ``reasoning_tokens`` key is omitted only in the + former case. Collapsing the two would make a model that genuinely spent no + thinking tokens indistinguishable from a gateway that reports nothing, which + is the same ambiguity ``openai_chat._usage_dict`` and + ``_responses_usage_dict`` avoid with their own ``is not None`` checks.""" + if usage is None: + return None + otd = getattr(usage, "output_tokens_details", None) + if otd is None: + return None + val = getattr(otd, "thinking_tokens", None) + if val is None and isinstance(otd, dict): + val = otd.get("thinking_tokens") + return None if val is None else int(val) + + +def _ordered_blocks(blocks: dict[int, dict[str, Any]]) -> list[dict[str, Any]]: + """Flatten the streamed block accumulator back into emission order. + + Anthropic numbers content blocks with a monotonically increasing ``index`` + per message, so sorting by key restores the order the provider produced — + which is the order signed thinking must be replayed in.""" + return [blocks[i] for i in sorted(blocks)] + + +def _has_thinking(blocks: list[dict[str, Any]]) -> bool: + """Whether a block list carries reasoning that needs verbatim replay. + + Mirrors ``_to_llm_response``'s ``thinking_parts or has_redacted`` gate: the + structured block list is only worth carrying when there is a signature or + an opaque redacted payload to preserve.""" + return any( + b.get("type") in ("thinking", "redacted_thinking") for b in blocks + ) + + +def _to_llm_response(raw: Any) -> LLMResponse: + text_parts: list[str] = [] + thinking_parts: list[str] = [] + has_redacted = False + blocks_out: list[dict[str, Any]] = [] + tool_calls: list[ToolCall] = [] + for block in (getattr(raw, "content", None) or []): + btype = getattr(block, "type", None) + if btype == "text": + text = getattr(block, "text", "") or "" + text_parts.append(text) + blocks_out.append({"type": "text", "text": text}) + elif btype == "thinking": + thinking = getattr(block, "thinking", "") or "" + thinking_parts.append(thinking) + blocks_out.append({ + "type": "thinking", + "thinking": thinking, + # ``signature`` is the cryptographic token Anthropic returns + # with each thinking block; resending it on the next turn + # lets the model continue from the same reasoning state. + "signature": getattr(block, "signature", "") or "", + }) + elif btype == "redacted_thinking": + # Encrypted thinking Anthropic chose not to surface. It carries no + # readable text but MUST be preserved verbatim (raw_content_blocks) + # and replayed unmodified — the outbound ``_to_anthropic_msg`` echoes + # it, and dropping it here would break signature/replay continuity. + has_redacted = True + blocks_out.append({ + "type": "redacted_thinking", + "data": getattr(block, "data", "") or "", + }) + elif btype == "tool_use": + tool_calls.append({ + "id": getattr(block, "id", ""), + "type": "function", + "function": { + "name": getattr(block, "name", ""), + "arguments": json.dumps(getattr(block, "input", {}) or {}, ensure_ascii=False), + }, + }) + + # When thinking is present (readable or redacted), keep the structured block + # list so the ``content_block`` parser picks out reasoning vs visible text + # AND the verbatim signed/redacted blocks survive for replay. Otherwise + # flatten to a string for the simpler downstream path. + if thinking_parts or has_redacted: + content: Any = blocks_out + else: + content = "\n".join(text_parts) + + usage = getattr(raw, "usage", None) + usage_dict = _anthropic_usage_dict( + getattr(usage, "input_tokens", None) if usage else None, + getattr(usage, "output_tokens", None) if usage else None, + getattr(usage, "cache_read_input_tokens", None) if usage else None, + _anthropic_cache_write_tokens(usage), + _anthropic_reasoning_tokens(usage), + ) + + return LLMResponse( + content=content, + tool_calls=tool_calls, + reasoning_content="\n".join(thinking_parts), + finish_reason=getattr(raw, "stop_reason", "") or "", + model=getattr(raw, "model", "") or "", + usage=usage_dict, + response_metadata={"id": getattr(raw, "id", "")}, + ) + + +__all__ = ["AnthropicClient"] diff --git a/agent_core/providers/fallback.py b/agent_core/providers/fallback.py new file mode 100644 index 0000000..1fb0bd6 --- /dev/null +++ b/agent_core/providers/fallback.py @@ -0,0 +1,382 @@ +"""``LLMFallbackChain`` — provider-failover :class:`LLMClient` wrapper. + +Master design §5.9. Wraps an ordered list of LLM clients; each call tries +the primary first, and on configured failure modes falls through to the +next entry. Implements the native :class:`agent_core.llm.LLMClient` +interface so existing callers (kernel loop, ``ResourceManager.get_llm()``) +get failover transparently — they keep calling ``chat`` / ``stream`` as +before. + +Triggers +-------- +Each ``FallbackEntry`` declares which exception classes / signatures +trigger the fall-through. Built-in triggers: + +- ``timeout`` — ``asyncio.TimeoutError`` or any exception whose name + or message contains "timeout". +- ``rate_limit`` — HTTP 429, or message contains "rate limit" / "quota" + / "too many requests". +- ``5xx`` — exception with a ``status_code`` attribute in + [500, 599], or message starting with "5". +- ``any_error`` — match anything (use this on the last entry to + guarantee at least one fallback). + +A trigger only applies to the entry that *failed*; the next entry +inherits its own trigger list. The chain stops on the first successful +generation, or re-raises the last exception when no entry's trigger +matched. + +Layering +-------- +This module imports nothing from ``core/runtime/`` or ``components/`` +— it is pure infra. The kernel loop's ``llm_client`` accepts any +``LLMClient``; passing an ``LLMFallbackChain`` instance just works. + +Telemetry +--------- +On a successful call, the chosen response's ``response_metadata`` is +stamped with two extra fields: + +- ``fallback_used`` — index of the entry that succeeded (0 means the + primary; >0 means a fallback fired). +- ``model_actually_used`` — best-effort model id of the entry that + succeeded (``model_name`` / ``model`` + attribute, or class name). + +The proxy / tracing middleware in ``core/runtime/middleware/llm/`` +reads these from ``response_metadata`` and forwards them into the +``llm_call_finished`` event so observability can flag failover runs +without a separate IPC channel. +""" + +from __future__ import annotations + +# pyright: basic, reportPrivateImportUsage=false +import asyncio +import logging +from collections.abc import AsyncIterator, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal + +from agent_core.llm import LLMResponse, StreamDelta +from agent_core.messages import Message + +logger = logging.getLogger(__name__) + +__all__ = [ + "FallbackEntry", + "FallbackTrigger", + "LLMFallbackChain", + "with_provider_stamp", +] + + +FallbackTrigger = Literal["timeout", "rate_limit", "5xx", "any_error"] + + +@dataclass +class FallbackEntry: + """A single tier in the fallback chain. + + Args: + model: any :class:`LLMClient` instance. + triggers: which failure modes count as "fall through to the + next entry". Empty tuple ``()`` means "never fall through" — + useful as a hard barrier at any position in the chain. + ``("any_error",)`` means "always fall through". ``None`` + (the default) means "unset": inside an + :class:`LLMFallbackChain` the chain's ``default_triggers`` + are substituted; standalone it behaves as ``("any_error",)``. + + ``None`` and ``()`` are deliberately distinct. Sharing one + sentinel would make a hard barrier indistinguishable from an + unconfigured entry, and chain normalisation would silently + overwrite the barrier with ``default_triggers``. + provider: vendor label (``openai`` / ``anthropic`` / ``qwen`` / + ``mirothinker``) — stamped onto ``response_metadata`` as + ``provider_actually_used`` so per-call ``usage`` events can + tell downstream billing which vendor served the request. + Empty string when the construction site doesn't know. + """ + model: Any # LLMClient + triggers: tuple[FallbackTrigger, ...] | None = None + provider: str = "" + + def matches(self, exc: BaseException) -> bool: + """Whether ``exc`` should trigger fall-through past this entry. + + An unset (``None``) trigger tuple falls back to ``any_error`` so a + standalone entry keeps the permissive default; an explicitly empty + tuple matches nothing, which is the hard-barrier contract. + """ + triggers = ("any_error",) if self.triggers is None else self.triggers + return any(_trigger_matches(trigger, exc) for trigger in triggers) + + +def _model_id(model: Any) -> str: + """Best-effort short label for a model. Used for telemetry.""" + return ( + getattr(model, "model_name", None) + or getattr(model, "model", None) + or type(model).__name__ + ) + + +def _trigger_matches(trigger: FallbackTrigger, exc: BaseException) -> bool: + """Decide whether ``exc`` matches the given trigger keyword.""" + if trigger == "any_error": + return True + + name = type(exc).__name__.lower() + msg = str(exc).lower() + + if trigger == "timeout": + if isinstance(exc, asyncio.TimeoutError): + return True + return "timeout" in name or "timeout" in msg or "timed out" in msg + + if trigger == "rate_limit": + status = getattr(exc, "status_code", None) + if status == 429: + return True + return any( + phrase in msg + for phrase in ( + "rate limit", + "rate_limit", + "quota", + "too many requests", + ) + ) + + if trigger == "5xx": + status = getattr(exc, "status_code", None) + if isinstance(status, int) and 500 <= status <= 599: + return True + # Many SDKs encode the code in the message. + return any( + phrase in msg + for phrase in ( + "internal server error", + "bad gateway", + "service unavailable", + "gateway timeout", + "503", + "502", + "500", + "504", + ) + ) + + return False + + +@dataclass +class LLMFallbackChain: + """Ordered list of :class:`LLMClient` entries with per-entry triggers. + + ``LLMFallbackChain([primary, fallback], default_triggers=("timeout","5xx"))`` + is the typical 2-tier setup. The chain's call surface is identical to + a single :class:`LLMClient`: ``chat`` / ``stream`` delegate to whichever + entry succeeds. + + Args: + entries: ordered list of ``FallbackEntry``. The first entry is + the primary; subsequent entries are tried in order on + matching failure. Must be non-empty. + default_triggers: applied to entries whose ``triggers`` field is + left unset (``None``). Default is ``("any_error",)``. Entries + that declare an explicit ``()`` keep it — that is the + "never fall through" barrier and is not a missing value. + + Notes: + - Streaming (``stream``) only fails over BEFORE the first chunk + is yielded. Once the consumer has seen output we cannot rewind, + so a mid-stream error propagates as-is. Token batching strategies + (yield-once-complete) sidestep this; for true streaming + robustness use a ``rate_limit`` / ``timeout`` trigger only and + accept the mid-stream failure mode. + """ + + entries: list[FallbackEntry] = field(default_factory=list) + default_triggers: tuple[FallbackTrigger, ...] = ("any_error",) + model: str = field(init=False, default="") + + def __post_init__(self) -> None: + if not self.entries: + raise ValueError("LLMFallbackChain requires at least one entry") + self.model = _model_id(self.entries[0].model) + # Normalise default triggers onto entries that left them unset. + # ``None`` means "unset" and inherits ``default_triggers``; an + # explicit ``()`` is a hard barrier and MUST survive untouched, so + # this tests for ``None`` rather than falsiness. + for entry in self.entries: + if entry.triggers is None: + entry.triggers = self.default_triggers + + @classmethod + def from_models( + cls, + models: Sequence[Any], + *, + triggers: tuple[FallbackTrigger, ...] = ("any_error",), + ) -> LLMFallbackChain: + """Convenience: build a chain where every entry uses the same triggers.""" + entries = [FallbackEntry(model=m, triggers=triggers) for m in models] + return cls(entries=entries, default_triggers=triggers) + + @property + def model_name(self) -> str: + names = ",".join(_model_id(e.model) for e in self.entries) + return f"llm_fallback_chain[{names}]" + + # ------------------------------------------------------------------ + # Non-streaming path — delegates to entries one at a time. + # ------------------------------------------------------------------ + async def chat( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> LLMResponse: + last_exc: BaseException | None = None + for idx, entry in enumerate(self.entries): + try: + result = await entry.model.chat( + messages, + tools=tools, + temperature=temperature, + max_tokens=max_tokens, + extra_headers=extra_headers, + timeout=timeout, + ) + _stamp_metadata(result, idx, entry.model, entry.provider) + return result + except Exception as exc: + last_exc = exc + if idx == len(self.entries) - 1 or not entry.matches(exc): + raise + logger.info( + "LLMFallbackChain: entry %d (%s) failed (%s); " + "falling through to entry %d", + idx, _model_id(entry.model), type(exc).__name__, idx + 1, + ) + raise RuntimeError("LLMFallbackChain exhausted") from last_exc + + async def stream( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> AsyncIterator[StreamDelta]: + # Try entries in order. We can only fail over BEFORE any chunk + # has been forwarded to the caller — once we yield, the consumer + # has committed to that entry. + last_exc: BaseException | None = None + for idx, entry in enumerate(self.entries): + yielded_any = False + try: + async for delta in entry.model.stream( + messages, + tools=tools, + temperature=temperature, + max_tokens=max_tokens, + extra_headers=extra_headers, + timeout=timeout, + ): + # ``StreamDelta`` has no ``response_metadata`` channel, but + # it does carry a ``provider`` slot: stamp the serving leg's + # vendor so the stream assembler can fold it into + # ``LLMResponse.response_metadata["provider_actually_used"]`` + # (matching the non-streaming ``chat`` path's + # ``_stamp_metadata``). Constant for the whole stream — + # failover only fires before the first yield, so every + # delta below comes from this single committed entry. + # ``fallback_used`` / ``model_actually_used`` still have no + # streaming landing spot; only the billing-critical provider + # is carried here. + if entry.provider: + delta.provider = entry.provider + yielded_any = True + yield delta + return + except Exception as exc: + last_exc = exc + if yielded_any: + raise + if idx == len(self.entries) - 1 or not entry.matches(exc): + raise + logger.info( + "LLMFallbackChain stream: entry %d (%s) failed " + "before any chunk (%s); falling through", + idx, _model_id(entry.model), type(exc).__name__, + ) + raise RuntimeError("LLMFallbackChain exhausted") from last_exc + + +def _stamp_metadata( + result: LLMResponse, idx: int, model: Any, provider: str = "", +) -> None: + """Record which entry served the call on the response metadata. + + Mutates ``LLMResponse.response_metadata`` in place — the proxy / + tracing middleware reads ``fallback_used`` / ``model_actually_used`` + from there and forwards them into the ``llm_call_finished`` event. + + ``provider`` is stamped as ``provider_actually_used`` so downstream + billing (``extract_usage`` → per-call ``usage`` event) can tell which + vendor served a given call. Empty string is allowed when the chain + construction site didn't know the vendor. + """ + if not isinstance(result, LLMResponse): + return + result.response_metadata["fallback_used"] = idx + result.response_metadata["model_actually_used"] = _model_id(model) + if provider: + result.response_metadata["provider_actually_used"] = provider + + +def with_provider_stamp(llm: Any, provider: str) -> Any: + """Wrap any :class:`LLMClient` so responses carry ``provider_actually_used``. + + Returns ``llm`` unchanged when ``provider`` is empty (no-op fast path). + Otherwise builds a 1-entry ``LLMFallbackChain`` with ``triggers=()`` + (the hard barrier — never falls through), making the wrap + semantically transparent: + + - Successful response → ``response_metadata.provider_actually_used`` + gets stamped with ``provider`` (and ``model_actually_used`` / + ``fallback_used=0`` come along — the chain's standard stamping). + - Any exception → propagates as-is, since the single entry's empty + trigger tuple matches nothing. + + Used by workflows that construct raw ``ChatOpenAI`` / + ``ChatAnthropic`` outside the kernel's ``llm_fallback_chain`` config + (swarm profiles, heavy_mode provider_chain attempts, SWE / apodex + bases, sdk_cli's dev factory). Without this wrap the per-call + ``usage`` event's ``provider`` field stays empty, blocking + cross-vendor billing attribution. + + No-op for duck-typed test stubs that don't implement the native + ``LLMClient`` dispatch surface — the chain routes through ``chat`` / + ``stream``, so a stub lacking those would crash the call. The quiet + skip keeps tests stable while still wrapping every real LLM instance + in production. + """ + if not provider: + return llm + if not (hasattr(llm, "chat") and hasattr(llm, "stream")): + return llm + return LLMFallbackChain( + entries=[ + FallbackEntry(model=llm, triggers=(), provider=provider), + ], + ) diff --git a/agent_core/providers/nonblocking_stream.py b/agent_core/providers/nonblocking_stream.py new file mode 100644 index 0000000..d6b405d --- /dev/null +++ b/agent_core/providers/nonblocking_stream.py @@ -0,0 +1,160 @@ +"""Non-blocking text-stream wrapper — console output must never wedge the +event loop. + +Background (2026-06-06 heavy_mode wedge): the swarm Rich console writes +to stderr synchronously while holding Rich's internal ``RLock``. When the +downstream pipe reader stalls and the 64KB pipe buffer fills, the event +loop thread blocks forever inside ``write()`` — every coroutine stops, +LLM responses rot unread in kernel buffers (CLOSE_WAIT pile-up), and the +process is alive-but-dead for the task's whole wall clock. + +:class:`NonBlockingStream` decouples producers from the pipe: ``write()`` +enqueues onto a bounded queue and returns immediately; a daemon writer +thread drains the queue into the real stream. If the pipe stalls, the +*writer thread* blocks (harmless) and, once the queue fills, further +writes are dropped and counted instead of blocking the caller. A drop +notice is emitted when the pipe recovers. + +Console/diagnostic output is best-effort by contract — losing log lines +under backpressure is acceptable; losing the engine is not. +""" + +from __future__ import annotations + +import atexit +import queue +import sys +import threading +from typing import TextIO + +__all__ = ["NonBlockingStream", "nonblocking_stderr"] + +_SENTINEL: object = object() + + +class NonBlockingStream: + """Write-only text stream whose ``write()`` never blocks the caller. + + Implements the subset of the file protocol Rich's ``Console`` (and + plain ``print(file=...)``) touch: ``write`` / ``flush`` / ``isatty`` + / ``encoding`` / ``fileno`` / ``closed``. + """ + + def __init__(self, target: TextIO, *, max_queue: int = 2000) -> None: + self._target = target + self._q: queue.Queue[object] = queue.Queue(maxsize=max_queue) + self._dropped_total = 0 # cumulative drops + self._dropped_reported = 0 # drops already covered by a notice + self._dropped_lock = threading.Lock() + self._thread = threading.Thread( + target=self._drain, + name="nonblocking-stream-writer", + daemon=True, + ) + self._thread.start() + atexit.register(self._shutdown) + + # ── file protocol ───────────────────────────────────────────────── + + def write(self, s: str) -> int: + try: + self._q.put_nowait(s) + except queue.Full: + # Pipe stalled long enough to back up the whole queue — + # drop instead of blocking. Counted and reported on recovery. + with self._dropped_lock: + self._dropped_total += 1 + return len(s) + + def flush(self) -> None: + """No-op — flushing is the writer thread's job. Never blocks.""" + + def isatty(self) -> bool: + try: + return self._target.isatty() + except Exception: + return False + + def fileno(self) -> int: + return self._target.fileno() + + @property + def encoding(self) -> str: + return getattr(self._target, "encoding", "utf-8") + + @property + def closed(self) -> bool: + return getattr(self._target, "closed", False) + + @property + def dropped(self) -> int: + """Cumulative writes dropped due to backpressure (tests/metrics).""" + with self._dropped_lock: + return self._dropped_total + + # ── writer thread ───────────────────────────────────────────────── + + def _drain(self) -> None: + while True: + item = self._q.get() + if item is _SENTINEL: + break + # Batch what's already queued so one syscall+flush covers a + # burst (9-run fan-out turns produce write storms). Bounded: + # an unbounded race against producers would keep the queue + # forever empty (no backpressure → no drops → unbounded + # memory growth while the pipe is stalled). + parts: list[str] = [item] if isinstance(item, str) else [] + try: + while len(parts) < 256: + nxt = self._q.get_nowait() + if nxt is _SENTINEL: + self._write_parts(parts) + return + if isinstance(nxt, str): + parts.append(nxt) + except queue.Empty: + pass + self._write_parts(parts) + + def _write_parts(self, parts: list[str]) -> None: + notice = "" + with self._dropped_lock: + unreported = self._dropped_total - self._dropped_reported + if unreported: + notice = ( + f"\n[nonblocking-stream] pipe backpressure: " + f"dropped {unreported} writes\n" + ) + self._dropped_reported = self._dropped_total + try: + if notice: + self._target.write(notice) + self._target.write("".join(parts)) + self._target.flush() + except Exception: + # Broken pipe / closed target: keep consuming the queue so + # producers never back up — output is best-effort. + pass + + def _shutdown(self) -> None: + """atexit: best-effort drain so tail output isn't lost on clean exit.""" + try: + self._q.put_nowait(_SENTINEL) + except queue.Full: + return + self._thread.join(timeout=1.0) + + +_stderr_wrapper: NonBlockingStream | None = None +_stderr_wrapper_lock = threading.Lock() + + +def nonblocking_stderr() -> NonBlockingStream: + """Process-wide non-blocking wrapper around ``sys.stderr`` (singleton).""" + global _stderr_wrapper + if _stderr_wrapper is None: + with _stderr_wrapper_lock: + if _stderr_wrapper is None: + _stderr_wrapper = NonBlockingStream(sys.stderr) + return _stderr_wrapper diff --git a/agent_core/providers/openai_chat.py b/agent_core/providers/openai_chat.py new file mode 100644 index 0000000..03e78c9 --- /dev/null +++ b/agent_core/providers/openai_chat.py @@ -0,0 +1,550 @@ +"""OpenAI-compatible LLMClient — wraps :class:`openai.AsyncOpenAI`. + +Works against the OpenAI API and any OpenAI-Chat-Completions-compatible +endpoint (vLLM, SGLang, OpenRouter, etc.). For Anthropic, +use ``infra.anthropic_client.AnthropicClient`` (separate file, separate SDK). + +Replaces ``langchain_openai.ChatOpenAI``. The wire-shape choices below +(``stream:false`` explicit, ``with_raw_response.create().parse()``, per-call +``timeout`` only when explicitly passed) are byte-aligned with LangChain's +ChatOpenAI request — see migration gotchas #3/#4. Do not "simplify" them. +""" + +from __future__ import annotations + +# pyright: basic, reportPrivateImportUsage=false +import logging +from collections.abc import AsyncIterator, Callable +from typing import Any + +from openai import AsyncOpenAI, BadRequestError + +from agent_core.llm import LLMClient, LLMResponse, StreamDelta +from agent_core.messages import Message, ToolCall, for_wire +from agent_core.runtime.llm_request_overrides import ( + current_thinking_retry_override, +) + +type SessionQueryResolver = Callable[[dict[str, str] | None], dict[str, str]] +type SessionScopeResolver = Callable[[], str] + + +def _no_session_query(_headers: dict[str, str] | None) -> dict[str, str]: + return {} + + +_session_query_resolver: SessionQueryResolver = _no_session_query +_session_scope_resolver: SessionScopeResolver | None = None + + +def configure_session_query_resolver(resolver: SessionQueryResolver | None) -> None: + """Configure the host affinity adapter used by subsequently built clients.""" + global _session_query_resolver + _session_query_resolver = resolver or _no_session_query + + +def configure_session_scope_resolver(resolver: SessionScopeResolver | None) -> None: + """Configure the host task scope used to reject stale cached affinity.""" + global _session_scope_resolver + _session_scope_resolver = resolver + +logger = logging.getLogger(__name__) + +# Stands in for a missing/empty tool_call function name. Mirrors the platform +# llm-worker's sanitize (packages/llm-worker/internal/worker/sanitize.go): the +# EAS "LLM intelligent routing" gateway 400s deterministically ("can only +# concatenate str (not \"NoneType\") to str") on an assistant history message +# whose tool_call carries an empty function.name, while the same bytes sent +# straight to the backend return 200. Models do emit such tool_calls now and +# then and the loop replays history verbatim, so we repair on the way out. +_PLACEHOLDER_TOOL_NAME = "unknown_tool" + + +def _sanitize_tool_call_names(messages: list[Message]) -> list[Message]: + """Rewrite empty tool_call function names before the wire (copy-on-write). + + Rewrite — never drop: the paired ``role:"tool"`` result points at this + call's ``tool_call_id``, and an orphaned tool result trips a *different* + chat-template failure upstream. A meaningless name costs the model a + little context; a broken message chain costs the whole request — and once + the malformed call is in history, every later turn of the task fails. + + Conservative and non-mutating, in the style of the workflow-level + normalizers: anything that doesn't parse as the expected shape is left + alone, and the input list itself is returned when nothing needed fixing. + """ + out: list[Message] | None = None + fixed = 0 + for i, msg in enumerate(messages): + if not isinstance(msg, dict): + continue + calls = msg.get("tool_calls") + if not isinstance(calls, list) or not calls: + continue + new_calls: list[Any] | None = None + for j, tc in enumerate(calls): + if not isinstance(tc, dict): + continue + fn = tc.get("function") + if not isinstance(fn, dict): + continue + name = fn.get("name") + if isinstance(name, str) and name: + continue + if new_calls is None: + new_calls = list(calls) + new_calls[j] = {**tc, "function": {**fn, "name": _PLACEHOLDER_TOOL_NAME}} + fixed += 1 + if new_calls is not None: + if out is None: + out = list(messages) + out[i] = {**msg, "tool_calls": new_calls} + if out is not None: + logger.warning( + "repaired %d tool_call(s) with an empty function.name before send " + "— the upstream LLM router 400s on these", + fixed, + ) + return out + return messages + + +def _unsupported_reasoning_effort(exc: Exception) -> bool: + """Whether ``exc`` is a gateway rejecting the requested reasoning effort.""" + message = str(exc).lower() + return "reasoning_effort" in message and ( + "unsupported" in message or "not support" in message + ) + + +def _demoted_reasoning_effort(model: str | None, current: Any) -> str | None: + """Return the next wire effort to try, or ``None`` when there is none. + + The gpt-5 family advertises ``none`` but a given gateway may still reject + it, and ``low`` is the next enum those models do accept (``minimal`` is + not in gpt-5.1/5.5's set). Every other model/effort pair stays + single-attempt. ``workflows/heavy_mode/reporter_v2/report_llm.py`` runs + the same policy inside the heavy reporter's own adapter, scoped there to + the model that reporter ships with. + """ + value = str(current or "").strip().lower() + normalized = (model or "").strip().lower().rsplit("/", 1)[-1] + if normalized.startswith("gpt-5") and value == "none": + return "low" + return None + + +class OpenAIClient(LLMClient): + """Thin async wrapper. Honors the standard OpenAI knobs (``temperature``, + ``max_completion_tokens``, ``tools``, ``stream``); per-call ``extra_headers`` + are forwarded for proxies needing custom auth or session affinity.""" + + def __init__( + self, + model: str, + *, + api_key: str | None = None, + base_url: str | None = None, + temperature: float | None = None, + max_completion_tokens: int | None = None, + timeout: float | None = None, + default_headers: dict[str, str] | None = None, + extra_body: dict[str, Any] | None = None, + session_query_resolver: SessionQueryResolver | None = None, + session_scope_resolver: SessionScopeResolver | None = None, + sdk_default_query: bool = True, + ) -> None: + self.model = model + self.default_temperature = temperature + self.default_max_tokens = max_completion_tokens + self.default_timeout = timeout + self.extra_body = extra_body or {} + self._session_query_resolver = session_query_resolver or _session_query_resolver + self._session_scope_resolver = ( + session_scope_resolver or _session_scope_resolver + ) + self._default_session_scope = ( + self._session_scope_resolver() + if self._session_scope_resolver is not None + else "" + ) + self._default_session_query = self._session_query_resolver(default_headers) + # Some OpenAI-compatible gateways reject ``stream_options`` with a 400. + # We probe optimistically and flip this off on first rejection so the + # stream still runs (streaming usage then reads 0 for that gateway). + self._stream_options_supported = True + # Per-call timeout is enforced by the agent loop's + # ``asyncio.wait_for`` wrapper, not by the SDK. + # + # EAS UCH affinity hashes the URL *query parameter*, not the header + # (see ``mirror_session_query``) — mirror a construction-time session + # header into ``default_query`` so it rides every request's URL. + self._client = AsyncOpenAI( + api_key=api_key, + base_url=base_url or None, + timeout=timeout, + default_headers=default_headers, + # A task-derived query cannot live in the SDK defaults: cached + # clients may outlive the task that constructed them. Static + # affinity (no construction scope) remains safe to install here. + default_query=( + self._default_session_query or None + if sdk_default_query and not self._default_session_scope + else None + ), + max_retries=0, # retries are owned by the runtime loop + ) + + def _effective_extra_body(self) -> dict[str, Any]: + """Return the profile body plus this task's one-attempt override. + + Only mutate thinking knobs the profile explicitly configured. This + keeps strict OpenAI endpoints from receiving SGLang-only fields while + letting Qwen/Apodex retries lower or disable thinking without changing + the cached client shared by concurrent tasks. + """ + body = dict(self.extra_body) + override = current_thinking_retry_override() + if override is None: + return body + + configured_template = body.get("chat_template_kwargs") + if isinstance(configured_template, dict): + template = dict(configured_template) + if override.disabled: + template["enable_thinking"] = False + # ``preserve_thinking`` controls how prior/interleaved + # reasoning is rendered, not whether this generation may + # think. Keep the profile value while hard-disabling only + # the current generation. + template.pop("thinking_budget", None) + elif template.get("enable_thinking") is True: + if override.thinking_budget is not None: + template["thinking_budget"] = int( + override.thinking_budget, + ) + body["chat_template_kwargs"] = template + + # Some OpenAI-compatible reasoning proxies use a top-level request + # body field instead of SGLang chat-template kwargs. Override it only + # when the profile already opted into that dialect. + if "reasoning_effort" in body and override.reasoning_effort: + body["reasoning_effort"] = override.reasoning_effort + + return body + + def _session_query(self, extra_headers: dict[str, str] | None) -> dict[str, str]: + """Resolve per-call affinity, falling back to construction-time affinity.""" + per_call = self._session_query_resolver(extra_headers) + if per_call: + return per_call + fallback = self._default_session_query + if ( + fallback + and self._default_session_scope + and self._session_scope_resolver is not None + and self._session_scope_resolver() != self._default_session_scope + ): + logger.debug( + "Dropping stale session-affinity query built for scope %r", + self._default_session_scope, + ) + return {} + return fallback + + def _demote_reasoning_effort(self, exc: Exception) -> bool: + """Sticky one-way downgrade after a rejected ``reasoning_effort``. + + Same self-healing shape as ``_stream_options_supported``: some + OpenAI-compatible gateways advertise an effort enum they then 400 on, + and without this the caller's only signal is a hard request failure. + Flipping ``self.extra_body`` means the whole client pays the probe once + instead of on every call. Returns whether the caller should retry. + """ + if not _unsupported_reasoning_effort(exc): + return False + current = self.extra_body.get("reasoning_effort") + demoted = _demoted_reasoning_effort(self.model, current) + if demoted is None: + return False + self.extra_body = {**self.extra_body, "reasoning_effort": demoted} + logger.warning( + "Gateway rejected reasoning_effort=%s for %s; falling back to %s " + "for this client. %s", + current, self.model, demoted, exc, + ) + return True + + # ── Non-streaming ──────────────────────────────────────────────────── + + async def chat( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> LLMResponse: + kwargs: dict[str, Any] = { + "model": self.model, + "messages": for_wire(_sanitize_tool_call_names(list(messages))), + "stream": False, + } + if tools: + kwargs["tools"] = tools + kwargs["parallel_tool_calls"] = True + eff_temp = temperature if temperature is not None else self.default_temperature + if eff_temp is not None: + kwargs["temperature"] = eff_temp + eff_mt = max_tokens if max_tokens is not None else self.default_max_tokens + if eff_mt is not None: + kwargs["max_completion_tokens"] = eff_mt + if extra_headers: + kwargs["extra_headers"] = extra_headers + # EAS UCH affinity keys on the URL query parameter (the header + # alone never pins an upstream replica) — mirror the per-call + # session id into ``extra_query``; per-call wins over any + # construction-time ``default_query``. + session_query = self._session_query(extra_headers) + if session_query: + kwargs["extra_query"] = session_query + extra_body = self._effective_extra_body() + if extra_body: + kwargs["extra_body"] = extra_body + # Per-call ``timeout`` triggers ``x-stainless-read-timeout`` + # header; only set when the caller explicitly passed one. + if timeout is not None: + kwargs["timeout"] = timeout + + raw_response = await self._create(kwargs) + raw = raw_response.parse() + return _to_llm_response(raw) + + async def _create(self, kwargs: dict[str, Any]) -> Any: + """Open a non-streaming completion, retrying once past a rejected effort.""" + create = self._client.chat.completions.with_raw_response.create + try: + return await create(**kwargs) + except Exception as exc: + if not self._demote_reasoning_effort(exc): + raise + return await create(**{**kwargs, "extra_body": self._effective_extra_body()}) + + # ── Streaming ──────────────────────────────────────────────────────── + + async def stream( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> AsyncIterator[StreamDelta]: + kwargs: dict[str, Any] = { + "model": self.model, + "messages": for_wire(_sanitize_tool_call_names(list(messages))), + "stream": True, + } + if tools: + kwargs["tools"] = tools + kwargs["parallel_tool_calls"] = True + eff_temp = temperature if temperature is not None else self.default_temperature + if eff_temp is not None: + kwargs["temperature"] = eff_temp + eff_mt = max_tokens if max_tokens is not None else self.default_max_tokens + if eff_mt is not None: + kwargs["max_completion_tokens"] = eff_mt + if extra_headers: + kwargs["extra_headers"] = extra_headers + # EAS UCH affinity keys on the URL query parameter (the header + # alone never pins an upstream replica) — mirror the per-call + # session id into ``extra_query``; per-call wins over any + # construction-time ``default_query``. + session_query = self._session_query(extra_headers) + if session_query: + kwargs["extra_query"] = session_query + extra_body = self._effective_extra_body() + if extra_body: + kwargs["extra_body"] = extra_body + if timeout is not None: + kwargs["timeout"] = timeout + elif self.default_timeout is not None: + kwargs["timeout"] = self.default_timeout + + async for chunk in await self._open_stream(kwargs): + chunk_usage = _usage_dict(getattr(chunk, "usage", None)) + chunk_model = getattr(chunk, "model", "") or "" + if not chunk.choices: + # Terminal ``include_usage`` chunk: empty ``choices`` but carries + # the final token usage. Forward it (was previously dropped, so + # streaming usage/billing read 0). + if chunk_usage or chunk_model: + yield StreamDelta(usage=chunk_usage, model=chunk_model) + continue + choice = chunk.choices[0] + delta = choice.delta + yield StreamDelta( + content=getattr(delta, "content", None) or "", + reasoning_content=_reasoning_text(delta), + tool_call_deltas=_tool_call_deltas(getattr(delta, "tool_calls", None)), + finish_reason=getattr(choice, "finish_reason", None) or "", + model=chunk_model, + usage=chunk_usage, + ) + + async def _open_stream(self, kwargs: dict[str, Any]) -> Any: + """Open the stream, retrying once past a rejected ``reasoning_effort``.""" + try: + return await self._open_stream_once(kwargs) + except Exception as exc: + if not self._demote_reasoning_effort(exc): + raise + return await self._open_stream_once( + {**kwargs, "extra_body": self._effective_extra_body()}, + ) + + async def _open_stream_once(self, kwargs: dict[str, Any]) -> Any: + """Open the streaming completion, requesting ``stream_options.include_usage`` + so the terminal usage chunk arrives. Some OpenAI-compatible gateways + reject ``stream_options`` with a 400 — on first rejection we disable it + for this client and retry once without it (the stream still runs; + streaming usage just reads 0 for that gateway, as it did before usage + forwarding existed).""" + if self._stream_options_supported: + try: + return await self._client.chat.completions.create( + stream_options={"include_usage": True}, **kwargs, + ) + except BadRequestError as exc: + if "stream_options" not in str(exc).lower(): + raise + self._stream_options_supported = False + logger.warning( + "Gateway rejected stream_options.include_usage; disabling " + "it for this client (streaming token usage will read 0). %s", + exc, + ) + return await self._client.chat.completions.create(**kwargs) + + +# ── Adapters ───────────────────────────────────────────────────────────── + + +def _reasoning_text(obj: Any) -> str: + """Pull a model's thinking-channel text off a streamed ``delta`` or a + completed ``message``. + + OpenAI-compatible endpoints disagree on the field name: SGLang / DeepSeek + use ``reasoning_content``, while OpenRouter-style proxies (incl. + ``api.miromind.site``) surface it as ``reasoning``. We accept either so the + thinking channel survives whichever gateway is in front of the model. + """ + return ( + getattr(obj, "reasoning_content", None) + or getattr(obj, "reasoning", None) + or "" + ) + + +def _usage_dict(usage: Any) -> dict[str, int]: + """Normalise an OpenAI ``usage`` object into the wire-shape token dict. + + Shared by the non-streaming ``_to_llm_response`` and the streaming + assembler (the terminal ``include_usage`` chunk carries the same shape). + """ + out: dict[str, int] = {} + if not usage: + return out + for k in ("prompt_tokens", "completion_tokens", "total_tokens"): + v = getattr(usage, k, None) + if v is not None: + out[k] = int(v) + # Prompt-caching surfaced under ``usage.prompt_tokens_details.cached_tokens`` + # on modern OpenAI / OpenRouter responses. + ptd = getattr(usage, "prompt_tokens_details", None) + cached = getattr(ptd, "cached_tokens", None) if ptd is not None else None + if cached is not None: + out["cached_tokens"] = int(cached) + # Thinking tokens, under ``usage.completion_tokens_details.reasoning_tokens``. + # Billed inside ``completion_tokens``, so the totals were always right, but + # ``extract_usage`` reads a FLAT ``reasoning_tokens`` key that nothing filled + # here — every reasoning model behind this client (deepseek / qwen / apodex, + # i.e. the whole chat-completions path) reported 0 and cost attribution could + # not separate thinking from output. The sibling adapters already do this + # (``anthropic_client._anthropic_reasoning_tokens``, + # ``openai_responses_client`` off ``output_tokens_details``); this one was the + # gap. Absent details (some gateways send ``null``) leave the key unset and + # ``extract_usage`` zero-fills it. + ctd = getattr(usage, "completion_tokens_details", None) + reasoning = getattr(ctd, "reasoning_tokens", None) if ctd is not None else None + if reasoning is not None: + out["reasoning_tokens"] = int(reasoning) + return out + + +def _to_llm_response(raw: Any) -> LLMResponse: + """Convert an OpenAI ``ChatCompletion`` into an :class:`LLMResponse`.""" + choices = getattr(raw, "choices", None) + if not choices: + raise ValueError( + "OpenAI-compatible response has no choices " + f"(response_id={getattr(raw, 'id', '')!r}, " + f"model={getattr(raw, 'model', '')!r})", + ) + choice = choices[0] + msg = choice.message + tool_calls: list[ToolCall] = [] + for tc in (getattr(msg, "tool_calls", None) or []): + tool_calls.append({ + "type": "function", + "id": tc.id, + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments or "{}", + }, + }) + usage_dict = _usage_dict(getattr(raw, "usage", None)) + content = getattr(msg, "content", "") or "" + # Mirror the streaming path (llm_client._stream_llm_response): a Qwen + # ``\n\n`` separator remnant is either the whole of ``content`` + # (whitespace-only → drop) or leads the real answer (``\n\nAnswer…`` → + # lstrip). It carries no user-visible meaning and doubles the separator + # when ``thinking_in_history`` reconstructs the turn. + if isinstance(content, str) and content: + content = content.lstrip() if content.strip() else "" + return LLMResponse( + content=content, + tool_calls=tool_calls, + reasoning_content=_reasoning_text(msg), + finish_reason=getattr(choice, "finish_reason", "") or "", + model=getattr(raw, "model", "") or "", + usage=usage_dict, + response_metadata={"id": getattr(raw, "id", "")}, + ) + + +def _tool_call_deltas(raw: Any) -> list[dict[str, Any]]: + if not raw: + return [] + out: list[dict[str, Any]] = [] + for tc in raw: + out.append({ + "index": getattr(tc, "index", 0), + "id": getattr(tc, "id", None), + "name": getattr(getattr(tc, "function", None), "name", None), + "arguments": getattr(getattr(tc, "function", None), "arguments", None), + }) + return out + + +__all__ = [ + "OpenAIClient", + "SessionQueryResolver", + "SessionScopeResolver", + "configure_session_query_resolver", + "configure_session_scope_resolver", +] diff --git a/agent_core/providers/openai_responses.py b/agent_core/providers/openai_responses.py new file mode 100644 index 0000000..ae49f60 --- /dev/null +++ b/agent_core/providers/openai_responses.py @@ -0,0 +1,427 @@ +"""OpenAI **Responses API** LLMClient — wraps :class:`openai.AsyncOpenAI`. + +The Responses API (``client.responses.create``) is a different wire protocol +from Chat Completions: it takes a typed ``input`` item list (messages, +``function_call`` / ``function_call_output``, and — the reason this client +exists — ``reasoning`` items that carry the model's protected reasoning as +opaque ``encrypted_content``). Requesting +``include=["reasoning.encrypted_content"]`` + ``store=False`` keeps the flow +stateless and lets us re-send the reasoning items on the next turn so the model +continues from the same reasoning state (the Responses-API analog of Anthropic +extended-thinking signature replay). Function tools + reasoning are supported +here (the Chat Completions endpoint 400s on that combo). + +Selected via a profile ``protocol: responses``. Reasoning is returned as a +typed block list, so the agent loop runs this client NON-STREAMING (see +``agent_loop`` protocol gate) — the verbatim reasoning items (incl. +``encrypted_content``) only survive intact on the non-streaming response, and +:class:`~agent_core.runtime.loop.model_profile.DefaultThinkingParser` +(``content_block`` format) keeps them as ``raw_content_blocks``. + +LIVE-VERIFIED 2026-07-09 against a real Responses endpoint (gpt-5.5 via the +miromind proxy): single-turn reasoning capture AND multi-turn +``encrypted_content`` replay through a tool call both round-trip correctly. The +pure conversion/parse helpers are additionally unit-tested against mock +payloads. See ``temp/2026-07-09_reasoning-protocol-live-verification.md`` and +``scripts/protocol_smoke/live_reasoning_smoke.py``. +""" + +from __future__ import annotations + +# pyright: basic, reportPrivateImportUsage=false +import logging +from collections.abc import AsyncIterator +from typing import Any + +from openai import AsyncOpenAI + +from agent_core.llm import LLMClient, LLMResponse, StreamDelta +from agent_core.messages import Message, ToolCall, text_of + +logger = logging.getLogger(__name__) + + +class OpenAIResponsesClient(LLMClient): + """OpenAI Responses API adapter with encrypted-reasoning round-trip.""" + + def __init__( + self, + model: str, + *, + api_key: str | None = None, + base_url: str | None = None, + temperature: float | None = None, + max_output_tokens: int | None = None, + timeout: float | None = None, + default_headers: dict[str, str] | None = None, + reasoning: dict[str, Any] | None = None, + store: bool = False, + ) -> None: + self.model = model + self.default_temperature = temperature + self.default_max_tokens = max_output_tokens + self.default_timeout = timeout + # ``reasoning`` = {effort?, summary?}. ``summary="auto"`` makes the + # response carry a readable reasoning summary; without it the reasoning + # item's summary array is empty (encrypted_content only). + self._reasoning = reasoning or None + self._store = store + self._client = AsyncOpenAI( + api_key=api_key, + base_url=base_url or None, + timeout=timeout, + default_headers=default_headers, + max_retries=0, + ) + + def _build_kwargs( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None, + temperature: float | None, + max_tokens: int | None, + extra_headers: dict[str, str] | None, + timeout: float | None, + ) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "model": self.model, + "input": _to_responses_input(messages), + # Client-side reasoning round-trip: ask for encrypted_content and + # keep the exchange stateless (no server-side response storage). + "include": ["reasoning.encrypted_content"], + "store": self._store, + } + if tools: + kwargs["tools"] = _to_responses_tools(tools) + if self._reasoning: + kwargs["reasoning"] = self._reasoning + eff_temp = temperature if temperature is not None else self.default_temperature + if eff_temp is not None: + kwargs["temperature"] = eff_temp + eff_mt = max_tokens if max_tokens is not None else self.default_max_tokens + if eff_mt is not None: + kwargs["max_output_tokens"] = eff_mt + if extra_headers: + kwargs["extra_headers"] = extra_headers + if timeout is not None: + kwargs["timeout"] = timeout + elif self.default_timeout is not None: + kwargs["timeout"] = self.default_timeout + return kwargs + + async def chat( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> LLMResponse: + kwargs = self._build_kwargs( + messages, tools=tools, temperature=temperature, + max_tokens=max_tokens, extra_headers=extra_headers, timeout=timeout, + ) + raw = await self._client.responses.create(**kwargs) + return _parse_responses_output(raw) + + async def stream( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> AsyncIterator[StreamDelta]: + # Provided for Protocol conformance. The agent loop forces this client + # non-streaming (reasoning blocks / encrypted_content only survive on + # the non-streaming response), so this path is not used to preserve + # verbatim reasoning — it maps the Responses semantic events to + # ``StreamDelta`` for any caller that streams anyway. + kwargs = self._build_kwargs( + messages, tools=tools, temperature=temperature, + max_tokens=max_tokens, extra_headers=extra_headers, timeout=timeout, + ) + kwargs["stream"] = True + stream = await self._client.responses.create(**kwargs) + async for event in stream: + etype = getattr(event, "type", "") + if etype == "response.output_text.delta": + yield StreamDelta(content=getattr(event, "delta", "") or "") + elif etype in ( + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", + ): + yield StreamDelta(reasoning_content=getattr(event, "delta", "") or "") + elif etype == "response.completed": + resp = getattr(event, "response", None) + usage = _responses_usage_dict(getattr(resp, "usage", None)) + yield StreamDelta( + usage=usage, + model=getattr(resp, "model", "") or "", + finish_reason="stop", + ) + + +# ── Conversion helpers (pure — unit-tested) ──────────────────────────────── + + +def _get(obj: Any, key: str, default: Any = None) -> Any: + """Read ``key`` off an SDK object (attr) or a plain dict.""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def _to_responses_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Chat-Completions ``{type:function, function:{name,description,parameters}}`` + → Responses flat ``{type:function, name, description, parameters}``.""" + out: list[dict[str, Any]] = [] + for t in tools: + fn = t.get("function") or t + out.append({ + "type": "function", + "name": fn.get("name", ""), + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + }) + return out + + +def _to_responses_input(messages: list[Message]) -> list[dict[str, Any]]: + """Convert Chat-Completions ``Message``s into a Responses ``input`` list. + + An assistant turn saved verbatim (``content`` is the block list + ``_parse_responses_output`` produced) is replayed in the PROVIDER'S OWN + ITEM ORDER — reasoning, text, and ``function_call`` items interleaved + exactly as they came back. The Responses API binds a ``reasoning`` item to + the output item that FOLLOWS it, so a turn that produced + ``reasoning → call_a → reasoning → call_b`` must not be replayed as + ``reasoning, reasoning, call_a, call_b``: that hands the model a different + reasoning-to-call pairing than the one it generated under. + + Adjacent ``text`` blocks are still joined into a single ``message`` item — + that is a concatenation within one position, not a reordering. + + A turn whose content is a plain string (or a block list carrying no + ``function_call`` items — history written before the parser recorded them) + falls back to appending the calls from ``Message.tool_calls`` after the + text, which is the best order recoverable from that shape. Tool results + become ``function_call_output`` items keyed by ``call_id``. + """ + items: list[dict[str, Any]] = [] + for m in messages: + role = m.get("role") + if role == "tool": + items.append({ + "type": "function_call_output", + "call_id": m.get("tool_call_id", ""), + "output": text_of(m.get("content", "")), + }) + continue + if role == "assistant": + raw = m.get("content") + pending_text: list[str] = [] + saw_function_call = False + + def _flush_text(buf: list[str] = pending_text) -> None: + body = "\n".join(p for p in buf if p) + buf.clear() + if body: + items.append({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": body}], + }) + + if isinstance(raw, list): + for block in raw: + if not isinstance(block, dict): + continue + bt = block.get("type") + if bt == "reasoning": + _flush_text() + item: dict[str, Any] = {"type": "reasoning"} + if block.get("id"): + item["id"] = block["id"] + item["summary"] = block.get("summary") or [] + if block.get("encrypted_content"): + item["encrypted_content"] = block["encrypted_content"] + items.append(item) + elif bt == "text": + pending_text.append(block.get("text", "") or "") + elif bt == "function_call": + call = _to_responses_function_call(block) + if call is not None: + _flush_text() + items.append(call) + saw_function_call = True + else: + pending_text.append(text_of(raw or "")) + _flush_text() + if not saw_function_call: + for tc in m.get("tool_calls", []) or []: + call = _to_responses_function_call(tc) + if call is not None: + items.append(call) + continue + # system / user + items.append({"role": role or "user", "content": text_of(m.get("content", ""))}) + return items + + +def _to_responses_function_call(src: Any) -> dict[str, Any] | None: + """Build a Responses ``function_call`` item from a verbatim block or a + Chat-Completions ``tool_calls`` entry. + + Accepts both shapes: a recorded block carries ``call_id`` / ``name`` / + ``arguments`` flat, while a ``tool_calls`` entry nests them under + ``function``. Returns ``None`` for a call missing an id or a name instead of + raising — this runs while replaying DURABLE history, so a ``KeyError`` on a + truncated call would not fail one request but every request for the rest of + the session. ``arguments`` is passed through as the raw string the API + expects; it is never parsed here. + """ + if not isinstance(src, dict): + return None + fn = src.get("function") + fn = fn if isinstance(fn, dict) else {} + call_id = src.get("call_id") or src.get("id") or "" + name = src.get("name") or fn.get("name") or "" + if not call_id or not name: + logger.warning( + "skipping malformed tool_call in Responses conversion " + "(call_id=%r, name=%r)", call_id, name, + ) + return None + arguments = src.get("arguments") + if arguments is None: + arguments = fn.get("arguments") + return { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments or "{}", + } + + +def _parse_responses_output(raw: Any) -> LLMResponse: + """Parse a Responses API result into an :class:`LLMResponse`. + + ``content`` is kept as a verbatim block list (reasoning items incl. + ``encrypted_content`` + text blocks) so the ``content_block`` thinking + parser preserves them as ``raw_content_blocks`` for faithful replay. + """ + blocks_out: list[dict[str, Any]] = [] + text_parts: list[str] = [] + summary_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + + for item in (_get(raw, "output", None) or []): + itype = _get(item, "type", "") + if itype == "reasoning": + summary = _get(item, "summary", None) or [] + norm_summary: list[Any] = [] + for s in summary: + stext = _get(s, "text", None) + if isinstance(s, str): + norm_summary.append({"type": "summary_text", "text": s}) + summary_parts.append(s) + elif stext is not None: + norm_summary.append({"type": "summary_text", "text": stext}) + summary_parts.append(stext) + block: dict[str, Any] = {"type": "reasoning", "summary": norm_summary} + if _get(item, "id", None): + block["id"] = _get(item, "id") + ec = _get(item, "encrypted_content", None) + if ec: + block["encrypted_content"] = ec + blocks_out.append(block) + elif itype == "message": + for part in (_get(item, "content", None) or []): + if _get(part, "type", "") == "output_text": + txt = _get(part, "text", "") or "" + text_parts.append(txt) + blocks_out.append({"type": "text", "text": txt}) + elif itype == "function_call": + call_id = _get(item, "call_id", "") or _get(item, "id", "") or "" + call_name = _get(item, "name", "") or "" + call_args = _get(item, "arguments", "") or "{}" + tool_calls.append({ + "id": call_id, + "type": "function", + "function": {"name": call_name, "arguments": call_args}, + }) + # Also recorded IN PLACE in the verbatim block list. The Responses + # API pairs each ``reasoning`` item with the output item that + # follows it, so replay has to know where the calls sat relative to + # the reasoning — ``tool_calls`` alone loses that, and appending + # them after the reasoning items re-pairs a multi-call turn wrongly. + # ``model_profile``'s content_block parser ignores block types it + # does not know and keeps the list verbatim, so this rides along + # without affecting visible text or thinking extraction. + blocks_out.append({ + "type": "function_call", + "call_id": call_id, + "name": call_name, + "arguments": call_args, + }) + + # Prefer the top-level ``output_text`` convenience string when the SDK + # provides it (it is the concatenation of message output_text parts). + flat_text = _get(raw, "output_text", None) + if flat_text and not text_parts: + text_parts.append(flat_text) + blocks_out.append({"type": "text", "text": flat_text}) + + content: Any = blocks_out if blocks_out else "\n".join(text_parts) + return LLMResponse( + content=content, + tool_calls=tool_calls, + reasoning_content="\n".join(summary_parts), + finish_reason=_get(raw, "status", "") or "", + model=_get(raw, "model", "") or "", + usage=_responses_usage_dict(_get(raw, "usage", None)), + response_metadata={"id": _get(raw, "id", "")}, + ) + + +def _responses_usage_dict(usage: Any) -> dict[str, int]: + """Normalise a Responses ``usage`` object into the wire-shape token dict. + + Responses reports ``input_tokens`` / ``output_tokens`` (not prompt/ + completion), reasoning tokens under ``output_tokens_details.reasoning_tokens``, + and cache reads under ``input_tokens_details.cached_tokens``.""" + out: dict[str, int] = {} + if not usage: + return out + inp = _get(usage, "input_tokens", None) + outp = _get(usage, "output_tokens", None) + total = _get(usage, "total_tokens", None) + if inp is not None: + out["prompt_tokens"] = int(inp) + if outp is not None: + out["completion_tokens"] = int(outp) + if total is not None: + out["total_tokens"] = int(total) + itd = _get(usage, "input_tokens_details", None) + cached = _get(itd, "cached_tokens", None) if itd is not None else None + if cached is not None: + out["cached_tokens"] = int(cached) + otd = _get(usage, "output_tokens_details", None) + reasoning = _get(otd, "reasoning_tokens", None) if otd is not None else None + # ``is not None``, not truthiness: an explicit ``reasoning_tokens: 0`` is a + # provider ASSERTING it spent no reasoning tokens, which is different from + # omitting the field (unknown). Collapsing the two is the ambiguity the + # sibling ``cached_tokens`` handling above — and ``openai_chat._usage_dict`` + # — exist to avoid. + if reasoning is not None: + out["reasoning_tokens"] = int(reasoning) + return out + + +__all__ = ["OpenAIResponsesClient"] diff --git a/agent_core/providers/prompt_cache.py b/agent_core/providers/prompt_cache.py new file mode 100644 index 0000000..0c13f53 --- /dev/null +++ b/agent_core/providers/prompt_cache.py @@ -0,0 +1,189 @@ +"""Anthropic prompt-cache adapter — opt-in cache_control injection. + +Anthropic's Messages API supports prompt caching but the feature is +**opt-in**: you must mark cacheable spans with ``cache_control: +{"type": "ephemeral"}``. Without it, every request bills the full +prompt as fresh input tokens (~$3/MTok on Claude Sonnet) instead of +the cache-hit rate (~$0.30/MTok). + +This wrapper sits between the agent loop and the upstream Claude +client (Anthropic direct OR Claude via OpenRouter). It mutates the +leading ``system`` message ``{"content": str, "role": "system"}`` into +a single content-block form that carries ``cache_control: ephemeral`` +so the system prompt span gets cached. Subsequent calls within the +cache TTL (5 min on Anthropic) that share the same system prompt land +on cache-read, billing at the discounted rate. + +Wire by calling :func:`maybe_wrap_for_prompt_cache(client, provider, +model)` in the LLM construction site (``build_aux_llm`` / +``create_swarm_llm``). The helper short-circuits to ``client`` when +the model isn't Claude-family, so callers don't need to gate. + +Scope notes: + +- **Only the system message is cached today.** Tool definitions and + long prior-turn tool messages are also cacheable but require deeper + request-payload hooks; deferred. +- **No effect on non-Anthropic providers.** OpenAI / qwen / etc. just + see the plain string content (both shapes are accepted on the wire). +- **Transparent under tool binding.** The native loop binds tools via + ``_BoundLLM`` (``bind_tools``) *outside* the client, threading them + through the per-call ``tools=`` kwarg. This adapter also exposes its + own :meth:`AnthropicPromptCacheAdapter.bind_tools` that returns a + fresh adapter carrying a default ``tools=`` payload — keeping the + cache wrapper in front of the tool-augmented call. Caller-supplied + ``tools`` override the bound default, matching native ``chat(..., + tools=...)`` precedence. +""" + +from __future__ import annotations + +# pyright: basic, reportPrivateImportUsage=false +import logging +from collections.abc import AsyncIterator +from typing import Any + +from agent_core.llm import LLMResponse, StreamDelta +from agent_core.messages import Message + +logger = logging.getLogger(__name__) + + +class AnthropicPromptCacheAdapter: + """:class:`~agent_core.llm.LLMClient` wrapper that converts the + leading ``system`` message ``{"content": str}`` into a single + content-block with ``cache_control: ephemeral`` before delegating to + the inner Claude client. See module docstring. + """ + + def __init__( + self, inner: Any, *, bound_tools: list[dict[str, Any]] | None = None, + ) -> None: + self.inner = inner + self._bound_tools = bound_tools + + @property + def model(self) -> str: + return getattr(self.inner, "model", "") or "" + + def bind_tools(self, tools: Any) -> AnthropicPromptCacheAdapter: + """Bind a default ``tools=`` payload, keeping the cache adapter on + the *outside* so the tool-augmented call still goes out with the + cache-marked system prompt. + + Native ``LLMClient`` carries no langchain ``RunnableBinding`` — + tools are simply threaded through the per-call ``tools=`` kwarg. + We therefore carry the bound payload here and merge it into + :meth:`chat` / :meth:`stream` (caller-supplied ``tools`` win, to + match native ``chat(..., tools=...)`` precedence). Returns a fresh + adapter; the shared inner client is never mutated. + """ + schemas = [ + t.to_openai_schema() if hasattr(t, "to_openai_schema") else t + for t in tools + ] + return AnthropicPromptCacheAdapter(inner=self.inner, bound_tools=schemas) + + async def chat( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> LLMResponse: + mutated = _inject_cache_control(messages) + return await self.inner.chat( + mutated, + tools=tools if tools is not None else self._bound_tools, + temperature=temperature, + max_tokens=max_tokens, + extra_headers=extra_headers, + timeout=timeout, + ) + + def stream( + self, + messages: list[Message], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> AsyncIterator[StreamDelta]: + mutated = _inject_cache_control(messages) + return self.inner.stream( + mutated, + tools=tools if tools is not None else self._bound_tools, + temperature=temperature, + max_tokens=max_tokens, + extra_headers=extra_headers, + timeout=timeout, + ) + + +def _inject_cache_control( + messages: list[Message], +) -> list[Message]: + """Mutate the first ``system`` message so its content carries + ``cache_control: ephemeral``. Returns a NEW list — original + messages are untouched (callers may reuse the list for retries + where the unmutated form needs to remain intact).""" + out: list[Message] = [] + sys_marked = False + for msg in messages: + content = msg.get("content") + if ( + not sys_marked + and msg.get("role") == "system" + and isinstance(content, str) + and content + ): + new_msg: Message = { + "content": [{ + "type": "text", + "text": content, + "cache_control": {"type": "ephemeral"}, + }], + "role": "system", + } + out.append(new_msg) + sys_marked = True + else: + out.append(msg) + return out + + +def _is_claude_family(provider: str, model: str) -> bool: + """Return True when the upstream is Anthropic-Messages-API-shaped + (Anthropic direct, OpenRouter forwarding to Anthropic, or any + proxy that preserves ``cache_control`` passthrough).""" + model_lower = (model or "").lower() + provider_lower = (provider or "").lower() + if provider_lower == "anthropic": + return True + # Claude via openrouter / new_api / any proxy that keeps the + # Anthropic message shape. We gate on model name rather than just + # provider so a non-Anthropic provider serving Claude (e.g. a + # custom relay) still benefits. + return "claude" in model_lower + + +def maybe_wrap_for_prompt_cache( + client: Any, *, provider: str, model: str, +) -> Any: + """Wrap ``client`` with :class:`AnthropicPromptCacheAdapter` when + the upstream is Claude-family. Returns ``client`` unchanged + otherwise — callers can use this in a single line without gating.""" + if _is_claude_family(provider, model): + return AnthropicPromptCacheAdapter(inner=client) + return client + + +__all__ = [ + "AnthropicPromptCacheAdapter", + "maybe_wrap_for_prompt_cache", +] diff --git a/agent_core/providers/protocol_client.py b/agent_core/providers/protocol_client.py new file mode 100644 index 0000000..0779a24 --- /dev/null +++ b/agent_core/providers/protocol_client.py @@ -0,0 +1,250 @@ +"""Per-profile ``protocol`` → native LLM client selection. + +Workflow LLM builders read ``llm.protocol`` and, for the reasoning-capturing +protocols, hand off here instead of building the default ``OpenAIClient``: + +- ``anthropic`` → native Anthropic Messages API + extended thinking + (:class:`~agent_core.providers.anthropic.AnthropicClient`). +- ``responses`` → OpenAI Responses API + encrypted reasoning + (:class:`~agent_core.providers.openai_responses.OpenAIResponsesClient`). +- ``chat_completions`` (default / anything else) → returns ``None`` so the + caller keeps its existing Chat Completions ``OpenAIClient`` path unchanged. + +Both reasoning protocols return content as a typed BLOCK LIST → the model +profile's ``thinking_format`` is ``content_block`` (see +:func:`thinking_format_for_protocol`) so the parser keeps the verbatim blocks +(signatures / encrypted_content) for faithful multi-turn replay. +""" + +from __future__ import annotations + +# pyright: basic, reportPrivateImportUsage=false +import logging +from typing import Any, get_args + +from agent_core.llm import LLMClient + +# Single source of truth for the valid ``llm.protocol`` values. Duplicating the +# set here would let the two drift, which is how a protocol becomes buildable +# but has no ``thinking_format`` (or vice versa). +from agent_core.runtime.loop.model_profile import WireProtocol, is_wire_protocol + +logger = logging.getLogger(__name__) + +# Anthropic's floor for ``thinking.budget_tokens`` on the legacy ``enabled`` +# shape. The API also requires ``budget_tokens < max_tokens``, so the two +# constraints together make any ``max_tokens <= 1024`` unsatisfiable. +_MIN_THINKING_BUDGET = 1024 + + +def protocol_of(cfg: dict[str, Any]) -> str: + """Normalised ``llm.protocol`` (default ``chat_completions``). + + An unusable value — wrong type, or a string that names no known protocol — + normalises to ``chat_completions`` and is LOGGED. The value comes from + hand-written YAML, and a typo (``anthropc``) otherwise degrades in total + silence: :func:`build_protocol_client` returns ``None``, + :func:`thinking_format_for_protocol` returns ``None``, and the profile ends + up on a Chat Completions client with tag-format thinking. Requests keep + succeeding while the signed reasoning is quietly dropped, and nothing points + at the profile. An ABSENT or empty value is the documented default and is + not logged. + """ + raw = cfg.get("protocol") + if raw is None or (isinstance(raw, str) and not raw.strip()): + return "chat_completions" + if isinstance(raw, str): + lowered = raw.lower() + if is_wire_protocol(lowered): + return lowered + logger.warning( + "unknown llm.protocol %r for model %r; falling back to " + "chat_completions. Valid values: %s", + raw, cfg.get("model", "?"), ", ".join(sorted(get_args(WireProtocol))), + ) + return "chat_completions" + + +def provider_label(cfg: dict[str, Any]) -> str: + """Usage-attribution provider label for a profile ``llm`` block. + + Prefers the explicit ``_provider_label`` / ``provider``; otherwise DERIVES + it from ``protocol`` so native reasoning profiles that omit ``provider`` + aren't mislabelled ``openai`` in billing/usage traces: ``anthropic`` → + ``anthropic``, ``bedrock`` → ``bedrock``, ``responses`` / ``chat_completions`` + → ``openai`` (both are OpenAI-wire).""" + explicit = cfg.get("_provider_label") or cfg.get("provider") + if explicit: + return str(explicit) + proto = protocol_of(cfg) + if proto == "anthropic": + return "anthropic" + if proto == "bedrock": + return "bedrock" + return "openai" + + +def thinking_format_for_protocol(protocol: str) -> str | None: + """``content_block`` for the reasoning protocols, else ``None`` (caller + falls back to explicit YAML / model-id inference).""" + return ( + "content_block" + if protocol in ("anthropic", "responses", "bedrock") + else None + ) + + +def _effort_str(cfg: dict[str, Any]) -> str: + effort = cfg.get("effort") + return effort.strip() if isinstance(effort, str) else "" + + +def _build_anthropic(cfg: dict[str, Any], *, bedrock: bool = False) -> LLMClient: + """Native Anthropic Messages API with extended thinking. + + Responses carry thinking + signature blocks, kept verbatim + (raw_content_blocks) and replayed unmodified. ``temperature`` is OMITTED + (the client drops it when thinking is on). ``base_url`` posts to + ``{base_url}/v1/messages`` (direct) or ``{base_url}/model/{id}/invoke`` + (``bedrock=True``, AWS Bedrock runtime, Bearer API-key auth + the + ``anthropic_version`` body stamp). Optional ``effort`` → + ``output_config.effort``. + + ``thinking_type`` selects the request shape (default ``adaptive``). Live- + verified against api.anthropic.com + Bedrock 2026-07-09 (see + ``temp/2026-07-09_reasoning-protocol-live-verification.md``); matches the + official matrix at platform.claude.com/docs/en/build-with-claude/adaptive-thinking: + + - ``adaptive`` (DEFAULT) — the RECOMMENDED mode for all current Claude + (Opus 4.6/4.7/4.8, Sonnet 4.6/5, Fable/Mythos), and the ONLY mode on the + newest (Opus 4.7/4.8, Sonnet 5) — ``enabled`` is rejected there with 400. + Emits ``thinking={"type":"adaptive"}`` + ``thinking_display`` (default + ``summarized`` so the readable thinking text is captured; the newest + models default ``display`` to ``omitted`` = empty ``thinking`` field with + the ``signature`` still present for replay). ``effort`` is forwarded only + in this mode (it is an adaptive-only knob; the oldest models 400 on + ``enabled``+effort). + - ``enabled`` — LEGACY opt-in for models older than Opus 4.6 / Sonnet 4.6 + (Sonnet 4.5, Opus 4.5, …), which reject ``adaptive`` with 400. Emits + ``thinking={"type":"enabled","budget_tokens":N}`` (``N`` from + ``thinking_budget_tokens``, default 8192, clamped to ``[1024, max_tokens-1]`` + since Anthropic requires ``budget_tokens < max_tokens``). When the + configured ``max_tokens`` is too small for the 1024 floor, ``max_tokens`` + is RAISED to ``budget + 1`` rather than emitting an invalid pair — see + :func:`_enabled_thinking_budget`. ``effort`` is + NOT sent (budget_tokens is the control knob here; oldest models 400 on it). + Deprecated on Opus 4.6 / Sonnet 4.6 per Anthropic. + """ + from agent_core.providers.anthropic import AnthropicClient + + max_tokens = int(cfg.get("max_tokens", 32768)) + ttype = str(cfg.get("thinking_type", "adaptive")).strip().lower() + if ttype == "enabled": + budget, max_tokens = _enabled_thinking_budget( + int(cfg.get("thinking_budget_tokens", 8192)), max_tokens, + ) + thinking: dict[str, Any] = {"type": "enabled", "budget_tokens": budget} + effort = "" + else: + thinking = {"type": "adaptive"} + display = cfg.get("thinking_display", "summarized") + if isinstance(display, str) and display.strip(): + thinking["display"] = display.strip() + effort = _effort_str(cfg) + return AnthropicClient( + model=cfg["model"], + api_key=cfg.get("api_key", "dummy"), + base_url=cfg.get("base_url") or None, + max_tokens=max_tokens, + thinking=thinking, + effort=effort, + bedrock=bedrock, + ) + + +def _enabled_thinking_budget(budget: int, max_tokens: int) -> tuple[int, int]: + """Reconcile ``thinking.budget_tokens`` with ``max_tokens``. + + Anthropic imposes two constraints on the legacy ``enabled`` thinking shape: + ``budget_tokens >= 1024`` and ``budget_tokens < max_tokens``. They are + jointly unsatisfiable whenever ``max_tokens <= 1024``, so a naive + ``max(1024, min(budget, max_tokens - 1))`` clamp silently emits a pair the + API rejects — ``max_tokens=512`` produced ``budget_tokens=1024``, i.e. a + budget LARGER than the response cap, and a 400 on every call. + + The floor wins, because it is the API's own minimum and cannot be + negotiated: when ``max_tokens`` cannot accommodate it, ``max_tokens`` is + raised to ``budget + 1`` and the adjustment logged. Returns the + ``(budget, max_tokens)`` pair to send. + """ + budget = max(_MIN_THINKING_BUDGET, min(budget, max_tokens - 1)) + if budget >= max_tokens: + raised = budget + 1 + logger.warning( + "thinking_type=enabled requires budget_tokens (%d) < max_tokens, " + "but max_tokens is %d; raising max_tokens to %d. Set max_tokens " + "above %d in the profile to silence this.", + budget, max_tokens, raised, _MIN_THINKING_BUDGET, + ) + max_tokens = raised + return budget, max_tokens + + +def _build_responses(cfg: dict[str, Any], title: str) -> LLMClient: + """OpenAI Responses API with encrypted reasoning. + + ``reasoning`` is built from ``effort`` + ``reasoning_summary`` (default + ``auto`` → the response carries a readable reasoning summary; opt out with + ``reasoning_summary: ""`` or set ``reasoning: {...}`` verbatim). The client + always sends ``include=['reasoning.encrypted_content']`` + ``store=False``. + ``temperature`` is only sent when the profile sets it (reasoning models + reject non-default values). + """ + from agent_core.providers.openai_responses import OpenAIResponsesClient + + reasoning = cfg.get("reasoning") + if reasoning is None: + reasoning = {} + if cfg.get("effort"): + reasoning["effort"] = cfg["effort"] + summary = cfg.get("reasoning_summary", "auto") + if isinstance(summary, str) and summary.strip(): + reasoning["summary"] = summary.strip() + return OpenAIResponsesClient( + model=cfg["model"], + api_key=cfg.get("api_key", "dummy"), + base_url=cfg.get("base_url"), + temperature=cfg.get("temperature"), + max_output_tokens=cfg.get("max_tokens", 32768), + default_headers={"X-Title": title}, + reasoning=reasoning or None, + store=False, + ) + + +def build_protocol_client(cfg: dict[str, Any], *, title: str) -> LLMClient | None: + """Build the native client for ``cfg['protocol']``. + + Returns ``None`` for ``chat_completions`` (the caller builds its usual + ``OpenAIClient``); an :class:`AnthropicClient` / :class:`OpenAIResponsesClient` + otherwise. ``title`` is the ``X-Title`` header stamped on Responses calls. + + An unrecognised ``protocol`` also lands here as ``chat_completions`` → + ``None``; :func:`protocol_of` is what logs it. + """ + protocol = protocol_of(cfg) + if protocol == "anthropic": + return _build_anthropic(cfg) + if protocol == "bedrock": + return _build_anthropic(cfg, bedrock=True) + if protocol == "responses": + return _build_responses(cfg, title) + return None + + +__all__ = [ + "build_protocol_client", + "protocol_of", + "provider_label", + "thinking_format_for_protocol", +] diff --git a/agent_core/runtime/loop/_streaming.py b/agent_core/runtime/loop/_streaming.py index 4367e3a..3643867 100644 --- a/agent_core/runtime/loop/_streaming.py +++ b/agent_core/runtime/loop/_streaming.py @@ -243,6 +243,13 @@ async def _stream_llm_response( # wrapper — folded into ``response_metadata`` below so # streamed calls carry billing attribution like the non-streaming path. final_provider = "" + # Verbatim provider content blocks, when the provider sends them (Anthropic + # extended thinking). Signed ``thinking`` / opaque ``redacted_thinking`` + # blocks cannot be reconstructed from the flattened text channels, so when + # present these become the assembled ``content`` — matching what the + # non-streaming path returns and what ``thinking_format="content_block"`` + # needs to replay the signed reasoning state on the next turn. + final_reasoning_blocks: list[dict[str, Any]] = [] think_splitter = _ThinkTagSplitter() accepts_tool_call_chunks = _accepts_tool_call_arg_chunks(on_delta) reasoning_timeout_s = max(float(reasoning_only_timeout_s or 0), 0.0) @@ -307,8 +314,14 @@ def _assembled_response() -> LLMResponse: logger.warning( "dropped %d streamed tool_call(s) with no function name", dropped, ) + # A provider that sent a verbatim block list wins over the flattened + # string: the blocks carry the thinking signatures, and the text blocks + # inside them hold the same visible text ``accumulated`` collected. The + # lstrip/think-tag normalisation above is deliberately NOT applied to + # them — replay requires the bytes Anthropic signed, unmodified. + assembled_content: Any = final_reasoning_blocks or visible_content return LLMResponse( - content=visible_content, + content=assembled_content, tool_calls=complete_tool_calls, reasoning_content=thinking_accum, usage=final_usage, @@ -342,6 +355,8 @@ async def _close_chunk_stream() -> None: final_model = delta.model if getattr(delta, "provider", ""): final_provider = delta.provider + if getattr(delta, "reasoning_blocks", None): + final_reasoning_blocks = delta.reasoning_blocks # Inline ``...`` tags (Qwen-style) are # split out so ``delta`` carries answer-only text and # ``thinking_delta`` collects both inline + typed diff --git a/agent_core/runtime/pause_check.py b/agent_core/runtime/pause_check.py new file mode 100644 index 0000000..f34662d --- /dev/null +++ b/agent_core/runtime/pause_check.py @@ -0,0 +1,87 @@ +"""Portable task-status pause checks for long-running agent loops.""" + +from __future__ import annotations + +import inspect +import logging +from collections.abc import Awaitable, Callable, Mapping +from typing import Any, cast + +from agent_core.types import TaskStatus + +logger = logging.getLogger(__name__) + +type PauseCheckFn = Callable[[], Awaitable[bool]] +type TaskStatusLoader = Callable[[str], Any | Awaitable[Any]] +_STOP_STATUSES = frozenset({TaskStatus.SUSPENDED, TaskStatus.ABORTED}) + + +def make_task_pause_check( + task_id: str, + load_status: TaskStatusLoader, + *, + missing_exceptions: tuple[type[BaseException], ...] = (), +) -> PauseCheckFn: + """Build a safe loop pause hook around a host-supplied status loader. + + ``load_status`` may return a status directly, a MAPPING with a ``status`` + key (the common shape when a host hands back a DB row as a dict), or an + object with a ``status`` attribute — synchronously or asynchronously. + Missing synthetic sub-runs are treated as active; other backend failures are + logged and also fail open. + """ + + async def _check() -> bool: + try: + loaded = load_status(task_id) + value = await loaded if inspect.isawaitable(loaded) else loaded + except missing_exceptions: + return False + except Exception as exc: + logger.warning("pause_check: load_status(%s) failed: %s", task_id, exc) + return False + status = _status_of(value) + if status is not None and status in _STOP_STATUSES: + logger.info( + "pause_check: task %s is %s; pausing the loop", task_id, status, + ) + return True + return False + + return _check + + +def _status_of(value: Any) -> str | None: + """Extract the status from whatever the host's loader returned. + + The Mapping branch comes FIRST and is not optional: a loader that returns a + row as ``{"status": "suspended"}`` used to fall through to the bare value, + which is a dict and therefore never matches a status — so a suspended or + aborted task kept running indefinitely, silently. + + Returns ``None`` when nothing status-like is present. Narrowing to ``str`` + also keeps the membership test total: ``TaskStatus`` is a ``StrEnum`` (so a + plain ``"suspended"`` compares equal to the member and needs no conversion), + while an unhashable leftover — a status-less dict, say — would otherwise + raise ``TypeError`` from ``in`` and turn a fail-open check into a crash. + """ + if isinstance(value, Mapping): + candidate = cast(Mapping[str, Any], value).get("status") + else: + candidate = getattr(value, "status", value) + return candidate if isinstance(candidate, str) else None + + +def pause_check_from_state(state: Mapping[str, Any] | None) -> PauseCheckFn | None: + """Read a host-injected pause hook from ``state.metadata``.""" + if not state: + return None + metadata_value: object = state.get("metadata") + if not isinstance(metadata_value, Mapping): + return None + metadata = cast(Mapping[str, object], metadata_value) + pause_check: object = metadata.get("pause_check") + return cast(PauseCheckFn, pause_check) if callable(pause_check) else None + + +__all__ = ["PauseCheckFn", "TaskStatusLoader", "make_task_pause_check", "pause_check_from_state"] diff --git a/agent_core/runtime/registries/workflows.py b/agent_core/runtime/registries/workflows.py new file mode 100644 index 0000000..97a51b6 --- /dev/null +++ b/agent_core/runtime/registries/workflows.py @@ -0,0 +1,96 @@ +"""WorkflowContext — registration interface for external workflow plugins. + +External workflows receive a WorkflowContext in their ``register()`` +function. It exposes only the registration APIs needed to wire up a +pipeline — no direct access to internal kernel services. + +Usage (inside a workflow package):: + + from agent_core.runtime.registries.workflows import WorkflowContext + + def register(ctx: WorkflowContext) -> None: + ctx.register_pipeline(MY_SPEC) + ctx.register_agent(MY_AGENT_DEF) +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from agent_core.models.agent_definition import AgentDefinition + from agent_core.models.pipeline_spec import PipelineSpec + from agent_core.runtime.registries.agents import AgentRegistry + from agent_core.scheduling.pipeline_registry import PipelineRegistry + from agent_core.scheduling.topology_registry import ( + TopologyFactory, + TopologyRegistry, + ) + +logger = logging.getLogger(__name__) + + +class WorkflowContext: + """Safe registration facade passed to workflow ``register()`` hooks.""" + + def __init__( + self, + pipeline_registry: PipelineRegistry, + agent_registry: AgentRegistry, + topology_registry: TopologyRegistry | None = None, + ) -> None: + self._pipelines = pipeline_registry + self._agents = agent_registry + self._topologies = topology_registry + + # -- Pipeline registration ------------------------------------------------ + + def register_pipeline(self, spec: PipelineSpec) -> None: + """Register a PipelineSpec so it can be selected at runtime.""" + if self._pipelines.has(spec.pipeline_id): + raise ValueError( + f"Pipeline '{spec.pipeline_id}' is already registered; " + "plugin registration cannot override existing pipelines" + ) + self._pipelines.register(spec) + logger.info( + "Workflow plugin registered pipeline: %s", spec.pipeline_id, + ) + + # -- Agent registration --------------------------------------------------- + + def register_agent(self, definition: AgentDefinition) -> None: + """Register a custom AgentDefinition (role, prompt, tools).""" + self._agents.register(definition) + + def register_agents(self, definitions: list[AgentDefinition]) -> None: + """Convenience: register multiple AgentDefinitions at once.""" + for defn in definitions: + self.register_agent(defn) + + # -- Topology registration ----------------------------------------------- + + def register_topology(self, name: str, factory: TopologyFactory) -> None: + """Register a topology factory — ``(options, role_tiers) -> PipelineSpec``. + + Noops if no ``TopologyRegistry`` was provided (unit-test paths that + don't wire the full runtime). Workflows relying on dynamic topology + dispatch should pass one in from bootstrap. + """ + if self._topologies is None: + logger.debug( + "register_topology(%s) skipped — no TopologyRegistry wired", + name, + ) + return + self._topologies.register(name, factory) + logger.info("Workflow plugin registered topology factory: %s", name) + + # -- Introspection (read-only) -------------------------------------------- + + def has_pipeline(self, pipeline_id: str) -> bool: + return self._pipelines.has(pipeline_id) + + def has_agent(self, role_id: str) -> bool: + return self._agents.has(role_id) diff --git a/agent_core/scheduling/topology_registry.py b/agent_core/scheduling/topology_registry.py index f0ebe95..06767eb 100644 --- a/agent_core/scheduling/topology_registry.py +++ b/agent_core/scheduling/topology_registry.py @@ -11,14 +11,15 @@ from __future__ import annotations import logging -from collections.abc import Callable +from collections.abc import Callable, Mapping +from typing import Any from agent_core.models.pipeline_spec import PipelineSpec logger = logging.getLogger(__name__) -TopologyFactory = Callable[[dict, dict], PipelineSpec] +TopologyFactory = Callable[[Mapping[str, Any], Mapping[str, str]], PipelineSpec] class TopologyRegistry: @@ -44,8 +45,8 @@ def has(self, name: str) -> bool: def build( self, name: str, - options: dict | None = None, - role_tiers: dict | None = None, + options: Mapping[str, Any] | None = None, + role_tiers: Mapping[str, str] | None = None, ) -> PipelineSpec: try: factory = self._factories[name] diff --git a/docs/provider-substrate-boundary.md b/docs/provider-substrate-boundary.md new file mode 100644 index 0000000..bf0f16f --- /dev/null +++ b/docs/provider-substrate-boundary.md @@ -0,0 +1,24 @@ +# Provider substrate boundary + +AgentCore owns the provider-neutral physical transports and wrappers used by +all host products: + +- OpenAI-compatible Chat Completions, including streamed usage, malformed tool + name repair, reasoning-effort fallback, and request-override handling; +- OpenAI Responses with encrypted reasoning replay; +- Anthropic Messages and Bedrock, including signed thinking replay; +- fallback chains, prompt-cache decoration, protocol selection, and + non-blocking diagnostic streams. + +Hosts continue to own provider catalogs, credentials, endpoint selection, +deployment-specific headers, billing meters, traces, and UI-facing provider +metadata. Session affinity is supplied to `OpenAIClient` through a +`SessionQueryResolver`; AgentCore never interprets a host header by itself. + +The SDK response boundary is intentionally dynamic. Third-party OpenAI and +Anthropic response classes vary by SDK and compatible gateway, so those files +use basic Pyright checking locally while the public constructors, AgentCore +messages, and the rest of the runtime remain strict. + +Task pause checks follow the same rule: AgentCore owns safe polling semantics, +while a host injects the task-status loader and its missing-task exception. diff --git a/pyproject.toml b/pyproject.toml index 178c27b..e58aeab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,14 @@ version = "0.1.0" description = "Shared, product-neutral runtime primitives for Apodex agents" readme = "README.md" requires-python = ">=3.12" -dependencies = ["jinja2>=3.1", "pydantic>=2.0", "pyyaml>=6.0"] +dependencies = [ + "anthropic[bedrock]>=0.69", + "httpx>=0.27,<1", + "jinja2>=3.1", + "openai>=1.0", + "pydantic>=2.0", + "pyyaml>=6.0", +] [project.optional-dependencies] tokenizer = ["tiktoken>=0.12.0"] diff --git a/tests/test_llm_runtime_stream_metadata.py b/tests/test_llm_runtime_stream_metadata.py index 6b47b03..c6816fd 100644 --- a/tests/test_llm_runtime_stream_metadata.py +++ b/tests/test_llm_runtime_stream_metadata.py @@ -304,3 +304,84 @@ async def _on_delta( }, }, ] + + +@pytest.mark.asyncio +async def test_streaming_verbatim_reasoning_blocks_become_the_content(monkeypatch): + """A provider that sends ``StreamDelta.reasoning_blocks`` wins over the + flattened string. + + Anthropic delivers a thinking block's cryptographic ``signature`` on its own + ``signature_delta`` event, and ``redacted_thinking`` payloads arrive with no + deltas at all — neither can be represented in ``content`` / + ``reasoning_content``. Without this channel a streamed extended-thinking + turn assembled into reasoning that ``thinking_format="content_block"`` could + not replay, silently breaking signed multi-turn continuity (while the + non-streaming path replayed it correctly). + """ + real_sleep = asyncio.sleep + + async def _noop(_): + await real_sleep(0) + + monkeypatch.setattr("asyncio.sleep", _noop) + + blocks = [ + {"type": "thinking", "thinking": "step one", "signature": "sig-abc"}, + {"type": "redacted_thinking", "data": "enc-payload"}, + {"type": "text", "text": "the answer"}, + ] + llm = _StubStreamingLLM(deltas=[ + StreamDelta(reasoning_content="step one"), + StreamDelta(content="the answer"), + StreamDelta( + usage={"prompt_tokens": 10, "completion_tokens": 40, + "reasoning_tokens": 30}, + finish_reason="end_turn", + model="claude-x", + reasoning_blocks=blocks, + ), + ]) + + async def _on_delta(*_): + pass + + result = await call_llm( + llm, [user_msg("hi")], timeout=10, max_retries=1, turn=0, + on_delta=_on_delta, + ) + + assert result is not None + # Verbatim blocks, signature intact — byte-identical to what the provider + # signed. The lstrip / think-tag normalisation is deliberately not applied. + assert result.content == blocks + # The flattened reasoning channel still carries the readable text. + assert result.reasoning_content == "step one" + assert result.finish_reason == "end_turn" + assert result.usage["reasoning_tokens"] == 30 + + +@pytest.mark.asyncio +async def test_streaming_without_reasoning_blocks_keeps_the_flat_string(monkeypatch): + """Providers that send no block list are unaffected.""" + real_sleep = asyncio.sleep + + async def _noop(_): + await real_sleep(0) + + monkeypatch.setattr("asyncio.sleep", _noop) + + llm = _StubStreamingLLM(deltas=[ + StreamDelta(content="hello "), + StreamDelta(content="world"), + StreamDelta(finish_reason="stop", model="gpt-x"), + ]) + async def _on_delta(*_): + pass + + result = await call_llm( + llm, [user_msg("hi")], timeout=10, max_retries=1, turn=0, + on_delta=_on_delta, + ) + assert result is not None + assert result.content == "hello world" diff --git a/tests/test_pause_check_status_shapes.py b/tests/test_pause_check_status_shapes.py new file mode 100644 index 0000000..43042d5 --- /dev/null +++ b/tests/test_pause_check_status_shapes.py @@ -0,0 +1,98 @@ +"""``make_task_pause_check`` must recognise every shape a host loader returns. + +The loader is host-injected (AgentCore ships no task store), so the shape is not +under this repo's control: a status enum, a plain string, an ORM row with a +``.status`` attribute, or — very commonly — a DB row handed back as a dict. The +attribute-only lookup silently missed the mapping case: ``getattr(row, "status", +row)`` returned the dict itself, which never matches a status, so a suspended or +aborted task kept running indefinitely with nothing in the logs. +""" + +from __future__ import annotations + +import logging + +import pytest + +from agent_core.runtime.pause_check import make_task_pause_check +from agent_core.types import TaskStatus + + +class _Row: + def __init__(self, status: object) -> None: + self.status = status + + +@pytest.mark.parametrize( + "value", + [ + TaskStatus.SUSPENDED, + "suspended", + _Row(TaskStatus.SUSPENDED), + _Row("suspended"), + {"status": TaskStatus.SUSPENDED}, + {"status": "suspended"}, + {"status": "aborted"}, + ], + ids=["enum", "str", "row-enum", "row-str", "map-enum", "map-str", "map-aborted"], +) +async def test_stop_statuses_pause_across_every_loader_shape(value): + check = make_task_pause_check("t1", lambda _tid: value) + assert await check() is True + + +@pytest.mark.parametrize( + "value", + [ + TaskStatus.RUNNING, + "running", + _Row("running"), + {"status": "running"}, + {"status": "completed"}, + # No status anywhere — fail open rather than wedge the loop. + {"id": "t1"}, + object(), + None, + ], + ids=["enum", "str", "row", "map", "map-done", "map-no-status", "opaque", "none"], +) +async def test_active_and_unknown_shapes_do_not_pause(value): + check = make_task_pause_check("t1", lambda _tid: value) + assert await check() is False + + +async def test_mapping_pause_is_logged(caplog): + """The old silent miss had no log line to point at the shape mismatch.""" + check = make_task_pause_check("t1", lambda _tid: {"status": "suspended"}) + with caplog.at_level(logging.INFO): + assert await check() is True + assert "pausing the loop" in caplog.text + + +async def test_async_mapping_loader_is_awaited(): + async def _load(_tid): + return {"status": "aborted"} + + assert await make_task_pause_check("t1", _load)() is True + + +async def test_missing_task_exception_fails_open(): + class _Missing(Exception): + pass + + def _load(_tid): + raise _Missing + + check = make_task_pause_check( + "t1", _load, missing_exceptions=(_Missing,), + ) + assert await check() is False + + +async def test_backend_failure_fails_open_and_warns(caplog): + def _load(_tid): + raise RuntimeError("db down") + + with caplog.at_level(logging.WARNING): + assert await make_task_pause_check("t1", _load)() is False + assert "load_status" in caplog.text diff --git a/tests/test_provider_fallback.py b/tests/test_provider_fallback.py new file mode 100644 index 0000000..a70cd6e --- /dev/null +++ b/tests/test_provider_fallback.py @@ -0,0 +1,406 @@ +"""``LLMFallbackChain`` unit tests. + +Cover the failover contract: primary success short-circuits, matching +trigger falls through to the next entry, non-matching trigger +propagates, exhausted chain re-raises the last error, and successful +calls stamp ``fallback_used`` / ``model_actually_used`` onto the +response metadata. +""" +from __future__ import annotations + +from typing import ClassVar + +import pytest + +from agent_core.llm import LLMResponse, StreamDelta +from agent_core.providers.fallback import ( + FallbackEntry, + FallbackTrigger, + LLMFallbackChain, + with_provider_stamp, +) + + +class _ScriptedLLM: + """Native :class:`LLMClient` mock returning a configured answer or raising.""" + + def __init__( + self, + *, + name: str = "scripted", + answer: str = "ok", + raise_exc: BaseException | None = None, + ) -> None: + self.model = name + self.model_name = name + self.answer = answer + self.raise_exc = raise_exc + + async def chat( + self, + messages, + *, + tools=None, + temperature=None, + max_tokens=None, + extra_headers=None, + timeout=None, + ) -> LLMResponse: + if self.raise_exc is not None: + raise self.raise_exc + return LLMResponse(content=self.answer, model=self.model) + + async def stream( + self, + messages, + *, + tools=None, + temperature=None, + max_tokens=None, + extra_headers=None, + timeout=None, + ): + if self.raise_exc is not None: + raise self.raise_exc + from agent_core.llm import StreamDelta + + yield StreamDelta(content=self.answer) + + +class _Status5xxError(Exception): + """Mock provider 5xx error carrying a ``status_code`` attr.""" + + def __init__(self, status: int, msg: str = "server error") -> None: + super().__init__(msg) + self.status_code = status + + +@pytest.mark.asyncio +async def test_primary_success_short_circuits() -> None: + primary = _ScriptedLLM(name="primary", answer="from-primary") + secondary = _ScriptedLLM(name="secondary", answer="from-secondary") + chain = LLMFallbackChain.from_models([primary, secondary]) + + result = await chain.chat([]) + + assert result.content == "from-primary" + assert result.response_metadata["fallback_used"] == 0 + assert result.response_metadata["model_actually_used"] == "primary" + + +@pytest.mark.asyncio +async def test_provider_actually_used_stamped_on_success() -> None: + """When ``FallbackEntry.provider`` is set, the served entry's provider + is stamped on ``response_metadata.provider_actually_used`` so + downstream billing can attribute the call per vendor.""" + primary = _ScriptedLLM(name="primary", answer="ok-primary") + secondary = _ScriptedLLM(name="secondary", answer="ok-secondary") + chain = LLMFallbackChain( + entries=[ + FallbackEntry(model=primary, provider="openai"), + FallbackEntry(model=secondary, provider="anthropic"), + ] + ) + result = await chain.chat([]) + rmd = result.response_metadata + assert rmd["provider_actually_used"] == "openai" + + +@pytest.mark.asyncio +async def test_stream_stamps_provider_on_deltas() -> None: + """Streaming has no ``response_metadata`` channel, so the chain stamps + the served leg's vendor onto each ``StreamDelta.provider`` — the + assembler folds it into the response's metadata downstream. Without + this, streamed calls (the main agent loop) billed against an empty + vendor.""" + primary = _ScriptedLLM(name="primary", answer="streamed") + chain = LLMFallbackChain( + entries=[FallbackEntry(model=primary, provider="apodex")], + ) + providers = [delta.provider async for delta in chain.stream([])] + assert providers and all(p == "apodex" for p in providers) + + +@pytest.mark.asyncio +async def test_provider_actually_used_reflects_fallback_vendor() -> None: + """Cross-provider fallback: primary openai fails, anthropic backup + serves — ``provider_actually_used`` reflects the vendor that actually + answered, not the primary's vendor.""" + primary = _ScriptedLLM( + name="primary", raise_exc=TimeoutError("slow"), + ) + secondary = _ScriptedLLM(name="secondary", answer="from-anthropic") + chain = LLMFallbackChain( + entries=[ + FallbackEntry( + model=primary, triggers=("timeout",), provider="openai", + ), + FallbackEntry( + model=secondary, triggers=(), provider="anthropic", + ), + ] + ) + result = await chain.chat([]) + rmd = result.response_metadata + assert rmd["fallback_used"] == 1 + assert rmd["provider_actually_used"] == "anthropic" + + +@pytest.mark.asyncio +async def test_timeout_triggers_fallback() -> None: + primary = _ScriptedLLM(name="primary", raise_exc=TimeoutError("slow")) + secondary = _ScriptedLLM(name="secondary", answer="from-secondary") + + chain = LLMFallbackChain( + entries=[ + FallbackEntry(model=primary, triggers=("timeout",)), + FallbackEntry(model=secondary, triggers=()), + ] + ) + result = await chain.chat([]) + + assert result.content == "from-secondary" + assert result.response_metadata["fallback_used"] == 1 + assert result.response_metadata["model_actually_used"] == "secondary" + + +@pytest.mark.asyncio +async def test_5xx_status_triggers_fallback() -> None: + primary = _ScriptedLLM( + name="primary", + raise_exc=_Status5xxError(503, "service unavailable"), + ) + secondary = _ScriptedLLM(name="secondary", answer="recovered") + + chain = LLMFallbackChain( + entries=[ + FallbackEntry(model=primary, triggers=("5xx",)), + FallbackEntry(model=secondary, triggers=()), + ] + ) + result = await chain.chat([]) + + assert result.content == "recovered" + assert result.response_metadata["fallback_used"] == 1 + + +@pytest.mark.asyncio +async def test_rate_limit_triggers_fallback_via_message_match() -> None: + primary = _ScriptedLLM( + name="primary", + raise_exc=RuntimeError("Quota exceeded for model gpt-4o"), + ) + secondary = _ScriptedLLM(name="secondary", answer="ok") + + chain = LLMFallbackChain( + entries=[ + FallbackEntry(model=primary, triggers=("rate_limit",)), + FallbackEntry(model=secondary, triggers=()), + ] + ) + result = await chain.chat([]) + assert result.content == "ok" + + +@pytest.mark.asyncio +async def test_non_matching_trigger_propagates() -> None: + """A timeout-only trigger must NOT swallow an unrelated ValueError.""" + primary = _ScriptedLLM(name="primary", raise_exc=ValueError("bad request")) + secondary = _ScriptedLLM(name="secondary", answer="never reached") + + chain = LLMFallbackChain( + entries=[ + FallbackEntry(model=primary, triggers=("timeout",)), + FallbackEntry(model=secondary, triggers=()), + ] + ) + with pytest.raises(ValueError, match="bad request"): + await chain.chat([]) + + +@pytest.mark.asyncio +async def test_bad_request_does_not_trigger_availability_fallback() -> None: + """A deterministic 400 payload defect should be fixed, not rerouted.""" + primary = _ScriptedLLM( + name="primary", + raise_exc=_Status5xxError(400, "zero-length empty document"), + ) + secondary = _ScriptedLLM(name="secondary", answer="must not be used") + chain = LLMFallbackChain( + entries=[ + FallbackEntry( + model=primary, + triggers=("timeout", "rate_limit", "5xx"), + ), + FallbackEntry(model=secondary, triggers=()), + ] + ) + + with pytest.raises(_Status5xxError, match="zero-length"): + await chain.chat([]) + + +@pytest.mark.asyncio +async def test_exhausted_chain_reraises_last_error() -> None: + err1 = TimeoutError("primary slow") + err2 = _Status5xxError(502, "secondary down") + primary = _ScriptedLLM(name="primary", raise_exc=err1) + secondary = _ScriptedLLM(name="secondary", raise_exc=err2) + + chain = LLMFallbackChain( + entries=[ + FallbackEntry(model=primary, triggers=("any_error",)), + FallbackEntry(model=secondary, triggers=("any_error",)), + ] + ) + with pytest.raises(_Status5xxError): + await chain.chat([]) + + +@pytest.mark.asyncio +async def test_any_error_trigger_catches_arbitrary_exception() -> None: + primary = _ScriptedLLM(name="primary", raise_exc=KeyError("oops")) + secondary = _ScriptedLLM(name="secondary", answer="ok") + + chain = LLMFallbackChain.from_models([primary, secondary]) + result = await chain.chat([]) + assert result.content == "ok" + + +def test_empty_entries_list_rejected() -> None: + with pytest.raises(ValueError, match="at least one entry"): + LLMFallbackChain(entries=[]) + + +def test_default_triggers_apply_to_unconfigured_entries() -> None: + primary = _ScriptedLLM(name="primary", answer="ok") + chain = LLMFallbackChain( + entries=[FallbackEntry(model=primary)], + default_triggers=("timeout", "5xx"), + ) + # ``__post_init__`` fills in the default for an entry that left triggers + # UNSET (``None``) — the only shape that means "not configured". + assert chain.entries[0].triggers == ("timeout", "5xx") + + +def test_explicit_empty_triggers_survive_normalisation() -> None: + """``triggers=()`` is a hard barrier, not a missing value. + + ``__post_init__`` must not substitute ``default_triggers`` for it, or an + entry declared as "never fall through" silently falls through on any error. + """ + primary = _ScriptedLLM(name="primary", answer="ok") + chain = LLMFallbackChain( + entries=[FallbackEntry(model=primary, triggers=()), FallbackEntry(model=primary)], + default_triggers=("any_error",), + ) + assert chain.entries[0].triggers == () + assert chain.entries[1].triggers == ("any_error",) + assert not chain.entries[0].matches(Exception("boom")) + + +async def test_mid_chain_barrier_stops_failover() -> None: + """A middle entry with ``triggers=()`` must end the chain, not be skipped.""" + tier1 = _ScriptedLLM(name="tier1", raise_exc=RuntimeError("tier1 down")) + tier2 = _ScriptedLLM(name="tier2", raise_exc=RuntimeError("tier2 down")) + tier3 = _ScriptedLLM(name="tier3", answer="should never be reached") + chain = LLMFallbackChain( + entries=[ + FallbackEntry(model=tier1), + FallbackEntry(model=tier2, triggers=()), + FallbackEntry(model=tier3), + ], + default_triggers=("any_error",), + ) + with pytest.raises(RuntimeError, match="tier2 down"): + await chain.chat([{"role": "user", "content": "hi"}]) + + +def test_standalone_entry_defaults_to_any_error() -> None: + """An unset entry used outside a chain keeps the permissive default.""" + primary = _ScriptedLLM(name="primary", answer="ok") + assert FallbackEntry(model=primary).matches(Exception("boom")) + + +def test_model_name_lists_all_models() -> None: + primary = _ScriptedLLM(name="primary", answer="ok") + secondary = _ScriptedLLM(name="secondary", answer="ok") + chain = LLMFallbackChain.from_models([primary, secondary]) + assert chain.model_name == "llm_fallback_chain[primary,secondary]" + + +def test_fallback_trigger_typing() -> None: + """All built-in trigger keywords must be accepted by ``_trigger_matches``.""" + from agent_core.providers.fallback import _trigger_matches + + triggers: list[FallbackTrigger] = ["timeout", "rate_limit", "5xx", "any_error"] + for trig in triggers: + # ``any_error`` is the only one that matches a generic Exception. + assert _trigger_matches(trig, Exception("x")) == (trig == "any_error") + + +class _KwargsRecordingLLM: + """Native :class:`LLMClient` mock recording the kwargs reaching ``chat``. + + Lets us assert that ``tools=[...]`` survives the trip through + ``LLMFallbackChain`` (the chain forwards ``tools`` to each entry's + ``chat``). Replaces the former langchain ``BaseChatModel`` + + ``Runnable.bind`` mock — the native chain has no ``bind_tools`` / + ``RunnableBinding`` indirection; ``tools`` is a first-class ``chat`` kwarg. + """ + + last_kwargs: ClassVar[dict] = {} + + def __init__(self, name: str = "recorder") -> None: + self.model = name + self.model_name = name + + async def chat( + self, messages, *, tools=None, temperature=None, + max_tokens=None, extra_headers=None, timeout=None, + ) -> LLMResponse: + type(self).last_kwargs = { + k: v for k, v in { + "tools": tools, "temperature": temperature, + "max_tokens": max_tokens, "extra_headers": extra_headers, + "timeout": timeout, + }.items() if v is not None + } + return LLMResponse(content="ok", model=self.model) + + async def stream(self, messages, *, tools=None, **kwargs): + type(self).last_kwargs = {"tools": tools} if tools is not None else {} + yield StreamDelta(content="ok") + + +@pytest.mark.asyncio +async def test_tools_kwarg_reaches_underlying_model() -> None: + """Regression: ``tools=`` must survive the fallback wrapper. + + Native ``LLMFallbackChain.chat`` forwards ``tools`` to each entry's + ``chat``; ``with_provider_stamp`` builds a transparent 1-entry chain, + so a tool-call request through it must still carry the tools. (Replaces + the old langchain ``bind_tools`` / ``RunnableBinding`` kwargs-merge + regression — that indirection no longer exists post-de-langchain; + ``tools`` is a first-class ``chat`` kwarg.) + """ + inner = _KwargsRecordingLLM() + chain = with_provider_stamp(inner, "aliyun") + fake_tools = [ + { + "type": "function", + "function": { + "name": "web_search", + "parameters": {"type": "object"}, + }, + } + ] + + _KwargsRecordingLLM.last_kwargs = {} + await chain.chat([{"content": "hello", "role": "user"}], tools=fake_tools) + + assert "tools" in _KwargsRecordingLLM.last_kwargs, ( + f"tools= dropped through LLMFallbackChain; " + f"saw kwargs={list(_KwargsRecordingLLM.last_kwargs.keys())}" + ) + assert _KwargsRecordingLLM.last_kwargs["tools"] == fake_tools diff --git a/tests/test_provider_native_clients.py b/tests/test_provider_native_clients.py new file mode 100644 index 0000000..f707229 --- /dev/null +++ b/tests/test_provider_native_clients.py @@ -0,0 +1,1122 @@ +"""Native OpenAI/Anthropic LLMClient adapters — request shape + response mapping. + +The SDK is mocked, so these run offline. They pin the wire-shape decisions that +the migration must not regress (``stream:false`` explicit, ``parallel_tool_calls`` +on tool calls, per-call ``timeout`` only when explicitly passed, tool_call +``{type,id,function}`` order, ``cached_tokens`` mapping, Anthropic thinking-block +``signature`` preservation). +""" +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from openai import BadRequestError + +from agent_core.llm import LLMClient, LLMResponse +from agent_core.messages import assistant_msg, system_msg, tool_msg, user_msg +from agent_core.providers import anthropic as ac +from agent_core.providers.openai_chat import OpenAIClient +from agent_core.runtime.llm_request_overrides import ( + ThinkingRetryOverride, + thinking_retry_override, +) +from agent_core.runtime.loop.llm_client import extract_usage + +# ── OpenAI client ────────────────────────────────────────────────────────── + + +def _session_query(headers): + if not headers: + return {} + for key, value in headers.items(): + if key.lower() == "x-upstream-session-id" and value: + return {"x-upstream-session-id": str(value)} + return {} + + +def _mk_openai_client(): + c = OpenAIClient("test-model", api_key="x", session_query_resolver=_session_query) + return c + + +def _fake_completion(): + return SimpleNamespace( + choices=[SimpleNamespace( + message=SimpleNamespace( + content="hello", + tool_calls=[SimpleNamespace( + id="call_1", + function=SimpleNamespace(name="search", arguments='{"q":"x"}'), + )], + reasoning_content="because", + ), + finish_reason="tool_calls", + )], + usage=SimpleNamespace( + prompt_tokens=10, completion_tokens=5, total_tokens=15, + prompt_tokens_details=SimpleNamespace(cached_tokens=3), + ), + model="test-model", + id="resp_1", + ) + + +def test_openai_thinking_retry_override_is_scoped_and_profile_aware(): + original = { + "top_p": 0.95, + "chat_template_kwargs": { + "enable_thinking": True, + "preserve_thinking": True, + }, + } + client = OpenAIClient("test-model", api_key="x", extra_body=original) + + with thinking_retry_override(ThinkingRetryOverride( + mode="reduced", thinking_budget=2048, reasoning_effort="low", + )): + reduced = client._effective_extra_body() + assert reduced["chat_template_kwargs"] == { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 2048, + } + + with thinking_retry_override(ThinkingRetryOverride(mode="disabled")): + disabled = client._effective_extra_body() + assert disabled["chat_template_kwargs"] == { + "enable_thinking": False, + "preserve_thinking": True, + } + + # The cached client/profile is unchanged after both one-request scopes. + assert client.extra_body == original + assert client._effective_extra_body() == original + + # Strict OpenAI profiles without an opted-in thinking dialect receive no + # SGLang-only request fields. + strict = OpenAIClient( + "test-model", api_key="x", extra_body={"top_p": 0.9}, + ) + with thinking_retry_override(ThinkingRetryOverride(mode="disabled")): + assert strict._effective_extra_body() == {"top_p": 0.9} + + +@pytest.mark.asyncio +async def test_openai_chat_sends_budget_preserve_and_disable_overrides(): + client = OpenAIClient( + "test-model", + api_key="x", + extra_body={ + "chat_template_kwargs": { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 8192, + }, + }, + ) + raw_resp = MagicMock() + raw_resp.parse.return_value = _fake_completion() + create = AsyncMock(return_value=raw_resp) + client._client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace( + with_raw_response=SimpleNamespace(create=create), + )), + ) + + await client.chat([user_msg("normal")]) + with thinking_retry_override(ThinkingRetryOverride( + mode="expanded", thinking_budget=12288, reasoning_effort="high", + )): + await client.chat([user_msg("expanded")]) + with thinking_retry_override(ThinkingRetryOverride( + mode="reduced", thinking_budget=2048, reasoning_effort="low", + )): + await client.chat([user_msg("reduced")]) + with thinking_retry_override(ThinkingRetryOverride(mode="disabled")): + await client.chat([user_msg("disabled")]) + await client.chat([user_msg("normal again")]) + + normal, expanded, reduced, disabled, restored = [ + call.kwargs["extra_body"]["chat_template_kwargs"] + for call in create.call_args_list + ] + assert normal == { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 8192, + } + assert expanded == { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 12288, + } + assert reduced == { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 2048, + } + assert disabled == { + "enable_thinking": False, + "preserve_thinking": True, + } + assert restored == normal + + +@pytest.mark.asyncio +async def test_thinking_retry_override_is_isolated_across_concurrent_tasks(): + client = OpenAIClient( + "test-model", + api_key="x", + extra_body={ + "chat_template_kwargs": { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 8192, + }, + }, + ) + both_scoped = asyncio.Event() + scoped_count = 0 + scoped_lock = asyncio.Lock() + + async def effective_body(override): + nonlocal scoped_count + with thinking_retry_override(override): + async with scoped_lock: + scoped_count += 1 + if scoped_count == 2: + both_scoped.set() + await both_scoped.wait() + return client._effective_extra_body()["chat_template_kwargs"] + + reduced, disabled = await asyncio.gather( + effective_body(ThinkingRetryOverride( + mode="reduced", thinking_budget=1024, + )), + effective_body(ThinkingRetryOverride(mode="disabled")), + ) + + assert reduced == { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 1024, + } + assert disabled == { + "enable_thinking": False, + "preserve_thinking": True, + } + assert client._effective_extra_body()["chat_template_kwargs"] == { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 8192, + } + + +@pytest.mark.asyncio +async def test_openai_stream_sends_disabled_thinking_for_one_request_only(): + client = OpenAIClient( + "test-model", + api_key="x", + extra_body={ + "chat_template_kwargs": { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 8192, + }, + }, + ) + request_bodies: list[dict] = [] + + async def fake_stream(): + yield SimpleNamespace( + choices=[SimpleNamespace( + delta=SimpleNamespace( + content="ok", reasoning_content=None, tool_calls=None, + ), + finish_reason="stop", + )], + model="test-model", + usage=None, + ) + + async def create(**kwargs): + request_bodies.append(kwargs["extra_body"]) + return fake_stream() + + client._client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=create)), + ) + + with thinking_retry_override(ThinkingRetryOverride(mode="disabled")): + _ = [delta async for delta in client.stream([user_msg("disabled")])] + _ = [delta async for delta in client.stream([user_msg("restored")])] + + assert request_bodies[0]["chat_template_kwargs"] == { + "enable_thinking": False, + "preserve_thinking": True, + } + assert request_bodies[1]["chat_template_kwargs"] == { + "enable_thinking": True, + "preserve_thinking": True, + "thinking_budget": 8192, + } + + +@pytest.mark.parametrize("choices", [None, []]) +def test_openai_response_rejects_missing_choices_clearly(choices): + """Malformed 200 responses must not leak a cryptic None subscript error.""" + from agent_core.providers.openai_chat import _to_llm_response + + raw = SimpleNamespace(choices=choices, id="bad", model="test-model") + with pytest.raises(ValueError, match="no choices"): + _to_llm_response(raw) + + +@pytest.mark.asyncio +async def test_openai_chat_request_shape_and_mapping(): + c = _mk_openai_client() + raw_resp = MagicMock() + raw_resp.parse.return_value = _fake_completion() + create = AsyncMock(return_value=raw_resp) + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + with_raw_response=SimpleNamespace(create=create), + ))) + + resp = await c.chat( + [system_msg("s"), user_msg("hi")], + tools=[{"type": "function", "function": {"name": "search"}}], + temperature=0.7, + max_tokens=256, + extra_headers={"x-upstream-session-id": "t1"}, + timeout=30, + ) + + kw = create.call_args.kwargs + assert kw["stream"] is False # explicit, byte-aligned + assert kw["parallel_tool_calls"] is True # set when tools present + assert kw["temperature"] == 0.7 + assert kw["max_completion_tokens"] == 256 + assert kw["extra_headers"] == {"x-upstream-session-id": "t1"} + # EAS UCH affinity hashes the URL query parameter, not the header — + # the session id must be mirrored into ``extra_query``. + assert kw["extra_query"] == {"x-upstream-session-id": "t1"} + assert kw["timeout"] == 30 + + assert isinstance(resp, LLMResponse) + assert resp.content == "hello" + assert resp.reasoning_content == "because" + assert resp.finish_reason == "tool_calls" + # tool_call wire key order {type, id, function}; arguments is a JSON string. + assert resp.tool_calls == [{ + "type": "function", "id": "call_1", + "function": {"name": "search", "arguments": '{"q":"x"}'}, + }] + assert list(resp.tool_calls[0].keys()) == ["type", "id", "function"] + assert resp.usage == { + "prompt_tokens": 10, "completion_tokens": 5, + "total_tokens": 15, "cached_tokens": 3, + } + + +def test_openai_session_header_mirrors_into_default_query(): + """Construction-time session header → ``default_query`` on the SDK + client (EAS UCH affinity keys on the URL query parameter; the header + alone never pins a replica). Non-session headers must NOT leak into + the query string.""" + c = OpenAIClient( + "m", api_key="x", + default_headers={"HTTP-Referer": "miroharness", + "x-upstream-session-id": "task-42"}, + session_query_resolver=_session_query, + ) + assert dict(c._client._custom_query) == {"x-upstream-session-id": "task-42"} + + c2 = OpenAIClient("m", api_key="x", + default_headers={"HTTP-Referer": "miroharness"}) + assert not c2._client._custom_query + + # helper semantics: case-insensitive key match, value coerced to str, + # empty/None-safe. + assert _session_query({"X-Upstream-Session-Id": "T"}) == { + "x-upstream-session-id": "T"} + assert _session_query(None) == {} + assert _session_query({"other": "v"}) == {} + + +def test_cached_openai_client_drops_previous_scope_affinity(): + scope = "task-1" + + def current_scope() -> str: + return scope + + c = OpenAIClient( + "m", + api_key="x", + default_headers={"x-upstream-session-id": "task-1"}, + session_query_resolver=_session_query, + session_scope_resolver=current_scope, + ) + # Task-derived affinity is evaluated by AgentCore instead of being frozen + # into the SDK client, so a cached client can safely cross task boundaries. + assert not c._client._custom_query + assert c._session_query(None) == {"x-upstream-session-id": "task-1"} + + scope = "task-2" + assert c._session_query(None) == {} + assert c._session_query({"x-upstream-session-id": "task-2"}) == { + "x-upstream-session-id": "task-2", + } + + +def test_static_openai_affinity_survives_scope_changes(): + c = OpenAIClient( + "m", + api_key="x", + default_headers={"x-upstream-session-id": "static-route"}, + session_query_resolver=_session_query, + session_scope_resolver=lambda: "", + ) + assert dict(c._client._custom_query) == { + "x-upstream-session-id": "static-route", + } + assert c._session_query(None) == { + "x-upstream-session-id": "static-route", + } + + +@pytest.mark.asyncio +async def test_openai_chat_omits_extra_query_without_session_header(): + """extra_headers without a session id must not grow an extra_query.""" + c = _mk_openai_client() + raw_resp = MagicMock() + raw_resp.parse.return_value = _fake_completion() + create = AsyncMock(return_value=raw_resp) + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + with_raw_response=SimpleNamespace(create=create), + ))) + + await c.chat([user_msg("hi")], extra_headers={"X-Title": "t"}) + kw = create.call_args.kwargs + assert kw["extra_headers"] == {"X-Title": "t"} + assert "extra_query" not in kw + + +@pytest.mark.asyncio +async def test_openai_stream_mirrors_session_into_extra_query(): + c = _mk_openai_client() + + async def fake_stream(): + yield SimpleNamespace(choices=[SimpleNamespace( + delta=SimpleNamespace(content="ok", reasoning_content=None, + tool_calls=None))]) + + create = AsyncMock(return_value=fake_stream()) + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + create=create, + ))) + + _ = [d async for d in c.stream( + [user_msg("hi")], + extra_headers={"x-upstream-session-id": "task-7:dag"}, + )] + kw = create.call_args.kwargs + assert kw["extra_headers"] == {"x-upstream-session-id": "task-7:dag"} + assert kw["extra_query"] == {"x-upstream-session-id": "task-7:dag"} + + +@pytest.mark.asyncio +async def test_openai_chat_omits_timeout_and_tools_when_absent(): + c = _mk_openai_client() + raw_resp = MagicMock() + raw_resp.parse.return_value = _fake_completion() + create = AsyncMock(return_value=raw_resp) + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + with_raw_response=SimpleNamespace(create=create), + ))) + + await c.chat([user_msg("hi")]) + kw = create.call_args.kwargs + # No explicit timeout -> no ``x-stainless-read-timeout`` header (gotcha #3). + assert "timeout" not in kw + # No tools -> neither tools nor parallel_tool_calls is sent. + assert "tools" not in kw and "parallel_tool_calls" not in kw + + +@pytest.mark.asyncio +async def test_openai_stream_yields_deltas_and_skips_choiceless_chunks(): + c = _mk_openai_client() + + async def fake_stream(): + yield SimpleNamespace(choices=[SimpleNamespace( + delta=SimpleNamespace(content="he", reasoning_content=None, tool_calls=None))]) + yield SimpleNamespace(choices=[]) # usage-only chunk -> skipped + yield SimpleNamespace(choices=[SimpleNamespace( + delta=SimpleNamespace( + content="llo", reasoning_content="r", + tool_calls=[SimpleNamespace( + index=0, id="c1", + function=SimpleNamespace(name="f", arguments='{}'))]))]) + + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + create=AsyncMock(return_value=fake_stream()), + ))) + + deltas = [d async for d in c.stream([user_msg("hi")])] + assert len(deltas) == 2 # choiceless chunk dropped + assert deltas[0].content == "he" + assert deltas[1].content == "llo" + assert deltas[1].reasoning_content == "r" + assert deltas[1].tool_call_deltas[0] == { + "index": 0, "id": "c1", "name": "f", "arguments": "{}", + } + + +@pytest.mark.asyncio +async def test_openai_stream_forwards_terminal_usage_chunk(): + """The empty-choices ``include_usage`` terminal chunk must surface as a + usage-bearing StreamDelta (was previously dropped → streaming usage 0).""" + c = _mk_openai_client() + + async def fake_stream(): + yield SimpleNamespace( + choices=[SimpleNamespace( + delta=SimpleNamespace(content="hi", reasoning_content=None, + tool_calls=None), + finish_reason="stop")], + model="m-1", usage=None) + # Terminal include_usage chunk: empty choices, carries final usage. + yield SimpleNamespace( + choices=[], model="m-1", + usage=SimpleNamespace( + prompt_tokens=12, completion_tokens=3, total_tokens=15, + prompt_tokens_details=SimpleNamespace(cached_tokens=4))) + + create = AsyncMock(return_value=fake_stream()) + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + create=create, + ))) + + deltas = [d async for d in c.stream([user_msg("hi")])] + # stream_options.include_usage requested on the create call. + assert create.call_args.kwargs["stream_options"] == {"include_usage": True} + assert deltas[0].content == "hi" + assert deltas[0].finish_reason == "stop" + # Terminal chunk forwarded with usage (not dropped). + assert deltas[-1].usage == { + "prompt_tokens": 12, "completion_tokens": 3, + "total_tokens": 15, "cached_tokens": 4, + } + + +def _bad_request(msg: str) -> BadRequestError: + req = httpx.Request("POST", "https://gw/v1/chat/completions") + return BadRequestError(msg, response=httpx.Response(400, request=req), body=None) + + +@pytest.mark.asyncio +async def test_openai_stream_degrades_when_gateway_rejects_stream_options(): + """A gateway that 400s on ``stream_options`` must not break streaming: the + client disables the option and retries once, then skips it on later calls.""" + c = _mk_openai_client() + calls: list[dict] = [] + + async def fake_stream(): + yield SimpleNamespace( + choices=[SimpleNamespace( + delta=SimpleNamespace(content="ok", reasoning_content=None, + tool_calls=None), + finish_reason="stop")], + model="m-1", usage=None) + + async def create(**kwargs): + calls.append(kwargs) + if "stream_options" in kwargs: + raise _bad_request("Unknown parameter: 'stream_options'.") + return fake_stream() + + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + create=create, + ))) + + deltas = [d async for d in c.stream([user_msg("hi")])] + # First attempt sent stream_options and was rejected; retried without it. + assert len(calls) == 2 + assert "stream_options" in calls[0] + assert "stream_options" not in calls[1] + assert c._stream_options_supported is False + assert deltas[0].content == "ok" # stream still ran + + # Subsequent calls skip stream_options entirely (no wasted round-trip). + calls.clear() + _ = [d async for d in c.stream([user_msg("hi")])] + assert len(calls) == 1 + assert "stream_options" not in calls[0] + + +@pytest.mark.asyncio +async def test_openai_stream_reraises_unrelated_bad_request(): + """A 400 unrelated to stream_options must propagate, not be swallowed.""" + c = _mk_openai_client() + + async def create(**kwargs): + raise _bad_request("model `x` does not exist") + + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + create=create, + ))) + + with pytest.raises(BadRequestError): + _ = [d async for d in c.stream([user_msg("hi")])] + + +@pytest.mark.asyncio +async def test_openai_stream_demotes_rejected_reasoning_effort_once(): + """gpt-5.x advertises ``none`` but gateways 400 on it — demote to ``low``. + + Without this the reporter's very first call dies and, because report + synthesis is fail-open, the run silently ships the pre-reporter answer. + """ + c = OpenAIClient( + "gpt-5.1", api_key="x", extra_body={"reasoning_effort": "none"}, + ) + calls: list[dict] = [] + + async def fake_stream(): + yield SimpleNamespace( + choices=[SimpleNamespace( + delta=SimpleNamespace(content="ok", reasoning_content=None, + tool_calls=None), + finish_reason="stop")], + model="gpt-5.1", usage=None) + + async def create(**kwargs): + calls.append(kwargs) + if kwargs.get("extra_body", {}).get("reasoning_effort") == "none": + raise _bad_request("Unsupported value for 'reasoning_effort': none") + return fake_stream() + + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + create=create, + ))) + + deltas = [d async for d in c.stream([user_msg("hi")])] + assert len(calls) == 2 + assert calls[0]["extra_body"]["reasoning_effort"] == "none" + assert calls[1]["extra_body"]["reasoning_effort"] == "low" + assert deltas[0].content == "ok" # stream still ran + + # Sticky: later calls go straight out at the accepted effort. + calls.clear() + _ = [d async for d in c.stream([user_msg("hi")])] + assert len(calls) == 1 + assert calls[0]["extra_body"]["reasoning_effort"] == "low" + + +@pytest.mark.asyncio +async def test_openai_chat_demotes_rejected_reasoning_effort(): + c = OpenAIClient( + "gpt-5.1", api_key="x", extra_body={"reasoning_effort": "none"}, + ) + calls: list[dict] = [] + raw_resp = MagicMock() + raw_resp.parse.return_value = _fake_completion() + + async def create(**kwargs): + calls.append(kwargs) + if kwargs.get("extra_body", {}).get("reasoning_effort") == "none": + raise _bad_request("reasoning_effort not supported for this model") + return raw_resp + + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + with_raw_response=SimpleNamespace(create=create), + ))) + + resp = await c.chat([user_msg("hi")]) + assert resp.content == "hello" + assert [k["extra_body"]["reasoning_effort"] for k in calls] == ["none", "low"] + + +@pytest.mark.asyncio +async def test_openai_stream_reraises_effort_rejection_without_a_fallback(): + """Only the gpt-5 family/``none`` pair has a next enum — the rest raises.""" + c = OpenAIClient( + "gpt-4o", api_key="x", extra_body={"reasoning_effort": "none"}, + ) + calls: list[dict] = [] + + async def create(**kwargs): + calls.append(kwargs) + raise _bad_request("Unsupported value for 'reasoning_effort': none") + + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + create=create, + ))) + + with pytest.raises(BadRequestError): + _ = [d async for d in c.stream([user_msg("hi")])] + assert len(calls) == 1 + assert c.extra_body["reasoning_effort"] == "none" # untouched + + +def test_openai_client_satisfies_protocol(): + assert isinstance(_mk_openai_client(), LLMClient) + + +# ── Anthropic conversions (pure, no SDK call) ─────────────────────────────── + + +def test_anthropic_split_system(): + sys, rest = ac._split_system([system_msg("you are x"), user_msg("hi")]) + assert sys == "you are x" + assert rest == [user_msg("hi")] + # no leading system -> empty system, messages unchanged + assert ac._split_system([user_msg("hi")]) == ("", [user_msg("hi")]) + + +def test_anthropic_message_conversions(): + # tool result -> user/tool_result block + assert ac._to_anthropic_msg(tool_msg("out", "c1")) == { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "c1", "content": "out"}], + } + # assistant with text + tool_use + am = ac._to_anthropic_msg(assistant_msg( + "thinking done", + tool_calls=[{"id": "c1", "type": "function", + "function": {"name": "f", "arguments": '{"a": 1}'}}], + )) + assert am["role"] == "assistant" + assert am["content"][0] == {"type": "text", "text": "thinking done"} + assert am["content"][1] == { + "type": "tool_use", "id": "c1", "name": "f", "input": {"a": 1}, + } + # plain user + assert ac._to_anthropic_msg(user_msg("hi")) == {"role": "user", "content": "hi"} + + +def test_anthropic_tool_conversion(): + out = ac._to_anthropic_tool( + {"type": "function", "function": { + "name": "search", "description": "d", "parameters": {"type": "object"}}}) + assert out == {"name": "search", "description": "d", + "input_schema": {"type": "object"}} + + +def test_anthropic_response_keeps_thinking_signature_and_cache_tokens(): + raw = SimpleNamespace( + content=[ + SimpleNamespace(type="thinking", thinking="reasoning...", signature="sig_abc"), + SimpleNamespace(type="text", text="the answer"), + SimpleNamespace(type="tool_use", id="c1", name="f", input={"a": 1}), + ], + usage=SimpleNamespace( + input_tokens=20, + output_tokens=8, + cache_read_input_tokens=12, + cache_creation_input_tokens=7, + cache_creation=SimpleNamespace(ephemeral_1h_input_tokens=3), + ), + stop_reason="tool_use", model="claude-x", id="msg_1", + ) + resp = ac._to_llm_response(raw) + # thinking present -> content stays a block list (so content_block parser works) + assert isinstance(resp.content, list) + thinking_block = resp.content[0] + assert thinking_block["type"] == "thinking" + assert thinking_block["signature"] == "sig_abc" # must be preserved + assert resp.reasoning_content == "reasoning..." + assert resp.tool_calls[0]["function"]["name"] == "f" + assert resp.usage["cache_read_tokens"] == 12 + assert resp.usage["cache_write_tokens"] == 10 + assert resp.usage["cached_tokens"] == 22 + assert resp.usage["cache_creation_tokens"] == 10 + assert resp.usage["total_tokens"] == 28 + extracted = extract_usage(resp) + assert extracted is not None + assert extracted["cache_read_tokens"] == 12 + assert extracted["cache_write_tokens"] == 10 + assert extracted["cached_tokens"] == 22 + assert extracted["cache_creation_tokens"] == 10 + + +@pytest.mark.asyncio +async def test_anthropic_real_streaming_maps_events_to_stream_deltas(): + """Real token-by-token streaming: Anthropic raw events map to the same + StreamDelta shape the kernel assembler consumes for OpenAI (text / + thinking / tool_call_deltas), with usage/finish/model on a terminal delta.""" + async def fake_events(): + yield SimpleNamespace(type="message_start", message=SimpleNamespace( + model="claude-x", + usage=SimpleNamespace( + input_tokens=20, + cache_read_input_tokens=5, + cache_creation_input_tokens=7, + ))) + yield SimpleNamespace(type="content_block_start", index=0, + content_block=SimpleNamespace(type="text")) + yield SimpleNamespace(type="content_block_delta", index=0, + delta=SimpleNamespace(type="text_delta", text="Hel")) + yield SimpleNamespace(type="content_block_delta", index=0, + delta=SimpleNamespace(type="text_delta", text="lo")) + # tool_use block: open slot (id+name), then partial-JSON arg fragments. + yield SimpleNamespace(type="content_block_start", index=1, + content_block=SimpleNamespace( + type="tool_use", id="c1", name="search")) + yield SimpleNamespace(type="content_block_delta", index=1, + delta=SimpleNamespace( + type="input_json_delta", partial_json='{"q":')) + yield SimpleNamespace(type="content_block_delta", index=1, + delta=SimpleNamespace( + type="input_json_delta", partial_json='"x"}')) + yield SimpleNamespace(type="message_delta", + delta=SimpleNamespace(stop_reason="tool_use"), + usage=SimpleNamespace(output_tokens=8)) + yield SimpleNamespace(type="message_stop") + + c = ac.AnthropicClient("claude-x", api_key="x") + create = AsyncMock(return_value=fake_events()) + c._client = SimpleNamespace(messages=SimpleNamespace(create=create)) + + deltas = [d async for d in c.stream([user_msg("hi")])] + assert create.call_args.kwargs["stream"] is True + + # Text deltas stream through as content. + assert [d.content for d in deltas if d.content] == ["Hel", "lo"] + # Tool-call deltas: one opening (id+name), two partial-JSON arg fragments, + # all at index 1 — the assembler stitches arguments into '{"q":"x"}'. + tcds = [tcd for d in deltas for tcd in d.tool_call_deltas] + assert tcds[0] == {"index": 1, "id": "c1", "name": "search", "arguments": ""} + assert [t["arguments"] for t in tcds[1:]] == ['{"q":', '"x"}'] + assert all(t["index"] == 1 for t in tcds) + # Terminal delta folds usage / finish_reason / model. + terminal = deltas[-1] + assert terminal.finish_reason == "tool_use" + assert terminal.model == "claude-x" + assert terminal.usage == { + "prompt_tokens": 20, "completion_tokens": 8, + "total_tokens": 28, + "cache_read_tokens": 5, + "cache_write_tokens": 7, + "cached_tokens": 12, + "cache_creation_tokens": 7, + } + + +# ── reasoning_tokens off completion_tokens_details ───────────────────────── +# +# Pre-existing gap (since the Phase 0.5 native adapters), found while smoke- +# testing the shared-loop migration: ``extract_usage`` reads a FLAT +# ``reasoning_tokens`` key, ``anthropic_client`` and ``openai_responses_client`` +# both fill it, and this client did not — so every reasoning model on the +# chat-completions path reported 0 thinking tokens. Verified against live +# deepseek-v4-flash (non-stream 66, stream 47) before the fix landed. + + +def _usage_with_reasoning(reasoning): + """OpenAI usage object whose ``completion_tokens_details`` is ``reasoning``. + + Pass ``None`` for the gateways that send ``completion_tokens_details: null`` + (apodex does). + """ + return SimpleNamespace( + prompt_tokens=10, completion_tokens=50, total_tokens=60, + prompt_tokens_details=SimpleNamespace(cached_tokens=0), + completion_tokens_details=reasoning, + ) + + +def _plain_completion(usage): + return SimpleNamespace( + choices=[SimpleNamespace( + message=SimpleNamespace( + content="answer", tool_calls=None, reasoning_content="thought" + ), + finish_reason="stop", + )], + usage=usage, + model="test-model", + id="resp_r", + ) + + +def test_openai_usage_carries_reasoning_tokens(): + """Non-streaming: thinking tokens reach ``usage`` and ``extract_usage``.""" + c = _mk_openai_client() + raw_resp = MagicMock() + raw_resp.parse.return_value = _plain_completion( + _usage_with_reasoning(SimpleNamespace(reasoning_tokens=42)) + ) + create = AsyncMock(return_value=raw_resp) + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + with_raw_response=SimpleNamespace(create=create), + ))) + + resp = asyncio.run(c.chat([user_msg("hi")])) + + assert resp.usage["reasoning_tokens"] == 42 + # The whole point of the flat key: the loop reads usage through this. + assert extract_usage(resp)["reasoning_tokens"] == 42 + # Thinking is billed INSIDE completion_tokens — it must not be added on top. + assert resp.usage["completion_tokens"] == 50 + assert resp.usage["total_tokens"] == 60 + + +def test_openai_usage_without_reasoning_details_stays_zero(): + """A gateway sending ``completion_tokens_details: null`` must not crash. + + The key is left unset and ``extract_usage`` zero-fills it, rather than the + adapter inventing a count. + """ + c = _mk_openai_client() + raw_resp = MagicMock() + raw_resp.parse.return_value = _plain_completion(_usage_with_reasoning(None)) + create = AsyncMock(return_value=raw_resp) + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + with_raw_response=SimpleNamespace(create=create), + ))) + + resp = asyncio.run(c.chat([user_msg("hi")])) + + assert "reasoning_tokens" not in resp.usage + assert extract_usage(resp)["reasoning_tokens"] == 0 + + +async def test_openai_stream_terminal_usage_carries_reasoning_tokens(): + """Streaming: the terminal ``include_usage`` chunk carries them too. + + Non-streaming and streaming share ``_usage_dict``, so the gap hit both — + and ``serve`` runs streaming, which is where the 0 was observed. + """ + c = _mk_openai_client() + + async def fake_stream(): + yield SimpleNamespace( + choices=[SimpleNamespace( + delta=SimpleNamespace(content="hi", reasoning_content=None, + tool_calls=None), + finish_reason="stop")], + model="m-1", usage=None) + yield SimpleNamespace( + choices=[], model="m-1", + usage=_usage_with_reasoning(SimpleNamespace(reasoning_tokens=17))) + + create = AsyncMock(return_value=fake_stream()) + c._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace( + create=create, + ))) + + deltas = [d async for d in c.stream([user_msg("hi")])] + + assert deltas[-1].usage["reasoning_tokens"] == 17 + + +# ── Anthropic message conversion: malformed / contentless history ────────── +# +# Every case below runs while REPLAYING durable history. A raise here is not a +# one-request failure: the offending message is already recorded, so every +# later turn in the session re-converts it and dies the same way. These assert +# the conversion degrades instead. + + +def test_anthropic_tool_use_survives_unparseable_arguments(): + """Truncated tool-call JSON must not wedge the session. + + The OpenAI path forwards ``arguments`` as an opaque string, so a malformed + call is survivable there; ``json.loads`` made it permanently fatal here. + """ + am = ac._to_anthropic_msg(assistant_msg( + "", + tool_calls=[{"id": "c1", "type": "function", + "function": {"name": "f", "arguments": '{"a": 1'}}], + )) + assert am is not None + assert am["content"] == [ + {"type": "tool_use", "id": "c1", "name": "f", "input": {}}, + ] + + +def test_anthropic_tool_use_rejects_non_object_arguments(): + """Anthropic's ``input`` must be an object; a bare JSON scalar becomes {}.""" + am = ac._to_anthropic_msg(assistant_msg( + "", + tool_calls=[{"id": "c1", "type": "function", + "function": {"name": "f", "arguments": "[1, 2]"}}], + )) + assert am is not None + assert am["content"][0]["input"] == {} + + +def test_anthropic_tool_use_drops_calls_missing_id_or_name(): + """Anthropic requires both; emitting ``id=""`` is a guaranteed 400.""" + am = ac._to_anthropic_msg(assistant_msg( + "some text", + tool_calls=[ + {"id": "", "type": "function", + "function": {"name": "f", "arguments": "{}"}}, + {"id": "c2", "type": "function", "function": {"arguments": "{}"}}, + {"id": "c3", "type": "function", + "function": {"name": "ok", "arguments": "{}"}}, + ], + )) + assert am is not None + assert [b.get("id") for b in am["content"] if b["type"] == "tool_use"] == ["c3"] + + +def test_anthropic_contentless_assistant_message_is_dropped(): + """An assistant turn with no text and no tool calls has nothing sendable. + + The old fallback emitted ``{"type": "text", "text": ""}``, which Anthropic + rejects ("text content blocks must be non-empty") — and once in history it + failed on every later turn. Reachable from ``finish_reason="length"`` with + empty content. + """ + assert ac._to_anthropic_msg(assistant_msg("")) is None + + +async def test_anthropic_build_kwargs_filters_dropped_messages(): + """The request builder must not leave a ``None`` in ``messages``.""" + c = ac.AnthropicClient("claude-x", api_key="x") + kwargs = c._build_kwargs( + [system_msg("s"), user_msg("hi"), assistant_msg(""), user_msg("again")], + tools=None, temperature=None, max_tokens=None, + extra_headers=None, timeout=None, + ) + assert all(m is not None for m in kwargs["messages"]) + assert [m["role"] for m in kwargs["messages"]] == ["user", "user"] + + +async def test_anthropic_stream_captures_signature_and_redacted_thinking(): + """Streamed extended thinking must replay exactly like the non-streaming path. + + ``signature`` arrives as its own ``signature_delta`` AFTER the thinking + text, and ``redacted_thinking`` carries its payload on + ``content_block_start`` with no deltas — neither fits in the flattened + ``reasoning_content`` string, so a streamed thinking turn used to produce + reasoning that ``thinking_format="content_block"`` could not replay. + """ + async def fake_events(): + yield SimpleNamespace(type="message_start", message=SimpleNamespace( + model="claude-x", usage=SimpleNamespace(input_tokens=10))) + yield SimpleNamespace(type="content_block_start", index=0, + content_block=SimpleNamespace(type="thinking")) + yield SimpleNamespace(type="content_block_delta", index=0, + delta=SimpleNamespace(type="thinking_delta", + thinking="step ")) + yield SimpleNamespace(type="content_block_delta", index=0, + delta=SimpleNamespace(type="thinking_delta", + thinking="one")) + yield SimpleNamespace(type="content_block_delta", index=0, + delta=SimpleNamespace(type="signature_delta", + signature="sig-abc")) + yield SimpleNamespace(type="content_block_start", index=1, + content_block=SimpleNamespace( + type="redacted_thinking", data="enc-payload")) + yield SimpleNamespace(type="content_block_start", index=2, + content_block=SimpleNamespace(type="text")) + yield SimpleNamespace(type="content_block_delta", index=2, + delta=SimpleNamespace(type="text_delta", + text="the answer")) + yield SimpleNamespace( + type="message_delta", + delta=SimpleNamespace(stop_reason="end_turn"), + usage=SimpleNamespace( + output_tokens=40, + output_tokens_details=SimpleNamespace(thinking_tokens=30), + )) + yield SimpleNamespace(type="message_stop") + + c = ac.AnthropicClient("claude-x", api_key="x", + thinking={"type": "adaptive"}) + c._client = SimpleNamespace( + messages=SimpleNamespace(create=AsyncMock(return_value=fake_events()))) + deltas = [d async for d in c.stream([user_msg("hi")])] + + # Flattened channels still work for live rendering. + assert "".join(d.reasoning_content for d in deltas) == "step one" + assert "".join(d.content for d in deltas) == "the answer" + + terminal = deltas[-1] + # Verbatim block list, in the provider's emission order, signature intact. + assert terminal.reasoning_blocks == [ + {"type": "thinking", "thinking": "step one", "signature": "sig-abc"}, + {"type": "redacted_thinking", "data": "enc-payload"}, + {"type": "text", "text": "the answer"}, + ] + # Streamed usage now reports thinking tokens like the non-streaming path. + assert terminal.usage["reasoning_tokens"] == 30 + + +async def test_anthropic_stream_omits_blocks_for_a_plain_text_turn(): + """No thinking → the flattened string stays the faithful shape.""" + async def fake_events(): + yield SimpleNamespace(type="message_start", message=SimpleNamespace( + model="claude-x", usage=SimpleNamespace(input_tokens=3))) + yield SimpleNamespace(type="content_block_start", index=0, + content_block=SimpleNamespace(type="text")) + yield SimpleNamespace(type="content_block_delta", index=0, + delta=SimpleNamespace(type="text_delta", text="hi")) + yield SimpleNamespace(type="message_delta", + delta=SimpleNamespace(stop_reason="end_turn"), + usage=SimpleNamespace(output_tokens=2)) + + c = ac.AnthropicClient("claude-x", api_key="x") + c._client = SimpleNamespace( + messages=SimpleNamespace(create=AsyncMock(return_value=fake_events()))) + deltas = [d async for d in c.stream([user_msg("hi")])] + assert deltas[-1].reasoning_blocks == [] + # An absent ``output_tokens_details`` leaves the key unset (unknown), while + # an explicit 0 would be recorded — the two are deliberately distinct. + assert "reasoning_tokens" not in deltas[-1].usage + + +def test_anthropic_usage_records_an_explicit_zero_reasoning_count(): + usage = SimpleNamespace( + input_tokens=5, output_tokens=7, + output_tokens_details=SimpleNamespace(thinking_tokens=0), + ) + assert ac._anthropic_reasoning_tokens(usage) == 0 + assert ac._anthropic_usage_dict(5, 7, None, None, 0)["reasoning_tokens"] == 0 + assert ac._anthropic_reasoning_tokens(SimpleNamespace()) is None + assert "reasoning_tokens" not in ac._anthropic_usage_dict(5, 7, None, None, None) + + +def test_anthropic_bedrock_requires_a_bearer_key(monkeypatch): + """The Bearer override replaces SigV4, so ambient AWS creds are NOT used. + + Building without a key used to send ``Authorization: Bearer `` and 401 as if + the key were merely wrong. + """ + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + with pytest.raises(ValueError, match="AWS_BEARER_TOKEN_BEDROCK"): + ac._build_bedrock_client(None, None, 30.0, None) + + +def test_anthropic_bedrock_falls_back_to_env_bearer(monkeypatch): + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-key") + client = ac._build_bedrock_client( + None, "https://bedrock-runtime.us-west-2.amazonaws.com", 30.0, None, + ) + assert client is not None + + +def test_session_resolver_hooks_are_reachable_from_the_package_facade(): + """Hosts configure these global hooks through ``agent_core.providers``. + + ``configure_session_scope_resolver`` / ``SessionScopeResolver`` shipped + without being exported, so the documented wiring path could not reach them — + only the submodule could. + """ + import agent_core.providers as providers + + for name in ( + "SessionQueryResolver", + "SessionScopeResolver", + "configure_session_query_resolver", + "configure_session_scope_resolver", + ): + assert hasattr(providers, name), name + assert name in providers.__all__, name diff --git a/tests/test_provider_prompt_cache.py b/tests/test_provider_prompt_cache.py new file mode 100644 index 0000000..f4416f1 --- /dev/null +++ b/tests/test_provider_prompt_cache.py @@ -0,0 +1,182 @@ +"""``AnthropicPromptCacheAdapter`` unit tests. + +The adapter wraps a Claude-family :class:`~agent_core.llm.LLMClient` +to inject ``cache_control: ephemeral`` on the system prompt before +delegating to the inner client's :meth:`chat` / :meth:`stream`. The +trickiest part of that wiring is :meth:`bind_tools`: it must keep the +cache adapter in front of a *tool-bound* inner so the reporter loop's +tool-augmented call still goes out with both the cache-marked system +prompt and the bound ``tools=[submit_report, …]`` definitions. If the +adapter dropped the bound tools, the model could never call +``submit_report`` and the agent could never terminate. + +Native substrate note: the old LangChain implementation wrapped a +``BaseChatModel`` and had to defeat ``RunnableBinding.__getattr__`` +silently dropping bound kwargs on the ``_generate`` / ``_agenerate`` +delegation path. The native ``LLMClient`` carries no such binding — +``bind_tools(llm, tools)`` threads ``tools`` straight into +:meth:`LLMClient.chat` — so the contract here is simply: bound tools +must reach the inner client's ``chat``. +""" +from __future__ import annotations + +import asyncio +from typing import Any + +from agent_core.llm import LLMResponse +from agent_core.messages import system_msg, user_msg +from agent_core.providers.prompt_cache import ( + AnthropicPromptCacheAdapter, + maybe_wrap_for_prompt_cache, +) + + +class _RecordingLLM: + """A minimal native ``LLMClient`` that records every ``chat`` / + ``stream`` call's messages + kwargs.""" + + model: str = "recorder" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def chat( + self, + messages: list[dict[str, Any]], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> LLMResponse: + self.calls.append({ + "messages": list(messages), + "kwargs": { + "tools": tools, + "temperature": temperature, + "max_tokens": max_tokens, + "extra_headers": extra_headers, + "timeout": timeout, + }, + }) + return LLMResponse(content="ok") + + async def stream( + self, + messages: list[dict[str, Any]], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + extra_headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Any: + self.calls.append({ + "messages": list(messages), + "kwargs": { + "tools": tools, + "temperature": temperature, + "max_tokens": max_tokens, + "extra_headers": extra_headers, + "timeout": timeout, + }, + }) + if False: # pragma: no cover - make this an async generator + yield None + + +def test_cache_control_injected_on_system_message() -> None: + """Sanity: system prompt gets ``cache_control: ephemeral`` added.""" + inner = _RecordingLLM() + adapter = AnthropicPromptCacheAdapter(inner=inner) + + messages = [system_msg("be helpful"), user_msg("hi")] + asyncio.run(adapter.chat(messages)) + + assert len(inner.calls) == 1 + sys_msg = inner.calls[0]["messages"][0] + assert sys_msg.get("role") == "system" + assert isinstance(sys_msg["content"], list) + assert sys_msg["content"][0]["cache_control"] == {"type": "ephemeral"} + + +def test_bound_tool_kwargs_survive_after_bind_tools() -> None: + """Regression: ``bind_tools`` must keep the cache adapter in front of a + tool-bound inner so the reporter loop's ``tools=[submit_report, …]`` + still reaches the upstream Claude client. Without this the request goes + out with no tools and the agent can never terminate. + """ + inner = _RecordingLLM() + adapter = AnthropicPromptCacheAdapter(inner=inner) + + tool_spec = [{ + "type": "function", + "function": { + "name": "submit_report", + "description": "x", + "parameters": {"type": "object", "properties": {}}, + }, + }] + bound = adapter.bind_tools(tool_spec) + + # Self-check: bind_tools keeps the cache adapter on the outside. + assert isinstance(bound, AnthropicPromptCacheAdapter) + + asyncio.run(bound.chat([ + system_msg("be helpful"), + user_msg("hi"), + ])) + + assert len(inner.calls) == 1 + kwargs = inner.calls[0]["kwargs"] + assert kwargs.get("tools") == tool_spec, ( + "bound tools must be forwarded to the underlying chat model; " + "without this the reporter loop sends requests with no tools " + "and the model can never call submit_report." + ) + # The cache adapter still injects cache_control on top of the tools. + sys_msg = inner.calls[0]["messages"][0] + assert isinstance(sys_msg["content"], list) + assert sys_msg["content"][0]["cache_control"] == {"type": "ephemeral"} + + +def test_caller_tools_override_bound_tools() -> None: + """When the caller passes ``tools`` directly it wins over the bound + default — matches the native ``chat(..., tools=...)`` precedence so + callers that override at the call site behave the same with or without + the cache adapter in the chain. + """ + inner = _RecordingLLM() + adapter = AnthropicPromptCacheAdapter(inner=inner) + bound = adapter.bind_tools([{ + "type": "function", + "function": { + "name": "x", + "description": "y", + "parameters": {"type": "object", "properties": {}}, + }, + }]) + + override = [{ + "type": "function", + "function": {"name": "z", "description": "w", "parameters": {}}, + }] + asyncio.run(bound.chat([user_msg("hi")], tools=override)) + + assert inner.calls[0]["kwargs"]["tools"] == override + + +def test_maybe_wrap_short_circuits_for_non_claude() -> None: + inner = _RecordingLLM() + result = maybe_wrap_for_prompt_cache(inner, provider="openai", model="gpt-4o") + assert result is inner + + +def test_maybe_wrap_wraps_claude_family() -> None: + inner = _RecordingLLM() + result = maybe_wrap_for_prompt_cache( + inner, provider="openrouter", model="anthropic/claude-sonnet-4-5", + ) + assert isinstance(result, AnthropicPromptCacheAdapter) + assert result.inner is inner diff --git a/tests/test_provider_protocol_reasoning.py b/tests/test_provider_protocol_reasoning.py new file mode 100644 index 0000000..673f499 --- /dev/null +++ b/tests/test_provider_protocol_reasoning.py @@ -0,0 +1,529 @@ +"""Per-profile ``protocol`` switch + verbatim reasoning capture/replay. + +Covers the native Anthropic extended-thinking path, the new OpenAI Responses +client (pure conversion/parse helpers — no live API), and the cross-cutting +plumbing that carries reasoning blocks through the loop and back onto the wire: + +- Anthropic: ``thinking=`` is sent (temperature dropped), signed thinking blocks + round-trip verbatim on re-send, reasoning tokens surface in usage. +- Responses: messages → ``input`` items (reasoning items re-sent with + encrypted_content), tools flattened, output parsed to a block list, usage + normalised. +- model_profile ``content_block`` parser keeps ``raw_content_blocks`` and + ``to_history`` replays them unmodified; ``TurnContext`` / ``extract_usage`` / + ``node_context`` carry them end-to-end. +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from agent_core.llm import LLMClient, LLMResponse +from agent_core.messages import assistant_msg, tool_msg, user_msg +from agent_core.providers import anthropic as ac +from agent_core.providers import openai_responses as rc +from agent_core.providers.anthropic import AnthropicClient +from agent_core.providers.openai_responses import OpenAIResponsesClient +from agent_core.providers.protocol_client import ( + build_protocol_client, + protocol_of, + thinking_format_for_protocol, +) + + +@pytest.mark.parametrize("value", [["anthropic"], {"protocol": "anthropic"}, 5, True, "unknown"]) +def test_protocol_of_rejects_unusable_values(value): + assert protocol_of({"protocol": value}) == "chat_completions" + +# ── Anthropic extended thinking ───────────────────────────────────────────── + + +def test_anthropic_build_kwargs_sends_thinking_and_drops_temperature(): + c = AnthropicClient( + "claude-x", api_key="x", temperature=0.3, + thinking={"type": "adaptive", "display": "summarized"}, effort="high", + ) + kw = c._build_kwargs( + [user_msg("hi")], tools=None, temperature=0.9, + max_tokens=None, extra_headers=None, timeout=None, + ) + assert kw["thinking"] == {"type": "adaptive", "display": "summarized"} + # Anthropic 400s on temperature + thinking → temperature must be omitted. + assert "temperature" not in kw + # effort rides on extra_body.output_config (bypasses the SDK Literal check). + assert kw["extra_body"] == {"output_config": {"effort": "high"}} + + +def test_anthropic_build_kwargs_keeps_temperature_when_thinking_off(): + c = AnthropicClient("claude-x", api_key="x", temperature=0.3) + kw = c._build_kwargs( + [user_msg("hi")], tools=None, temperature=None, + max_tokens=None, extra_headers=None, timeout=None, + ) + assert kw["temperature"] == 0.3 + assert "thinking" not in kw and "extra_body" not in kw + + +def test_anthropic_assistant_roundtrips_verbatim_signed_blocks(): + # History kept the verbatim block list (content is a list, not a string). + m = assistant_msg([ + {"type": "thinking", "thinking": "reason", "signature": "SIG"}, + {"type": "redacted_thinking", "data": "REDACTED"}, + {"type": "text", "text": "answer"}, + ], tool_calls=[{"id": "c1", "type": "function", + "function": {"name": "f", "arguments": '{"a": 1}'}}]) + out = ac._to_anthropic_msg(m) + assert out["role"] == "assistant" + kinds = [b["type"] for b in out["content"]] + assert kinds == ["thinking", "redacted_thinking", "text", "tool_use"] + assert out["content"][0]["signature"] == "SIG" # signature echoed back + assert out["content"][1]["data"] == "REDACTED" + assert out["content"][3] == { + "type": "tool_use", "id": "c1", "name": "f", "input": {"a": 1}, + } + + +def test_anthropic_plain_string_assistant_unchanged(): + out = ac._to_anthropic_msg(assistant_msg("just text")) + assert out == {"role": "assistant", "content": [{"type": "text", "text": "just text"}]} + + +def test_anthropic_response_preserves_redacted_thinking_block(): + # Inbound redacted_thinking must survive verbatim (raw_content_blocks) so the + # outbound replay (which already echoes redacted_thinking) has it to re-send; + # dropping it would break signature/replay continuity. Its presence also + # forces the structured block-list content (no readable text to key on). + raw = SimpleNamespace( + content=[SimpleNamespace(type="redacted_thinking", data="REDACTED"), + SimpleNamespace(type="text", text="answer")], + usage=SimpleNamespace(input_tokens=5, output_tokens=8, + cache_read_input_tokens=0, output_tokens_details=None), + stop_reason="end_turn", model="claude-x", id="m1", + ) + resp = ac._to_llm_response(raw) + assert isinstance(resp.content, list) + kinds = [b["type"] for b in resp.content] + assert kinds == ["redacted_thinking", "text"] + assert resp.content[0] == {"type": "redacted_thinking", "data": "REDACTED"} + + +def test_provider_label_derives_from_protocol(): + from agent_core.providers.protocol_client import provider_label + assert provider_label({"protocol": "anthropic"}) == "anthropic" + assert provider_label({"protocol": "bedrock"}) == "bedrock" + assert provider_label({"protocol": "responses"}) == "openai" + assert provider_label({"protocol": "chat_completions"}) == "openai" + assert provider_label({}) == "openai" + # explicit label / provider wins over the protocol-derived default. + assert provider_label({"protocol": "anthropic", "provider": "new_api"}) == "new_api" + assert provider_label({"protocol": "bedrock", "_provider_label": "x"}) == "x" + + +def test_anthropic_usage_surfaces_reasoning_tokens(): + raw = SimpleNamespace( + content=[SimpleNamespace(type="thinking", thinking="t", signature="s"), + SimpleNamespace(type="text", text="a")], + usage=SimpleNamespace( + input_tokens=10, output_tokens=20, cache_read_input_tokens=2, + output_tokens_details=SimpleNamespace(thinking_tokens=7)), + stop_reason="end_turn", model="claude-x", id="m1", + ) + resp = ac._to_llm_response(raw) + assert resp.usage["reasoning_tokens"] == 7 + assert resp.usage["completion_tokens"] == 20 + + +# ── OpenAI Responses client (pure conversions) ────────────────────────────── + + +def test_responses_tools_flattened(): + out = rc._to_responses_tools( + [{"type": "function", "function": { + "name": "search", "description": "d", "parameters": {"type": "object"}}}]) + assert out == [{"type": "function", "name": "search", + "description": "d", "parameters": {"type": "object"}}] + + +def test_responses_input_reemits_reasoning_and_tool_calls(): + messages = [ + user_msg("q"), + assistant_msg( + [{"type": "reasoning", "id": "rs_1", + "summary": [{"type": "summary_text", "text": "plan"}], + "encrypted_content": "ENC"}, + {"type": "text", "text": "calling tool"}], + tool_calls=[{"id": "call_1", "type": "function", + "function": {"name": "f", "arguments": '{"a":1}'}}]), + tool_msg("result", "call_1"), + ] + items = rc._to_responses_input(messages) + types = [it.get("type") or ("message:" + it.get("role", "")) for it in items] + # user message, then reasoning item FIRST, assistant message, function_call, + # then the tool result as function_call_output. + assert types == [ + "message:user", "reasoning", "message", "function_call", + "function_call_output", + ] + reasoning_item = items[1] + assert reasoning_item["id"] == "rs_1" + assert reasoning_item["encrypted_content"] == "ENC" # replayed verbatim + fc = items[3] + assert fc["call_id"] == "call_1" and fc["name"] == "f" + fco = items[4] + assert fco["call_id"] == "call_1" and fco["output"] == "result" + + +def test_responses_parse_output_keeps_reasoning_blocks_and_tool_calls(): + raw = SimpleNamespace( + output=[ + SimpleNamespace(type="reasoning", id="rs_1", + summary=[SimpleNamespace(type="summary_text", text="plan")], + encrypted_content="ENC"), + SimpleNamespace(type="message", content=[ + SimpleNamespace(type="output_text", text="the answer")]), + SimpleNamespace(type="function_call", call_id="call_1", + name="f", arguments='{"a":1}'), + ], + usage=SimpleNamespace( + input_tokens=10, output_tokens=30, total_tokens=40, + input_tokens_details=SimpleNamespace(cached_tokens=2), + output_tokens_details=SimpleNamespace(reasoning_tokens=12)), + status="completed", model="gpt-x", id="resp_1", + ) + resp = rc._parse_responses_output(raw) + assert isinstance(resp.content, list) + assert resp.content[0]["type"] == "reasoning" + assert resp.content[0]["encrypted_content"] == "ENC" + assert resp.content[1] == {"type": "text", "text": "the answer"} + assert resp.reasoning_content == "plan" + assert resp.tool_calls[0]["id"] == "call_1" + assert resp.tool_calls[0]["function"]["name"] == "f" + assert resp.usage == { + "prompt_tokens": 10, "completion_tokens": 30, "total_tokens": 40, + "cached_tokens": 2, "reasoning_tokens": 12, + } + + +@pytest.mark.asyncio +async def test_responses_chat_sends_reasoning_include_and_store(): + from unittest.mock import AsyncMock + c = OpenAIResponsesClient("gpt-x", api_key="x", + reasoning={"effort": "high", "summary": "auto"}) + raw = SimpleNamespace(output=[SimpleNamespace( + type="message", content=[SimpleNamespace(type="output_text", text="ok")])], + usage=None, status="completed", model="gpt-x", id="r1") + create = AsyncMock(return_value=raw) + c._client = SimpleNamespace(responses=SimpleNamespace(create=create)) + resp = await c.chat([user_msg("hi")], + tools=[{"type": "function", "function": {"name": "f"}}]) + kw = create.call_args.kwargs + assert kw["include"] == ["reasoning.encrypted_content"] + assert kw["store"] is False + assert kw["reasoning"] == {"effort": "high", "summary": "auto"} + assert kw["tools"][0]["name"] == "f" # flattened tool schema + assert isinstance(resp, LLMResponse) + + +def test_responses_client_satisfies_protocol(): + assert isinstance(OpenAIResponsesClient("gpt-x", api_key="x"), LLMClient) + + +# ── protocol_client selection ─────────────────────────────────────────────── + + +def test_anthropic_builder_defaults_to_adaptive_thinking(): + # Default thinking_type=adaptive → the recommended / only mode on current + # Claude (enabled is rejected with 400 on Opus 4.7/4.8, Sonnet 5). + # display defaults to summarized so the readable thinking text is captured. + c = build_protocol_client( + {"model": "claude", "protocol": "anthropic", "max_tokens": 2048, + "effort": "high"}, title="T") + kw = c._build_kwargs([user_msg("hi")], tools=None, temperature=None, + max_tokens=None, extra_headers=None, timeout=None) + assert kw["thinking"] == {"type": "adaptive", "display": "summarized"} + assert "temperature" not in kw + # adaptive forwards effort → output_config.effort. + assert kw["extra_body"] == {"output_config": {"effort": "high"}} + + +def test_anthropic_builder_enabled_is_legacy_optin_and_drops_effort(): + # Opt-in enabled (legacy models < Opus 4.6): budget clamped < max_tokens, + # and effort is NOT forwarded (oldest models 400 on enabled+effort; + # budget_tokens is the control knob for enabled). + c = build_protocol_client( + {"model": "claude", "protocol": "anthropic", "max_tokens": 2048, + "thinking_type": "enabled", "effort": "high"}, title="T") + kw = c._build_kwargs([user_msg("hi")], tools=None, temperature=None, + max_tokens=None, extra_headers=None, timeout=None) + assert kw["thinking"] == {"type": "enabled", "budget_tokens": 2047} + assert "temperature" not in kw + assert "extra_body" not in kw # effort dropped for enabled + + +def test_protocol_client_selection(): + assert isinstance( + build_protocol_client({"model": "claude", "protocol": "anthropic"}, title="T"), + AnthropicClient) + assert isinstance( + build_protocol_client({"model": "gpt", "protocol": "responses"}, title="T"), + OpenAIResponsesClient) + # chat_completions (and default) → None: caller keeps its OpenAIClient path. + assert build_protocol_client({"model": "gpt"}, title="T") is None + assert build_protocol_client({"model": "gpt", "protocol": "chat_completions"}, + title="T") is None + + +def test_thinking_format_for_protocol(): + assert thinking_format_for_protocol("anthropic") == "content_block" + assert thinking_format_for_protocol("responses") == "content_block" + assert thinking_format_for_protocol("bedrock") == "content_block" + assert thinking_format_for_protocol("chat_completions") is None + assert protocol_of({}) == "chat_completions" + assert protocol_of({"protocol": "ANTHROPIC"}) == "anthropic" + + +def test_bedrock_protocol_builds_anthropic_client_with_thinking(): + # protocol=bedrock → AnthropicClient over the Bedrock transport (Bearer + # API-key + /model/{id}/invoke). Same thinking request shape as direct + # Anthropic (adaptive default, temperature dropped). Bedrock signature is + # cross-platform compatible so replay works the same. + c = build_protocol_client( + {"model": "global.anthropic.claude-sonnet-4-6", "protocol": "bedrock", + "api_key": "ABSK-x", "base_url": "https://bedrock-runtime.us-east-1.amazonaws.com", + "max_tokens": 2048}, title="T") + assert isinstance(c, AnthropicClient) + kw = c._build_kwargs([user_msg("hi")], tools=None, temperature=None, + max_tokens=None, extra_headers=None, timeout=None) + assert kw["thinking"] == {"type": "adaptive", "display": "summarized"} + assert "temperature" not in kw + + +# ── Cross-cutting plumbing ────────────────────────────────────────────────── + + +def test_content_block_parser_and_history_roundtrip(): + from agent_core.runtime.loop.model_profile import ( + DefaultThinkingParser, + HistoryPolicy, + ModelProfile, + NativeMessageNormalizer, + ) + blocks = [ + {"type": "thinking", "thinking": "think", "signature": "SIG"}, + {"type": "text", "text": "answer"}, + ] + resp = LLMResponse(content=blocks, reasoning_content="think") + prof = ModelProfile(model_id="claude", provider="anthropic", + thinking_format="content_block", protocol="anthropic") + tr = DefaultThinkingParser().extract(resp, prof) + assert tr.raw_content_blocks == blocks + assert tr.thinking == "think" and tr.visible_content == "answer" + hist = NativeMessageNormalizer().to_history(resp, tr, HistoryPolicy(), "content_block") + # Verbatim replay: history content is the exact block list incl. signature. + assert hist["content"] == blocks + + +def test_content_block_parser_handles_plain_string_content(): + # Regression: a content_block turn can arrive as a BARE STRING — the native + # Anthropic client returns a string whenever a turn has no thinking block + # (adaptive thinking omitted / the post-tool-call final answer turn). The + # parser must NOT iterate it char-by-char (which silently dropped the whole + # visible answer); the string is the visible content. + from agent_core.runtime.loop.model_profile import ( + DefaultThinkingParser, + ModelProfile, + ) + prof = ModelProfile(model_id="claude", provider="anthropic", + thinking_format="content_block", protocol="anthropic") + resp = LLMResponse(content="MIRO-4271-ZK", reasoning_content="") + tr = DefaultThinkingParser().extract(resp, prof) + assert tr.visible_content == "MIRO-4271-ZK" + assert tr.raw_content_blocks is None # nothing to replay this turn + + +def test_turn_context_and_extract_usage_carry_reasoning(): + from agent_core.loop_types import TurnContext + from agent_core.runtime.loop.llm_client import extract_usage + + tc = TurnContext( + turn=1, max_turns=5, task_id="t", role_id="r", ai_text="a", + thinking="th", tool_calls=[], messages=[], usage=None, metadata={}, + ) + assert tc.thinking_blocks == [] # default empty for other formats + + resp = LLMResponse(model="m", usage={ + "prompt_tokens": 5, "completion_tokens": 10, "reasoning_tokens": 4}) + u = extract_usage(resp) + assert u["reasoning_tokens"] == 4 + # The normalized shape is stable even when no reasoning was used. + resp2 = LLMResponse(model="m", usage={"prompt_tokens": 5, "completion_tokens": 10}) + assert extract_usage(resp2)["reasoning_tokens"] == 0 + + +# ── Responses replay: reasoning↔function_call pairing ─────────────────────── + + +def test_responses_input_preserves_interleaved_reasoning_and_calls(): + """The Responses API binds a ``reasoning`` item to the item that FOLLOWS it. + + A turn that produced ``reasoning → call_a → reasoning → call_b`` must not be + replayed as ``reasoning, reasoning, call_a, call_b`` — that hands the model + a different pairing than the one it generated under. + """ + messages = [ + user_msg("q"), + assistant_msg( + [{"type": "reasoning", "id": "rs_1", "summary": [], + "encrypted_content": "E1"}, + {"type": "function_call", "call_id": "call_a", "name": "a", + "arguments": '{"i":1}'}, + {"type": "reasoning", "id": "rs_2", "summary": [], + "encrypted_content": "E2"}, + {"type": "function_call", "call_id": "call_b", "name": "b", + "arguments": '{"i":2}'}], + tool_calls=[ + {"id": "call_a", "type": "function", + "function": {"name": "a", "arguments": '{"i":1}'}}, + {"id": "call_b", "type": "function", + "function": {"name": "b", "arguments": '{"i":2}'}}, + ]), + ] + items = rc._to_responses_input(messages) + assert [it.get("type") for it in items[1:]] == [ + "reasoning", "function_call", "reasoning", "function_call", + ] + assert items[1]["encrypted_content"] == "E1" + assert items[2]["call_id"] == "call_a" + assert items[3]["encrypted_content"] == "E2" + assert items[4]["call_id"] == "call_b" + # Calls come from the block list, so they are not ALSO appended from + # ``tool_calls`` — a duplicated call_id is a 400. + assert sum(1 for it in items if it.get("type") == "function_call") == 2 + + +def test_responses_parser_records_function_calls_in_the_block_list(): + """Ordering can only be replayed if the parser records call positions.""" + raw = SimpleNamespace( + output=[ + SimpleNamespace(type="reasoning", id="rs_1", summary=[], + encrypted_content="E1"), + SimpleNamespace(type="function_call", call_id="call_a", + name="a", arguments="{}"), + SimpleNamespace(type="reasoning", id="rs_2", summary=[], + encrypted_content="E2"), + SimpleNamespace(type="function_call", call_id="call_b", + name="b", arguments="{}"), + ], + usage=None, status="completed", model="gpt-x", id="resp_1", + ) + resp = rc._parse_responses_output(raw) + assert [b["type"] for b in resp.content] == [ + "reasoning", "function_call", "reasoning", "function_call", + ] + # ``tool_calls`` still carries them for the executor. + assert [tc["id"] for tc in resp.tool_calls] == ["call_a", "call_b"] + + +def test_responses_input_falls_back_to_tool_calls_for_legacy_history(): + """History written before the parser recorded ``function_call`` blocks.""" + messages = [assistant_msg( + [{"type": "text", "text": "calling"}], + tool_calls=[{"id": "call_1", "type": "function", + "function": {"name": "f", "arguments": "{}"}}])] + items = rc._to_responses_input(messages) + assert [it.get("type") for it in items] == ["message", "function_call"] + assert items[1]["call_id"] == "call_1" + + +def test_responses_input_drops_calls_missing_id_or_name(): + """A truncated call must not raise mid-replay of durable history.""" + messages = [assistant_msg( + "text", + tool_calls=[ + {"id": "", "type": "function", + "function": {"name": "f", "arguments": "{}"}}, + {"id": "c2", "type": "function", "function": {"arguments": "{}"}}, + {"id": "c3", "type": "function", + "function": {"name": "ok", "arguments": "{}"}}, + ])] + items = rc._to_responses_input(messages) + calls = [it for it in items if it.get("type") == "function_call"] + assert [c["call_id"] for c in calls] == ["c3"] + + +def test_responses_usage_records_an_explicit_zero_reasoning_count(): + """``reasoning_tokens: 0`` asserts zero; omitting the field means unknown. + + Truthiness collapsed the two, which is the exact ambiguity the sibling + ``cached_tokens`` handling exists to avoid. + """ + zero = rc._responses_usage_dict(SimpleNamespace( + input_tokens=1, output_tokens=2, + output_tokens_details=SimpleNamespace(reasoning_tokens=0))) + assert zero["reasoning_tokens"] == 0 + absent = rc._responses_usage_dict(SimpleNamespace( + input_tokens=1, output_tokens=2, output_tokens_details=None)) + assert "reasoning_tokens" not in absent + + +# ── protocol_client: budget/max_tokens reconciliation + typo detection ─────── + + +def test_enabled_thinking_budget_never_exceeds_max_tokens(): + """Anthropic needs ``1024 <= budget_tokens < max_tokens``. + + The two are jointly unsatisfiable when ``max_tokens <= 1024``, and the naive + clamp emitted ``budget_tokens=1024`` against ``max_tokens=512`` — a budget + larger than the response cap, i.e. a 400 on every call. + """ + c = build_protocol_client( + {"model": "claude", "protocol": "anthropic", "max_tokens": 512, + "thinking_type": "enabled"}, title="T") + kw = c._build_kwargs([user_msg("hi")], tools=None, temperature=None, + max_tokens=None, extra_headers=None, timeout=None) + assert kw["thinking"]["budget_tokens"] < kw["max_tokens"] + assert kw["thinking"]["budget_tokens"] == 1024 + assert kw["max_tokens"] == 1025 + + +def test_enabled_thinking_budget_leaves_a_valid_pair_alone(): + from agent_core.providers.protocol_client import _enabled_thinking_budget + + assert _enabled_thinking_budget(8192, 32768) == (8192, 32768) + # Budget above the cap is clamped down, not raised. + assert _enabled_thinking_budget(8192, 4096) == (4095, 4096) + # Below Anthropic's 1024 floor is raised to the floor. + assert _enabled_thinking_budget(100, 32768) == (1024, 32768) + + +def test_unknown_protocol_warns_instead_of_degrading_silently(caplog): + """A YAML typo used to build a Chat Completions client with tag thinking — + the request succeeded and the signed reasoning was quietly lost.""" + import logging + + with caplog.at_level(logging.WARNING): + assert build_protocol_client( + {"model": "claude", "protocol": "anthropc"}, title="T") is None + assert "unknown llm.protocol" in caplog.text + assert "anthropc" in caplog.text + + +@pytest.mark.parametrize("value", ["anthropc", 5, ["anthropic"], True, "unknown"]) +def test_unusable_protocol_values_are_logged(value, caplog): + import logging + + with caplog.at_level(logging.WARNING): + assert protocol_of({"protocol": value}) == "chat_completions" + assert "unknown llm.protocol" in caplog.text + + +@pytest.mark.parametrize("cfg", [{}, {"protocol": None}, {"protocol": ""}, + {"protocol": " "}, {"protocol": "anthropic"}]) +def test_absent_or_valid_protocol_is_not_logged(cfg, caplog): + """The documented default must not produce warning noise on every build.""" + import logging + + with caplog.at_level(logging.WARNING): + protocol_of(cfg) + assert "unknown llm.protocol" not in caplog.text diff --git a/uv.lock b/uv.lock index 0dffdc0..fe9fa88 100644 --- a/uv.lock +++ b/uv.lock @@ -11,12 +11,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "anthropic" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "docstring-parser" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/50/463166f02179ab279edb61de1589a6f69cb3838d6a2fb6f2c92a3f8042f1/anthropic-1.3.0.tar.gz", hash = "sha256:6873492a77ede8849a161ab1bc78bc9a1e492a006d0b5bb4c57ac77845df838a", size = 1148177, upload-time = "2026-09-01T17:37:10.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/5d/7863a9961d320c23787c7b594956afe4e878f9c0ae2376b11a20e416791d/anthropic-1.3.0-py3-none-any.whl", hash = "sha256:e7e7dbebf9f3c84a23954ab989378af6ae10a4d1804c81e9fea4b5ced695ce75", size = 1296959, upload-time = "2026-09-01T17:37:08.525Z" }, +] + +[package.optional-dependencies] +bedrock = [ + { name = "boto3" }, + { name = "botocore" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "apodex-agent-core" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "anthropic", extra = ["bedrock"] }, + { name = "httpx" }, { name = "jinja2" }, + { name = "openai" }, { name = "pydantic" }, { name = "pyyaml" }, ] @@ -46,7 +86,10 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", extras = ["bedrock"], specifier = ">=0.69" }, + { name = "httpx", specifier = ">=0.27,<1" }, { name = "jinja2", specifier = ">=3.1" }, + { name = "openai", specifier = ">=1.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", extras = ["nodejs"], marker = "extra == 'dev'", specifier = ">=1.1.400" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, @@ -67,6 +110,34 @@ dev = [ { name = "ruff", specifier = ">=0.7" }, ] +[[package]] +name = "boto3" +version = "1.43.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a0/b8693637bee21e266a52f3e1b8bb9fe4897934aa62c55cc132f8721c1b8f/boto3-1.43.86.tar.gz", hash = "sha256:aca7b5d7f31a90ad37bec773552ec9bfb57e0029c9aa0f0f4d51d5bf9607b1c4", size = 112685, upload-time = "2026-09-01T19:24:02.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/dd/f4874f7bab882a778178b03965b1474c63f2e8bd2eea5dd714f06b0e8713/boto3-1.43.86-py3-none-any.whl", hash = "sha256:94543a5ce482df8bb6a0bf8a944bf5ccf6625889b1df0d7886dfc3311ebd4169", size = 140026, upload-time = "2026-09-01T19:24:00.638Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/77/0ed1c398b7d3aa5d02c1b15df32a049e07961339fe41640af1440079c2f7/botocore-1.43.86.tar.gz", hash = "sha256:0e943c77ab6a54aaaf4d57e6026ede5c3dca341af71ce91f71849a6eae9d5dbf", size = 16059433, upload-time = "2026-09-01T19:23:57.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/59/eb95d57ea576feaf1693f5e914f9f27ff9961429cf9ecbc85cef610a6d84/botocore-1.43.86-py3-none-any.whl", hash = "sha256:4efc7fbd6e7616edbd55623bb585a044906149b9a5d3d8558420506526e5a5ac", size = 15751556, upload-time = "2026-09-01T19:23:54.013Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -216,6 +287,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -246,6 +402,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -334,6 +567,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" }, ] +[[package]] +name = "openai" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/ba/6d46da4232f80cb5842280e024242e6fed163418ab81bebc1c83f693bb0b/openai-3.7.0.tar.gz", hash = "sha256:e836eb7effee89df802cd0c7d1bad8de8c993976cf238c44d5b5b844f5aefd38", size = 1455615, upload-time = "2026-09-02T01:30:54.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/9d/77e0f43f04ae51a3d21bb08bd372a752856b86ee22648b1458c93a623927/openai-3.7.0-py3-none-any.whl", hash = "sha256:008fa33e0a01dc71039c27355ef8f476eed690e8e48bec18183878dcd32fb841", size = 1699594, upload-time = "2026-09-02T01:30:52.445Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -498,6 +748,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -672,6 +934,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tiktoken" version = "0.14.0" @@ -726,6 +1018,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"