This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new f87aa58e09d Migrate common.ai provider to pydantic-ai 2.x and remove
the <2 cap (#69358)
f87aa58e09d is described below
commit f87aa58e09d18710ef6db57d2f4661a28f6ba6e6
Author: Kaxil Naik <[email protected]>
AuthorDate: Fri Jul 3 22:00:46 2026 +0100
Migrate common.ai provider to pydantic-ai 2.x and remove the <2 cap (#69358)
pydantic-ai 2.x removed the ``instrument`` keyword from the ``Agent()`` and
``Agent.from_file()`` constructors (it is now only the ``agent.instrument``
property), which broke ``PydanticAIHook.create_agent`` when a caller passed
``instrument``. Pop a caller-supplied ``instrument`` out of the constructor
kwargs and apply it through the property instead, so the caller still wins
over
the provider's auto-instrumentation.
- Bump the OTel GenAI semantic-convention format to version 5 (formats 2-4
are
deprecated in 2.x and warn). Agent-run token usage now reports under
``gen_ai.aggregated_usage.*`` while model-request spans keep
``gen_ai.usage.*``;
documented in observability.rst and asserted in the end-to-end span test.
- Update ``Agent[None, ...]`` annotations to ``Agent[object, ...]`` to
match the
2.x generic-deps default (object, not None).
- Raise the floor to ``pydantic-ai-slim>=2.0.0`` and re-lock (slim +
pydantic-graph
1.107.0 -> 2.1.0).
Closes #69122
---
providers/common/ai/README.rst | 2 +-
providers/common/ai/docs/index.rst | 2 +-
providers/common/ai/docs/observability.rst | 8 +++++
providers/common/ai/pyproject.toml | 10 +++----
.../providers/common/ai/hooks/pydantic_ai.py | 34 ++++++++++++++--------
.../airflow/providers/common/ai/observability.py | 11 ++++---
.../airflow/providers/common/ai/operators/agent.py | 2 +-
.../airflow/providers/common/ai/operators/llm.py | 2 +-
.../common/ai/operators/llm_file_analysis.py | 2 +-
.../tests/unit/common/ai/hooks/test_pydantic_ai.py | 15 ++++++++--
.../ai/tests/unit/common/ai/test_observability.py | 9 ++++--
uv.lock | 14 ++++-----
12 files changed, 73 insertions(+), 38 deletions(-)
diff --git a/providers/common/ai/README.rst b/providers/common/ai/README.rst
index 3c5439b4e83..30a30c8e09a 100644
--- a/providers/common/ai/README.rst
+++ b/providers/common/ai/README.rst
@@ -56,7 +56,7 @@ PIP package Version required
``apache-airflow`` ``>=3.0.0``
``apache-airflow-providers-common-compat`` ``>=1.15.0``
``apache-airflow-providers-standard`` ``>=1.12.1``
-``pydantic-ai-slim`` ``>=1.99.0,<2``
+``pydantic-ai-slim`` ``>=2.0.0``
========================================== ==================
Optional cross provider package dependencies
diff --git a/providers/common/ai/docs/index.rst
b/providers/common/ai/docs/index.rst
index 0be69bb300b..385ed423861 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -109,7 +109,7 @@ PIP package Version required
``apache-airflow`` ``>=3.0.0``
``apache-airflow-providers-common-compat`` ``>=1.15.0``
``apache-airflow-providers-standard`` ``>=1.12.1``
-``pydantic-ai-slim`` ``>=1.99.0,<2``
+``pydantic-ai-slim`` ``>=2.0.0``
========================================== ==================
Optional cross provider package dependencies
diff --git a/providers/common/ai/docs/observability.rst
b/providers/common/ai/docs/observability.rst
index 6669419c8aa..54d5f5f7e9a 100644
--- a/providers/common/ai/docs/observability.rst
+++ b/providers/common/ai/docs/observability.rst
@@ -52,6 +52,14 @@ How it works
names, and finish reason are recorded. Prompt and completion text is never
emitted unless you opt in (see below).
+.. note::
+
+ On pydantic-ai 2.x the agent-run span reports token usage under
+ ``gen_ai.aggregated_usage.*`` while the per-model-call span keeps
+ ``gen_ai.usage.*``. This avoids double-counting in backends that sum a
+ parent span and its children. Dashboards or alerts that read run-level
token
+ usage from ``gen_ai.usage.*`` should switch to
``gen_ai.aggregated_usage.*``.
+
Enabling it
-----------
diff --git a/providers/common/ai/pyproject.toml
b/providers/common/ai/pyproject.toml
index f4597e957f9..4531d2cfb8c 100644
--- a/providers/common/ai/pyproject.toml
+++ b/providers/common/ai/pyproject.toml
@@ -69,9 +69,8 @@ dependencies = [
"apache-airflow>=3.0.0",
"apache-airflow-providers-common-compat>=1.15.0",
"apache-airflow-providers-standard>=1.12.1",
- # Capped to 1.x: pydantic-ai 2.x changed the agent instrumentation API and
breaks the provider.
- # Remove the cap after migrating; tracked at
https://github.com/apache/airflow/issues/69122
- "pydantic-ai-slim>=1.99.0,<2",
+ # Requires the pydantic-ai 2.x agent/instrumentation API (see #69122).
+ "pydantic-ai-slim>=2.0.0",
]
# The optional dependencies should be modified in place in the generated file
@@ -87,9 +86,8 @@ dependencies = [
# Enables AgentOperator(code_mode=True). Monty is pre-1.0; pinned here as an
# opt-in extra so its churn never breaks the base provider install.
"code-mode" = ["pydantic-ai-harness[codemode]>=0.3.0"]
-# Agent Skills (agentskills.io) support. pydantic-ai-skills provides the
toolset
-# (pulls in pydantic-ai-slim>=1.74 transitively; the provider base floor stays
-# 1.71); the git provider supplies GitHook + GitPython for cloning GitSkills
with
+# Agent Skills (agentskills.io) support. pydantic-ai-skills provides the
toolset;
+# the git provider supplies GitHook + GitPython for cloning GitSkills with
# credentials from a `git` connection. Native progressive disclosure is tracked
# upstream in pydantic/pydantic-ai#5230; revisit this extra once that lands.
"skills" = [
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
b/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
index eec7c5e7944..c46f974a59c 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
@@ -28,6 +28,11 @@ from airflow.providers.common.compat.sdk import BaseHook
OutputT = TypeVar("OutputT")
+# Sentinel distinguishing "caller did not pass ``instrument``" from an explicit
+# ``instrument=None`` / ``instrument=False`` (which mean "do not instrument,
and
+# do not auto-enable it either").
+_UNSET: Any = object()
+
if TYPE_CHECKING:
from pydantic_ai.models import KnownModelName, Model
@@ -196,10 +201,10 @@ class PydanticAIHook(BaseHook):
@overload
def create_agent(
self, output_type: type[OutputT], *, instructions: str, **agent_kwargs
- ) -> Agent[None, OutputT]: ...
+ ) -> Agent[object, OutputT]: ...
@overload
- def create_agent(self, *, instructions: str, **agent_kwargs) ->
Agent[None, str]: ...
+ def create_agent(self, *, instructions: str, **agent_kwargs) ->
Agent[object, str]: ...
@overload
def create_agent(
@@ -209,7 +214,7 @@ class PydanticAIHook(BaseHook):
spec_file: str | Path,
instructions: str | None = ...,
**agent_kwargs,
- ) -> Agent[None, OutputT]: ...
+ ) -> Agent[object, OutputT]: ...
@overload
def create_agent(
@@ -218,7 +223,7 @@ class PydanticAIHook(BaseHook):
spec_file: str | Path,
instructions: str | None = ...,
**agent_kwargs,
- ) -> Agent[None, str]: ...
+ ) -> Agent[object, str]: ...
def create_agent(
self,
@@ -227,7 +232,7 @@ class PydanticAIHook(BaseHook):
instructions: str | None = None,
spec_file: str | Path | None = None,
**agent_kwargs,
- ) -> Agent[None, Any]:
+ ) -> Agent[object, Any]:
"""
Create a pydantic-ai Agent configured with this hook's model.
@@ -247,6 +252,14 @@ class PydanticAIHook(BaseHook):
the spec file's ``model`` is used.
:param agent_kwargs: Additional keyword arguments passed to the Agent
constructor.
"""
+ # ``instrument`` is no longer an ``Agent()`` / ``Agent.from_file()``
+ # constructor argument in pydantic-ai 2.x; it is configured through the
+ # ``agent.instrument`` property (which is unchanged across the 2.x
line).
+ # Pop any caller-supplied value out of the constructor kwargs and apply
+ # it after construction so a caller that passes its own ``instrument``
+ # still wins over the provider's auto-instrumentation.
+ caller_instrument = agent_kwargs.pop("instrument", _UNSET)
+
if spec_file is not None:
from_file_kwargs = dict(agent_kwargs)
model = self._get_conn_if_model_configured()
@@ -264,13 +277,10 @@ class PydanticAIHook(BaseHook):
if instructions is None:
raise ValueError("instructions is required when spec_file is
not provided.")
agent = Agent(self.get_conn(), output_type=output_type,
instructions=instructions, **agent_kwargs)
- if "instrument" not in agent_kwargs:
- # Set the public ``agent.instrument`` surface rather than the
- # ``Agent(instrument=...)`` constructor kwarg, which is deprecated
in
- # current pydantic-ai. Assigning ``agent.instrument`` works across
the
- # provider's ``pydantic-ai-slim>=1.71`` floor (a plain instance
- # attribute on older versions, a property on newer ones). A caller
- # that passed its own ``instrument`` wins.
+
+ if caller_instrument is not _UNSET:
+ agent.instrument = caller_instrument
+ else:
settings = genai_instrumentation_settings()
if settings is not None:
agent.instrument = settings
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/observability.py
b/providers/common/ai/src/airflow/providers/common/ai/observability.py
index e81a6680d04..08a59c40595 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/observability.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/observability.py
@@ -40,10 +40,13 @@ if TYPE_CHECKING:
SECTION = "common.ai"
-# OTel GenAI semantic-convention attribute set. Pinned so the emitted span and
-# ``gen_ai.*`` attribute names stay stable regardless of the pydantic-ai
default
-# (which tracks the latest, still-evolving revision).
-_SEMCONV_VERSION: Literal[4] = 4
+# OTel GenAI semantic-convention format version. Pinned so a change in the
+# pydantic-ai default does not silently shift the emitted span/attribute format
+# between provider releases. Version 5 is the current default in pydantic-ai
2.x;
+# formats 2-4 still work but are deprecated. Note: independent of this version,
+# pydantic-ai 2.x reports agent-run token usage under
``gen_ai.aggregated_usage.*``
+# (model-request spans keep ``gen_ai.usage.*``) -- see docs/observability.rst.
+_SEMCONV_VERSION: Literal[5] = 5
def _otel_export_enabled() -> bool:
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
index 56c9ec5bbb6..2eed3da9435 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
@@ -301,7 +301,7 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
}
return PydanticAIHook.get_hook(self.llm_conn_id,
hook_params=hook_params)
- def _build_agent(self) -> Agent[None, Any]:
+ def _build_agent(self) -> Agent[object, Any]:
"""Build and return a pydantic-ai Agent from the operator's config."""
extra_kwargs = dict(self.agent_params)
if self.toolsets:
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
index c9a22632f2e..95f33bffffa 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
@@ -163,7 +163,7 @@ class LLMOperator(BaseOperator, LLMApprovalMixin):
f"str prompt, or disable require_approval."
)
- agent: Agent[None, Any] = self.llm_hook.create_agent(
+ agent: Agent[object, Any] = self.llm_hook.create_agent(
output_type=self.output_type, instructions=self.system_prompt,
**self.agent_params
)
result = agent.run_sync(self.prompt, usage_limits=self.usage_limits)
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
index 0c9d1df1bdd..8e919acf13b 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
+++
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
@@ -129,7 +129,7 @@ class LLMFileAnalysisOperator(LLMOperator):
self.sample_rows,
)
self.log.debug("Resolved file analysis paths: %s",
request.resolved_paths)
- agent: Agent[None, Any] = self.llm_hook.create_agent(
+ agent: Agent[object, Any] = self.llm_hook.create_agent(
output_type=self.output_type,
instructions=self._build_system_prompt(),
**self.agent_params,
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
b/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
index c203a3782a1..4d7baf9b4e7 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
@@ -404,12 +404,23 @@ class TestPydanticAIHookCreateAgentInstrumentation:
@patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model",
autospec=True)
def test_caller_instrument_short_circuits(self, mock_infer_model,
mock_settings, mock_agent_cls):
"""A caller that passes its own ``instrument`` wins; we don't override
it."""
- mock_infer_model.return_value = MagicMock(spec=Model)
+ mock_model = MagicMock(spec=Model)
+ mock_infer_model.return_value = mock_model
hook = self._hook()
conn = Connection(conn_id="test_conn", conn_type="pydanticai")
with patch.object(hook, "get_connection", return_value=conn):
- hook.create_agent(instructions="hi", instrument=False)
+ agent = hook.create_agent(instructions="hi", instrument=False)
+ # ``instrument`` is not an Agent() constructor kwarg in pydantic-ai
2.x:
+ # it must be stripped from the constructor call and applied through the
+ # ``agent.instrument`` property instead, and the provider's own
+ # auto-instrumentation must not override the caller's value.
+ mock_agent_cls.assert_called_once_with(
+ mock_model,
+ output_type=str,
+ instructions="hi",
+ )
+ assert agent.instrument is False
mock_settings.assert_not_called()
diff --git a/providers/common/ai/tests/unit/common/ai/test_observability.py
b/providers/common/ai/tests/unit/common/ai/test_observability.py
index bfb6f0fbe6a..4b0c84f7e50 100644
--- a/providers/common/ai/tests/unit/common/ai/test_observability.py
+++ b/providers/common/ai/tests/unit/common/ai/test_observability.py
@@ -56,7 +56,7 @@ class TestGenaiInstrumentationSettings:
settings = observability.genai_instrumentation_settings()
assert settings is not None
- assert settings.version == observability._SEMCONV_VERSION == 4
+ assert settings.version == observability._SEMCONV_VERSION == 5
# Content is never emitted unless explicitly opted in.
assert settings.include_content is False
assert settings.include_binary_content is False
@@ -123,8 +123,13 @@ class TestEndToEndSpanEmission:
genai, parent_trace_id, attrs_blob = self._run(capture=False)
assert genai, "expected gen_ai spans to be emitted"
- # Token usage is captured even with content off.
+ # Token usage is captured even with content off. In pydantic-ai 2.x the
+ # model-request span keeps ``gen_ai.usage.*`` while the agent-run span
+ # reports ``gen_ai.aggregated_usage.*`` (avoids double-counting when a
+ # backend sums parent and child spans); assert both so a change in that
+ # split is caught here rather than silently shifting users' telemetry.
assert any("gen_ai.usage.input_tokens" in (s.attributes or {}) for s
in genai)
+ assert any("gen_ai.aggregated_usage.input_tokens" in (s.attributes or
{}) for s in genai)
# Parenting is implicit: agent spans share the task span's trace_id.
assert all(s.context.trace_id == parent_trace_id for s in genai)
# The prompt text must not leak when content capture is off.
diff --git a/uv.lock b/uv.lock
index 3df0f85ac23..bf13cb8afb9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4492,7 +4492,7 @@ requires-dist = [
{ name = "pyarrow", marker = "python_full_version < '3.14' and extra ==
'parquet'", specifier = ">=18.0.0" },
{ name = "pydantic-ai-harness", extras = ["codemode"], marker = "extra ==
'code-mode'", specifier = ">=0.3.0" },
{ name = "pydantic-ai-skills", marker = "extra == 'skills'", specifier =
">=0.11.0" },
- { name = "pydantic-ai-slim", specifier = ">=1.99.0,<2" },
+ { name = "pydantic-ai-slim", specifier = ">=2.0.0" },
{ name = "pydantic-ai-slim", extras = ["anthropic"], marker = "extra ==
'anthropic'" },
{ name = "pydantic-ai-slim", extras = ["bedrock"], marker = "extra ==
'bedrock'" },
{ name = "pydantic-ai-slim", extras = ["google"], marker = "extra ==
'google'" },
@@ -19221,7 +19221,7 @@ wheels = [
[[package]]
name = "pydantic-ai-slim"
-version = "1.107.0"
+version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
@@ -19233,9 +19233,9 @@ dependencies = [
{ name = "pydantic-graph" },
{ name = "typing-inspection" },
]
-sdist = { url =
"https://files.pythonhosted.org/packages/4c/26/ced63dfaabbc77f3beb86d59689cdea748e7ccffb6b419dbaf4780f211e8/pydantic_ai_slim-1.107.0.tar.gz",
hash =
"sha256:4616f689a92fcfecfecf2a7af27aca22f139a873cf6d7a8929eaeee9c0eedbb4", size
= 779902, upload-time = "2026-06-10T14:53:10.574Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/ca/d1/78fd15c9c68b95ac0bec2d2afe22feb3b2f46e4b8e3a6dd1cead61cde434/pydantic_ai_slim-2.1.0.tar.gz",
hash =
"sha256:f79dca2429dbb9d2e32a0e2c613cfb9b9d6f0dc81d42cff8a5c81ede8ca0a6c7", size
= 738698, upload-time = "2026-06-29T09:51:18.634Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/15/57/71044e17f931b08cc3930bc0fe5a1e1fd37fa474ae826be004729ef1cb4a/pydantic_ai_slim-1.107.0-py3-none-any.whl",
hash =
"sha256:1af49bbae06a6c598f72c54d4734ba377100cac493c9a05fa8e089bebeae0da6", size
= 964046, upload-time = "2026-06-10T14:53:03.333Z" },
+ { url =
"https://files.pythonhosted.org/packages/5e/c7/b531cf65a1f8b221ab39739a16fe08f7b6a56fd24db875b9c33010320c9c/pydantic_ai_slim-2.1.0-py3-none-any.whl",
hash =
"sha256:2afda56459606226113ab433ee659aac7ff29bb66feefa716c67144bd82e5ce2", size
= 910109, upload-time = "2026-06-29T09:51:11.016Z" },
]
[package.optional-dependencies]
@@ -19387,7 +19387,7 @@ wheels = [
[[package]]
name = "pydantic-graph"
-version = "1.107.0"
+version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -19395,9 +19395,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-inspection" },
]
-sdist = { url =
"https://files.pythonhosted.org/packages/dd/c3/6e8c2d13b8701041f1b3eac5deb41f25d4dbfa479a190d5c6becc23f2a49/pydantic_graph-1.107.0.tar.gz",
hash =
"sha256:278dd89b3e33f3a2963ac949f27a53aef705c5d883a8ce5d06d23e6e3cfbd972", size
= 62564, upload-time = "2026-06-10T14:53:13.366Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/39/20/018532c826aba3c29ffc37bda46c20f3db4584bb555a8c7dc2769866267d/pydantic_graph-2.1.0.tar.gz",
hash =
"sha256:36ed6af24543421fb628fee593ccf5553a286c1dd1677018f5064a4e44c4daba", size
= 43052, upload-time = "2026-06-29T09:51:20.832Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/fc/72/621556e3f5068400d43a0375d38e5963de30256eaa5a702aba12e82ed0ff/pydantic_graph-1.107.0-py3-none-any.whl",
hash =
"sha256:71add94fe7e14c703977a895117c475aae6c0b02a774a036c4d00d9a63c78b00", size
= 80106, upload-time = "2026-06-10T14:53:06.543Z" },
+ { url =
"https://files.pythonhosted.org/packages/42/a8/7f32bdeda6cff28652bba1c21ab2155c81d1733abd4b8bfef46a26e8d3fe/pydantic_graph-2.1.0-py3-none-any.whl",
hash =
"sha256:bae1e99829abf590a8693442ba5693ace38d2d93a80ae48be55c610a503e3709", size
= 50772, upload-time = "2026-06-29T09:51:14.161Z" },
]
[[package]]