DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Function Signatures to Multi-Provider Tool Schemas: One Source of Truth for All LLM Providers

You maintain tool schemas for three LLM providers. Anthropic wants input_schema with JSON Schema. OpenAI wants parameters with a function object. Google wants function_declarations in a different nesting. When you add a new tool, you write the same thing three times. When you change a parameter, you find and update it in three places.

agent-tool-spec-pack takes Python function signatures and docstrings and generates tool schemas for all three providers at once.


The Shape of the Fix

from agent_tool_spec_pack import ToolSpecPack

pack = ToolSpecPack()

@pack.register
def search_documents(query: str, max_results: int = 5, include_metadata: bool = False) -> dict:
    """Search the document corpus for relevant passages.

    Args:
        query: The search query string.
        max_results: Maximum number of results to return (1-20).
        include_metadata: Whether to include document metadata in results.

    Returns:
        dict with 'results' list and 'total_count' int.
    """
    return documents.search(query, max_results, include_metadata)

@pack.register
def create_ticket(title: str, priority: str, description: str = "") -> dict:
    """Create a support ticket.

    Args:
        title: Short title for the ticket.
        priority: Priority level. One of: low, medium, high, critical.
        description: Optional detailed description.
    """
    return tickets.create(title, priority, description)

# Get schemas for any provider
anthropic_tools = pack.for_anthropic()
openai_tools = pack.for_openai()
google_tools = pack.for_google()
Enter fullscreen mode Exit fullscreen mode

One function definition. Three provider formats.


What It Does NOT Do

agent-tool-spec-pack does not execute tools. It only generates schemas. Your tool registry or function dispatcher handles execution. The pack holds schemas and callables; you decide how to route a tool call to the right function.

It does not handle complex nested schemas. If your tool takes a Pydantic model as input, the type inference will not auto-expand the model fields into a flat JSON Schema. For complex types, you can pass an explicit schema_override to register().

It does not validate that the generated schema exactly matches each provider's requirements for all edge cases. Unusual type combinations (Union types, Optional with complex defaults, generics) may need manual schema_override. For standard Python types (str, int, float, bool, list, dict, Optional), generation is reliable.


Inside the Library

The schema generation uses inspect.signature() and typing.get_type_hints():

import inspect
import typing

def _build_json_schema(fn: Callable) -> dict:
    sig = inspect.signature(fn)
    hints = typing.get_type_hints(fn)
    doc = inspect.getdoc(fn) or ""

    properties = {}
    required = []

    for name, param in sig.parameters.items():
        if name == "return":
            continue

        type_hint = hints.get(name, typing.Any)
        prop = _type_to_schema(type_hint)

        # Extract arg description from docstring
        prop["description"] = _extract_arg_doc(doc, name)

        properties[name] = prop
        if param.default is inspect.Parameter.empty:
            required.append(name)

    return {
        "type": "object",
        "properties": properties,
        "required": required,
    }
Enter fullscreen mode Exit fullscreen mode

The provider-specific formatters wrap the shared JSON Schema in the right envelope:

def for_anthropic(self) -> list[dict]:
    return [
        {
            "name": name,
            "description": _extract_fn_description(fn),
            "input_schema": schema,
        }
        for name, (fn, schema) in self._tools.items()
    ]

def for_openai(self) -> list[dict]:
    return [
        {
            "type": "function",
            "function": {
                "name": name,
                "description": _extract_fn_description(fn),
                "parameters": schema,
            },
        }
        for name, (fn, schema) in self._tools.items()
    ]

def for_google(self) -> list[dict]:
    return [{
        "function_declarations": [
            {
                "name": name,
                "description": _extract_fn_description(fn),
                "parameters": schema,
            }
            for name, (fn, schema) in self._tools.items()
        ]
    }]
Enter fullscreen mode Exit fullscreen mode

The call(name, args) helper dispatches a tool call by name:

def call(self, name: str, args: dict) -> Any:
    if name not in self._tools:
        raise ToolNotFound(name)
    fn, _ = self._tools[name]
    return fn(**args)
Enter fullscreen mode Exit fullscreen mode

When to Use It

Use it when your agent needs to support multiple LLM providers and you do not want to maintain separate schema definitions. The single-source-of-truth property is the main value: one Python function, one docstring, all formats.

Use it when you want type safety in tool definitions. Writing JSON Schema manually is error-prone. Deriving it from Python type hints means the schema stays in sync with the implementation.

Skip it for tools with complex input types. If your tool takes a typed dataclass or Pydantic model, the annotation-based schema generation will not expand the fields. You are better off writing the JSON Schema once and passing it as schema_override.

Skip it if you only target one provider. If you only call Anthropic, writing the Anthropic schema format directly is simpler than adding an abstraction layer.


Install

pip install git+https://github.com/MukundaKatta/agent-tool-spec-pack

# Or from PyPI
pip install agent-tool-spec-pack
Enter fullscreen mode Exit fullscreen mode
from agent_tool_spec_pack import ToolSpecPack

pack = ToolSpecPack()

@pack.register
def get_weather(location: str, units: str = "celsius") -> dict:
    """Get current weather for a location.

    Args:
        location: City name or coordinates.
        units: Temperature units. One of: celsius, fahrenheit.
    """
    return weather_api.get(location, units)

@pack.register
def list_files(directory: str, pattern: str = "*") -> list:
    """List files in a directory.

    Args:
        directory: Absolute path to directory.
        pattern: Glob pattern to filter files.
    """
    return glob.glob(f"{directory}/{pattern}")

# Use with any provider
def run_with_anthropic(task: str) -> str:
    response = anthropic_client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        tools=pack.for_anthropic(),
        messages=[{"role": "user", "content": task}],
    )

    if response.stop_reason == "tool_use":
        for block in response.content:
            if block.type == "tool_use":
                result = pack.call(block.name, block.input)
                # ... continue loop
Enter fullscreen mode Exit fullscreen mode

Sibling Libraries

Library What it solves
tool-schema-from-fn Single-function annotation-to-schema (no registry)
tool-arg-defaults Fill missing tool args from schema defaults
tool-arg-coerce-py Coerce LLM tool args to expected Python types
agent-fn-registry Registry combining schema, side effects, defaults
tool-side-effects-tag Tag tools as READ/WRITE/IDEMPOTENT/DESTRUCTIVE

The tool infrastructure stack: agent-tool-spec-pack for multi-provider schema generation, tool-arg-coerce-py for type coercion, tool-arg-defaults for default filling, tool-side-effects-tag for safety classification.


What's Next

Pydantic model support: when a parameter is annotated with a Pydantic model, auto-expand the model fields into a nested JSON Schema. This would handle the most common complex-input case without requiring schema_override.

Schema validation mode: pack.validate_schemas() that checks all registered schemas against each provider's documented constraints and returns a list of issues. Catches missing required arrays, forbidden format keys, and other provider-specific gotchas before runtime.

Diff output: pack.diff(other_pack) that shows which tools changed, which were added or removed, and whether any schema changes are breaking. Useful for code review when the tool library evolves.


Built as part of the agent-stack family: composable Python primitives for production LLM agents.

Top comments (0)