Skip to content

feat(tool): normalize OpenAI tool-call wire formats and sanitize tool names - #43

Closed
isaacbmiller wants to merge 1 commit into
mainfrom
isaac/toolcalls-normalize
Closed

feat(tool): normalize OpenAI tool-call wire formats and sanitize tool names#43
isaacbmiller wants to merge 1 commit into
mainfrom
isaac/toolcalls-normalize

Conversation

@isaacbmiller

Copy link
Copy Markdown

What

Adds robust normalization in ToolCalls.validate_input so the adapter postprocessor can call ToolCalls.model_validate(tool_calls) directly on whatever LiteLLM hands us, instead of hand-rolling json_repair and shape-sniffing in every call site.

Changes

  • _normalize_openai_tool_call handles three wire formats:
    • OpenAI Chat Completions: {type:'function', function:{name, arguments}}
    • OpenAI Responses API: {type:'function_call', name, arguments, call_id}
    • DSPy-native: {name, args}
  • Cached LiteLLM pydantic objects whose model_dump() raises TypeError (MockValSer/SchemaSerializer bug) fall back to attribute access via _build_call; objects with no recoverable attributes re-raise the original TypeError instead of silently returning malformed data.
  • _validate_call_list reports the failing index, type, and value when an item can't be coerced, replacing the previous generic Received invalid value for \dspy.ToolCalls`` error.
  • ToolCall.id optional field added for OpenAI tool_call_id round-tripping.
  • _sanitize_tool_name enforces OpenAI's ^[a-zA-Z0-9_-]+$ pattern at Tool construction time so emitted tool definitions don't 400 from the API.

Why this is a standalone PR

This is a prerequisite for the native-FC adapter pipeline work (formerly #40), but it's a pure data-normalization concern with no adapter-layer coupling. Splitting it out lets reviewers focus on the wire-format edge cases (tested across 7 cases including TypeError recovery, garbage model_dump, None-name guards, and explicit-None-args) without wading through the adapter refactor.

Tests

  • tests/adapters/test_tool.py — 36 tests pass, including:
    • OpenAI Chat Completions wire format
    • OpenAI Responses API wire format
    • Cached LiteLLM MockValSer recovery (with and without recoverable attrs)
    • Garbage model_dump returns (None, list, dict-with-no-name)
    • Explicit None for name and args
    • Per-index error reporting
  • All 169 tests/adapters/ tests pass against this branch in isolation.

… names

Adds robust normalization in `ToolCalls.validate_input` so the adapter
postprocessor can call `ToolCalls.model_validate(tool_calls)` directly
on whatever LiteLLM hands us, instead of hand-rolling `json_repair` and
shape-sniffing in every call site.

- `_normalize_openai_tool_call` handles OpenAI Chat Completions
  (`{type:'function', function:{name, arguments}}`), OpenAI Responses
  API (`{type:'function_call', name, arguments, call_id}`), and DSPy-
  native `{name, args}` shapes.
- Cached LiteLLM pydantic objects whose `model_dump()` raises
  `TypeError` (the MockValSer/SchemaSerializer bug) fall back to
  attribute access via `_build_call`; objects with no recoverable
  attributes re-raise the original `TypeError` instead of silently
  returning malformed data.
- `_validate_call_list` reports the failing index, type, and value when
  an item can't be coerced, replacing the previous generic
  `Received invalid value for \`dspy.ToolCalls\`` error.
- `ToolCall` gains an optional `id` field for OpenAI `tool_call_id`
  round-tripping.
- `_sanitize_tool_name` enforces OpenAI's `^[a-zA-Z0-9_-]+$` pattern at
  `Tool` construction time so emitted tool definitions don't 400 from
  the API.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@greptile-apps

greptile-apps Bot commented May 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR centralises OpenAI tool-call wire-format normalization into ToolCalls.validate_input so downstream call sites no longer need to shape-sniff raw LiteLLM payloads themselves, and enforces OpenAI's ^[a-zA-Z0-9_-]+$ name constraint at Tool construction time.

  • _normalize_openai_tool_call dispatches across three shapes (Chat Completions, Responses API, DSPy-native) and handles the LiteLLM MockValSer/SchemaSerializer TypeError by falling back to getattr-based field extraction when model_dump() fails.
  • ToolCall.id is added as an optional field and conditionally emitted in format() for round-trip fidelity with OpenAI's tool_call_id.
  • _validate_call_list replaces the previous generic error with a per-index diagnostic that includes the offending item's type and repr.

Confidence Score: 4/5

Safe to merge; normalization logic is sound across all three wire formats and the TypeError-recovery path is well-tested.

The core normalization branches work correctly for every identified real-world input. The or-based call_id lookup in _build_call can silently drop an explicit but falsy ID, and the single-dict fast-path in validate_input bypasses _normalize_openai_tool_call, leaving string args unparsed and OpenAI-format single dicts unhandled. Neither defect fires against today's callers, but both are on the hot path that downstream adapter work will rely on.

dspy/adapters/types/tool.py — specifically _build_call's call_id resolution and the single-dict branch in validate_input.

Important Files Changed

Filename Overview
dspy/adapters/types/tool.py Adds multi-format OpenAI tool-call normalization, tool-name sanitization, and ToolCall.id round-tripping. Logic is correct for the happy paths; two P2 issues: or-based call_id priority can silently discard a falsy but present ID, and the single-dict fast-path in validate_input bypasses _normalize_openai_tool_call.
tests/adapters/test_tool.py Adds 5 targeted tests for the new normalization paths (MockValSer recovery, None-name/None-args rejection, unrecoverable TypeError propagation). Missing explicit dict-based tests for the two new OpenAI wire formats despite them being called out in the PR description.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[ToolCalls.model_validate / validate_input] --> B{data type?}
    B -->|list| C[_validate_call_list]
    B -->|dict with tool_calls list| C
    B -->|dict with name+args| D[ToolCall directly]
    B -->|other| E[raise ValueError]

    C --> F[for each item: _normalize_openai_tool_call]
    F --> G{type == function and function?}
    G -->|yes| H[_build_call fn+fn+item]
    G -->|no| I{type == function_call and name?}
    I -->|yes| J[_build_call all=item]
    I -->|no| K{not dict and model_dump?}
    K -->|yes| L[model_dump and recurse]
    L -->|TypeError + name=None| M[re-raise TypeError]
    L -->|TypeError + name OK| N[_build_call via getattr]
    K -->|no| O[return item as-is]

    H --> P[guard: name != None and args != None]
    J --> P
    N --> P
    O --> P
    P -->|pass| Q[ToolCall created]
    P -->|fail| R[raise ValueError with index+type+value]
Loading

Reviews (1): Last reviewed commit: "feat(tool): normalize OpenAI tool-call w..." | Re-trigger Greptile

Comment on lines +391 to +395
call_id = (
ToolCalls._get_tool_call_value(id_source, "call_id")
or ToolCalls._get_tool_call_value(id_source, "id")
)
if call_id:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 or silently drops a falsy call_id

The expression _get_tool_call_value(id_source, "call_id") or _get_tool_call_value(id_source, "id") will skip call_id whenever the field is present but falsy (e.g., an empty string ""). In that case the code falls through to the id field, potentially returning the wrong ID or None, and the if call_id: guard below would then omit the field entirely. Using an explicit is None check avoids silently losing an explicit-but-empty value.

Suggested change
call_id = (
ToolCalls._get_tool_call_value(id_source, "call_id")
or ToolCalls._get_tool_call_value(id_source, "id")
)
if call_id:
call_id = ToolCalls._get_tool_call_value(id_source, "call_id")
if call_id is None:
call_id = ToolCalls._get_tool_call_value(id_source, "id")
if call_id is not None:
normalized["id"] = call_id

Comment on lines +457 to 462
if isinstance(data, dict):
inner = data.get("tool_calls")
if isinstance(inner, list):
return cls._validate_call_list(inner)
if "name" in data and "args" in data:
return {"tool_calls": [cls.ToolCall(**data)]}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Single-dict shortcut bypasses normalization

The "name" in data and "args" in data fast-path at line 462 constructs a ToolCall directly from data without going through _normalize_openai_tool_call. This means a single DSPy-native dict whose args value is a JSON string (rather than an already-parsed dict) will hit a Pydantic ValidationError instead of being repaired by json_repair, inconsistently with the list path which calls _parse_tool_call_args. Additionally, a single OpenAI Chat Completions dict {type:"function", function:{...}} passed here falls through to the final raise ValueError rather than being normalized.

Comment on lines +500 to +563
def test_toolcalls_normalizes_cached_litellm_tool_call_object():
class Function:
name = "search"
arguments = '{"query": "hello", "k": 5}'

class CachedToolCall:
id = "call_123"
type = "function"
function = Function()

def model_dump(self):
raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'")

tc = ToolCalls.model_validate([CachedToolCall()])

assert len(tc.tool_calls) == 1
assert tc.tool_calls[0].name == "search"
assert tc.tool_calls[0].args == {"query": "hello", "k": 5}
assert tc.tool_calls[0].id == "call_123"


def test_toolcalls_normalizes_cached_dspy_native_object_with_broken_model_dump():
"""A cached pydantic object that doesn't match the OpenAI wire shape but has
DSPy-native (name, args) attributes should still normalize via getattr when
model_dump() raises TypeError (MockValSer error)."""
class CachedDSPyToolCall:
id = "call_456"
name = "lookup"
args = {"key": "value"}

def model_dump(self):
raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'")

tc = ToolCalls.model_validate([CachedDSPyToolCall()])

assert len(tc.tool_calls) == 1
assert tc.tool_calls[0].name == "lookup"
assert tc.tool_calls[0].args == {"key": "value"}
assert tc.tool_calls[0].id == "call_456"


def test_toolcalls_propagates_typeerror_when_no_recoverable_attributes():
"""When model_dump raises and the object has no name attribute, the
TypeError should propagate rather than being silently swallowed."""
class BrokenObject:
def model_dump(self):
raise TypeError("'MockValSer' object cannot be converted to 'SchemaSerializer'")

with pytest.raises(TypeError, match="MockValSer"):
ToolCalls.model_validate([BrokenObject()])


def test_toolcalls_rejects_explicit_none_name():
"""A normalized item with name=None should fail with a clear targeted error,
not slip through the key-existence guard into a downstream pydantic error."""
with pytest.raises(ValueError, match="Could not normalize tool call at index 0"):
ToolCalls.model_validate([{"name": None, "args": {"q": "x"}}])


def test_toolcalls_rejects_explicit_none_args():
"""A normalized item with args=None should also be rejected with a clear error."""
with pytest.raises(ValueError, match="Could not normalize tool call at index 0"):
ToolCalls.model_validate([{"name": "search", "args": None}])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No explicit dict-based tests for the two new OpenAI wire formats

The PR description says "OpenAI Chat Completions wire format" and "OpenAI Responses API wire format" are among the tested cases, but the added tests only exercise these formats via Python object attribute access. There are no tests that pass actual dicts such as {"type": "function", "function": {"name": "search", "arguments": "{}"}} through ToolCalls.model_validate, which risks silent regressions if someone refactors the branch conditions.

@isaacbmiller

Copy link
Copy Markdown
Author

Superseded by #47, which moves all tool-call wire-shape coercion to a single boundary function (to_tool_call) at the LiteLLM exit in BaseLM. Downstream adapters and inspect_history collapse to one-liners. MockValSer / SchemaSerializer fallback (pydantic#7713, litellm#9345) is contained to one place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant