From 2b1941379dfd0430534d9fb2fd79c39f970581e0 Mon Sep 17 00:00:00 2001 From: hallerite Date: Thu, 6 Aug 2026 18:34:07 +0200 Subject: [PATCH] feat: normalize provider tool specifications --- README.md | 17 ++- renderers/__init__.py | 26 ++++ renderers/base.py | 10 +- renderers/deepseek_v3.py | 2 + renderers/default.py | 2 + renderers/glm45.py | 2 + renderers/glm5.py | 2 + renderers/gpt_oss.py | 2 + renderers/hy3.py | 2 + renderers/kimi_k2.py | 2 + renderers/kimi_k25.py | 6 +- renderers/laguna_xs2.py | 3 + renderers/llama_3.py | 2 + renderers/minimax_m2.py | 2 + renderers/nemotron3.py | 9 +- renderers/parsing.py | 30 ++-- renderers/prime_qwen3.py | 2 + renderers/qwen3.py | 2 + renderers/qwen35.py | 2 + renderers/qwen3_vl.py | 2 + renderers/tools.py | 238 +++++++++++++++++++++++++++++++ tests/test_tool_normalization.py | 183 ++++++++++++++++++++++++ tests/test_tool_shape_parity.py | 39 +++++ 23 files changed, 558 insertions(+), 29 deletions(-) create mode 100644 renderers/tools.py create mode 100644 tests/test_tool_normalization.py create mode 100644 tests/test_tool_shape_parity.py diff --git a/README.md b/README.md index c1cfbdb8..a7399dc8 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5` class Renderer(Protocol): def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ... def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ... - def parse_response(token_ids) -> ParsedResponse: ... + def parse_response(token_ids, *, tools=None) -> ParsedResponse: ... def get_stop_token_ids() -> list[int]: ... def bridge_to_next_turn(prev_prompt_ids, prev_completion_ids, new_messages, *, tools=None) -> RenderedTokens | None: ... ``` @@ -62,6 +62,21 @@ class Renderer(Protocol): - `ParsedResponse` is `(content, reasoning_content, tool_calls)`. It scans token ids for special-token boundaries (e.g. id `151657` for `` on Qwen3) — a literal `""` in user content tokenizes to ordinary text ids and never matches. - Round-trip: rendering `[user, assistant(content, reasoning, tool_calls)]`, slicing the assistant completion, and feeding it through `parse_response` returns an equivalent structured message. Tested per-renderer in `tests/test_roundtrip.py`. +### Tool definitions + +Renderer entry points accept the common wire shapes for client-executed JSON-schema functions: + +- legacy / verifiers and Gemini function declarations: `{name, description, parameters}` +- OpenAI Chat Completions: `{type: "function", function: {...}}` +- OpenAI Responses and Gemini Interactions: `{type: "function", name, description, parameters, strict}` +- Anthropic: `{name, description, input_schema}` +- MCP: `{name, description, inputSchema}` +- Pydantic-style objects exposing `model_dump()` + +`normalize_tool_spec()` exposes the conversion directly. All supported shapes become the existing Chat-style envelope before rendering or schema-aware response parsing, so switching provider payloads does not change the model's token stream. Inputs are deep-copied and never mutated. + +Hosted and non-JSON function protocols—such as OpenAI web search, file search, remote MCP, custom-text, namespace, shell, and computer tools—raise `UnsupportedToolSpecError`. Those tools need model-specific execution semantics and cannot be faithfully rewritten as ordinary client functions. + ### `bridge_to_next_turn` (the core contract) Given `(prev_prompt_ids, prev_completion_ids)` and new environment messages, return a `RenderedTokens` object for the next turn's prompt whose `token_ids` start with `prev_prompt_ids + prev_completion_ids` byte-for-byte and continue with the new messages plus the next assistant opener. If that cannot be proven safe, return `None` and the caller falls back to a full render. Attribution in a bridge result is relative to `new_messages`; the preserved prefix uses `message_indices=-1` because only its raw token IDs are available. diff --git a/renderers/__init__.py b/renderers/__init__.py index 7f7a75ef..cdcfb50c 100644 --- a/renderers/__init__.py +++ b/renderers/__init__.py @@ -67,6 +67,20 @@ Qwen3VLRendererConfig, RendererConfig, ) +from renderers.tools import ( + AnthropicToolSpec, + CanonicalToolSpec, + ChatCompletionToolSpec, + FunctionSpec, + KnownToolSpec, + MCPToolSpec, + normalize_tool_spec, + normalize_tool_specs, + ResponsesFunctionToolSpec, + ToolSpecError, + ToolSpecInput, + UnsupportedToolSpecError, +) # Concrete renderer classes are lazy-loaded so that consumers needing # only the config layer (``RendererConfig`` discriminated union) don't @@ -121,9 +135,12 @@ def __dir__() -> list[str]: __all__ = [ "AutoRendererConfig", + "AnthropicToolSpec", "BaseRendererConfig", "Content", "ContentPart", + "CanonicalToolSpec", + "ChatCompletionToolSpec", "DeepSeekR1Renderer", "DeepSeekR1RendererConfig", "DeepSeekV3Renderer", @@ -138,6 +155,8 @@ def __dir__() -> list[str]: "GLM5RendererConfig", "GptOssRenderer", "GptOssRendererConfig", + "FunctionSpec", + "KnownToolSpec", "Hy3Renderer", "Hy3RendererConfig", "ImagePart", @@ -154,6 +173,7 @@ def __dir__() -> list[str]: "MULTIMODAL_MODELS", "MalformedGenerateResponseError", "Message", + "MCPToolSpec", "MiniMaxM2Renderer", "MiniMaxM2RendererConfig", "MultiModalData", @@ -182,12 +202,16 @@ def __dir__() -> list[str]: "Renderer", "RendererConfig", "RendererPool", + "ResponsesFunctionToolSpec", "TextPart", "ThinkingPart", "ToolCall", "ToolCallFunction", "ToolCallParseStatus", "ToolSpec", + "ToolSpecError", + "ToolSpecInput", + "UnsupportedToolSpecError", "VideoPart", "__version__", "attribute_text_segments", @@ -198,6 +222,8 @@ def __dir__() -> list[str]: "create_renderer_pool", "extract_message_tool_names", "is_multimodal", + "normalize_tool_spec", + "normalize_tool_specs", "reject_assistant_in_extension", "trim_to_turn_close", ] diff --git a/renderers/base.py b/renderers/base.py index 9ea02a3c..54c002ef 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -17,6 +17,8 @@ runtime_checkable, ) +from renderers.tools import ToolSpec + if TYPE_CHECKING: from renderers.configs import ( AutoRendererConfig, @@ -97,14 +99,6 @@ class ToolCall(TypedDict, total=False): function: ToolCallFunction -class ToolSpec(TypedDict): - """Tool specification (OpenAI function-calling format).""" - - name: str - description: str - parameters: dict[str, Any] - - class Message(TypedDict, total=False): """A single turn in a multi-turn conversation. diff --git a/renderers/deepseek_v3.py b/renderers/deepseek_v3.py index a00f1f20..f400553b 100644 --- a/renderers/deepseek_v3.py +++ b/renderers/deepseek_v3.py @@ -30,6 +30,7 @@ ) from renderers.configs import DeepSeekV3RendererConfig from renderers.parsing import parse_deepseek_v3 +from renderers.tools import normalize_tool_specs # Fullwidth vertical bar used in DeepSeek special token names. _SEP = "\uff5c" # | (U+FF5C) @@ -125,6 +126,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/default.py b/renderers/default.py index 785a5375..5dd63d3b 100644 --- a/renderers/default.py +++ b/renderers/default.py @@ -26,6 +26,7 @@ get_reasoning_parser, get_tool_parser, ) +from renderers.tools import normalize_tool_specs def _decode_tool_call_arguments(messages: list) -> list: @@ -124,6 +125,7 @@ def render( tools: list[ToolSpec] | None = None, add_generation_prompt: bool = False, ) -> RenderedTokens: + tools = normalize_tool_specs(tools) # Incremental rendering to get per-token message attribution token_ids: list[int] = [] message_indices: list[int] = [] diff --git a/renderers/glm45.py b/renderers/glm45.py index bfc5f09c..59cf1b30 100644 --- a/renderers/glm45.py +++ b/renderers/glm45.py @@ -28,6 +28,7 @@ ) from renderers.configs import GLM45RendererConfig from renderers.parsing import parse_glm +from renderers.tools import normalize_tool_specs _TOOLS_HEADER = ( "\n# Tools\n\n" @@ -125,6 +126,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/glm5.py b/renderers/glm5.py index 4f34d98f..462a3eca 100644 --- a/renderers/glm5.py +++ b/renderers/glm5.py @@ -29,6 +29,7 @@ ) from renderers.configs import GLM5RendererConfig, GLM51RendererConfig from renderers.parsing import parse_glm +from renderers.tools import normalize_tool_specs _TOOLS_HEADER = ( "\n# Tools\n\n" @@ -151,6 +152,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/gpt_oss.py b/renderers/gpt_oss.py index 6165ed09..1761c172 100644 --- a/renderers/gpt_oss.py +++ b/renderers/gpt_oss.py @@ -64,6 +64,7 @@ ) from renderers.configs import GptOssRendererConfig from renderers.parsing import parse_gpt_oss +from renderers.tools import normalize_tool_specs def _reasoning_effort(effort: str | None) -> ReasoningEffort: @@ -269,6 +270,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/hy3.py b/renderers/hy3.py index 7eaef656..2dbea7e1 100644 --- a/renderers/hy3.py +++ b/renderers/hy3.py @@ -42,6 +42,7 @@ ) from renderers.configs import Hy3RendererConfig, ResolvedThinkingRetention from renderers.parsing import parse_hy3 +from renderers.tools import normalize_tool_specs # Special-token strings, constructed exactly as the Jinja template does # (``'<|hy_eos{}|>'.format(':opensource')`` etc.) so ``convert_tokens_to_ids`` @@ -277,6 +278,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) # fallback_strategy="reasoning_toolcall_retry" suppresses the gen prompt. if self._force_no_gen_prompt: diff --git a/renderers/kimi_k2.py b/renderers/kimi_k2.py index 73376003..8ed933af 100644 --- a/renderers/kimi_k2.py +++ b/renderers/kimi_k2.py @@ -31,6 +31,7 @@ ) from renderers.configs import KimiK2RendererConfig from renderers.parsing import parse_kimi_k2 +from renderers.tools import normalize_tool_specs _DEFAULT_SYSTEM = "You are Kimi, an AI assistant created by Moonshot AI." @@ -122,6 +123,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) # Preserve the caller's list — ``message_roles`` and per-token # attribution refer to this frame (not the post-normalisation diff --git a/renderers/kimi_k25.py b/renderers/kimi_k25.py index 48ea426d..eac585ea 100644 --- a/renderers/kimi_k25.py +++ b/renderers/kimi_k25.py @@ -23,7 +23,7 @@ import json import re -from typing import Any +from typing import Any, cast from transformers.tokenization_utils import PreTrainedTokenizer @@ -44,6 +44,7 @@ ) from renderers.configs import KimiK25RendererConfig from renderers.parsing import _reasoning_end_token_index, parse_kimi_k2_section +from renderers.tools import normalize_tool_specs from renderers.qwen3_vl import ( _image_hash, _is_image_part, @@ -393,7 +394,7 @@ def _encode_tools_typescript(tools: list[ToolSpec]) -> str: func_def_dict = tool if not func_def_dict: continue - func_def = _function_to_typescript(func_def_dict) + func_def = _function_to_typescript(cast(dict[str, Any], func_def_dict)) if func_def: functions.append(func_def) if not functions: @@ -749,6 +750,7 @@ def render( """ if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) # Hist/suffix split — assistants up to and including the last # non-tool-call assistant strip reasoning_content, those after diff --git a/renderers/laguna_xs2.py b/renderers/laguna_xs2.py index bd174f42..81fe7060 100644 --- a/renderers/laguna_xs2.py +++ b/renderers/laguna_xs2.py @@ -58,6 +58,7 @@ ) from renderers.configs import LagunaXS2RendererConfig, LagunaXS21RendererConfig from renderers.parsing import parse_laguna_xs2 +from renderers.tools import normalize_tool_specs _DEFAULT_SYSTEM_MESSAGE = ( "You are a helpful, conversationally-fluent assistant made by Poolside. " @@ -177,6 +178,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] @@ -646,6 +648,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/llama_3.py b/renderers/llama_3.py index d18d8c87..3f5abe76 100644 --- a/renderers/llama_3.py +++ b/renderers/llama_3.py @@ -57,6 +57,7 @@ ) from renderers.configs import Llama3RendererConfig from renderers.parsing import parse_llama_3 +from renderers.tools import normalize_tool_specs # --------------------------------------------------------------------------- # Constants — must match the Jinja chat template's literal strings exactly. @@ -183,6 +184,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/minimax_m2.py b/renderers/minimax_m2.py index a7f0bc70..8539f80e 100644 --- a/renderers/minimax_m2.py +++ b/renderers/minimax_m2.py @@ -30,6 +30,7 @@ ) from renderers.configs import MiniMaxM2RendererConfig from renderers.parsing import parse_minimax +from renderers.tools import normalize_tool_specs _TOOLS_HEADER = ( "\n\n# Tools\n" @@ -112,6 +113,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/nemotron3.py b/renderers/nemotron3.py index 5cc76c91..3e26fafb 100644 --- a/renderers/nemotron3.py +++ b/renderers/nemotron3.py @@ -15,7 +15,8 @@ from __future__ import annotations import json -from typing import Any +from collections.abc import Mapping +from typing import Any, cast from transformers.tokenization_utils import PreTrainedTokenizer @@ -33,6 +34,7 @@ ) from renderers.configs import Nemotron3RendererConfig, Nemotron3UltraRendererConfig from renderers.parsing import parse_qwen35 +from renderers.tools import normalize_tool_specs # --------------------------------------------------------------------------- # Tool system prompt constants @@ -60,7 +62,7 @@ ) -def _render_extra_keys(obj: dict[str, Any], handled_keys: set[str]) -> list[str]: +def _render_extra_keys(obj: Mapping[str, Any], handled_keys: set[str]) -> list[str]: """Render extra dict keys as XML, mirroring the HF template's render_extra_keys macro. Dicts and lists are JSON-encoded; scalars are string-coerced. @@ -208,7 +210,7 @@ def _format_tool_declaration(tool: ToolSpec) -> str: # Accept the OpenAI-style ``{"type":"function","function":{...}}`` # envelope by unwrapping before formatting. if "function" in tool and isinstance(tool["function"], dict): - tool = tool["function"] + tool = cast(ToolSpec, tool["function"]) lines = [ "", f"{tool['name']}", @@ -279,6 +281,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) original_messages = list(messages) # Always ensure an empty system message is present. diff --git a/renderers/parsing.py b/renderers/parsing.py index cbc7504f..d9553303 100644 --- a/renderers/parsing.py +++ b/renderers/parsing.py @@ -20,6 +20,7 @@ from typing import Any from renderers.base import ParsedResponse, ParsedToolCall, ToolCallParseStatus, ToolSpec +from renderers.tools import normalize_tool_specs # ── Schema-aware argument coercion ────────────────────────────────── @@ -39,16 +40,15 @@ def _build_param_type_index( """Map tool name → param name → param JSON-schema fragment. Accepts both flat ``ToolSpec`` (``{name, description, parameters}``) - and the OpenAI envelope (``{"type": "function", "function": {...}}``) - so callers can pass either shape. + and every function-compatible provider shape accepted by + :func:`renderers.tools.normalize_tool_specs`. """ - if not tools: + normalized_tools = normalize_tool_specs(tools) + if not normalized_tools: return {} index: dict[str, dict[str, dict[str, Any]]] = {} - for tool in tools: - spec = tool.get("function", tool) if isinstance(tool, dict) else None - if not isinstance(spec, dict): - continue + for tool in normalized_tools: + spec = tool["function"] name = spec.get("name") if not isinstance(name, str): continue @@ -64,17 +64,17 @@ def _extract_tool_names(tools: list[ToolSpec] | None) -> set[str] | None: ``None`` disables name validation — mirroring vLLM's ``ParserEngine._is_valid_tool_name``, which returns ``True`` whenever - the request carries no tools. Accepts both flat ``ToolSpec`` and the - OpenAI ``{"type": "function", "function": {...}}`` envelope, like - ``_build_param_type_index`` (but independent of it: a tool with no - ``parameters.properties`` still counts as a known name). + the request carries no tools. Accepts every function-compatible provider + shape handled by ``_build_param_type_index`` (but is independent of it: a + tool with no ``parameters.properties`` still counts as a known name). """ - if not tools: + normalized_tools = normalize_tool_specs(tools) + if not normalized_tools: return None names: set[str] = set() - for tool in tools: - spec = tool.get("function", tool) if isinstance(tool, dict) else None - if isinstance(spec, dict) and isinstance(spec.get("name"), str): + for tool in normalized_tools: + spec = tool["function"] + if isinstance(spec.get("name"), str): names.add(spec["name"]) return names diff --git a/renderers/prime_qwen3.py b/renderers/prime_qwen3.py index ba639a09..e103e729 100644 --- a/renderers/prime_qwen3.py +++ b/renderers/prime_qwen3.py @@ -22,6 +22,7 @@ ) from renderers.configs import PrimeQwen3RendererConfig from renderers.parsing import parse_qwen35 +from renderers.tools import normalize_tool_specs _DEFAULT_TOOL_SYSTEM = "You are Qwen, a helpful AI assistant that can interact with a computer to solve tasks." _TOOLS_HEADER = "\n\n# Tools\n\nYou have access to the following functions:\n\n" @@ -237,6 +238,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) builder = _TokenBuilder(self._tokenizer) first_is_system = messages[0].get("role") == "system" diff --git a/renderers/qwen3.py b/renderers/qwen3.py index d85d161d..ac0473c6 100644 --- a/renderers/qwen3.py +++ b/renderers/qwen3.py @@ -36,6 +36,7 @@ ) from renderers.configs import Qwen3RendererConfig from renderers.parsing import parse_qwen3 +from renderers.tools import normalize_tool_specs _TOOLS_HEADER = ( "# Tools\n\n" @@ -130,6 +131,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/qwen35.py b/renderers/qwen35.py index 52de8867..a3ef7515 100644 --- a/renderers/qwen35.py +++ b/renderers/qwen35.py @@ -43,6 +43,7 @@ ) from renderers.configs import Qwen35RendererConfig from renderers.parsing import parse_qwen35 +from renderers.tools import normalize_tool_specs from renderers.qwen3_vl import ( _image_hash, _is_image_part, @@ -318,6 +319,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) tokens: list[int] = [] indices: list[int] = [] diff --git a/renderers/qwen3_vl.py b/renderers/qwen3_vl.py index 97072d21..a3f4c26a 100644 --- a/renderers/qwen3_vl.py +++ b/renderers/qwen3_vl.py @@ -51,6 +51,7 @@ ) from renderers.configs import Qwen3VLRendererConfig from renderers.parsing import parse_qwen3 +from renderers.tools import normalize_tool_specs _TOOLS_HEADER = ( "# Tools\n\n" @@ -460,6 +461,7 @@ def render( ) -> RenderedTokens: if not messages: raise ValueError("No messages provided.") + tools = normalize_tool_specs(tools) em = _Emitter(self._encode, tokenizer=self._tokenizer) mm_hashes: dict[str, list[str]] = {} diff --git a/renderers/tools.py b/renderers/tools.py new file mode 100644 index 00000000..28fb2370 --- /dev/null +++ b/renderers/tools.py @@ -0,0 +1,238 @@ +"""Tool-definition types and normalization. + +Renderers only know how to describe client-executed, JSON-schema function +tools in a model prompt. Provider APIs expose that same concept through +several wire shapes, so normalize those shapes once before model-specific +formatting begins. + +The canonical representation deliberately matches the OpenAI Chat +Completions envelope. Existing renderer templates already consume that +shape, which lets callers use other provider shapes without changing the +rendered token stream. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from copy import deepcopy +from typing import Any, Literal, Protocol, TypeAlias, TypedDict, cast + + +class _OptionalFunctionFields(TypedDict, total=False): + description: str + parameters: dict[str, Any] | None + strict: bool | None + defer_loading: bool + allowed_callers: list[str] + + +class FunctionSpec(_OptionalFunctionFields): + """Canonical body of a client-executed JSON-schema function tool.""" + + name: str + + +class ChatCompletionToolSpec(TypedDict): + """OpenAI Chat Completions function-tool envelope.""" + + type: Literal["function"] + function: FunctionSpec + + +class ResponsesFunctionToolSpec(_OptionalFunctionFields): + """OpenAI Responses flat function-tool definition.""" + + type: Literal["function"] + name: str + + +class _OptionalDescription(TypedDict, total=False): + description: str + + +class _OptionalAnthropicFields(_OptionalDescription, total=False): + strict: bool + defer_loading: bool + input_examples: list[dict[str, Any]] + cache_control: dict[str, Any] + + +class AnthropicToolSpec(_OptionalAnthropicFields): + """Anthropic Messages function-tool definition.""" + + name: str + input_schema: dict[str, Any] + + +class _OptionalMCPFields(_OptionalDescription, total=False): + title: str + outputSchema: dict[str, Any] + annotations: dict[str, Any] + _meta: dict[str, Any] + + +class MCPToolSpec(_OptionalMCPFields): + """Model Context Protocol function-tool definition.""" + + name: str + inputSchema: dict[str, Any] + + +class ToolSpec(_OptionalFunctionFields, total=False): + """Provider-agnostic tool mapping accepted by renderer entry points. + + This remains a constructible ``TypedDict`` for compatibility with the + original flat tool contract, while also describing the keys used by Chat + Completions, Responses, Anthropic, and MCP function tools. + """ + + type: str + function: FunctionSpec + name: str + input_schema: dict[str, Any] + inputSchema: dict[str, Any] + input_examples: list[dict[str, Any]] + cache_control: dict[str, Any] + title: str + outputSchema: dict[str, Any] + annotations: dict[str, Any] + _meta: dict[str, Any] + + +class ToolSpecModel(Protocol): + """Pydantic-style object that can expose a tool definition mapping.""" + + def model_dump(self, **kwargs: Any) -> Mapping[str, Any]: ... + + +KnownToolSpec: TypeAlias = ( + FunctionSpec + | ChatCompletionToolSpec + | ResponsesFunctionToolSpec + | AnthropicToolSpec + | MCPToolSpec +) +ToolSpecInput: TypeAlias = ToolSpec | Mapping[str, Any] | ToolSpecModel +CanonicalToolSpec: TypeAlias = ChatCompletionToolSpec + + +class ToolSpecError(ValueError): + """A tool definition cannot be normalized safely.""" + + +class UnsupportedToolSpecError(ToolSpecError): + """The tool requires a protocol the renderer cannot serialize.""" + + +def _as_mapping(tool: ToolSpecInput) -> Mapping[str, Any]: + if isinstance(tool, Mapping): + return cast(Mapping[str, Any], tool) + + model_dump = getattr(tool, "model_dump", None) + if not callable(model_dump): + raise ToolSpecError("tool definitions must be mappings or expose model_dump()") + try: + dumped = model_dump(mode="python", exclude_none=True) + except TypeError: + dumped = model_dump() + if not isinstance(dumped, Mapping): + raise ToolSpecError("tool model_dump() must return a mapping") + return dumped + + +def _function_body(raw_tool: Mapping[str, Any]) -> dict[str, Any]: + tool_type = raw_tool.get("type") + nested = raw_tool.get("function") + + if nested is not None: + if tool_type not in (None, "function"): + raise ToolSpecError( + f"a nested function definition cannot use tool type {tool_type!r}" + ) + if not isinstance(nested, Mapping): + raise ToolSpecError("tool.function must be a mapping") + function = deepcopy(dict(nested)) + else: + if tool_type not in (None, "function"): + raise UnsupportedToolSpecError( + f"tool type {tool_type!r} is not a client-executed JSON-schema " + "function tool; hosted, custom-text, and namespace tools need " + "a model-specific execution protocol" + ) + function = deepcopy(dict(raw_tool)) + function.pop("type", None) + + name = function.get("name") + if not isinstance(name, str) or not name: + raise ToolSpecError("function tool name must be a non-empty string") + + description = function.get("description") + if description is None: + function.pop("description", None) + elif not isinstance(description, str): + raise ToolSpecError("function tool description must be a string") + + schema_aliases = [ + key for key in ("parameters", "input_schema", "inputSchema") if key in function + ] + if len(schema_aliases) > 1: + first = function[schema_aliases[0]] + if any(function[key] != first for key in schema_aliases[1:]): + raise ToolSpecError( + "function tool provides conflicting parameter schemas: " + + ", ".join(schema_aliases) + ) + if schema_aliases: + schema = function[schema_aliases[0]] + if schema is not None and not isinstance(schema, Mapping): + raise ToolSpecError("function tool parameters must be a mapping or None") + for key in ("parameters", "input_schema", "inputSchema"): + function.pop(key, None) + function["parameters"] = deepcopy(dict(schema)) if schema is not None else None + + strict = function.get("strict") + if strict is not None and not isinstance(strict, bool): + raise ToolSpecError("function tool strict must be bool or None") + + defer_loading = function.get("defer_loading") + if defer_loading is not None and not isinstance(defer_loading, bool): + raise ToolSpecError("function tool defer_loading must be bool") + + allowed_callers = function.get("allowed_callers") + if allowed_callers is not None and ( + not isinstance(allowed_callers, list) + or not all(isinstance(caller, str) for caller in allowed_callers) + ): + raise ToolSpecError("function tool allowed_callers must be a list of strings") + + return function + + +def normalize_tool_spec(tool: ToolSpecInput) -> CanonicalToolSpec: + """Return a detached Chat-style envelope for one function tool. + + Supported inputs are the legacy/verifiers flat shape, OpenAI Chat + Completions and Responses function tools, Anthropic ``input_schema`` + tools, MCP ``inputSchema`` tools, and Pydantic-style objects containing + any of those mappings. + """ + + function = cast(FunctionSpec, _function_body(_as_mapping(tool))) + return {"type": "function", "function": function} + + +def normalize_tool_specs( + tools: Iterable[ToolSpecInput] | None, +) -> list[ToolSpec] | None: + """Normalize a tool collection without retaining caller-owned objects.""" + + if tools is None: + return None + + normalized: list[ToolSpec] = [] + for index, tool in enumerate(tools): + try: + normalized.append(cast(ToolSpec, normalize_tool_spec(tool))) + except ToolSpecError as exc: + raise type(exc)(f"tools[{index}]: {exc}") from exc + return normalized diff --git a/tests/test_tool_normalization.py b/tests/test_tool_normalization.py new file mode 100644 index 00000000..bf3487e9 --- /dev/null +++ b/tests/test_tool_normalization.py @@ -0,0 +1,183 @@ +"""Provider tool definitions normalize to one renderer-facing contract.""" + +from __future__ import annotations + +from copy import deepcopy + +import pytest +from openai.types.chat import ChatCompletionFunctionToolParam +from openai.types.responses import FunctionToolParam + +from renderers import ( + ToolSpec, + ToolSpecError, + UnsupportedToolSpecError, + normalize_tool_spec, + normalize_tool_specs, +) +from renderers.parsing import _build_param_type_index, _extract_tool_names + + +FUNCTION = { + "name": "get_weather", + "description": "Return the weather for a city.", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, +} + +CANONICAL = {"type": "function", "function": FUNCTION} + +TOOL_SHAPES = { + "flat": FUNCTION, + "openai-chat": CANONICAL, + "openai-responses": {"type": "function", **FUNCTION}, + "anthropic": { + "name": FUNCTION["name"], + "description": FUNCTION["description"], + "input_schema": FUNCTION["parameters"], + }, + "mcp": { + "name": FUNCTION["name"], + "description": FUNCTION["description"], + "inputSchema": FUNCTION["parameters"], + }, +} + + +@pytest.mark.parametrize( + "tool", + TOOL_SHAPES.values(), + ids=TOOL_SHAPES, +) +def test_function_tool_wire_shapes_normalize_identically(tool): + assert normalize_tool_spec(tool) == CANONICAL + + +def test_legacy_tool_spec_remains_constructible(): + assert ToolSpec(**FUNCTION) == FUNCTION + + +def test_openai_sdk_chat_and_responses_request_types_are_supported(): + chat = ChatCompletionFunctionToolParam(type="function", function=FUNCTION) + responses = FunctionToolParam(type="function", **FUNCTION) + + assert normalize_tool_spec(chat) == CANONICAL + assert normalize_tool_spec(responses) == CANONICAL + + +@pytest.mark.parametrize("tool", TOOL_SHAPES.values(), ids=TOOL_SHAPES) +def test_provider_shapes_feed_schema_aware_parsing(tool): + assert _build_param_type_index([tool]) == { + "get_weather": {"city": {"type": "string"}} + } + assert _extract_tool_names([tool]) == {"get_weather"} + + +def test_normalization_preserves_function_options_and_extensions(): + tool = { + "type": "function", + "name": "lookup", + "parameters": None, + "strict": False, + "defer_loading": True, + "allowed_callers": ["programmatic_tool_calling"], + } + + assert normalize_tool_spec(tool) == { + "type": "function", + "function": { + "name": "lookup", + "parameters": None, + "strict": False, + "defer_loading": True, + "allowed_callers": ["programmatic_tool_calling"], + }, + } + + +def test_normalization_does_not_retain_or_mutate_caller_data(): + original = deepcopy(CANONICAL) + normalized = normalize_tool_spec(original) + + normalized["function"]["parameters"]["properties"]["city"]["type"] = "integer" + + assert original == CANONICAL + + +class _ToolModel: + def __init__(self): + self.kwargs = None + + def model_dump(self, **kwargs): + self.kwargs = kwargs + return {"type": "function", **FUNCTION, "strict": None} + + +def test_pydantic_style_tool_objects_are_supported(): + tool = _ToolModel() + + assert normalize_tool_spec(tool) == { + "type": "function", + "function": {**FUNCTION, "strict": None}, + } + assert tool.kwargs == {"mode": "python", "exclude_none": True} + + +@pytest.mark.parametrize( + "tool_type", + [ + "apply_patch", + "code_interpreter", + "computer", + "computer_use_preview", + "custom", + "file_search", + "image_generation", + "local_shell", + "mcp", + "namespace", + "programmatic_tool_calling", + "shell", + "skills", + "tool_search", + "web_search", + "web_search_preview", + ], +) +def test_non_function_tool_protocols_fail_loudly(tool_type): + with pytest.raises(UnsupportedToolSpecError, match=repr(tool_type)): + normalize_tool_spec({"type": tool_type}) + + +@pytest.mark.parametrize( + ("tool", "message"), + [ + ({"description": "missing name"}, "name"), + ({"name": ""}, "name"), + ({"name": "x", "description": 42}, "description"), + ({"name": "x", "parameters": []}, "parameters"), + ({"name": "x", "strict": "yes"}, "strict"), + ({"name": "x", "defer_loading": 1}, "defer_loading"), + ({"name": "x", "allowed_callers": "direct"}, "allowed_callers"), + ( + {"name": "x", "parameters": {}, "input_schema": {"type": "object"}}, + "conflicting", + ), + ], +) +def test_invalid_function_tools_fail_at_the_boundary(tool, message): + with pytest.raises(ToolSpecError, match=message): + normalize_tool_spec(tool) + + +def test_collection_errors_identify_the_bad_tool_index(): + with pytest.raises(UnsupportedToolSpecError, match=r"tools\[1\]"): + normalize_tool_specs([FUNCTION, {"type": "web_search"}]) + + +def test_none_and_empty_collections_remain_distinct(): + assert normalize_tool_specs(None) is None + assert normalize_tool_specs([]) == [] diff --git a/tests/test_tool_shape_parity.py b/tests/test_tool_shape_parity.py new file mode 100644 index 00000000..592b7221 --- /dev/null +++ b/tests/test_tool_shape_parity.py @@ -0,0 +1,39 @@ +"""All renderers treat function-compatible provider shapes identically.""" + +from __future__ import annotations + +from functools import lru_cache + +import pytest + +from tests.golden_corpus import GOLDEN_CASES, SYSTEM_AND_USER, GoldenCase, _renderer_for +from tests.model_assets import load_test_tokenizer +from tests.test_tool_normalization import TOOL_SHAPES + + +pytestmark = [pytest.mark.network, pytest.mark.model_parity] + + +@lru_cache(maxsize=None) +def _load_case(case: GoldenCase): + tokenizer = load_test_tokenizer(case.model_name) + return _renderer_for(case, tokenizer) + + +@pytest.mark.parametrize("case", GOLDEN_CASES, ids=lambda case: case.slug) +@pytest.mark.parametrize("shape", TOOL_SHAPES) +def test_provider_tool_shapes_render_identically(case, shape): + renderer = _load_case(case) + expected = renderer.render( + SYSTEM_AND_USER, + tools=[TOOL_SHAPES["openai-chat"]], + add_generation_prompt=True, + ) + + actual = renderer.render( + SYSTEM_AND_USER, + tools=[TOOL_SHAPES[shape]], + add_generation_prompt=True, + ) + + assert actual == expected