Files
pydantic--pydantic-ai/tests/test_code_execution_files_vcr.py
wehub-resource-sync 9201ef759e
Harness Compat / harness compat (push) Failing after 0s
CI / test on 3.12 (standard) (push) Has been cancelled
CI / test on 3.13 (standard) (push) Has been cancelled
CI / test on 3.14 (standard) (push) Has been cancelled
CI / test on 3.10 (all-extras) (push) Has been cancelled
CI / test on 3.11 (all-extras) (push) Has been cancelled
CI / test on 3.12 (all-extras) (push) Has been cancelled
CI / test on 3.14 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.10 (pydantic-evals) (push) Has been cancelled
CI / test on 3.11 (pydantic-evals) (push) Has been cancelled
CI / test on 3.12 (pydantic-evals) (push) Has been cancelled
CI / deploy-docs-preview (push) Has been cancelled
CI / build release artifacts (push) Has been cancelled
CI / publish to PyPI (push) Has been cancelled
CI / Send tweet (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / test on 3.10 (standard) (push) Has been cancelled
CI / test on 3.11 (standard) (push) Has been cancelled
CI / test on 3.13 (all-extras) (push) Has been cancelled
CI / test on 3.14 (all-extras) (push) Has been cancelled
CI / test on 3.10 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.11 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.12 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-evals) (push) Has been cancelled
CI / test on 3.14 (pydantic-evals) (push) Has been cancelled
CI / test on 3.10 (lowest-versions) (push) Has been cancelled
CI / test on 3.11 (lowest-versions) (push) Has been cancelled
CI / test on 3.12 (lowest-versions) (push) Has been cancelled
CI / test on 3.13 (lowest-versions) (push) Has been cancelled
CI / test on 3.14 (lowest-versions) (push) Has been cancelled
CI / test examples on 3.11 (push) Has been cancelled
CI / test examples on 3.12 (push) Has been cancelled
CI / test examples on 3.13 (push) Has been cancelled
CI / test examples on 3.14 (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / check (push) Has been cancelled
CI / deploy-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

341 lines
15 KiB
Python

"""Feature-central tests for `CodeExecutionTool.files` (uploaded-file support).
The two VCR tests upload a real file via the provider's Files API and then run an
agent with `CodeExecutionTool(files=[...])`, proving the upload round-trip end to
end: the file reference reaches the provider (Anthropic `container_upload` block +
`files-api-2025-04-14` beta; OpenAI Responses `code_interpreter` container
`file_ids`) and the model reads the uploaded file. Each test also passes a
foreign-provider `UploadedFile` to show it is filtered out on the wire.
`test_openai_code_execution_files_all_filtered` is the one branch the round-trip
can't exercise (files set, but none match the provider, so no `file_ids` is sent):
it stays a unit test on the request-building path, kept here so the whole feature
lives in one file.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Literal
import pytest
from inline_snapshot import snapshot
from pydantic_ai import Agent
from pydantic_ai.capabilities import NativeTool
from pydantic_ai.messages import ModelResponse, UploadedFile
from pydantic_ai.models import ModelRequestParameters
from pydantic_ai.native_tools import CodeExecutionTool
from .conftest import try_import
with try_import() as anthropic_imports_successful:
import anthropic
from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
from pydantic_ai.providers.anthropic import AnthropicProvider
with try_import() as openai_imports_successful:
import openai
from pydantic_ai.models.openai import OpenAIResponsesModel
from pydantic_ai.providers.openai import OpenAIProvider
pytestmark = [
pytest.mark.anyio,
pytest.mark.vcr,
]
_CSV_BYTES = b'item,value\napple,30\nbanana,70\n'
_PROMPT = 'Use the code execution tool to read the uploaded CSV file and report the sum of the `value` column.'
@pytest.mark.skipif(not anthropic_imports_successful(), reason='anthropic not installed')
async def test_anthropic_code_execution_files(allow_model_requests: None, anthropic_api_key: str, vcr: Any):
"""Upload a real file to the Anthropic Files API and have code execution read it."""
client = anthropic.AsyncAnthropic(api_key=anthropic_api_key)
uploaded = await client.beta.files.upload(
file=('data.csv', _CSV_BYTES, 'text/csv'),
betas=['files-api-2025-04-14'],
)
try:
model = AnthropicModel('claude-sonnet-4-6', provider=AnthropicProvider(anthropic_client=client))
agent = Agent(
model,
capabilities=[
NativeTool(
CodeExecutionTool(
files=[
UploadedFile(file_id=uploaded.id, provider_name='anthropic'),
UploadedFile(file_id='file-other-provider', provider_name='openai'),
]
)
)
],
)
result = await agent.run(_PROMPT)
finally:
await client.beta.files.delete(uploaded.id, betas=['files-api-2025-04-14'])
await client.close()
assert '100' in result.output
# The uploaded file goes up as a `container_upload` block (the API only accepts this under
# the `files-api-2025-04-14` beta, which the model auto-enables); the foreign-provider file
# is filtered out, so only the anthropic file id is sent.
messages_request = [r for r in vcr.requests if '/v1/messages' in r.uri][0]
assert 'beta=true' in messages_request.uri
user_content = json.loads(messages_request.body)['messages'][0]['content']
container_uploads = [block for block in user_content if block['type'] == 'container_upload']
assert container_uploads == [{'type': 'container_upload', 'file_id': uploaded.id}]
@pytest.mark.skipif(not anthropic_imports_successful(), reason='anthropic not installed')
async def test_anthropic_code_execution_files_multi_turn(allow_model_requests: None, anthropic_api_key: str, vcr: Any):
"""Across two turns, the container is reused by id *and* the `container_upload` block is re-sent.
`pending_container_uploads` is recomputed from the static `CodeExecutionTool.files` config on
every request and injected into the first user message, so turn 2 re-sends the `container_upload`
block for a file the reused container already holds. This pins, against the live API, that
Anthropic tolerates that redundant re-send: turn 2 carries both `container=<id from turn 1>` and
the same `container_upload`, and still succeeds. (The re-send is also what keeps the first user
message byte-identical across turns, so it must not be gated to the first turn — see
`test_anthropic_code_execution_files_cache_prefix_stable`.)
"""
client = anthropic.AsyncAnthropic(api_key=anthropic_api_key)
uploaded = await client.beta.files.upload(
file=('data.csv', _CSV_BYTES, 'text/csv'),
betas=['files-api-2025-04-14'],
)
try:
model = AnthropicModel('claude-sonnet-4-6', provider=AnthropicProvider(anthropic_client=client))
agent = Agent(
model,
capabilities=[
NativeTool(CodeExecutionTool(files=[UploadedFile(file_id=uploaded.id, provider_name='anthropic')]))
],
)
first = await agent.run(_PROMPT)
second = await agent.run(
'Use the code execution tool again to report the average of the `value` column.',
message_history=first.all_messages(),
)
finally:
await client.beta.files.delete(uploaded.id, betas=['files-api-2025-04-14'])
await client.close()
assert '100' in first.output
assert '50' in second.output
# The container id from turn 1's response is what turn 2 reuses.
first_response = first.all_messages()[-1]
assert isinstance(first_response, ModelResponse)
container_id = (first_response.provider_details or {}).get('container_id')
assert container_id
messages_requests = [r for r in vcr.requests if '/v1/messages' in r.uri]
assert len(messages_requests) == 2
first_body, second_body = (json.loads(r.body) for r in messages_requests)
first_uploads = [
block
for message in first_body['messages']
for block in message['content']
if block['type'] == 'container_upload'
]
second_uploads = [
block
for message in second_body['messages']
for block in message['content']
if block['type'] == 'container_upload'
]
upload_block = {'type': 'container_upload', 'file_id': uploaded.id}
# Turn 1: fresh container (no `container` param), file uploaded.
assert 'container' not in first_body
assert first_uploads == [upload_block]
# Turn 2: container reused by id, *and* the same upload block re-sent — accepted by the API.
assert second_body['container'] == container_id
assert second_uploads == [upload_block]
# Large instructions so the cacheable prefix (system + tools + user text) clears Anthropic's
# ~1024-token minimum for claude-sonnet — otherwise a cache miss would be a too-small-to-cache
# artifact rather than a real signal. The text is request-stable across turns.
_CACHE_INSTRUCTIONS = (
'You are a meticulous data analyst. Always use the code execution tool to read and compute '
'over any attached CSV file before answering. '
) + ' '.join(f'Guideline {i}: prefer exact arithmetic over estimation when analyzing tabular data.' for i in range(120))
# `mode` (not a built `AnthropicModelSettings`) so this list stays import-safe when anthropic isn't
# installed — the slim test job collects this module with the `try_import` symbols undefined.
@dataclass(frozen=True)
class _CacheCase:
id: str
model: str
mode: Literal['messages', 'automatic']
_CACHE_CASES = [
_CacheCase('messages-sonnet-4-6', 'claude-sonnet-4-6', 'messages'),
_CacheCase('messages-sonnet-5', 'claude-sonnet-5', 'messages'),
_CacheCase('automatic-sonnet-4-6', 'claude-sonnet-4-6', 'automatic'),
_CacheCase('automatic-sonnet-5', 'claude-sonnet-5', 'automatic'),
]
@pytest.mark.skipif(not anthropic_imports_successful(), reason='anthropic not installed')
@pytest.mark.parametrize('case', [pytest.param(c, id=c.id) for c in _CACHE_CASES])
async def test_anthropic_code_execution_files_cache_prefix_stable(
case: _CacheCase, allow_model_requests: None, anthropic_api_key: str, vcr: Any
):
"""The `container_upload` injection keeps the cacheable prefix stable across steps.
The upload blocks are recomputed from the static `CodeExecutionTool.files` config every request
and injected into the *first* user message, so that message stays byte-identical as history
grows. This pins both halves of the guarantee against the live API:
(a) Structural — the `container_upload` appears only on the first user message, and that message
is identical across every request, so the upload never moves and never perturbs the prefix.
(b) Real reuse — turn 2 reads back at least everything turn 1 wrote
(`cache_read >= turn-1 cache_write`). This is the property `cache_read > 0` could not prove:
a moving injection point would shrink the reused prefix below what turn 1 cached.
Parametrized over the per-block (`anthropic_cache_messages`) and automatic (`anthropic_cache`)
cache-control paths and across models, whose breakpoint placement differs.
"""
client = anthropic.AsyncAnthropic(api_key=anthropic_api_key)
uploaded = await client.beta.files.upload(
file=('data.csv', _CSV_BYTES, 'text/csv'),
betas=['files-api-2025-04-14'],
)
settings = (
AnthropicModelSettings(anthropic_cache_messages=True)
if case.mode == 'messages'
else AnthropicModelSettings(anthropic_cache=True)
)
try:
model = AnthropicModel(case.model, provider=AnthropicProvider(anthropic_client=client))
agent = Agent(
model,
capabilities=[
NativeTool(CodeExecutionTool(files=[UploadedFile(file_id=uploaded.id, provider_name='anthropic')]))
],
instructions=_CACHE_INSTRUCTIONS,
model_settings=settings,
)
first = await agent.run(_PROMPT)
second = await agent.run(
'Use the code execution tool again to report the average of the `value` column.',
message_history=first.all_messages(),
)
finally:
await client.beta.files.delete(uploaded.id, betas=['files-api-2025-04-14'])
await client.close()
messages_requests = [r for r in vcr.requests if '/v1/messages' in r.uri]
assert len(messages_requests) == 2
bodies = [json.loads(r.body) for r in messages_requests]
# (a) Prefix stability: the upload lives only on the first user message, and that message's
# content is identical across both requests, so it never moves as history grows. The
# `cache_control` breakpoint marker is stripped before comparing — in `messages` mode it
# lands on the first user message in turn 1 (when it is also the last) but not in turn 2,
# which is orthogonal to stability: the cached *content* (text + upload) is what must not move.
first_user_contents: list[Any] = []
for body in bodies:
first_user_index = next(i for i, m in enumerate(body['messages']) if m['role'] == 'user')
upload_message_indices = [
i
for i, m in enumerate(body['messages'])
if m['role'] == 'user' and any(b['type'] == 'container_upload' for b in m['content'])
]
assert upload_message_indices == [first_user_index]
content = body['messages'][first_user_index]['content']
first_user_contents.append([{k: v for k, v in block.items() if k != 'cache_control'} for block in content])
assert first_user_contents[0] == first_user_contents[1]
# (b) Cache-control placement differs by mode, but `container_upload` is never the breakpoint.
if case.mode == 'messages':
for body in bodies:
cached_blocks = [b for m in body['messages'] for b in m['content'] if 'cache_control' in b]
assert cached_blocks and all(b['type'] != 'container_upload' for b in cached_blocks)
assert 'cache_control' not in body
else:
for body in bodies:
assert body['cache_control'] == {'type': 'ephemeral', 'ttl': '5m'}
assert all('cache_control' not in b for m in body['messages'] for b in m['content'])
# (c) The prefix is genuinely reused: turn 2 reads back at least everything turn 1 wrote.
assert first.usage.cache_write_tokens > 0
assert second.usage.cache_read_tokens >= first.usage.cache_write_tokens
@pytest.mark.skipif(not openai_imports_successful(), reason='openai not installed')
async def test_openai_code_execution_files(allow_model_requests: None, openai_api_key: str, vcr: Any):
"""Upload a real file to the OpenAI Files API and have code execution read it."""
client = openai.AsyncOpenAI(api_key=openai_api_key)
uploaded = await client.files.create(file=('data.csv', _CSV_BYTES), purpose='assistants')
try:
model = OpenAIResponsesModel('gpt-5', provider=OpenAIProvider(openai_client=client))
agent = Agent(
model,
capabilities=[
NativeTool(
CodeExecutionTool(
files=[
UploadedFile(file_id=uploaded.id, provider_name='openai'),
UploadedFile(file_id='file-other-provider', provider_name='anthropic'),
]
)
)
],
)
result = await agent.run(_PROMPT)
finally:
await client.files.delete(uploaded.id)
await client.close()
assert '100' in result.output
# The uploaded file id goes into the `code_interpreter` container `file_ids`; the
# foreign-provider file is filtered out.
responses_request = [r for r in vcr.requests if '/v1/responses' in r.uri][0]
tools = json.loads(responses_request.body)['tools']
code_interpreter = [t for t in tools if t['type'] == 'code_interpreter'][0]
assert code_interpreter['container'] == {'type': 'auto', 'file_ids': [uploaded.id]}
@pytest.mark.skipif(not openai_imports_successful(), reason='openai not installed')
def test_openai_code_execution_files_all_filtered():
"""Files set but none match the provider: no `file_ids` is sent (unit-tested branch).
This is not a VCR test because there is no observable round-trip — the whole point
is that nothing file-related reaches the provider, so we assert the built request.
"""
model = OpenAIResponsesModel('gpt-5', provider=OpenAIProvider(api_key='mock-api-key'))
parameters = ModelRequestParameters(
native_tools=[
CodeExecutionTool(
files=[
UploadedFile(file_id='file-anthropic', provider_name='anthropic'),
UploadedFile(file_id='file-google', provider_name='google-gla'),
]
)
],
)
tools = model._get_native_tools(parameters) # pyright: ignore[reportPrivateUsage]
assert tools == snapshot([{'type': 'code_interpreter', 'container': {'type': 'auto'}}])