diff --git a/.github/scripts/pydantic-ai-runner.lock b/.github/scripts/pydantic-ai-runner.lock index d7c67f9460..bdbccca8b8 100644 --- a/.github/scripts/pydantic-ai-runner.lock +++ b/.github/scripts/pydantic-ai-runner.lock @@ -510,15 +510,15 @@ client = [ [[package]] name = "genai-prices" -version = "0.1.9" +version = "0.1.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx2" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/f0/4b4c7f63de6d558714d2207808080dd0988d0317a54ebcde790e4f6c1b6e/genai_prices-0.1.9.tar.gz", hash = "sha256:20ec774d32ec5d345510877fac415f2560bbfeb081bc4cd4e7ebce57a5503e8d", size = 118447, upload-time = "2026-09-25T03:47:27.569Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/0b/159528e8580f9a5c985be78fcbea6230a1bcc1d46a9f8b681511d2c37e3f/genai_prices-0.1.10.tar.gz", hash = "sha256:889ba486497f8a85e9dd0e9228a27f2e135e31a850e96924b880f22c132092ac", size = 125604, upload-time = "2026-10-09T17:58:15.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/e0/57d07f653813b7ea9e3a82064a2b6a112f63004530ac665e0934829473c7/genai_prices-0.1.9-py3-none-any.whl", hash = "sha256:3da8faa0a126e4df7be36696b90abe2be1fea8826bfcf45d39d91bc397fa9d1d", size = 125549, upload-time = "2026-09-25T03:47:26.483Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/379fe71fccf01d829f1d72f805189453f12ba4b96e4b49a5cde8e1afd5ba/genai_prices-0.1.10-py3-none-any.whl", hash = "sha256:1f0eb9ff0b02f3a40b3aa590098fbc3bfc9e08ef124fa0bd132e9d899a6fec88", size = 132788, upload-time = "2026-10-09T17:58:14.463Z" }, ] [[package]] @@ -1229,7 +1229,7 @@ requires-dist = [ { name = "fastmcp-slim", extras = ["client"], marker = "extra == 'mcp'", specifier = ">=3.3.0,<5" }, { name = "fastmcp-slim", extras = ["client"], marker = "extra == 'mcp-tasks'", specifier = ">=4.0.0b1,<5" }, { name = "fastmcp-tasks", marker = "extra == 'mcp-tasks'", specifier = ">=4.0.0b1,<5" }, - { name = "genai-prices", specifier = ">=0.1.9" }, + { name = "genai-prices", specifier = ">=0.1.10" }, { name = "google-genai", marker = "extra == 'google'", specifier = ">=2.25.0" }, { name = "google-genai", marker = "extra == 'google-realtime'", specifier = ">=2.25.0" }, { name = "griffelib", specifier = ">=2.0" }, diff --git a/docs/native-tools.md b/docs/native-tools.md index b3e16aa440..d6494c7cde 100644 --- a/docs/native-tools.md +++ b/docs/native-tools.md @@ -122,7 +122,7 @@ _(This example is complete, it can be run "as is")_ With Anthropic, the number of searches is reported as `web_search_requests` in [`RequestUsage.details`][pydantic_ai.usage.RequestUsage.details] and included in [`RunUsage.cost`][pydantic_ai.usage.RunUsage.cost]. -With Google, the number of Google Search grounding queries is reported as `web_search_requests` in [`RequestUsage.details`][pydantic_ai.usage.RequestUsage.details], and the billed count as `web_searches` on [`RequestUsage`][pydantic_ai.usage.RequestUsage]: one per unique query on Gemini 3, and one per grounded request that returned a web source on Gemini 2.5 and older. Unlike Anthropic and OpenAI, this usage is not yet included in [`RunUsage.cost`][pydantic_ai.usage.RunUsage.cost]. +With Google, the number of Google Search grounding queries is reported as `web_search_requests` in [`RequestUsage.details`][pydantic_ai.usage.RequestUsage.details], and the billed count as `web_searches` on [`RequestUsage`][pydantic_ai.usage.RequestUsage]: one per unique query on Gemini 3, and one per grounded request that returned a web source on Gemini 2.5 and older. The billed count is included in [`RunUsage.cost`][pydantic_ai.usage.RunUsage.cost], without deducting Google's free grounding allowance. With OpenAI, you must use their Responses API to access the web search tool. The number of searches is reported as `web_search_requests` in [`RequestUsage.details`][pydantic_ai.usage.RequestUsage.details] and included in [`RunUsage.cost`][pydantic_ai.usage.RunUsage.cost]. Pages the model opens or searches within don't count as searches. diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index a74a755133..4f167d05fd 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ "pydantic-graph=={{ version }}", "opentelemetry-api>=1.28.0", "typing-inspection>=0.4.0", - "genai-prices>=0.1.9", + "genai-prices>=0.1.10", ] [tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies] diff --git a/tests/clai2/test_prompt_surface.py b/tests/clai2/test_prompt_surface.py index 89bacfae62..228c4bc17f 100644 --- a/tests/clai2/test_prompt_surface.py +++ b/tests/clai2/test_prompt_surface.py @@ -198,7 +198,9 @@ async def test_close_prints_the_session_into_the_main_screen_once() -> None: screen.surface.restore() assert not screen.terminal.alternate assert screen.lines()[:3] == ['$ clai2', 'banner', 'linked answer'] - printed = Text.from_ansi(screen.terminal.getvalue().rsplit(LEAVE, 1)[1]) + # The surface prints CRLF rows, and Rich 15.0.0's `from_ansi` blanks each one: + # https://github.com/Textualize/rich/issues/4090 + printed = Text.from_ansi(screen.terminal.getvalue().rsplit(LEAVE, 1)[1].replace('\r\n', '\n')) assert printed.get_style_at_offset(Console(), 0).link == 'https://example.com', 'scrollback keeps links' screen.surface.restore() assert screen.lines()[:4] == ['$ clai2', 'banner', 'linked answer', ''] diff --git a/tests/clai2/test_prompt_transcript.py b/tests/clai2/test_prompt_transcript.py index 5ecbf48df1..215bd2e8be 100644 --- a/tests/clai2/test_prompt_transcript.py +++ b/tests/clai2/test_prompt_transcript.py @@ -276,7 +276,8 @@ def test_printed_skips_output_already_in_scrollback_and_cleared_output() -> None block.write('answer\n') buffer.clear() buffer.write('after clear\n') - assert Text.from_ansi(buffer.printed(width=40)).plain == 'after clear' + # Rich 15.0.0's `from_ansi` keeps a trailing newline (https://github.com/Textualize/rich/pull/4076). + assert [Text.from_ansi(line).plain for line in buffer.printed(width=40).splitlines()] == ['after clear'] assert buffer.printed(width=40) == '' block.freeze() assert plain(buffer) == ['after clear', ''] @@ -345,7 +346,9 @@ def test_pre_live_transcript_migrates_in_place_preserving_styles_and_partial_ans transcript.write('m green\x1b[0m\n') assert plain(transcript) == ['old output', 'pending green', ''] assert '\x1b[32m' in transcript.frame(width=80, height=24).rows[1] - assert Text.from_ansi(transcript.printed(width=80)).plain == 'pending green', 'old output was already emitted' + # Rich 15.0.0's `from_ansi` keeps a trailing newline (https://github.com/Textualize/rich/pull/4076). + printed = [Text.from_ansi(line).plain for line in transcript.printed(width=80).splitlines()] + assert printed == ['pending green'], 'old output was already emitted' output = io.StringIO() console = Console(file=output) with transcript.capture(console): diff --git a/tests/clai2/test_tool_call_groups.py b/tests/clai2/test_tool_call_groups.py index 12a90247de..58184a21be 100644 --- a/tests/clai2/test_tool_call_groups.py +++ b/tests/clai2/test_tool_call_groups.py @@ -350,7 +350,9 @@ def test_set_menu_previews_each_style_in_the_choice_picker(tmp_path: Path, monke monkeypatch.setattr('pydantic_clai2.ui.menus.field_menu.menu_key', lambda: next(keys)) result = menu.build_choices(row).run() assert result.item is not None and result.item.value == 'grouped' - plain = Text.from_ansi(output.getvalue()).plain + # The menu paints CRLF rows, and Rich 15.0.0's `from_ansi` blanks each one: + # https://github.com/Textualize/rich/issues/4090 + plain = Text.from_ansi(output.getvalue().replace('\r\n', '\n')).plain assert '● shell git status' in plain assert '● shell 3, read_file 2, shell 1' in plain # The typed-value row has no sample to preview. diff --git a/tests/durable_exec/test_dbos.py b/tests/durable_exec/test_dbos.py index cf53b7c3a1..0b1d1eaeae 100644 --- a/tests/durable_exec/test_dbos.py +++ b/tests/durable_exec/test_dbos.py @@ -4887,8 +4887,9 @@ async def test_dbos_default_ids_survive_recovery(dbos: DBOS) -> None: assert [run_id for run_id, _ in seen] == [f'{workflow_id}:1', f'{workflow_id}:3', f'{workflow_id}:3:1'] assert len({*seen[0], *seen[1], *seen[2]}) == 6 - # Recovery re-executes the workflow function: model requests replay from their recorded steps, while - # the function tool, which runs in the workflow, runs again and sees the same IDs. - handle = await asyncio.to_thread(DBOS._execute_workflow_id, workflow_id) # pyright: ignore[reportPrivateUsage] - assert sorted(tuple(ids) for ids in await asyncio.to_thread(handle.get_result)) == seen + # Resuming the pending workflow re-executes it under the same ID, the way recovery does: model requests + # replay from their recorded steps, while the function tool, which runs in the workflow, runs again and + # sees the same IDs. + handle = await DBOS.resume_workflow_async(workflow_id) + assert sorted(tuple(ids) for ids in await handle.get_result()) == seen assert sorted(stable_ids_seen[3:]) == seen diff --git a/tests/harness/e2b_sandbox/fake_e2b.py b/tests/harness/e2b_sandbox/fake_e2b.py index 40084f34a1..c013f07d21 100644 --- a/tests/harness/e2b_sandbox/fake_e2b.py +++ b/tests/harness/e2b_sandbox/fake_e2b.py @@ -32,6 +32,7 @@ from typing import IO, TYPE_CHECKING, Literal, Protocol import anyio import anyio.lowlevel +import e2b from e2b import CommandExitException, CommandResult, FileType, WriteInfo from e2b.exceptions import ( AuthenticationException, @@ -41,9 +42,13 @@ from e2b.exceptions import ( SandboxException, SandboxNotFoundException, TimeoutException, - format_sandbox_timeout_exception, ) +# What the SDK raises for an envd call on a gone sandbox. Looked up by its public name rather than +# through the SDK's formatting helper, which e2b renamed in 2.53.1: from then on it is the exported +# `SandboxNotRunningException` (e2b-dev/E2B#1949), before it `TimeoutException` itself. +_SandboxGoneException: type[TimeoutException] = getattr(e2b, 'SandboxNotRunningException', TimeoutException) + __all__ = ( 'FakeCommandCall', 'FakeCreateCall', @@ -651,10 +656,11 @@ class FakeSandbox: E2B's proxy answers a request for a killed sandbox with a 502, which the SDK raises as a `TimeoutException` blaming the sandbox timeout -- the same type it uses for a slow - request, so only the health probe tells the two apart. + request, so only the health probe tells the two apart. From e2b 2.53.1 it is the + `SandboxNotRunningException` subclass, which the backend still probes as a timeout. """ if self.killed: - raise format_sandbox_timeout_exception('The sandbox was not found') + raise _SandboxGoneException('The sandbox was not found: it was killed or reached its timeout.') async def is_running(self, request_timeout: float | None = None) -> bool: del request_timeout diff --git a/tests/models/google/test_image_generation.py b/tests/models/google/test_image_generation.py index 64f095d413..81c5e6484f 100644 --- a/tests/models/google/test_image_generation.py +++ b/tests/models/google/test_image_generation.py @@ -612,7 +612,7 @@ async def test_google_image_generation_with_web_search(allow_model_requests: Non }, output_reasoning_tokens=529, web_searches=1, - cost=Decimal('0.148734'), + cost=Decimal('0.162734'), ), model_name='gemini-3-pro-image-preview', timestamp=IsDatetime(), diff --git a/tests/models/google/test_structured_output.py b/tests/models/google/test_structured_output.py index 63a287cbb2..1d5e899c32 100644 --- a/tests/models/google/test_structured_output.py +++ b/tests/models/google/test_structured_output.py @@ -381,7 +381,7 @@ async def test_native_output_with_builtin_tools_stream(allow_model_requests: Non details={'thoughts_tokens': 78, 'text_prompt_tokens': 87, 'web_search_requests': 1}, output_reasoning_tokens=78, web_searches=1, - cost=Decimal('0.0002775'), + cost=Decimal('0.0142775'), ), model_name='gemini-3-flash-preview', timestamp=IsDatetime(), @@ -640,7 +640,7 @@ As for the weather in Tokyo, it is currently **cloudy** with a temperature of ap details={'thoughts_tokens': 121, 'text_prompt_tokens': 132, 'web_search_requests': 1}, output_reasoning_tokens=121, web_searches=1, - cost=Decimal('0.000615'), + cost=Decimal('0.014615'), ), model_name='gemini-3-flash-preview', timestamp=IsDatetime(), @@ -790,7 +790,7 @@ async def test_native_output_with_function_and_builtin_tools( output_reasoning_tokens=27, input_tool_tokens=86, web_searches=1, - cost=Decimal('0.000344'), + cost=Decimal('0.014344'), ), model_name='gemini-3-flash-preview', timestamp=IsDatetime(), @@ -880,7 +880,7 @@ async def test_native_output_with_builtin_tools(allow_model_requests: None, goog details={'thoughts_tokens': 71, 'text_prompt_tokens': 351, 'web_search_requests': 1}, output_reasoning_tokens=71, web_searches=1, - cost=Decimal('0.0004215'), + cost=Decimal('0.0144215'), ), model_name='gemini-3-flash-preview', timestamp=IsDatetime(), diff --git a/tests/models/test_google.py b/tests/models/test_google.py index 7bead2f252..743d37e1f1 100644 --- a/tests/models/test_google.py +++ b/tests/models/test_google.py @@ -1145,7 +1145,7 @@ Overall, today's weather in San Francisco is pleasant, with a mix of sun and clo input_tool_tokens=119, input_text_tool_tokens=119, web_searches=1, - cost=Decimal('0.00431'), + cost=Decimal('0.03931'), ), model_name='gemini-2.5-pro', timestamp=IsDatetime(), @@ -1234,7 +1234,7 @@ Tonight, the skies will remain cloudy with a continued chance of showers, and th input_tool_tokens=286, input_text_tool_tokens=286, web_searches=1, - cost=Decimal('0.00398875'), + cost=Decimal('0.03898875'), ), model_name='gemini-2.5-pro', timestamp=IsDatetime(), @@ -1390,7 +1390,7 @@ Hourly forecasts show temperatures remaining in the low 70s during the afternoon input_tool_tokens=102, input_text_tool_tokens=102, web_searches=1, - cost=Decimal('0.00667875'), + cost=Decimal('0.04167875'), ), model_name='gemini-2.5-pro', timestamp=IsDatetime(), @@ -1637,7 +1637,7 @@ There is a high chance of rain throughout the day, with some reports stating a 6 input_tool_tokens=319, input_text_tool_tokens=319, web_searches=1, - cost=Decimal('0.00612'), + cost=Decimal('0.04112'), ), model_name='gemini-2.5-pro', timestamp=IsDatetime(), @@ -3728,7 +3728,7 @@ Based on your location in **San Francisco**, here is the weather forecast for to output_tokens=706, output_reasoning_tokens=456, web_searches=1, - cost=Decimal('0.0021805'), + cost=Decimal('0.0161805'), ), model_name='gemini-3-flash-preview', timestamp=IsDatetime(), diff --git a/uv.lock b/uv.lock index 8ea8f46e0b..31e87946b9 100644 --- a/uv.lock +++ b/uv.lock @@ -2346,15 +2346,15 @@ wheels = [ [[package]] name = "genai-prices" -version = "0.1.9" +version = "0.1.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx2" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/f0/4b4c7f63de6d558714d2207808080dd0988d0317a54ebcde790e4f6c1b6e/genai_prices-0.1.9.tar.gz", hash = "sha256:20ec774d32ec5d345510877fac415f2560bbfeb081bc4cd4e7ebce57a5503e8d", size = 118447, upload-time = "2026-09-25T03:47:27.569Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/0b/159528e8580f9a5c985be78fcbea6230a1bcc1d46a9f8b681511d2c37e3f/genai_prices-0.1.10.tar.gz", hash = "sha256:889ba486497f8a85e9dd0e9228a27f2e135e31a850e96924b880f22c132092ac", size = 125604, upload-time = "2026-10-09T17:58:15.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/e0/57d07f653813b7ea9e3a82064a2b6a112f63004530ac665e0934829473c7/genai_prices-0.1.9-py3-none-any.whl", hash = "sha256:3da8faa0a126e4df7be36696b90abe2be1fea8826bfcf45d39d91bc397fa9d1d", size = 125549, upload-time = "2026-09-25T03:47:26.483Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/379fe71fccf01d829f1d72f805189453f12ba4b96e4b49a5cde8e1afd5ba/genai_prices-0.1.10-py3-none-any.whl", hash = "sha256:1f0eb9ff0b02f3a40b3aa590098fbc3bfc9e08ef124fa0bd132e9d899a6fec88", size = 132788, upload-time = "2026-10-09T17:58:14.463Z" }, ] [[package]] @@ -6545,7 +6545,7 @@ requires-dist = [ { name = "fastmcp-slim", extras = ["client"], marker = "extra == 'mcp'", specifier = ">=3.3.0,<5" }, { name = "fastmcp-slim", extras = ["client"], marker = "extra == 'mcp-tasks'", specifier = ">=4.0.0b1,<5" }, { name = "fastmcp-tasks", marker = "extra == 'mcp-tasks'", specifier = ">=4.0.0b1,<5" }, - { name = "genai-prices", specifier = ">=0.1.9" }, + { name = "genai-prices", specifier = ">=0.1.10" }, { name = "google-genai", marker = "extra == 'google'", specifier = ">=2.25.0" }, { name = "google-genai", marker = "extra == 'google-realtime'", specifier = ">=2.25.0" }, { name = "griffelib", specifier = ">=2.0" },