From 2e9e653d14ffdd119a6d8f3e439dd4ac7b4c2a3c Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Tue, 11 Aug 2026 19:17:31 +0300 Subject: [PATCH 01/14] build(deps): depend on pydantic-ai-slim[anthropic,openai] and pin openai --- pyproject.toml | 3 +- .../test_providers/test_dependency_surface.py | 50 ++ uv.lock | 780 +----------------- 3 files changed, 58 insertions(+), 775 deletions(-) create mode 100644 tests/test_providers/test_dependency_surface.py diff --git a/pyproject.toml b/pyproject.toml index 63f32ae3..85e42ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,8 @@ dependencies = [ "simpleeval>=1.0.0", "github-copilot-sdk>=1.0.9", "anthropic>=0.77.0,<1.0.0", - "pydantic-ai>=1.44.0", + "pydantic-ai-slim[anthropic,openai]>=1.44.0", + "openai>=2.48.0,<2.49.0", "mcp>=1.28.1", "fastapi>=0.115.0", "uvicorn>=0.30.0", diff --git a/tests/test_providers/test_dependency_surface.py b/tests/test_providers/test_dependency_surface.py new file mode 100644 index 00000000..d574ceb4 --- /dev/null +++ b/tests/test_providers/test_dependency_surface.py @@ -0,0 +1,50 @@ +"""Smoke tests for the dependency surface required by Pydantic AI providers. + +These tests guard the module-level import contract that the rest of the +provider stack relies on. The extras on ``pydantic-ai-slim[anthropic,openai]`` +determine whether the model/provider classes below are importable at all; +``openai`` must be pinned to a 2.x API compatible with ``AsyncOpenAI(..., +max_retries=0)`` construction. A regression here would surface as an +ImportError or TypeError long before any provider unit test runs, so this +module acts as an early warning. +""" + +from __future__ import annotations + +from openai import AsyncOpenAI +from pydantic_ai.models.openai import OpenAIChatModel, OpenAIChatModelSettings +from pydantic_ai.providers.openai import OpenAIProvider + +from conductor.providers.claude import ClaudeProvider + + +def test_import_openai_chat_model_settings() -> None: + """OpenAIChatModelSettings must exist in the installed pydantic-ai-slim.""" + assert OpenAIChatModelSettings is not None + + +def test_import_openai_chat_model() -> None: + """OpenAIChatModel must be importable from the openai extra.""" + assert OpenAIChatModel is not None + + +def test_import_openai_provider() -> None: + """The OpenAIProvider pydantic-ai shim must be importable.""" + assert OpenAIProvider is not None + + +def test_import_claude_provider() -> None: + """ClaudeProvider must still import cleanly after the dependency swap.""" + assert ClaudeProvider is not None + + +def test_openai_async_client_construction() -> None: + """AsyncOpenAI accepts explicit max_retries=0 on the pinned 2.x API.""" + client = AsyncOpenAI(api_key="dummy", max_retries=0) + assert client.max_retries == 0 + + +def test_openai_chat_model_settings_has_reasoning_field() -> None: + """OpenAIChatModelSettings exposes the reasoning-effort field we use.""" + settings = OpenAIChatModelSettings(openai_reasoning_effort="low") + assert settings.get("openai_reasoning_effort") == "low" diff --git a/uv.lock b/uv.lock index 6f122dc2..e69c8463 100644 --- a/uv.lock +++ b/uv.lock @@ -8,18 +8,6 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'win32'", ] -[[package]] -name = "aiofile" -version = "3.11.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "caio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.7.1" @@ -192,15 +180,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] -[[package]] -name = "argcomplete" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/c0/c8e94135e66fabf89a120d9b4b123fe6993506beca6c1938a74c24cfa5fd/argcomplete-3.7.0.tar.gz", hash = "sha256:afde224f753f874807b1dc1414e883ab8fe0cda9c04807b6047dcb8e1ac23913", size = 73284, upload-time = "2026-06-30T22:28:22.249Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/f6/5b8ec087cd9cfa9449491ec83f76fb6b7006b4dff57d2ba8aaab330fe8e4/argcomplete-3.7.0-py3-none-any.whl", hash = "sha256:d8f0f22d2a8a7caa383be1e22b6caf1ecaf0ebd10d8f83cc125e36540c95830c", size = 42575, upload-time = "2026-06-30T22:28:20.547Z" }, -] - [[package]] name = "attrs" version = "25.4.0" @@ -210,19 +189,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] -[[package]] -name = "authlib" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "joserfc" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, -] - [[package]] name = "azure-core" version = "1.41.0" @@ -257,45 +223,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, ] -[[package]] -name = "beartype" -version = "0.22.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, -] - -[[package]] -name = "cachetools" -version = "7.1.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" }, -] - -[[package]] -name = "caio" -version = "0.9.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, - { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, - { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, - { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, - { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, -] - [[package]] name = "certifi" version = "2026.1.4" @@ -464,7 +391,7 @@ wheels = [ [[package]] name = "conductor-cli" -version = "0.1.33" +version = "0.1.27" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -473,9 +400,10 @@ dependencies = [ { name = "httpx" }, { name = "jinja2" }, { name = "mcp" }, + { name = "openai" }, { name = "packaging" }, { name = "pydantic" }, - { name = "pydantic-ai" }, + { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, { name = "regex" }, { name = "rich" }, { name = "ruamel-yaml" }, @@ -493,9 +421,6 @@ aca = [ claude-agent-sdk = [ { name = "claude-agent-sdk" }, ] -tui = [ - { name = "textual" }, -] [package.dev-dependencies] dev = [ @@ -503,7 +428,6 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, - { name = "textual" }, { name = "ty" }, ] @@ -518,19 +442,19 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "mcp", specifier = ">=1.28.1" }, + { name = "openai", specifier = ">=2.48.0,<2.49.0" }, { name = "packaging", specifier = ">=21.0" }, { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pydantic-ai", specifier = ">=1.44.0" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], specifier = ">=1.44.0" }, { name = "regex", specifier = ">=2024.11.6" }, { name = "rich", specifier = ">=13.0.0" }, { name = "ruamel-yaml", specifier = ">=0.18.0" }, { name = "simpleeval", specifier = ">=1.0.0" }, - { name = "textual", marker = "extra == 'tui'", specifier = ">=8.0" }, { name = "typer", specifier = ">=0.12.0" }, { name = "uvicorn", specifier = ">=0.30.0" }, { name = "websockets", specifier = ">=12.0" }, ] -provides-extras = ["claude-agent-sdk", "aca", "tui"] +provides-extras = ["claude-agent-sdk", "aca"] [package.metadata.requires-dev] dev = [ @@ -538,7 +462,6 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.8.0" }, - { name = "textual", specifier = ">=8.0" }, { name = "ty", specifier = ">=0.0.1a0" }, ] @@ -675,15 +598,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "docstring-parser" version = "0.17.0" @@ -693,40 +607,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - [[package]] name = "fastapi" version = "0.133.0" @@ -743,34 +623,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/b4/023e75a2ec3f5440e380df6caf4d28edc0806d007193e6fb0707237886a4/fastapi-0.133.0-py3-none-any.whl", hash = "sha256:0a78878483d60702a1dde864c24ab349a1a53ef4db6b6f74f8cd4a2b2bc67d2f", size = 104787, upload-time = "2026-02-24T09:53:41.404Z" }, ] -[[package]] -name = "fastmcp-slim" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "pydantic", extra = ["email"] }, - { name = "pydantic-settings" }, - { name = "python-dotenv" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" }, -] - -[package.optional-dependencies] -client = [ - { name = "authlib" }, - { name = "exceptiongroup" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "opentelemetry-api" }, - { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, - { name = "starlette" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -886,57 +738,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/b5/c4d11863213835b65d19633e62de347001528bb834b5c376dffe6836358a/github_copilot_sdk-1.0.9-py3-none-any.whl", hash = "sha256:4521c9a98ac340e61ce5eb46bcffe0b1f0755cf198282497403776cc65c99fcb", size = 477929, upload-time = "2026-08-06T00:44:01.66Z" }, ] -[[package]] -name = "google-auth" -version = "2.56.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" }, -] - -[package.optional-dependencies] -requests = [ - { name = "requests" }, -] - -[[package]] -name = "google-genai" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "google-auth", extra = ["requests"] }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sniffio" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/df/4f820054c99f29f2fe3de4a8a7c9534dd795302e4a07483a0cb07c3a29b6/google_genai-2.14.0.tar.gz", hash = "sha256:a9d1f4f362d76280f1be1340fcb3c86e63dbca128f6a4ae09d86ab47ff7148e8", size = 641055, upload-time = "2026-07-22T21:35:44.717Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/86/5ac5fb53e44cca4a6607fb917eb331fa237c65a103b9ec2e8e8acc8a42db/google_genai-2.14.0-py3-none-any.whl", hash = "sha256:ae7172cdd35695189b516b33a878e4132e5daa2dbc03a5b44cddfa8a82fad664", size = 1030738, upload-time = "2026-07-22T21:35:42.785Z" }, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.75.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, -] - [[package]] name = "griffelib" version = "2.1.0" @@ -1038,48 +839,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "jaraco-classes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, -] - -[[package]] -name = "jaraco-context" -version = "6.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, -] - -[[package]] -name = "jaraco-functools" -version = "4.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, -] - -[[package]] -name = "jeepney" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -1160,18 +919,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, ] -[[package]] -name = "joserfc" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -1199,58 +946,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "keyring" -version = "25.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jaraco-classes" }, - { name = "jaraco-context" }, - { name = "jaraco-functools" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", marker = "sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, -] - -[[package]] -name = "linkify-it-py" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, -] - -[[package]] -name = "logfire" -version = "4.39.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "executing" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "protobuf" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/7d/9d04b6c716c7963cc0176b0aafee1b7becd0d3c3b2febe704dc9ae5a4318/logfire-4.39.0.tar.gz", hash = "sha256:7291ae695a145c21b4fa9baea2ffaf42b23c79a08e1f3edcef5a4cad41867a0d", size = 1242395, upload-time = "2026-07-24T18:31:34.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/57/b40307cdfd81d07433ad5ae38de70fe6e543f3fb7e764bdf6944695a386b/logfire-4.39.0-py3-none-any.whl", hash = "sha256:e6046e03ce45098c15a9dbf42ced8b95dfcb60cc1f3600a6250c8f515755be5f", size = 405126, upload-time = "2026-07-24T18:31:30.844Z" }, -] - -[package.optional-dependencies] -httpx = [ - { name = "opentelemetry-instrumentation-httpx" }, -] - [[package]] name = "logfire-api" version = "4.39.0" @@ -1272,11 +967,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -1365,18 +1055,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1386,15 +1064,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "more-itertools" -version = "11.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, -] - [[package]] name = "msal" version = "1.37.0" @@ -1551,115 +1220,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.65b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b", size = 42689, upload-time = "2026-07-16T15:25:50.275Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-httpx" -version = "0.65b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/03/a529140241addd4d0acc73bafbd6f74691651b92fc0ae9b4513cf80f07fa/opentelemetry_instrumentation_httpx-0.65b0.tar.gz", hash = "sha256:4627aa9c6bb99bf4462c8b565b0ef6aeb9ffad95c6c92868be1ef7895de112ee", size = 26309, upload-time = "2026-07-16T15:26:07.973Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/0f/c6144096b4914bbf44b43ba21c962e8f333ff045770b50a3e79ed8bd455f/opentelemetry_instrumentation_httpx-0.65b0-py3-none-any.whl", hash = "sha256:400f1b78afa4ee2332b5debe58e1ed1b317913d58812c952576be76660aeadb1", size = 17436, upload-time = "2026-07-16T15:25:15.772Z" }, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.65b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, -] - -[[package]] -name = "opentelemetry-util-http" -version = "0.65b0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/32/a9/d7525a59fdd240e69b5af4a6338e78fafa1b4203394122cbd6701fb5f84a/opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7", size = 11243, upload-time = "2026-07-16T15:26:27.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/3f/ab8d29df207ce5f470a07fa96ebb48af4e95b7fab7e7635311b9a32f2fab/opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348", size = 8245, upload-time = "2026-07-16T15:25:46.482Z" }, -] - [[package]] name = "packaging" version = "26.0" @@ -1669,15 +1229,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "platformdirs" -version = "4.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -1687,18 +1238,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - [[package]] name = "propcache" version = "0.5.2" @@ -1793,67 +1332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] -[[package]] -name = "protobuf" -version = "7.35.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, - { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, - { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, -] - -[[package]] -name = "py-key-value-aio" -version = "0.4.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, -] - -[package.optional-dependencies] -filetree = [ - { name = "aiofile" }, - { name = "anyio" }, -] -keyring = [ - { name = "keyring" }, -] -memory = [ - { name = "cachetools" }, -] - -[[package]] -name = "pyasn1" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1878,23 +1356,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - -[[package]] -name = "pydantic-ai" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic-ai-slim", extra = ["anthropic", "cli", "evals", "google", "logfire", "mcp", "openai", "retries", "web"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/5b/426115bfb6bdb40f31a738a3dc13f235073f3a079fa58c0633636a5a942e/pydantic_ai-2.18.0.tar.gz", hash = "sha256:13a53a9b453136637ca59ad2d13ca59ab7ce4b9cf2d5c150602598824f034781", size = 18830, upload-time = "2026-07-25T01:21:03.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/2c/39217e550b21aa05e32f501ee56bb433dfdf6e39d2f577ceeda9a8bd4e66/pydantic_ai-2.18.0-py3-none-any.whl", hash = "sha256:8302e5f8f2b50f2cda71bd6a92cbc64762cf44e7bb50070e9668f19f7e03ee38", size = 7743, upload-time = "2026-07-25T01:20:53.852Z" }, -] - [[package]] name = "pydantic-ai-slim" version = "2.18.0" @@ -1917,37 +1378,10 @@ wheels = [ anthropic = [ { name = "anthropic" }, ] -cli = [ - { name = "argcomplete" }, - { name = "prompt-toolkit" }, - { name = "pyperclip" }, - { name = "pyyaml" }, - { name = "rich" }, -] -evals = [ - { name = "pydantic-evals" }, -] -google = [ - { name = "google-genai" }, -] -logfire = [ - { name = "logfire", extra = ["httpx"] }, -] -mcp = [ - { name = "fastmcp-slim", extra = ["client"] }, -] openai = [ { name = "openai" }, { name = "tiktoken" }, ] -retries = [ - { name = "tenacity" }, -] -web = [ - { name = "httpx" }, - { name = "starlette" }, - { name = "uvicorn" }, -] [[package]] name = "pydantic-core" @@ -2020,23 +1454,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] -[[package]] -name = "pydantic-evals" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "logfire-api" }, - { name = "pydantic" }, - { name = "pydantic-ai-slim" }, - { name = "pyyaml" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/4a/e2b629f4724a6026c792a5bd7ceb470bf38d48c6a2ebe8cec091ed85485f/pydantic_evals-2.18.0.tar.gz", hash = "sha256:d26ab006290564a5e56394b9033fbd5925e4a172d9e23be5f3c034ae3637eb18", size = 85222, upload-time = "2026-07-25T01:21:07.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/8d/be8abc897f9cd9ef83e000bfaa8d9bec0d93fe2f59f219d2c3835d66b033/pydantic_evals-2.18.0-py3-none-any.whl", hash = "sha256:98d20973df83c3ca6d7341ce2f704ae59b53e02698b55df765f9a1cd5ec5ea1d", size = 100524, upload-time = "2026-07-25T01:20:59.284Z" }, -] - [[package]] name = "pydantic-graph" version = "2.18.0" @@ -2089,15 +1506,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "pyperclip" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -2187,61 +1595,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -2488,19 +1841,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] -[[package]] -name = "secretstorage" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" @@ -2563,32 +1903,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] -[[package]] -name = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - -[[package]] -name = "textual" -version = "8.2.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify"] }, - { name = "mdit-py-plugins" }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, -] - [[package]] name = "tiktoken" version = "0.13.0" @@ -2717,15 +2031,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" @@ -2748,15 +2053,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] -[[package]] -name = "wcwidth" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, -] - [[package]] name = "websockets" version = "16.0" @@ -2802,70 +2098,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] -[[package]] -name = "wrapt" -version = "2.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, - { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, - { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, - { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, - { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, - { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, - { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, - { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, - { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, - { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, - { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, - { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, - { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, - { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, - { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, - { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, - { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, - { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, - { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, - { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, - { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, - { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, - { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, - { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, - { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, - { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, - { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, - { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, -] - [[package]] name = "yarl" version = "1.24.5" From 8f2e51fc4b1876c3210e69f19ea3e107d24d543a Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Tue, 11 Aug 2026 19:40:34 +0300 Subject: [PATCH 02/14] feat(providers): parameterize pydantic-ai agent builder for openai backend --- .../providers/_pydantic_ai/agent_builder.py | 157 ++++++++++++++-- .../test_parameter_flow_verification.py | 2 +- .../test_pydantic_ai_agent_builder.py | 173 +++++++++++++++++- 3 files changed, 306 insertions(+), 26 deletions(-) diff --git a/src/conductor/providers/_pydantic_ai/agent_builder.py b/src/conductor/providers/_pydantic_ai/agent_builder.py index ffde883c..fde0fcbb 100644 --- a/src/conductor/providers/_pydantic_ai/agent_builder.py +++ b/src/conductor/providers/_pydantic_ai/agent_builder.py @@ -9,17 +9,20 @@ import logging import os -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from anthropic import NOT_GIVEN as ANTHROPIC_NOT_GIVEN from anthropic import AsyncAnthropic from anthropic.types.beta.beta_thinking_config_enabled_param import ( BetaThinkingConfigEnabledParam, ) +from openai import AsyncOpenAI from pydantic_ai import Agent, AgentRetries from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings +from pydantic_ai.models.openai import OpenAIChatModel, OpenAIChatModelSettings from pydantic_ai.output import ToolOutput from pydantic_ai.providers.anthropic import AnthropicProvider +from pydantic_ai.providers.openai import OpenAIProvider from conductor.exceptions import ValidationError from conductor.providers._pydantic_ai.converters import ( @@ -34,6 +37,7 @@ ) if TYPE_CHECKING: + import httpx from pydantic_ai import AgentToolset from conductor.config.schema import AgentDef @@ -46,6 +50,9 @@ # identifier current without YAML changes. DEFAULT_ANTHROPIC_MODEL: str = "claude-3-5-sonnet-latest" +# Default OpenAI model used when the agent and runtime fail to declare one. +DEFAULT_OPENAI_MODEL: str = "gpt-5-mini" + # Default cap for total output tokens when Anthropic extended thinking is # enabled. This matches CLAUDE_EXTENDED_THINKING_OUTPUT_CAP in reasoning.py # and is used by the temperature/max_tokens coercion helper. @@ -65,6 +72,66 @@ _OUTPUT_RECOVERY_RETRIES: int = 2 +def _resolve_openai_model( + agent: AgentDef, + default_model: str | None = None, + api_key: str | None = None, + base_url: str | None = None, + http_client: httpx.AsyncClient | None = None, + timeout: float | None = None, +) -> OpenAIChatModel: + """Build a Pydantic AI ``OpenAIChatModel`` from the agent definition. + + Resolves the model identifier, API key, optional base URL, custom HTTP + client, and timeout. Unlike the Anthropic branch, an explicit custom + ``base_url`` without an explicit ``api_key`` is rejected because Conductor + does not want to rely on ambient ``OPENAI_API_KEY`` for authenticated custom + endpoints — custom routing must be explicitly provided in full. + + Args: + agent: The Conductor agent definition. + default_model: Fallback model identifier when ``agent.model`` is unset. + api_key: OpenAI API key. When passed explicitly it is used directly. + Otherwise ``OPENAI_API_KEY`` is read from the environment. + base_url: Optional custom API endpoint. + http_client: Optional ``httpx.AsyncClient`` to share across requests. + timeout: Request timeout in seconds. ``None`` lets the OpenAI SDK apply + its own default. + + Returns: + A configured Pydantic AI ``OpenAIChatModel`` instance. + + Raises: + ValidationError: If no API key is available, or if a custom base_url is + provided without an explicit api_key. + """ + effective_api_key = api_key if api_key is not None else os.environ.get("OPENAI_API_KEY") + + if base_url is not None and api_key is None: + raise ValidationError( + "Custom base_url requires an explicit api_key for the openai backend.", + suggestion="Pass api_key in the provider config or set OPENAI_API_KEY.", + ) + + if not effective_api_key: + raise ValidationError( + "OPENAI_API_KEY environment variable is not set and no api_key was provided", + suggestion="Set OPENAI_API_KEY or pass api_key to the provider.", + ) + + model_name = (agent.model or default_model) or DEFAULT_OPENAI_MODEL + + openai_client = AsyncOpenAI( + api_key=effective_api_key, + base_url=base_url, + timeout=timeout, + max_retries=0, + http_client=http_client, + ) + provider = OpenAIProvider(openai_client=openai_client) + return OpenAIChatModel(model_name=model_name, provider=provider) + + def _resolve_anthropic_model( agent: AgentDef, default_model: str | None = None, @@ -268,7 +335,39 @@ def _coerce_for_thinking( return 1.0, effective_max_tokens -def _build_model_settings( +def _build_openai_model_settings( + agent: AgentDef, + default_temperature: float | None, + default_max_tokens: int | None, + default_reasoning_effort: ReasoningEffort | None, + timeout: float | None = None, +) -> OpenAIChatModelSettings: + """Build ``OpenAIChatModelSettings`` from the agent and runtime defaults. + + OpenAI reasoning effort is forwarded directly as the + ``openai_reasoning_effort`` field; unlike Anthropic extended thinking it + does not require coercion of temperature or max_tokens. + """ + effort = resolve_reasoning_effort(agent, default_reasoning_effort) + + agent_temperature = getattr(agent, "temperature", None) + temperature = agent_temperature if agent_temperature is not None else default_temperature + agent_max_tokens = getattr(agent, "max_tokens", None) + max_tokens = agent_max_tokens if agent_max_tokens is not None else default_max_tokens + + settings: OpenAIChatModelSettings = OpenAIChatModelSettings() + if temperature is not None: + settings["temperature"] = temperature + if max_tokens is not None: + settings["max_tokens"] = max_tokens + if timeout is not None: + settings["timeout"] = timeout + if effort is not None: + settings["openai_reasoning_effort"] = effort + return settings + + +def _build_anthropic_model_settings( agent: AgentDef, default_temperature: float | None, default_max_tokens: int | None, @@ -344,6 +443,8 @@ def build_agent( timeout: float | None = None, toolsets: list[AgentToolset[Any]] | None = None, tools: list[Any] | None = None, + backend: Literal["anthropic", "openai"] = "anthropic", + http_client: httpx.AsyncClient | None = None, ) -> Agent[Any, Any]: """Build a Pydantic AI Agent from a Conductor agent definition. @@ -357,35 +458,55 @@ def build_agent( default_reasoning_effort: Workflow-level default reasoning effort. max_parse_recovery_attempts: Output correction retries handled inside the Pydantic AI agent. - api_key: Anthropic API key. Falls back to ``ANTHROPIC_API_KEY`` env var. - auth_token: Optional bearer-auth token for gateway / LiteLLM endpoints. + api_key: API key. Falls back to the backend-specific env var. + auth_token: Anthropic bearer-auth token for gateway / LiteLLM endpoints + (anthropic backend only). base_url: Optional custom API endpoint. - timeout: Request timeout in seconds. ``None`` lets the Anthropic SDK use - its own default. + timeout: Request timeout in seconds. ``None`` lets the SDK use its own + default. toolsets: Optional Pydantic AI toolsets to register (e.g. the MCP tool bridge). tools: Optional plain Pydantic AI tools to register. + backend: Which LLM backend to build the agent for. + http_client: Optional ``httpx.AsyncClient`` shared across model requests + (openai backend only). Returns: A configured Pydantic AI ``Agent`` ready to run. """ - model = _resolve_anthropic_model( - agent, default_model, api_key, base_url, auth_token=auth_token, timeout=timeout - ) + if backend == "openai": + model = _resolve_openai_model( + agent, + default_model, + api_key, + base_url, + http_client=http_client, + timeout=timeout, + ) + model_settings = _build_openai_model_settings( + agent, + default_temperature, + default_max_tokens, + default_reasoning_effort, + timeout=timeout, + ) + else: + model = _resolve_anthropic_model( + agent, default_model, api_key, base_url, auth_token=auth_token, timeout=timeout + ) + model_settings = _build_anthropic_model_settings( + agent, + default_temperature, + default_max_tokens, + default_reasoning_effort, + default_model=default_model, + timeout=timeout, + ) output_type = _build_output_type(agent) if output_type is None: output_type = str - model_settings = _build_model_settings( - agent, - default_temperature, - default_max_tokens, - default_reasoning_effort, - default_model=default_model, - timeout=timeout, - ) - pydantic_agent: Agent[Any, Any] = Agent( model=model, output_type=output_type, diff --git a/tests/test_integration/test_parameter_flow_verification.py b/tests/test_integration/test_parameter_flow_verification.py index 67cb0130..a276af51 100644 --- a/tests/test_integration/test_parameter_flow_verification.py +++ b/tests/test_integration/test_parameter_flow_verification.py @@ -221,7 +221,7 @@ async def test_none_parameters_use_defaults(self, tmp_path, monkeypatch) -> None await engine.run({}) # ClaudeProvider defaults: temperature=None, max_tokens=8192. - # _build_model_settings only includes temperature when it is not None. + # _build_anthropic_model_settings only includes temperature when it is not None. assert captured["model_settings"].get("temperature") is None assert captured["model_settings"]["max_tokens"] == 8192 diff --git a/tests/test_providers/test_pydantic_ai_agent_builder.py b/tests/test_providers/test_pydantic_ai_agent_builder.py index 81559304..53749e3e 100644 --- a/tests/test_providers/test_pydantic_ai_agent_builder.py +++ b/tests/test_providers/test_pydantic_ai_agent_builder.py @@ -10,6 +10,7 @@ from typing import Any +import httpx import pytest from pydantic import BaseModel from pydantic_ai import Agent @@ -17,26 +18,23 @@ from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart from pydantic_ai.models.anthropic import AnthropicModel from pydantic_ai.models.function import FunctionModel +from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.output import ToolOutput from conductor.config.schema import AgentDef, OutputField, ReasoningConfig from conductor.exceptions import ValidationError from conductor.providers._pydantic_ai.agent_builder import ( DEFAULT_ANTHROPIC_MODEL, + DEFAULT_OPENAI_MODEL, build_agent, ) @pytest.fixture(autouse=True) def _ensure_api_key(monkeypatch: pytest.MonkeyPatch) -> None: - """Provide a dummy API key so AnthropicModel construction succeeds.""" + """Provide dummy API keys so model construction succeeds for both backends.""" monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - - -def _extract_model_name(agent: Agent[Any, Any]) -> str: - """Return the underlying Anthropic model name from a built agent.""" - assert isinstance(agent.model, AnthropicModel) - return agent.model.model_name + monkeypatch.setenv("OPENAI_API_KEY", "test-key") def _extract_output_model(agent: Agent[Any, Any]) -> type[BaseModel] | None: @@ -68,6 +66,12 @@ def _assert_no_keys(node: Any, *keys: str) -> None: _assert_no_keys(item, *keys) +def _extract_model_name(agent: Agent[Any, Any]) -> str: + """Return the underlying model name from a built agent.""" + assert isinstance(agent.model, AnthropicModel | OpenAIChatModel) + return agent.model.model_name + + class TestModelMapping: """Tests for resolving the Anthropic model identifier.""" @@ -101,6 +105,161 @@ def test_default_constant_used_when_no_model_anywhere(self) -> None: assert _extract_model_name(pydantic_agent) == DEFAULT_ANTHROPIC_MODEL +class TestOpenAIModelMapping: + """Tests for resolving the OpenAI model identifier.""" + + def test_openai_agent_model_is_used_when_present(self) -> None: + """agent.model must be forwarded to OpenAIChatModel.model_name.""" + agent_def = AgentDef(name="mapper", model="gpt-5") + + pydantic_agent = build_agent( + agent_def, system_prompt="", rendered_prompt="", backend="openai" + ) + + assert _extract_model_name(pydantic_agent) == "gpt-5" + assert isinstance(pydantic_agent.model, OpenAIChatModel) + + def test_openai_default_model_falls_back_when_agent_model_missing(self) -> None: + """The default_model parameter must be used when agent.model is None.""" + agent_def = AgentDef(name="mapper") + + pydantic_agent = build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + backend="openai", + default_model="gpt-5", + ) + + assert _extract_model_name(pydantic_agent) == "gpt-5" + + def test_openai_default_constant_used_when_no_model_anywhere(self) -> None: + """The module-level default must be used when no model is supplied.""" + agent_def = AgentDef(name="mapper") + + pydantic_agent = build_agent( + agent_def, system_prompt="", rendered_prompt="", backend="openai" + ) + + assert _extract_model_name(pydantic_agent) == DEFAULT_OPENAI_MODEL + assert isinstance(pydantic_agent.model, OpenAIChatModel) + + +class TestOpenAIBackend: + """Tests specific to the openai backend branch.""" + + def test_openai_output_schema_becomes_tool_output(self) -> None: + """OpenAI backend must wrap a non-empty output schema in ToolOutput.""" + agent_def = AgentDef( + name="formatter", + output={"answer": OutputField(type="string")}, + ) + + pydantic_agent = build_agent( + agent_def, system_prompt="", rendered_prompt="", backend="openai" + ) + + assert isinstance(pydantic_agent.model, OpenAIChatModel) + assert isinstance(pydantic_agent.output_type, ToolOutput) + + def test_openai_sampling_settings(self) -> None: + """OpenAI model settings must carry temperature, max_tokens and timeout.""" + agent_def = AgentDef(name="sampler") + + pydantic_agent = build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + backend="openai", + default_temperature=0.7, + default_max_tokens=4096, + timeout=120.0, + ) + + assert isinstance(pydantic_agent.model, OpenAIChatModel) + assert pydantic_agent.model_settings.get("temperature") == 0.7 + assert pydantic_agent.model_settings.get("max_tokens") == 4096 + assert pydantic_agent.model_settings.get("timeout") == 120.0 + + @pytest.mark.parametrize( + ("effort",), + [("low",), ("medium",), ("high",), ("xhigh",), ("max",)], + ) + def test_openai_reasoning_effort_maps_to_openai_reasoning_effort(self, effort: str) -> None: + """Each reasoning effort level must be forwarded as openai_reasoning_effort.""" + agent_def = AgentDef( + name="reasoner", + model="gpt-5", + reasoning=ReasoningConfig(effort=effort), # type: ignore[arg-type] + ) + + pydantic_agent = build_agent( + agent_def, system_prompt="", rendered_prompt="", backend="openai" + ) + + assert pydantic_agent.model_settings.get("openai_reasoning_effort") == effort + + def test_openai_custom_base_url_without_key_raises( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Custom base_url without an explicit api_key must raise ValidationError.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + agent_def = AgentDef(name="custom_endpoint") + + with pytest.raises(ValidationError): + build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + backend="openai", + base_url="http://localhost:11434/v1", + ) + + def test_openai_explicit_api_key_allows_custom_base_url( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Custom base_url is allowed when api_key is passed explicitly.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + agent_def = AgentDef(name="custom_endpoint") + + pydantic_agent = build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + backend="openai", + api_key="explicit-key", + base_url="http://localhost:11434/v1", + ) + + assert isinstance(pydantic_agent.model, OpenAIChatModel) + assert str(pydantic_agent.model.client.base_url).rstrip("/") == "http://localhost:11434/v1" + + def test_openai_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Building an openai agent without OPENAI_API_KEY or api_key must raise.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + agent_def = AgentDef(name="unauthenticated") + + with pytest.raises(ValidationError): + build_agent(agent_def, system_prompt="", rendered_prompt="", backend="openai") + + def test_openai_http_client_is_forwarded(self) -> None: + """A provided httpx.AsyncClient must be forwarded to the OpenAI client.""" + agent_def = AgentDef(name="shared_client") + shared = httpx.AsyncClient() + + pydantic_agent = build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + backend="openai", + api_key="explicit-key", + http_client=shared, + ) + + assert isinstance(pydantic_agent.model, OpenAIChatModel) + assert pydantic_agent.model.client._client._transport is shared._transport + + class TestSystemPromptMapping: """Tests for mapping the rendered system prompt.""" From a868f3068269e68638195eaa7cced9897626d350 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Tue, 11 Aug 2026 21:09:32 +0300 Subject: [PATCH 03/14] refactor(providers): extract shared pydantic-ai run pipeline into runner module --- AGENTS.md | 13 +- .../providers/_pydantic_ai/__init__.py | 11 +- .../providers/_pydantic_ai/runner.py | 213 ++++++++++++++++++ src/conductor/providers/claude.py | 185 ++++----------- .../test_providers/test_pydantic_ai_retry.py | 149 ++++++++++++ 5 files changed, 423 insertions(+), 148 deletions(-) create mode 100644 src/conductor/providers/_pydantic_ai/runner.py diff --git a/AGENTS.md b/AGENTS.md index f393c57f..073a172e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -636,4 +636,15 @@ Implementation parity rules: - The async helpers (`run_workflow_async` and `resume_workflow_async` in `cli/run.py`) must wire up the same event emitter, JSONL event log subscriber, console event subscriber, and `WebDashboard` lifecycle. - The `WorkflowEngine` constructor receives the same kwargs in both paths (`event_emitter`, `web_dashboard`, `run_context`, `interrupt_event`, `keyboard_listener`, `instructions_preamble`). - Background-process forking lives in `cli/bg_runner.py`. `run --web-bg` calls `launch_background()` and `resume --web-bg` calls `launch_background_resume()`. Both must forward equivalent options and write a PID file via `cli/pid.py`. -- Note: on resume, the dashboard is seeded with prior events before it starts accepting clients. The CLI prepends a fresh `workflow_started` event built from the **current** workflow YAML (via `WorkflowEngine.build_workflow_started_data()`) so historical events apply to the correct topology; it then either replays the original JSONL event log (`WebDashboard.replay_events_from_jsonl()` — when the checkpoint records an `event_log_path` and the file exists) or synthesises minimal `*_started` / `*_completed` pairs from the restored `WorkflowContext` (`replay_synthetic_from_context()`). The resumed engine's own `workflow_started` emit is suppressed via `engine.suppress_workflow_started_emit()` so the dashboard sees exactly one root `workflow_started` (no `wfDepth` double-count). Two disjoint sets of events are filtered on replay. `_REPLAY_ROOT_SKIP_TYPES` (`workflow_started` / `workflow_completed` / `workflow_failed` / `checkpoint_saved` / `checkpoint_save_failed`) is filtered **only at root depth** — subworkflow-level lifecycle events are preserved so frontend `wfDepth` stays balanced. `_REPLAY_INTERACTIVE_SKIP_TYPES` (`agent_paused` / `agent_resumed` / `iteration_limit_reached` / `iteration_limit_resolved` / `dialog_started` / `dialog_completed`) is filtered at **every depth**, because the control channel is the root dashboard's `resume_event` / `kill_event` / gate id no matter which engine emitted the event. Each of those sets a *global* store latch (`isPaused`, `iterationLimitGate`, `activeDialog`) that only its counterpart event can clear — plus, for `isPaused`/`iterationLimitGate` only, a root terminal event, which the first set filters — so replaying the opening half of an unresolved pair latches it on for the whole resumed run. Concretely, a run stopped then killed from the dashboard logs `agent_paused` with no `agent_resumed`, so replaying it renders Resume/Kill instead of Stop on the live resumed run, hiding the only graceful stop behind a Kill that hard-stops a healthy workflow. A pause or gate the resumed run genuinely re-enters emits its own fresh event; `dialog_message` is left unfiltered only because it is *inert* once `dialog_started` is filtered — both renderers of `node.dialog_messages` (`DialogEngagementPrompt` via `DetailPanel`, `DialogDetail` via `DialogOverlay`) gate on `dialog_active`/`activeDialog`, which only `dialog_started` sets, so replayed dialog transcripts are **not** visible on a resumed dashboard. `components/layout/Header.tsx` additionally hides all live-control buttons when `replayMode` is set, since `ReplayDashboard` serves no `/api/stop`|`/api/resume`|`/api/kill`; `replayMode` is latched in `hooks/use-replay.ts` on mount (that hook only mounts once `App`'s `/api/replay/info` probe confirmed replay) rather than when `/api/state` resolves, so a slow or failed event fetch cannot leave the live controls rendered. The resumed `EventLogSubscriber` opens the original JSONL in append mode (when available) so a multi-resume session produces one continuous log file and `run_id` stays stable for log-correlation tools. +- Note: on resume, the dashboard is seeded with prior events before it starts accepting clients. The CLI prepends a fresh `workflow_started` event built from the **current** workflow YAML (via `WorkflowEngine.build_workflow_started_data()`) so historical events apply to the correct topology; it then either replays the original JSONL event log (`WebDashboard.replay_events_from_jsonl()` — when the checkpoint records an `event_log_path` and the file exists) or synthesises minimal `*_started` / `*_completed` pairs from the restored `WorkflowContext` (`replay_synthetic_from_context()`). The resumed engine's own `workflow_started` emit is suppressed via `engine.suppress_workflow_started_emit()` so the dashboard sees exactly one root `workflow_started` (no `wfDepth` double-count). Two disjoint sets of events are filtered on replay. `_REPLAY_ROOT_SKIP_TYPES` (`workflow_started` / `workflow_completed` / `workflow_failed` / `checkpoint_saved` / `checkpoint_save_failed`) is filtered **only at root depth** — subworkflow-level lifecycle events are preserved so frontend `wfDepth` stays balanced. `_REPLAY_INTERACTIVE_SKIP_TYPES` (`agent_paused` / `agent_resumed` / `iteration_limit_reached` / `iteration_limit_resolved` / `dialog_started` / `dialog_completed`) is filtered at **every depth**, because the control channel is the root dashboard's `resume_event` / `kill_event` / gate id no matter which engine emitted the event. Each of those sets a *global* store latch (`isPaused`, `iterationLimitGate`, `activeDialog`) that only its counterpart event can clear — plus, for `isPaused`/`iterationLimitGate` only, a root terminal event, which the first set filters — so replaying the opening half of an unresolved pair latches it on for the whole resumed run. Concretely, a run stopped then killed from the dashboard logs `agent_paused` with no `agent_resumed`, so replaying it renders Resume/Kill instead of Stop on the live resumed run, hiding the only graceful stop behind a Kill that hard-stops a healthy workflow. + +## Pydantic AI Runner Extraction + +The shared Pydantic AI execution loop from `ClaudeProvider.execute()` was extracted into `src/conductor/providers/_pydantic_ai/runner.py::run_agent_pipeline()`. `ClaudeProvider.execute()` is now a thin wrapper that: + +1. Resolves the MCP manager for the agent's working directory. +2. Builds a backend-specific `build_agent_fn(toolsets, *, max_parse_recovery_attempts)` closure. +3. Converts the provider-level `RetryConfig` to the internal `PydanticRetryConfig` representation. +4. Delegates to `run_agent_pipeline(...)`. + +The runner owns the toolset wiring, retry/interrupt/extract pipeline, partial-output construction, and model-name resolution. To keep existing tests green, the runner imports its Pydantic-AI seam helpers (`run_with_interrupt`, `execute_with_retry`, `extract_content`, etc.) **inside** `run_agent_pipeline()` rather than at module scope. This preserves the historical patching surface (`conductor.providers._pydantic_ai.interrupt.run_with_interrupt`, etc.) that tests rely on. diff --git a/src/conductor/providers/_pydantic_ai/__init__.py b/src/conductor/providers/_pydantic_ai/__init__.py index 3901a2c8..d4080d7e 100644 --- a/src/conductor/providers/_pydantic_ai/__init__.py +++ b/src/conductor/providers/_pydantic_ai/__init__.py @@ -1,7 +1,8 @@ -"""Internal helpers for the Pydantic AI-based Claude provider rewrite. +"""Internal helpers for Pydantic AI-based provider implementations. -This package contains adapters used by ``conductor.providers.claude`` to -build Pydantic AI agents, bridge MCP tools, convert output schemas, and -map streaming events. The underscore prefix signals that these modules are -not a public API of Conductor. +This package contains adapters shared by providers that use Pydantic AI +(currently ``conductor.providers.claude``) to build Pydantic AI agents, +bridge MCP tools, convert output schemas, map streaming events, and run the +shared interrupt-aware retry/execution pipeline. The underscore prefix signals +that these modules are not a public API of Conductor. """ diff --git a/src/conductor/providers/_pydantic_ai/runner.py b/src/conductor/providers/_pydantic_ai/runner.py new file mode 100644 index 00000000..760c217b --- /dev/null +++ b/src/conductor/providers/_pydantic_ai/runner.py @@ -0,0 +1,213 @@ +"""Shared execution pipeline for Pydantic AI-based providers. + +This module extracts the provider-agnostic execution loop from the Claude +provider into a reusable helper. It wires MCP toolsets into a caller-supplied +Pydantic AI agent factory, runs the interrupt-aware retry loop, and normalizes +the result into an :class:`~conductor.providers.base.AgentOutput`. + +The underscore prefix signals that this module is an internal implementation +detail, not a public API of Conductor. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from pydantic import BaseModel +from pydantic_ai import Agent + +from conductor.config.schema import AgentDef, OutputField, ToolOutputConfig +from conductor.exceptions import ProviderError, ValidationError +from conductor.mcp.manager import MCPManager +from conductor.providers._pydantic_ai.retry import ( + RetryConfig as PydanticRetryConfig, +) +from conductor.providers.base import AgentOutput, EventCallback + + +def _model_name_from_pydantic_agent( + pydantic_agent: Any, + default_model: str, +) -> str: + """Return a resolved model name from a Pydantic AI agent instance.""" + model = pydantic_agent.model + if model is None: + return default_model + if hasattr(model, "model_name"): + return model.model_name + if hasattr(model, "name"): + return model.name + return str(model) + + +def _build_partial_content( + partial_output: Any, + output_schema: dict[str, OutputField] | None, + agent_name: str, +) -> dict[str, Any]: + """Build a content dict from a partial/interrupted output.""" + from conductor.executor.output import parse_json_output + + if isinstance(partial_output, BaseModel): + return partial_output.model_dump() + + if isinstance(partial_output, str): + if output_schema is not None: + try: + return parse_json_output(partial_output) + except ValidationError: + pass + return {"result": partial_output} + + return {"result": partial_output} + + +async def run_agent_pipeline( + *, + agent: AgentDef, + rendered_prompt: str, + mcp_manager: MCPManager | None, + tools: list[str] | None, + tool_output_config: ToolOutputConfig, + retry_config: PydanticRetryConfig, + interrupt_signal: asyncio.Event | None, + event_callback: EventCallback | None, + max_agent_iterations: int, + max_session_seconds: float | None, + default_model: str, + retry_history: list[dict[str, Any]], + build_agent_fn: Callable[..., Agent[Any, Any]], +) -> AgentOutput: + """Run the shared Pydantic AI execution pipeline. + + Constructs the MCP toolset, resolves the effective retry configuration, + builds the Pydantic AI agent via ``build_agent_fn(toolsets)``, runs the + interrupt-aware retry loop, and returns a normalized ``AgentOutput``. + + All Pydantic-AI seam helpers are imported inside this function so that tests + can patch the module paths the provider historically used (e.g. + ``conductor.providers._pydantic_ai.interrupt.run_with_interrupt``) without + having to know the internal runner module. + + Args: + agent: Conductor agent definition. + rendered_prompt: Jinja2-rendered user prompt. + mcp_manager: MCP manager for the resolved working directory, or ``None`` + if no MCP servers are configured. + tools: Optional list of tool names available to this agent. ``None`` + grants all tools; ``[]`` grants none. + tool_output_config: MCP tool result output-size configuration. + retry_config: Provider-level retry defaults (merged with any per-agent + ``retry`` policy). + interrupt_signal: Optional event for mid-agent interrupt signaling. + event_callback: Optional Conductor event callback for streaming events. + max_agent_iterations: Maximum Pydantic AI requests for this run. + max_session_seconds: Optional wall-clock cap for the session. + default_model: Fallback model name when the agent's model cannot be + resolved from the Pydantic AI agent instance. + retry_history: Mutable list that receives ``agent_retry`` events. + build_agent_fn: Callable that accepts ``toolsets`` and keyword args + (currently ``max_parse_recovery_attempts``) and returns a configured + Pydantic AI ``Agent``. + + Returns: + Normalized ``AgentOutput``. + + Raises: + asyncio.CancelledError: When the run is hard-aborted via interrupt. + ProviderError: When the agent produces no result or an unrecoverable + provider failure occurs. + ValidationError: When the output fails schema validation. + """ + from pydantic_ai import UsageLimits + + from conductor.providers._pydantic_ai.interrupt import run_with_interrupt + from conductor.providers._pydantic_ai.mcp_toolset import MCPManagerToolset + from conductor.providers._pydantic_ai.retry import ( + _resolve_retry_config, + execute_with_retry, + ) + from conductor.providers._pydantic_ai.structured_output import extract_content + from conductor.providers._pydantic_ai.usage import build_agent_output + + toolsets: list[Any] = [] + if mcp_manager is not None: + tool_names = None if agent.tools is None and not tools else tools + toolsets.append( + MCPManagerToolset( + mcp_manager, + tool_names, + tool_output_config, + event_callback=event_callback, + ) + ) + + retry_cfg = _resolve_retry_config(agent, retry_config) + + pydantic_agent = build_agent_fn( + toolsets, + max_parse_recovery_attempts=retry_cfg.max_parse_recovery_attempts, + ) + + def intercepting_callback(event_type: str, data: dict[str, Any]) -> None: + if event_type == "agent_retry": + retry_history.append(data) + if event_callback is not None: + event_callback(event_type, data) + + outcome = await execute_with_retry( + coro_factory=lambda: run_with_interrupt( + agent=pydantic_agent, + user_prompt=rendered_prompt, + interrupt_signal=interrupt_signal, + event_callback=intercepting_callback, + has_output_schema=bool(agent.output), + usage_limits=UsageLimits(request_limit=max_agent_iterations), + max_session_seconds=max_session_seconds, + max_parse_recovery_attempts=retry_cfg.max_parse_recovery_attempts, + ), + retry_config=retry_cfg, + event_callback=intercepting_callback, + agent_name=agent.name, + ) + + if outcome.is_cancelled: + raise asyncio.CancelledError() + + model_name = _model_name_from_pydantic_agent(pydantic_agent, default_model) + + if outcome.is_partial: + content = _build_partial_content( + outcome.partial_output, + agent.output, + agent.name, + ) + total_usage = outcome.total_usage or {} + return AgentOutput( + content=content, + raw_response=outcome.partial_output, + tokens_used=total_usage.get("total_tokens"), + input_tokens=total_usage.get("request_tokens"), + output_tokens=total_usage.get("response_tokens"), + last_call_input_tokens=outcome.last_call_input_tokens, + partial=True, + model=model_name, + ) + + if outcome.result is None: + raise ProviderError( + f"Agent '{agent.name}' produced no result", + suggestion="Check the model and prompt configuration.", + is_retryable=False, + ) + + content = extract_content(outcome.result.output, agent.output, agent.name) + return build_agent_output( + content=content, + raw_response=outcome.result, + usage=outcome.result.usage, + model=model_name, + last_call_input_tokens=outcome.last_call_input_tokens, + ) diff --git a/src/conductor/providers/claude.py b/src/conductor/providers/claude.py index bbb32573..2948dcf5 100644 --- a/src/conductor/providers/claude.py +++ b/src/conductor/providers/claude.py @@ -28,7 +28,7 @@ from pydantic import BaseModel -from conductor.config.schema import AgentDef, OutputField, ToolOutputConfig +from conductor.config.schema import AgentDef, ToolOutputConfig from conductor.exceptions import ProviderError, ValidationError from conductor.mcp.manager import ( MCPManager, @@ -898,67 +898,42 @@ async def execute( ValidationError: If output doesn't match schema. """ del skill_directories # Claude relies on eager preamble injection (see docstring). - from pydantic_ai import UsageLimits - from conductor.providers._pydantic_ai.agent_builder import build_agent - from conductor.providers._pydantic_ai.interrupt import run_with_interrupt - from conductor.providers._pydantic_ai.mcp_toolset import MCPManagerToolset - from conductor.providers._pydantic_ai.retry import ( - RetryConfig as PydanticRetryConfig, - ) - from conductor.providers._pydantic_ai.retry import ( - _resolve_retry_config, - execute_with_retry, - ) - from conductor.providers._pydantic_ai.structured_output import extract_content - from conductor.providers._pydantic_ai.usage import build_agent_output + from conductor.providers._pydantic_ai.retry import RetryConfig as PydanticRetryConfig + from conductor.providers._pydantic_ai.runner import run_agent_pipeline resolved_cwd = agent.working_dir or os.getcwd() manager = await self._get_mcp_manager_for_cwd(resolved_cwd) - toolsets: list[Any] = [] - if manager is not None: - tool_names = None if agent.tools is None and not tools else tools - toolsets.append( - MCPManagerToolset( - manager, - tool_names, - self._tool_output_config, - event_callback=event_callback, - ) + def build_agent_fn(toolsets: list[Any], *, max_parse_recovery_attempts: int) -> Any: + return build_agent( + agent=agent, + system_prompt=agent.system_prompt or "", + rendered_prompt=rendered_prompt, + default_model=self._default_model, + default_temperature=self._default_temperature, + default_max_tokens=self._default_max_tokens, + default_reasoning_effort=self._default_reasoning_effort, + max_parse_recovery_attempts=max_parse_recovery_attempts, + api_key=self._api_key, + auth_token=self._auth_token, + base_url=self._base_url, + timeout=self._timeout, + toolsets=toolsets, ) - retry_cfg = _resolve_retry_config( - agent, - PydanticRetryConfig( - max_attempts=self._retry_config.max_attempts, - base_delay=self._retry_config.base_delay, - max_delay=self._retry_config.max_delay, - jitter=self._retry_config.jitter, - backoff=self._retry_config.backoff, - retry_on=( - list(self._retry_config.retry_on) - if self._retry_config.retry_on is not None - else None - ), - max_parse_recovery_attempts=self._retry_config.max_parse_recovery_attempts, + retry_config = PydanticRetryConfig( + max_attempts=self._retry_config.max_attempts, + base_delay=self._retry_config.base_delay, + max_delay=self._retry_config.max_delay, + jitter=self._retry_config.jitter, + backoff=self._retry_config.backoff, + retry_on=( + list(self._retry_config.retry_on) + if self._retry_config.retry_on is not None + else None ), - ) - - pydantic_agent = build_agent( - agent=agent, - system_prompt=agent.system_prompt or "", - rendered_prompt=rendered_prompt, - default_model=self._default_model, - default_temperature=self._default_temperature, - default_max_tokens=self._default_max_tokens, - default_reasoning_effort=self._default_reasoning_effort, - max_parse_recovery_attempts=retry_cfg.max_parse_recovery_attempts, - api_key=self._api_key, - auth_token=self._auth_token, - base_url=self._base_url, - timeout=self._timeout, - toolsets=toolsets, + max_parse_recovery_attempts=self._retry_config.max_parse_recovery_attempts, ) max_iterations = ( @@ -974,92 +949,18 @@ async def execute( self._retry_history.clear() - def intercepting_callback(event_type: str, data: dict[str, Any]) -> None: - if event_type == "agent_retry": - self._retry_history.append(data) - if event_callback is not None: - event_callback(event_type, data) - - outcome = await execute_with_retry( - coro_factory=lambda: run_with_interrupt( - agent=pydantic_agent, - user_prompt=rendered_prompt, - interrupt_signal=interrupt_signal, - event_callback=intercepting_callback, - has_output_schema=bool(agent.output), - usage_limits=UsageLimits(request_limit=max_iterations), - max_session_seconds=max_session, - max_parse_recovery_attempts=retry_cfg.max_parse_recovery_attempts, - ), - retry_config=retry_cfg, - event_callback=intercepting_callback, - agent_name=agent.name, - ) - - if outcome.is_cancelled: - raise asyncio.CancelledError() - - model_name = self._model_name_from_pydantic_agent(pydantic_agent) - - if outcome.is_partial: - content = self._build_partial_content(outcome.partial_output, agent.output, agent.name) - total_usage = outcome.total_usage or {} - return AgentOutput( - content=content, - raw_response=outcome.partial_output, - tokens_used=total_usage.get("total_tokens"), - input_tokens=total_usage.get("request_tokens"), - output_tokens=total_usage.get("response_tokens"), - last_call_input_tokens=outcome.last_call_input_tokens, - partial=True, - model=model_name, - ) - - if outcome.result is None: - raise ProviderError( - f"Agent '{agent.name}' produced no result", - suggestion="Check the model and prompt configuration.", - is_retryable=False, - ) - - content = extract_content(outcome.result.output, agent.output, agent.name) - return build_agent_output( - content=content, - raw_response=outcome.result, - usage=outcome.result.usage, - model=model_name, - last_call_input_tokens=outcome.last_call_input_tokens, + return await run_agent_pipeline( + agent=agent, + rendered_prompt=rendered_prompt, + mcp_manager=manager, + tools=tools, + tool_output_config=self._tool_output_config, + retry_config=retry_config, + interrupt_signal=interrupt_signal, + event_callback=event_callback, + max_agent_iterations=max_iterations, + max_session_seconds=max_session, + default_model=self._default_model, + retry_history=self._retry_history, + build_agent_fn=build_agent_fn, ) - - def _model_name_from_pydantic_agent(self, pydantic_agent: Any) -> str: - """Return a resolved model name from a Pydantic AI agent instance.""" - model = pydantic_agent.model - if model is None: - return self._default_model - if hasattr(model, "model_name"): - return model.model_name - if hasattr(model, "name"): - return model.name - return str(model) - - def _build_partial_content( - self, - partial_output: Any, - output_schema: dict[str, OutputField] | None, - agent_name: str, - ) -> dict[str, Any]: - """Build a content dict from a partial/interrupted output.""" - from conductor.executor.output import parse_json_output - - if isinstance(partial_output, BaseModel): - return partial_output.model_dump() - - if isinstance(partial_output, str): - if output_schema is not None: - try: - return parse_json_output(partial_output) - except ValidationError: - pass - return {"result": partial_output} - - return {"result": partial_output} diff --git a/tests/test_providers/test_pydantic_ai_retry.py b/tests/test_providers/test_pydantic_ai_retry.py index 23b73f47..60a08047 100644 --- a/tests/test_providers/test_pydantic_ai_retry.py +++ b/tests/test_providers/test_pydantic_ai_retry.py @@ -12,6 +12,8 @@ from typing import Any from unittest.mock import Mock, patch +import httpx +import openai import pytest from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior @@ -30,6 +32,37 @@ ) +def _make_http_request() -> httpx.Request: + """Return a minimal httpx request for constructing openai exceptions.""" + return httpx.Request("GET", "http://example.com") + + +def _make_openai_rate_limit_error(retry_after: str | None = None) -> openai.RateLimitError: + """Return a real openai RateLimitError with an optional Retry-After header.""" + request = _make_http_request() + headers: dict[str, str] = {} + if retry_after is not None: + headers["Retry-After"] = retry_after + response = httpx.Response(429, text="rate limited", headers=headers, request=request) + return openai.RateLimitError("rate limited", response=response, body=None) + + +def _make_openai_status_error(status_code: int) -> openai.APIStatusError: + """Return a real openai APIStatusError subclass for the given status code.""" + request = _make_http_request() + response = httpx.Response(status_code, text="boom", request=request) + mapping: dict[int, type[openai.APIStatusError]] = { + 400: openai.BadRequestError, + 401: openai.AuthenticationError, + 403: openai.PermissionDeniedError, + 404: openai.NotFoundError, + 429: openai.RateLimitError, + 500: openai.InternalServerError, + } + cls = mapping.get(status_code, openai.APIStatusError) + return cls("boom", response=response, body=None) + + class MockRateLimitError(Exception): """Fake Anthropic RateLimitError with a retry-after header.""" @@ -163,6 +196,122 @@ def test_model_api_error_has_no_other_subclasses(self) -> None: assert ModelAPIError.__subclasses__() == [ModelHTTPError] +class TestOpenAIErrorClassification: + """Tests for pydantic-ai wrapped and raw openai error classification.""" + + def test_model_http_error_429_408_and_5xx_are_retryable(self) -> None: + """Wrapped ModelHTTPError 429/408/5xx must be retryable.""" + assert _is_retryable_error(ModelHTTPError(429, model_name="gpt-4o")) is True + assert _is_retryable_error(ModelHTTPError(408, model_name="gpt-4o")) is True + assert _is_retryable_error(ModelHTTPError(500, model_name="gpt-4o")) is True + assert _is_retryable_error(ModelHTTPError(503, model_name="gpt-4o")) is True + + def test_model_http_error_400_401_403_404_are_fatal(self) -> None: + """Wrapped ModelHTTPError 400/401/403/404 must be fatal.""" + assert _is_retryable_error(ModelHTTPError(400, model_name="gpt-4o")) is False + assert _is_retryable_error(ModelHTTPError(401, model_name="gpt-4o")) is False + assert _is_retryable_error(ModelHTTPError(403, model_name="gpt-4o")) is False + assert _is_retryable_error(ModelHTTPError(404, model_name="gpt-4o")) is False + + def test_model_api_error_is_retryable(self) -> None: + """Wrapped ModelAPIError must be retryable.""" + err = ModelAPIError(model_name="gpt-4o", message="stream interrupted") + assert _is_retryable_error(err) is True + + def test_raw_openai_rate_limit_is_retryable(self) -> None: + """Raw openai RateLimitError must be retryable.""" + assert _is_retryable_error(_make_openai_rate_limit_error()) is True + + def test_raw_openai_api_status_5xx_and_429_are_retryable(self) -> None: + """Raw openai APIStatusError 5xx and 429 must be retryable.""" + assert _is_retryable_error(_make_openai_status_error(500)) is True + assert _is_retryable_error(_make_openai_status_error(503)) is True + assert _is_retryable_error(_make_openai_status_error(429)) is True + + def test_raw_openai_api_status_4xx_are_fatal(self) -> None: + """Raw openai APIStatusError 400/401/403/404 must be fatal.""" + assert _is_retryable_error(_make_openai_status_error(400)) is False + assert _is_retryable_error(_make_openai_status_error(401)) is False + assert _is_retryable_error(_make_openai_status_error(403)) is False + assert _is_retryable_error(_make_openai_status_error(404)) is False + + def test_raw_openai_connection_and_timeout_are_retryable(self) -> None: + """Raw openai APIConnectionError and APITimeoutError must be retryable.""" + request = _make_http_request() + assert ( + _is_retryable_error( + openai.APIConnectionError(message="connection reset", request=request) + ) + is True + ) + assert _is_retryable_error(openai.APITimeoutError(request=request)) is True + + def test_raw_openai_authentication_and_bad_request_are_fatal(self) -> None: + """Raw openai AuthenticationError and BadRequestError must be fatal.""" + request = _make_http_request() + assert ( + _is_retryable_error( + openai.AuthenticationError( + "unauthorized", response=httpx.Response(401, request=request), body=None + ) + ) + is False + ) + assert ( + _is_retryable_error( + openai.BadRequestError( + "bad request", response=httpx.Response(400, request=request), body=None + ) + ) + is False + ) + + def test_extract_status_code_from_model_http_error(self) -> None: + """_extract_status_code must read ModelHTTPError.status_code.""" + assert _extract_status_code(ModelHTTPError(503, model_name="gpt-4o")) == 503 + + def test_extract_status_code_from_raw_openai(self) -> None: + """_extract_status_code must read raw openai APIStatusError status_code.""" + assert _extract_status_code(_make_openai_status_error(429)) == 429 + assert _extract_status_code(_make_openai_status_error(500)) == 500 + + def test_get_retry_after_from_model_http_error_returns_none(self) -> None: + """Wrapped ModelHTTPError loses Retry-After headers; fallback to backoff.""" + assert _get_retry_after(ModelHTTPError(429, model_name="gpt-4o")) is None + + def test_get_retry_after_from_raw_openai_rate_limit(self) -> None: + """Raw openai RateLimitError with Retry-After header must be honored.""" + assert _get_retry_after(_make_openai_rate_limit_error("42")) == 42.0 + + def test_get_retry_after_from_raw_openai_rate_limit_without_header(self) -> None: + """Raw openai RateLimitError without header must fall back to backoff.""" + assert _get_retry_after(_make_openai_rate_limit_error()) is None + + @pytest.mark.asyncio + async def test_execute_with_retry_retries_raw_openai_rate_limit(self) -> None: + """execute_with_retry must retry a raw openai RateLimitError and respect Retry-After.""" + events: list[tuple[str, dict[str, Any]]] = [] + + def callback(event_type: str, payload: dict[str, Any]) -> None: + events.append((event_type, payload)) + + config = RetryConfig(max_attempts=2, base_delay=1.0, jitter=0.0) + factory = _make_factory([_make_openai_rate_limit_error("3"), "ok"]) + + with patch("conductor.providers._pydantic_ai.retry.asyncio.sleep") as mock_sleep: + result = await execute_with_retry( + factory, + retry_config=config, + event_callback=callback, + agent_name="retryer", + ) + + assert result == "ok" + assert len(events) == 1 + assert events[0][1]["delay"] == 3.0 + mock_sleep.assert_called_once_with(3.0) + + class TestRetryConfig: """Tests for retry configuration resolution.""" From 7457365b7d38f23b76ba9724f40a8872595886fb Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Tue, 11 Aug 2026 22:24:52 +0300 Subject: [PATCH 04/14] feat(providers): add OpenAI provider on shared pydantic-ai runtime --- src/conductor/config/schema.py | 10 +- src/conductor/providers/__init__.py | 6 + src/conductor/providers/capabilities.py | 1 + src/conductor/providers/diagnostics.py | 5 + src/conductor/providers/factory.py | 38 +- src/conductor/providers/openai.py | 629 ++++++++++++++++++++++++ src/conductor/providers/registry.py | 10 +- tests/test_providers/test_openai.py | 282 +++++++++++ 8 files changed, 973 insertions(+), 8 deletions(-) create mode 100644 src/conductor/providers/openai.py create mode 100644 tests/test_providers/test_openai.py diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 889b51c4..34eded63 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -1191,7 +1191,7 @@ class AgentDef(BaseModel): ) = None """Agent type. Defaults to 'agent' if not specified.""" - provider: Literal["copilot", "claude", "claude-agent-sdk", "hermes"] | None = None + provider: Literal["copilot", "openai", "claude", "claude-agent-sdk", "hermes"] | None = None """Provider override for this agent. If None (default), the agent uses the workflow.runtime.provider. @@ -2602,9 +2602,9 @@ class ProviderSettings(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - name: Literal["copilot", "openai-agents", "claude", "claude-agent-sdk", "hermes", "aca"] = ( - "copilot" - ) + name: Literal[ + "copilot", "openai", "openai-agents", "claude", "claude-agent-sdk", "hermes", "aca" + ] = "copilot" """SDK provider to use for agent execution.""" type: Literal["openai", "azure", "anthropic"] | None = None @@ -2815,7 +2815,7 @@ def _check_field_compatibility(self) -> ProviderSettings: f"Provider fields {extras} are only supported when name='copilot'. " "Structured provider config for other providers is not yet implemented." ) - if self.name not in ("copilot", "claude", "hermes") and ( + if self.name not in ("copilot", "openai", "claude", "hermes") and ( self.base_url is not None or self.api_key is not None ): raise ValueError( diff --git a/src/conductor/providers/__init__.py b/src/conductor/providers/__init__.py index 92bc5cb8..a511b321 100644 --- a/src/conductor/providers/__init__.py +++ b/src/conductor/providers/__init__.py @@ -15,6 +15,7 @@ from conductor.providers.claude_agent_sdk import ClaudeAgentSdkProvider from conductor.providers.copilot import CopilotProvider from conductor.providers.factory import create_provider + from conductor.providers.openai import OpenAIProvider __all__ = [ "AgentOutput", @@ -23,6 +24,7 @@ "ClaudeProvider", "CopilotProvider", "create_provider", + "OpenAIProvider", ] @@ -39,6 +41,10 @@ def __getattr__(name: str) -> Any: from conductor.providers.copilot import CopilotProvider return CopilotProvider + if name == "OpenAIProvider": + from conductor.providers.openai import OpenAIProvider + + return OpenAIProvider if name == "create_provider": from conductor.providers.factory import create_provider diff --git a/src/conductor/providers/capabilities.py b/src/conductor/providers/capabilities.py index 097cb184..94db26ec 100644 --- a/src/conductor/providers/capabilities.py +++ b/src/conductor/providers/capabilities.py @@ -284,6 +284,7 @@ def declared_limitations(self) -> list[str]: # the provider (instantiation can require API keys / network). _PROVIDER_CLASS_PATHS: Final[dict[str, str]] = { "copilot": "conductor.providers.copilot:CopilotProvider", + "openai": "conductor.providers.openai:OpenAIProvider", "claude": "conductor.providers.claude:ClaudeProvider", "claude-agent-sdk": "conductor.providers.claude_agent_sdk:ClaudeAgentSdkProvider", "hermes": "conductor.providers.hermes:HermesProvider", diff --git a/src/conductor/providers/diagnostics.py b/src/conductor/providers/diagnostics.py index 3dd0d5c4..e4c19130 100644 --- a/src/conductor/providers/diagnostics.py +++ b/src/conductor/providers/diagnostics.py @@ -107,6 +107,7 @@ def optional(self) -> bool: "authenticates via `claude login`; ANTHROPIC_API_KEY is an optional override" ), ), + "openai": _CredentialSpec(env_vars=("OPENAI_API_KEY",)), "hermes": _CredentialSpec(), "openai-agents": _CredentialSpec(), } @@ -326,6 +327,10 @@ def _sdk_available(name: str) -> bool: from conductor.providers.claude_agent_sdk import CLAUDE_AGENT_SDK_AVAILABLE return CLAUDE_AGENT_SDK_AVAILABLE + if name == "openai": + from conductor.providers.openai import OPENAI_SDK_AVAILABLE + + return OPENAI_SDK_AVAILABLE if name == "hermes": from conductor.providers.hermes import HERMES_SDK_AVAILABLE diff --git a/src/conductor/providers/factory.py b/src/conductor/providers/factory.py index 3fa96279..994e6d3e 100644 --- a/src/conductor/providers/factory.py +++ b/src/conductor/providers/factory.py @@ -21,13 +21,22 @@ from conductor.providers.context_tier import ContextTier from conductor.providers.copilot import CopilotProvider, IdleRecoveryConfig from conductor.providers.hermes import HERMES_SDK_AVAILABLE, HermesProvider +from conductor.providers.openai import OPENAI_SDK_AVAILABLE, OpenAIProvider from conductor.providers.reasoning import ReasoningEffort if TYPE_CHECKING: from conductor.config.schema import ProviderSettings -ProviderType = Literal["copilot", "openai-agents", "claude", "claude-agent-sdk", "hermes", "aca"] +ProviderType = Literal[ + "copilot", + "openai", + "openai-agents", + "claude", + "claude-agent-sdk", + "hermes", + "aca", +] async def create_provider( @@ -107,6 +116,31 @@ async def create_provider( provider_settings=provider_settings, tool_output=tool_output, ) + case "openai": + if not OPENAI_SDK_AVAILABLE: + raise ProviderError( + "OpenAI provider requires the openai package", + suggestion="Install with: uv add 'openai>=1.0.0'", + ) + openai_api_key: str | None = None + openai_base_url: str | None = None + if provider_settings is not None and provider_settings.name == "openai": + if provider_settings.api_key is not None: + openai_api_key = provider_settings.api_key.get_secret_value() + openai_base_url = provider_settings.base_url + provider = OpenAIProvider( + api_key=openai_api_key, + base_url=openai_base_url, + model=default_model, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout if timeout is not None else 600.0, + mcp_servers=mcp_servers, + max_agent_iterations=max_agent_iterations, + max_session_seconds=max_session_seconds, + default_reasoning_effort=default_reasoning_effort, + tool_output=tool_output, + ) case "openai-agents": raise ProviderError( "OpenAI Agents provider not yet implemented", @@ -232,7 +266,7 @@ async def create_provider( raise ProviderError( f"Unknown provider: {provider_type}", suggestion=( - "Valid providers are: copilot, openai-agents, claude, " + "Valid providers are: copilot, openai, openai-agents, claude, " "claude-agent-sdk, hermes, aca" ), ) diff --git a/src/conductor/providers/openai.py b/src/conductor/providers/openai.py new file mode 100644 index 00000000..765c16eb --- /dev/null +++ b/src/conductor/providers/openai.py @@ -0,0 +1,629 @@ +"""OpenAI provider implementation on the shared Pydantic AI runtime. + +This module provides the ``OpenAIProvider`` class for executing agents via the +OpenAI API using the Pydantic AI agent loop shared with ``ClaudeProvider``. It +uses the same :mod:`~conductor.providers._pydantic_ai.runner` pipeline, the +same MCP toolset bridge, and the same retry/interrupt/event contracts. + +Error Handling Strategy: +- ValidationError: Used for invalid inputs, schema violations, and parameter range + errors. These are non-retryable and indicate user/configuration errors that + should fail fast. Examples: temperature out of range, missing API key, invalid + output schema. +- ProviderError: Used for API failures, network errors, and SDK exceptions. These + may be retryable (connection errors, rate limits) or non-retryable (invalid API + key). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +from conductor.config.schema import AgentDef, ToolOutputConfig +from conductor.exceptions import ProviderError, ValidationError +from conductor.mcp.manager import MCPManager +from conductor.providers.base import ( + AgentOutput, + AgentProvider, + EventCallback, + ModelCapabilityInfo, +) +from conductor.providers.capabilities import ProviderCapabilities +from conductor.providers.reasoning import ReasoningEffort + +if TYPE_CHECKING: + from conductor.engine.pricing import ModelPricing + + +def _import_openai_sdk() -> tuple[bool, Any]: + """Import the OpenAI SDK and return availability flag plus module reference.""" + try: + import openai + + return True, openai + except ImportError: + return False, None + + +OPENAI_SDK_AVAILABLE, openai = _import_openai_sdk() + +if TYPE_CHECKING: + from openai import AsyncOpenAI + +logger = logging.getLogger(__name__) + +_OPENAI_REASONING_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "xhigh") + + +class RetryConfig(BaseModel): + """Configuration for retry behavior. + + Attributes: + max_attempts: Maximum number of retry attempts (including first attempt). + base_delay: Base delay in seconds before first retry. + max_delay: Maximum delay in seconds between retries. + jitter: Maximum random jitter to add to delay (0.0 to 1.0 fraction of delay). + backoff: Backoff strategy: "exponential" or "fixed". + retry_on: Error categories that trigger a retry ("provider_error", "timeout"). + max_parse_recovery_attempts: Maximum number of in-session recovery attempts + for JSON parse failures. When parsing fails, a follow-up message is sent + to the same session asking the model to correct its response format. + """ + + max_attempts: int = 3 + base_delay: float = 1.0 + max_delay: float = 30.0 + jitter: float = 0.25 + backoff: str = "exponential" + retry_on: list[str] | None = None + max_parse_recovery_attempts: int = 2 + + +class OpenAIProvider(AgentProvider): + """OpenAI API provider built on the shared Pydantic AI runtime. + + Translates Conductor agent definitions into Pydantic AI/OpenAI API calls and + normalizes responses into :class:`~conductor.providers.base.AgentOutput`. + Supports incremental event streaming, structured output via tool use, retry + logic, interrupts, and workflow-level MCP servers. + + Example: + >>> provider = OpenAIProvider(api_key="sk-...") + >>> await provider.validate_connection() + True + >>> await provider.close() + """ + + CAPABILITIES = ProviderCapabilities( + tier="stable", + # ``runtime.mcp_servers`` are forwarded via the Pydantic AI MCP toolset bridge. + mcp_tools=True, + # Per-agent ``tools:`` allowlists are passed through to the Pydantic AI agent. + workflow_tools_passthrough=True, + streaming_events=True, + # Reasoning content is surfaced as ``agent_reasoning`` events when the model + # returns it. + agent_reasoning_events=True, + # OpenAI supports reasoning_effort on its reasoning models (o-series, etc.). + reasoning_effort=("low", "medium", "high", "xhigh"), + # Tool-based structured output: the schema is enforced via a forced tool call. + structured_output="native", + # ``interrupt_signal`` is monitored by the shared Pydantic AI interrupt helper. + interrupt=True, + # ``max_session_seconds`` is enforced by the shared Pydantic AI interrupt helper. + max_session_seconds=True, + # OpenAI's API is stateless per-request — no session state to persist across + # ``conductor resume``. + checkpoint_resume=False, + # Token counts and model identifier are populated on every AgentOutput. + usage_tracking=True, + # No global mutable state — safe to run N parallel agents. + concurrent_safe=True, + # The resolved ``working_dir`` selects the MCPManager pool key and is forwarded + # to stdio MCP server ``cwd``. + working_dir=True, + # Skill content is eagerly injected into the rendered prompt by + # :class:`~conductor.executor.agent.AgentExecutor` (OpenAI's Responses/Chat + # Completions API has no native skill-directory surface). + skills=True, + # No plugin support: there is no subagent or MCP surface native to the OpenAI + # API that Conductor can deconstruct into. + plugins=False, + upstream_pin=None, + maintainer="@microsoft/conductor", + ) + + @property + def supports_native_skills(self) -> bool: + """OpenAI has no native skill-directory surface; rely on eager injection.""" + return False + + @property + def supports_native_plugins(self) -> bool: + """OpenAI has no native plugin/subagent surface.""" + return False + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + model: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + timeout: float = 600.0, + retry_config: RetryConfig | None = None, + mcp_servers: dict[str, Any] | None = None, + max_agent_iterations: int | None = None, + max_session_seconds: float | None = None, + default_reasoning_effort: ReasoningEffort | None = None, + tool_output: ToolOutputConfig | None = None, + ) -> None: + """Initialize the OpenAI provider. + + Args: + api_key: OpenAI API key. If None, falls back to ``OPENAI_API_KEY`` env var. + base_url: Optional custom API endpoint. If set, ``api_key`` must be provided + explicitly — Conductor does not rely on ambient ``OPENAI_API_KEY`` for + authenticated custom endpoints. + model: Default model to use. Defaults to ``gpt-5-mini``. + temperature: Default temperature (0.0-2.0). + max_tokens: Maximum output tokens. Defaults to 4096. + timeout: Request timeout in seconds. Defaults to 600s. + retry_config: Optional retry configuration. Uses default if not provided. + mcp_servers: Optional MCP server configurations for tool support. + Each server config should have: command, args, env (optional). + max_agent_iterations: Maximum tool-use iterations per agent execution. + Defaults to 50 if not specified. + max_session_seconds: Maximum wall-clock duration for agent sessions. + Defaults to None (unlimited). + default_reasoning_effort: Workflow-wide default reasoning effort applied + when an agent does not declare its own ``reasoning`` config. Mapped to + OpenAI's ``reasoning_effort`` parameter on supported models. + tool_output: MCP tool result output-size configuration. + + Raises: + ProviderError: If the OpenAI SDK is not installed. + ValidationError: If no API key is available or parameters are out of range. + """ + if not OPENAI_SDK_AVAILABLE: + raise ProviderError( + "OpenAI SDK not installed", + suggestion="Install with: uv add 'openai>=1.0.0'", + ) + + self._client: AsyncOpenAI | None = None + self._api_key = api_key + self._base_url = base_url + self._default_model = model or "gpt-5-mini" + + if temperature is not None: + self._validate_temperature(temperature) + self._default_temperature = temperature + + if max_tokens is not None: + self._validate_max_tokens(max_tokens) + self._default_max_tokens = max_tokens or 4096 + + self._timeout = timeout + self._sdk_version: str | None = None + self._retry_config = retry_config or RetryConfig() + self._retry_history: list[dict[str, Any]] = [] + self._default_max_agent_iterations = ( + max_agent_iterations if max_agent_iterations is not None else 50 + ) + self._default_max_session_seconds = max_session_seconds + self._default_reasoning_effort: ReasoningEffort | None = default_reasoning_effort + self._tool_output_config = tool_output or ToolOutputConfig() + + self._mcp_servers_config = mcp_servers + self._mcp_managers: dict[str, MCPManager] = {} + self._mcp_manager_locks: dict[str, asyncio.Lock] = {} + + self._initialize_client() + + def _initialize_client(self) -> None: + """Initialize the OpenAI client and log SDK version. + + Model verification is deferred to :meth:`validate_connection` to keep + initialization synchronous. + """ + if not OPENAI_SDK_AVAILABLE or openai is None: + return + + from openai import AsyncOpenAI + + client_kwargs: dict[str, Any] = {"timeout": self._timeout, "max_retries": 0} + effective_api_key = self._api_key + if effective_api_key is None: + effective_api_key = os.environ.get("OPENAI_API_KEY") + + if not effective_api_key: + raise ValidationError( + "OPENAI_API_KEY environment variable is not set and no api_key was provided", + suggestion="Set OPENAI_API_KEY or pass api_key to the provider.", + ) + + self._api_key = effective_api_key + + client_kwargs["api_key"] = effective_api_key + if self._base_url is not None: + client_kwargs["base_url"] = self._base_url + + self._client = AsyncOpenAI(**client_kwargs) + + if openai is not None: + self._sdk_version = getattr(openai, "__version__", "unknown") + logger.info(f"Initialized OpenAI provider with SDK version {self._sdk_version}") + + def _validate_temperature(self, temperature: float) -> None: + """Validate temperature parameter is in the OpenAI-acceptable range. + + Args: + temperature: Temperature value to validate. + + Raises: + ValidationError: If temperature is out of range (0.0-2.0). + """ + if not (0.0 <= temperature <= 2.0): + raise ValidationError( + f"Temperature must be between 0.0 and 2.0 (OpenAI range), got {temperature}", + suggestion="Adjust temperature to be within the valid range", + ) + + def _validate_max_tokens(self, max_tokens: int) -> None: + """Validate max_tokens parameter is in an acceptable range. + + Args: + max_tokens: Max tokens value to validate. + + Raises: + ValidationError: If max_tokens is out of range (1-200000). + """ + if not (1 <= max_tokens <= 200000): + raise ValidationError( + f"max_tokens must be between 1 and 200000, got {max_tokens}", + suggestion="Adjust max_tokens to be within the valid range", + ) + + def get_retry_history(self) -> list[dict[str, Any]]: + """Get the retry history for debugging purposes. + + Returns: + List of dictionaries containing retry attempt details. + """ + return self._retry_history.copy() + + async def validate_connection(self) -> bool: + """Verify the provider can connect to the OpenAI API. + + Returns: + True if connection successful, False otherwise. + """ + if self._client is None: + return False + + try: + await self._client.models.list() + return True + except Exception as e: + logger.error(f"Connection validation failed: {e}") + return False + + async def list_models(self) -> list[str] | None: + """Return the model ids advertised by the OpenAI API. + + Returns ``None`` when the client is unavailable or the listing call fails. + """ + if not OPENAI_SDK_AVAILABLE or self._client is None: + return None + try: + page = await self._client.models.list() + except Exception as e: # noqa: BLE001 - diagnostics must never raise + logger.debug("Failed to list OpenAI models: %s", e) + return None + return [model.id for model in page.data] + + async def get_max_prompt_tokens(self, model: str) -> int | None: + """Return the maximum prompt tokens for ``model``. + + The OpenAI API does not expose per-model input limits through + ``models.list()``, so this always returns ``None``. + """ + del model + return None + + async def get_model_capabilities(self, model: str) -> ModelCapabilityInfo | None: + """Return reasoning-effort support and prompt-token limits for ``model``. + + OpenAI does not expose token limits or reasoning defaults through its model + listing, so only the reasoning-effort capability is inferred from the model + id: models that are part of the OpenAI reasoning family advertise the + provider's supported effort tuple; other models advertise an empty list + ("supports none"). Unknown fields remain ``None``. + """ + lowered = model.lower() + is_reasoning_model = lowered.startswith("o") or "reasoning" in lowered + supported = list(_OPENAI_REASONING_EFFORTS) if is_reasoning_model else [] + return ModelCapabilityInfo( + supported_reasoning_efforts=supported, + default_reasoning_effort=None, + max_prompt_tokens=None, + max_output_tokens=None, + max_context_window_tokens=None, + ) + + async def get_model_pricing(self, model: str) -> ModelPricing | None: + """OpenAI pricing is not provided by the SDK; return None.""" + del model + return None + + async def _get_mcp_manager_for_cwd(self, resolved_cwd: str) -> MCPManager | None: + """Return the pooled MCPManager for ``resolved_cwd``, connecting on first use. + + Mirrors :meth:`ClaudeProvider._get_mcp_manager_for_cwd` exactly. + """ + if resolved_cwd in self._mcp_managers: + return self._mcp_managers[resolved_cwd] + if not self._mcp_servers_config: + return None + + from conductor.mcp.manager import MCP_SDK_AVAILABLE, MCPManager + + if not MCP_SDK_AVAILABLE: + logger.warning( + "MCP servers configured but MCP SDK not installed. " + "Install with: uv add 'mcp>=1.0.0'" + ) + return None + + lock = self._mcp_manager_locks.get(resolved_cwd) + if lock is None: + lock = asyncio.Lock() + self._mcp_manager_locks[resolved_cwd] = lock + + async with lock: + if resolved_cwd in self._mcp_managers: + return self._mcp_managers[resolved_cwd] + + manager = MCPManager(tool_output=self._tool_output_config) + for name, config in self._mcp_servers_config.items(): + server_type = config.get("type", "stdio") + if server_type == "stdio": + try: + await manager.connect_server( + name=name, + command=config["command"], + args=config.get("args", []), + env=config.get("env"), + timeout=config.get("timeout"), + cwd=resolved_cwd, + ) + logger.info(f"Connected to MCP server '{name}' (cwd={resolved_cwd})") + except Exception as e: + logger.error(f"Failed to connect to MCP server '{name}': {e}") + else: + logger.warning( + f"MCP server '{name}' has unsupported type '{server_type}' " + "(OpenAI provider only supports 'stdio')" + ) + + if manager.has_servers(): + self._mcp_managers[resolved_cwd] = manager + else: + logger.warning( + "No MCP servers connected for cwd=%s; manager not pooled so " + "the next agent for this cwd will retry the connect.", + resolved_cwd, + ) + return manager + + async def close(self) -> None: + """Release provider resources and close connections.""" + if self._mcp_managers: + for cwd, manager in self._mcp_managers.items(): + try: + await manager.close() + except Exception as e: + logger.warning(f"Error closing MCP manager for cwd={cwd}: {e}") + self._mcp_managers.clear() + self._mcp_manager_locks.clear() + logger.debug("All pooled MCP managers closed") + + if self._client is not None: + client = self._client + self._client = None + await client.close() + logger.debug("OpenAI provider closed") + + async def execute_dialog_turn( + self, + system_prompt: str, + user_message: str, + history: list[dict[str, str]] | None = None, + model: str | None = None, + ) -> str: + """Execute a single dialog turn using a Pydantic AI OpenAI agent. + + Args: + system_prompt: System prompt providing dialog context. + user_message: The latest user message. + history: Optional prior conversation history. + model: Optional model override. Falls back to provider default. + + Returns: + The agent's response text. + + Raises: + ProviderError: If the dialog turn fails. + """ + from pydantic_ai import Agent + from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart + from pydantic_ai.models.openai import OpenAIChatModelSettings + + from conductor.providers._pydantic_ai.agent_builder import ( + _resolve_openai_model, + ) + + resolved_model = model or self._default_model + + pydantic_history: list[ModelRequest | ModelResponse] = [] + for msg in history or []: + if msg["role"] == "user": + pydantic_history.append( + ModelRequest(parts=[UserPromptPart(content=msg["content"])]) + ) + elif msg["role"] == "assistant": + pydantic_history.append(ModelResponse(parts=[TextPart(content=msg["content"])])) + + model_settings: OpenAIChatModelSettings = OpenAIChatModelSettings() + max_tokens = 4096 + model_settings["max_tokens"] = max_tokens + + if self._default_reasoning_effort is not None: + model_settings["openai_reasoning_effort"] = self._default_reasoning_effort + + try: + dummy_agent = AgentDef( + name="dialog_agent", + model=resolved_model, + prompt="", + max_depth=None, + timeout_seconds=None, + max_session_seconds=None, + max_agent_iterations=None, + ) + pydantic_model = _resolve_openai_model( + agent=dummy_agent, + default_model=self._default_model, + api_key=self._api_key, + base_url=self._base_url, + timeout=self._timeout, + ) + + pydantic_agent = Agent( + model=pydantic_model, + output_type=str, + system_prompt=system_prompt, + model_settings=model_settings, + retries=0, + ) + result = await pydantic_agent.run( + user_prompt=user_message, + message_history=pydantic_history, + ) + return str(result.output) + except ValidationError: + raise + except Exception as exc: + raise ProviderError( + f"Dialog turn failed: {exc}", + is_retryable=False, + ) from exc + + async def execute( + self, + agent: AgentDef, + context: dict[str, Any], + rendered_prompt: str, + tools: list[str] | None = None, + interrupt_signal: asyncio.Event | None = None, + event_callback: EventCallback | None = None, + skill_directories: list[str] | None = None, + custom_agents: list[dict[str, Any]] | None = None, + extra_mcp_servers: dict[str, Any] | None = None, + ) -> AgentOutput: + """Execute an agent using the shared Pydantic AI pipeline. + + Args: + agent: Agent definition from workflow config. + context: Accumulated workflow context. + rendered_prompt: Jinja2-rendered user prompt. + tools: List of tool names available to this agent. ``None`` grants all + MCP tools, ``[]`` grants none. + interrupt_signal: Optional event for mid-agent interrupt signaling. + event_callback: Optional callback for streaming SDK events. + skill_directories: Ignored. OpenAI has no native skill surface; the + executor has already eager-injected skill content into the prompt. + custom_agents: Ignored. ``plugins=False``. + extra_mcp_servers: Ignored. ``plugins=False``. + + Returns: + Normalized AgentOutput with structured content. + + Raises: + ProviderError: If SDK execution fails. + ValidationError: If output doesn't match schema. + """ + del skill_directories, custom_agents, extra_mcp_servers + from conductor.providers._pydantic_ai.agent_builder import build_agent + from conductor.providers._pydantic_ai.retry import RetryConfig as PydanticRetryConfig + from conductor.providers._pydantic_ai.runner import run_agent_pipeline + + resolved_cwd = agent.working_dir or os.getcwd() + manager = await self._get_mcp_manager_for_cwd(resolved_cwd) + + def build_agent_fn(toolsets: list[Any], *, max_parse_recovery_attempts: int) -> Any: + return build_agent( + agent=agent, + system_prompt=agent.system_prompt or "", + rendered_prompt=rendered_prompt, + default_model=self._default_model, + default_temperature=self._default_temperature, + default_max_tokens=self._default_max_tokens, + default_reasoning_effort=self._default_reasoning_effort, + max_parse_recovery_attempts=max_parse_recovery_attempts, + api_key=self._api_key, + base_url=self._base_url, + timeout=self._timeout, + toolsets=toolsets, + backend="openai", + http_client=None, + ) + + retry_config = PydanticRetryConfig( + max_attempts=self._retry_config.max_attempts, + base_delay=self._retry_config.base_delay, + max_delay=self._retry_config.max_delay, + jitter=self._retry_config.jitter, + backoff=self._retry_config.backoff, + retry_on=( + list(self._retry_config.retry_on) + if self._retry_config.retry_on is not None + else None + ), + max_parse_recovery_attempts=self._retry_config.max_parse_recovery_attempts, + ) + + max_iterations = ( + agent.max_agent_iterations + if agent.max_agent_iterations is not None + else self._default_max_agent_iterations + ) + max_session = ( + agent.max_session_seconds + if agent.max_session_seconds is not None + else self._default_max_session_seconds + ) + + self._retry_history.clear() + + return await run_agent_pipeline( + agent=agent, + rendered_prompt=rendered_prompt, + mcp_manager=manager, + tools=tools, + tool_output_config=self._tool_output_config, + retry_config=retry_config, + interrupt_signal=interrupt_signal, + event_callback=event_callback, + max_agent_iterations=max_iterations, + max_session_seconds=max_session, + default_model=self._default_model, + retry_history=self._retry_history, + build_agent_fn=build_agent_fn, + ) diff --git a/src/conductor/providers/registry.py b/src/conductor/providers/registry.py index 7a88b93e..87f4584e 100644 --- a/src/conductor/providers/registry.py +++ b/src/conductor/providers/registry.py @@ -15,7 +15,15 @@ from conductor.config.schema import AgentDef, WorkflowConfig -ProviderType = Literal["copilot", "openai-agents", "claude", "claude-agent-sdk", "hermes"] +ProviderType = Literal[ + "copilot", + "openai", + "openai-agents", + "claude", + "claude-agent-sdk", + "hermes", + "aca", +] class ProviderRegistry: diff --git a/tests/test_providers/test_openai.py b/tests/test_providers/test_openai.py new file mode 100644 index 00000000..e84247b0 --- /dev/null +++ b/tests/test_providers/test_openai.py @@ -0,0 +1,282 @@ +"""Tests for the OpenAI provider. + +These tests verify construction, configuration forwarding, and the +execute()/execute_dialog_turn() surfaces without making network calls. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import SecretStr +from pydantic_ai import Agent +from pydantic_ai.models.test import TestModel + +from conductor.config.schema import AgentDef, OutputField, ProviderSettings +from conductor.exceptions import ValidationError +from conductor.providers.factory import create_provider +from conductor.providers.openai import OpenAIProvider + + +@pytest.fixture +def provider() -> OpenAIProvider: + """Return a fresh OpenAIProvider instance using a dummy API key.""" + return OpenAIProvider(api_key="test-key") + + +@pytest.fixture +def no_mcp_manager(provider: OpenAIProvider) -> Any: + """Disable MCP manager resolution so execute() does not spawn tools.""" + with patch.object(provider, "_get_mcp_manager_for_cwd", return_value=None) as mock: + yield mock + + +def _build_text_agent(text: str) -> Agent[Any, str]: + """Build a Pydantic AI text agent backed by TestModel.""" + return Agent(model=TestModel(custom_output_text=text), output_type=str) + + +class TestProviderConstruction: + """Tests for OpenAIProvider construction and validation.""" + + def test_default_model_is_gpt_5_mini(self) -> None: + """The provider defaults to gpt-5-mini when no model is passed.""" + p = OpenAIProvider(api_key="test-key") + assert p._default_model == "gpt-5-mini" + + def test_explicit_model_is_used(self) -> None: + """An explicitly passed model overrides the default.""" + p = OpenAIProvider(api_key="test-key", model="gpt-5") + assert p._default_model == "gpt-5" + + def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Construction without an API key or env var raises ValidationError.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + with pytest.raises(ValidationError, match="OPENAI_API_KEY"): + OpenAIProvider() + + def test_temperature_validation_accepts_range(self) -> None: + """OpenAI accepts temperatures up to 2.0.""" + p = OpenAIProvider(api_key="test-key", temperature=2.0) + assert p._default_temperature == 2.0 + + def test_temperature_validation_rejects_out_of_range(self) -> None: + """Temperatures outside 0..2 are rejected at construction time.""" + with pytest.raises(ValidationError, match="between 0.0 and 2.0"): + OpenAIProvider(api_key="test-key", temperature=2.5) + + def test_capabilities_are_stable(self) -> None: + """CAPABILITIES declares the expected stable-provider contract.""" + caps = OpenAIProvider.CAPABILITIES + assert caps.tier == "stable" + assert caps.mcp_tools is True + assert caps.workflow_tools_passthrough is True + assert caps.streaming_events is True + assert caps.agent_reasoning_events is True + assert caps.reasoning_effort == ("low", "medium", "high", "xhigh") + assert caps.structured_output == "native" + assert caps.interrupt is True + assert caps.max_session_seconds is True + assert caps.checkpoint_resume is False + assert caps.usage_tracking is True + assert caps.concurrent_safe is True + assert caps.working_dir is True + assert caps.skills is True + assert caps.plugins is False + + def test_supports_native_skills_is_false(self) -> None: + """OpenAI relies on eager skill injection by AgentExecutor.""" + p = OpenAIProvider(api_key="test-key") + assert p.supports_native_skills is False + + +class TestFactoryIntegration: + """Tests that the factory wires the openai provider correctly.""" + + @pytest.mark.asyncio + async def test_factory_creates_openai_provider(self) -> None: + """create_provider('openai') returns an OpenAIProvider.""" + settings = ProviderSettings(name="openai", api_key=SecretStr("sk-test")) + p = await create_provider("openai", validate=False, provider_settings=settings) + assert isinstance(p, OpenAIProvider) + assert p._api_key == "sk-test" + await p.close() + + @pytest.mark.asyncio + async def test_factory_forwards_base_url(self) -> None: + """YAML base_url for name='openai' reaches the provider.""" + settings = ProviderSettings( + name="openai", + api_key=SecretStr("sk-test"), + base_url="http://localhost:1234/v1", + ) + p = await create_provider("openai", validate=False, provider_settings=settings) + assert isinstance(p, OpenAIProvider) + assert p._base_url == "http://localhost:1234/v1" + await p.close() + + +class TestExecute: + """Tests for the execute() path.""" + + async def test_execute_forwards_backend_openai( + self, provider: OpenAIProvider, no_mcp_manager: Any + ) -> None: + """execute() calls build_agent with backend='openai' and http_client=None.""" + agent = AgentDef(name="greeter", model="test", prompt="say hi") + captured_kwargs: dict[str, Any] = {} + + def spy_build_agent(*args: Any, **kwargs: Any) -> Agent[Any, Any]: + captured_kwargs.update(kwargs) + return _build_text_agent("hello") + + with patch( + "conductor.providers._pydantic_ai.agent_builder.build_agent", + side_effect=spy_build_agent, + ): + output = await provider.execute(agent, {}, "say hi") + + assert output.content == {"result": "hello"} + assert captured_kwargs.get("backend") == "openai" + assert captured_kwargs.get("http_client") is None + assert captured_kwargs.get("api_key") == "test-key" + + async def test_execute_forwards_temperature_and_max_tokens(self, no_mcp_manager: Any) -> None: + """Runtime temperature/max_tokens are passed to the agent builder.""" + provider = OpenAIProvider(api_key="test-key", temperature=0.5, max_tokens=1024) + agent = AgentDef(name="greeter", model="test", prompt="say hi") + captured_kwargs: dict[str, Any] = {} + + def spy_build_agent(*args: Any, **kwargs: Any) -> Agent[Any, Any]: + captured_kwargs.update(kwargs) + return _build_text_agent("hello") + + with patch( + "conductor.providers._pydantic_ai.agent_builder.build_agent", + side_effect=spy_build_agent, + ): + await provider.execute(agent, {}, "say hi") + + assert captured_kwargs.get("default_temperature") == 0.5 + assert captured_kwargs.get("default_max_tokens") == 1024 + + async def test_execute_returns_structured_output( + self, provider: OpenAIProvider, no_mcp_manager: Any + ) -> None: + """execute() returns validated structured output from a Pydantic model.""" + from pydantic import BaseModel + + class AnswerModel(BaseModel): + answer: str + + agent = AgentDef( + name="greeter", + model="test", + prompt="say hi", + output={"answer": OutputField(type="string")}, + ) + + structured_agent = Agent( + model=TestModel(custom_output_args={"answer": "hello"}), + output_type=AnswerModel, + ) + + with patch( + "conductor.providers._pydantic_ai.agent_builder.build_agent", + return_value=structured_agent, + ): + output = await provider.execute(agent, {}, "say hi") + + assert output.content == {"answer": "hello"} + + +class TestExecuteDialogTurn: + """Tests for execute_dialog_turn().""" + + async def test_dialog_turn_returns_text(self, provider: OpenAIProvider) -> None: + """execute_dialog_turn() returns the Pydantic AI text response.""" + + async def fake_run(*args: Any, **kwargs: Any) -> Any: + class FakeResult: + output = "dialog reply" + + return FakeResult() + + with ( + patch( + "conductor.providers._pydantic_ai.agent_builder._resolve_openai_model" + ) as mock_resolve_model, + patch("pydantic_ai.Agent") as mock_agent_cls, + ): + mock_agent = mock_agent_cls.return_value + mock_agent.run = fake_run + result = await provider.execute_dialog_turn( + "system prompt", + "user message", + history=[{"role": "user", "content": "previous"}], + model="gpt-test", + ) + + assert result == "dialog reply" + kwargs = mock_resolve_model.call_args.kwargs + assert kwargs.get("api_key") == "test-key" + assert kwargs.get("timeout") == 600.0 + + +class TestConnectionHelpers: + """Tests for validate_connection/list_models/get_model_capabilities.""" + + @pytest.mark.asyncio + async def test_validate_connection_returns_true_when_list_succeeds( + self, provider: OpenAIProvider + ) -> None: + """validate_connection() returns True when models.list() succeeds.""" + from unittest.mock import AsyncMock + + mock_client = MagicMock() + mock_client.models.list = AsyncMock(return_value=MagicMock(data=[])) + provider._client = mock_client # type: ignore[assignment] + assert await provider.validate_connection() is True + + @pytest.mark.asyncio + async def test_validate_connection_returns_false_on_error( + self, provider: OpenAIProvider + ) -> None: + """validate_connection() returns False when models.list() fails.""" + from unittest.mock import AsyncMock + + mock_client = MagicMock() + mock_client.models.list = AsyncMock(side_effect=RuntimeError("boom")) + provider._client = mock_client # type: ignore[assignment] + assert await provider.validate_connection() is False + + @pytest.mark.asyncio + async def test_list_models_returns_ids(self, provider: OpenAIProvider) -> None: + """list_models() returns model ids from the OpenAI API.""" + from unittest.mock import AsyncMock + + mock_client = MagicMock() + mock_client.models.list = AsyncMock( + return_value=MagicMock(data=[MagicMock(id="gpt-5-mini"), MagicMock(id="gpt-5")]) + ) + provider._client = mock_client # type: ignore[assignment] + ids = await provider.list_models() + assert ids == ["gpt-5-mini", "gpt-5"] + + @pytest.mark.asyncio + async def test_get_model_capabilities_reasoning_models(self, provider: OpenAIProvider) -> None: + """Reasoning models advertise the full supported effort tuple.""" + caps = await provider.get_model_capabilities("o3-mini") + assert caps is not None + assert caps.supported_reasoning_efforts == ["low", "medium", "high", "xhigh"] + + @pytest.mark.asyncio + async def test_get_model_capabilities_non_reasoning_models( + self, provider: OpenAIProvider + ) -> None: + """Non-reasoning models advertise no supported reasoning efforts.""" + caps = await provider.get_model_capabilities("gpt-5-mini") + assert caps is not None + assert caps.supported_reasoning_efforts == [] From e0dd7ff3dd668e78967402966453ab0ca287de3a Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 00:32:55 +0300 Subject: [PATCH 05/14] feat(config): register openai provider and enforce provider-aware temperature bounds --- AGENTS.md | 6 ++ src/conductor/config/schema.py | 17 ++++- src/conductor/config/validator.py | 18 +++++ src/conductor/providers/diagnostics.py | 2 +- src/conductor/providers/registry.py | 1 + .../test_claude_parameter_errors.py | 12 ++-- tests/test_config/test_provider_settings.py | 28 ++++++++ tests/test_config/test_schema.py | 21 ++---- .../test_validator_capabilities.py | 71 +++++++++++++++++++ tests/test_providers/test_factory.py | 20 ++++++ tests/test_providers/test_registry.py | 2 + 11 files changed, 173 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 073a172e..f9672483 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -648,3 +648,9 @@ The shared Pydantic AI execution loop from `ClaudeProvider.execute()` was extrac 4. Delegates to `run_agent_pipeline(...)`. The runner owns the toolset wiring, retry/interrupt/extract pipeline, partial-output construction, and model-name resolution. To keep existing tests green, the runner imports its Pydantic-AI seam helpers (`run_with_interrupt`, `execute_with_retry`, `extract_content`, etc.) **inside** `run_agent_pipeline()` rather than at module scope. This preserves the historical patching surface (`conductor.providers._pydantic_ai.interrupt.run_with_interrupt`, etc.) that tests rely on. + +## Provider registration and validation notes + +- **Provider-aware temperature bounds**: `RuntimeConfig.temperature` widens the schema upper bound to `2.0` because OpenAI supports it, but most providers (copilot, claude, hermes, aca, claude-agent-sdk) cap at `1.0`. `config/validator.py` enforces the per-provider bound at `conductor validate` time so high temperatures do not fail mysteriously at the SDK boundary. Only the `openai` provider is exempt; override agents to `openai` or keep `temperature <= 1.0` for the others. +- **Provider registration completeness**: adding a new provider requires updating `providers/factory.py::ProviderType` and `providers/diagnostics.py::_CREDENTIAL_SPECS` (and `providers/capabilities.py::_PROVIDER_CLASS_PATHS` for validate-time capability checks). Latent forwarding bugs happen when `ProviderRegistry.get_or_create_provider` does not pass new runtime fields to `create_provider`. +- **OpenAI provider routing restrictions**: `ProviderSettings` for `name="openai"` rejects `type`, `wire_api`, `bearer_token`, `headers`, `azure`, and Copilot runtime fields with targeted messages because the native OpenAI provider always speaks Chat Completions and does not support Copilot custom routing. diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 34eded63..9ded2fd5 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -2810,6 +2810,19 @@ def _check_field_compatibility(self) -> ProviderSettings: } if self.name != "copilot": extras = sorted(k for k, v in copilot_only_fields.items() if v is not None) + if self.name == "openai" and extras: + if "wire_api" in extras: + raise ValueError( + "Provider fields ['wire_api'] are Copilot-only. " + "The 'openai' provider always speaks the Chat Completions " + "wire API; remove the field." + ) + if "type" in extras: + raise ValueError( + "Provider fields ['type'] are Copilot-only. " + "The 'openai' provider always speaks the Chat Completions " + "wire API; remove the field." + ) if extras: raise ValueError( f"Provider fields {extras} are only supported when name='copilot'. " @@ -3338,8 +3351,8 @@ def _coerce_provider(cls, value: Any) -> Any: temperature: float | None = Field( None, ge=0.0, - le=1.0, - description="Controls randomness. Range: 0.0-1.0", + le=2.0, + description="Controls randomness. Range: 0.0-2.0", ) """Temperature parameter for models. Controls randomness in responses.""" diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index 5c2948b8..7dc05cfb 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -1825,6 +1825,7 @@ def _validate_provider_capabilities( runtime_skills = config.workflow.runtime.skills skill_limits = config.workflow.runtime.skill_injection discovery = config.workflow.runtime.skill_discovery + runtime_temperature = config.workflow.runtime.temperature skill_base_dir = workflow_path.resolve().parent if workflow_path is not None else None # Keyed by (entries, discovery sources, discovery excludes), so agents # sharing a skill list resolve once but an agent that overrides the list @@ -2524,6 +2525,23 @@ def _check_agent_capabilities( f"or remove the workflow-level skills." ) + if runtime_temperature is not None and runtime_temperature > 1.0: + providers_over_one: dict[str, list[str]] = {} + for agent in all_llm_agents: + pname = _resolved_provider_name(agent, default_provider) + if pname == "openai": + continue + providers_over_one.setdefault(pname, []).append(agent.name) + for pname, agent_names in providers_over_one.items(): + errors.append( + f"Workflow declares 'runtime.temperature'={runtime_temperature!r} " + f"but provider '{pname}' only supports temperatures up to 1.0 " + f"and is used by agent(s): {sorted(agent_names)!r}. " + f"Override these agents to provider 'openai', lower the " + f"temperature to 1.0 or below, or remove the workflow-level " + f"temperature." + ) + # ----- Per-agent checks ----- for agent in config.agents: if not _is_llm_agent(agent): diff --git a/src/conductor/providers/diagnostics.py b/src/conductor/providers/diagnostics.py index e4c19130..f3d2ac28 100644 --- a/src/conductor/providers/diagnostics.py +++ b/src/conductor/providers/diagnostics.py @@ -107,7 +107,7 @@ def optional(self) -> bool: "authenticates via `claude login`; ANTHROPIC_API_KEY is an optional override" ), ), - "openai": _CredentialSpec(env_vars=("OPENAI_API_KEY",)), + "openai": _CredentialSpec(env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL")), "hermes": _CredentialSpec(), "openai-agents": _CredentialSpec(), } diff --git a/src/conductor/providers/registry.py b/src/conductor/providers/registry.py index 87f4584e..1b46e7e6 100644 --- a/src/conductor/providers/registry.py +++ b/src/conductor/providers/registry.py @@ -132,6 +132,7 @@ async def _get_or_create_provider(self, provider_type: ProviderType) -> AgentPro timeout=runtime.timeout, max_session_seconds=runtime.max_session_seconds, max_agent_iterations=runtime.max_agent_iterations, + default_reasoning_effort=runtime.default_reasoning_effort, provider_settings=provider_settings, tool_output=runtime.tool_output, ) diff --git a/tests/test_config/test_claude_parameter_errors.py b/tests/test_config/test_claude_parameter_errors.py index 578ac29f..8863486a 100644 --- a/tests/test_config/test_claude_parameter_errors.py +++ b/tests/test_config/test_claude_parameter_errors.py @@ -23,10 +23,10 @@ def test_temperature_out_of_range_low(): def test_temperature_out_of_range_high(): - """Verify temperature > 1 raises validation error.""" + """Verify temperature above the widened bound raises validation error.""" with pytest.raises(ValidationError) as exc_info: RuntimeConfig( - provider="claude", default_model="claude-3-5-sonnet-20241022", temperature=1.1 + provider="claude", default_model="claude-3-5-sonnet-20241022", temperature=2.1 ) errors = exc_info.value.errors() @@ -34,18 +34,16 @@ def test_temperature_out_of_range_high(): def test_temperature_at_boundaries(): - """Verify temperature at 0.0 and 1.0 boundaries is valid.""" - # Temperature = 0.0 should be valid + """Verify temperature at 0.0 and 2.0 boundaries is valid.""" config = RuntimeConfig( provider="claude", default_model="claude-3-5-sonnet-20241022", temperature=0.0 ) assert config.temperature == 0.0 - # Temperature = 1.0 should be valid config = RuntimeConfig( - provider="claude", default_model="claude-3-5-sonnet-20241022", temperature=1.0 + provider="claude", default_model="claude-3-5-sonnet-20241022", temperature=2.0 ) - assert config.temperature == 1.0 + assert config.temperature == 2.0 def test_max_tokens_negative(): diff --git a/tests/test_config/test_provider_settings.py b/tests/test_config/test_provider_settings.py index 882a048a..fad427a9 100644 --- a/tests/test_config/test_provider_settings.py +++ b/tests/test_config/test_provider_settings.py @@ -72,10 +72,38 @@ def test_non_copilot_with_copilot_only_field_rejected(self) -> None: with pytest.raises(ValidationError, match="only supported when name='copilot'"): ProviderSettings(name="claude", type="anthropic") + def test_openai_wire_api_rejected_with_targeted_message(self) -> None: + with pytest.raises( + ValidationError, match=r"Provider fields \['wire_api'\] are Copilot-only" + ): + ProviderSettings(name="openai", wire_api="completions") + + def test_openai_type_rejected_with_targeted_message(self) -> None: + with pytest.raises(ValidationError, match=r"Provider fields \['type'\] are Copilot-only"): + ProviderSettings(name="openai", type="openai") + + def test_openai_bearer_token_rejected(self) -> None: + with pytest.raises(ValidationError, match="only supported when name='copilot'"): + ProviderSettings(name="openai", bearer_token="tok") + + def test_openai_headers_rejected(self) -> None: + with pytest.raises(ValidationError, match="only supported when name='copilot'"): + ProviderSettings(name="openai", headers={"X-Foo": "1"}) + def test_non_copilot_with_base_url_rejected(self) -> None: with pytest.raises(ValidationError, match="not yet implemented"): ProviderSettings(name="openai-agents", base_url="http://some-proxy/v1") + def test_openai_with_base_url_and_api_key_accepted(self) -> None: + s = ProviderSettings(name="openai", base_url="https://api.openai.com/v1", api_key="sk-xxx") + assert s.base_url == "https://api.openai.com/v1" + assert s.api_key is not None + assert s.api_key.get_secret_value() == "sk-xxx" + + def test_openai_with_base_url_only_accepted(self) -> None: + s = ProviderSettings(name="openai", base_url="http://localhost:11434/v1") + assert s.base_url == "http://localhost:11434/v1" + def test_claude_with_base_url_accepted(self) -> None: s = ProviderSettings(name="claude", base_url="https://my-gateway.example.com/api/v1") assert s.base_url == "https://my-gateway.example.com/api/v1" diff --git a/tests/test_config/test_schema.py b/tests/test_config/test_schema.py index 1498cfb6..88f5a657 100644 --- a/tests/test_config/test_schema.py +++ b/tests/test_config/test_schema.py @@ -709,29 +709,20 @@ def test_claude_provider_with_temperature(self) -> None: def test_temperature_boundary_values(self) -> None: """Test temperature field accepts boundary values.""" - # Lower bound - config = RuntimeConfig(temperature=0.0) - assert config.temperature == 0.0 - - # Upper bound - config = RuntimeConfig(temperature=1.0) - assert config.temperature == 1.0 - - # Mid-range value - config = RuntimeConfig(temperature=0.5) - assert config.temperature == 0.5 + assert RuntimeConfig(temperature=0.0).temperature == 0.0 + assert RuntimeConfig(temperature=1.0).temperature == 1.0 + assert RuntimeConfig(temperature=2.0).temperature == 2.0 + assert RuntimeConfig(temperature=0.5).temperature == 0.5 def test_temperature_out_of_range_raises(self) -> None: """Test temperature field rejects out-of-range values.""" - # Below lower bound with pytest.raises(ValidationError) as exc_info: RuntimeConfig(temperature=-0.1) assert "greater than or equal to 0" in str(exc_info.value) - # Above upper bound with pytest.raises(ValidationError) as exc_info: - RuntimeConfig(temperature=1.1) - assert "less than or equal to 1" in str(exc_info.value) + RuntimeConfig(temperature=2.1) + assert "less than or equal to 2" in str(exc_info.value) def test_max_tokens_boundary_values(self) -> None: """Test max_tokens field accepts boundary values.""" diff --git a/tests/test_config/test_validator_capabilities.py b/tests/test_config/test_validator_capabilities.py index e2c82efd..725a502e 100644 --- a/tests/test_config/test_validator_capabilities.py +++ b/tests/test_config/test_validator_capabilities.py @@ -51,12 +51,15 @@ def _build_workflow( mcp_servers: dict[str, MCPServerDef] | None = None, tools: list[str] | None = None, skills: list[str] | None = None, + temperature: float | None = None, ) -> WorkflowConfig: runtime_kwargs: dict[str, Any] = {"provider": "copilot"} if mcp_servers is not None: runtime_kwargs["mcp_servers"] = mcp_servers if skills is not None: runtime_kwargs["skills"] = skills + if temperature is not None: + runtime_kwargs["temperature"] = temperature workflow_kwargs: dict[str, Any] = {} if tools is not None: workflow_kwargs["tools"] = tools @@ -162,6 +165,74 @@ def test_per_agent_provider_override_against_mcp_errors(self, patch_caps: Any) - validate_workflow_config(config) +class TestTemperatureCrossCheck: + def test_temperature_above_one_with_openai_default_passes(self, patch_caps: Any) -> None: + patch_caps({"openai": _caps()}) + config = _build_workflow( + temperature=1.5, + agents=[AgentDef(name="a", prompt="hi", provider="openai")], + ) + validate_workflow_config(config) # no raise + + def test_temperature_above_one_with_copilot_default_errors(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps()}) + config = _build_workflow( + temperature=1.5, + agents=[AgentDef(name="a", prompt="hi")], + ) + with pytest.raises(ConfigurationError, match="temperature.*1.5.*copilot"): + validate_workflow_config(config) + + def test_temperature_above_one_agent_override_to_openai_passes(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps(), "openai": _caps()}) + config = _build_workflow( + temperature=1.5, + agents=[ + AgentDef(name="a", prompt="hi", provider="openai"), + AgentDef(name="b", prompt="hi", provider="openai"), + ], + ) + validate_workflow_config(config) # no raise + + def test_temperature_above_one_mixed_providers_errors(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps(), "openai": _caps()}) + config = _build_workflow( + temperature=1.5, + agents=[ + AgentDef(name="a", prompt="hi", provider="openai"), + AgentDef(name="b", prompt="hi", provider="copilot"), + ], + ) + with pytest.raises(ConfigurationError, match="temperature.*1.5.*copilot"): + validate_workflow_config(config) + + def test_temperature_above_one_for_each_inline_copilot_errors(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps()}) + config = _build_workflow( + temperature=1.5, + agents=[AgentDef(name="entry", prompt="hi", output={"items": {"type": "array"}})], + for_each=[ + ForEachDef( + name="loop", + type="for_each", + source="entry.output.items", + **{"as": "item"}, + agent=AgentDef(name="inline", prompt="{{ item }}"), + ) + ], + ) + with pytest.raises(ConfigurationError, match="temperature.*1.5.*copilot"): + validate_workflow_config(config) + + def test_temperature_at_one_allowed_for_all_providers(self, patch_caps: Any) -> None: + patch_caps({"copilot": _caps()}) + config = _build_workflow( + temperature=1.0, + agents=[AgentDef(name="a", prompt="hi")], + ) + validate_workflow_config(config) # no raise + + class TestToolsAllowlistCrossCheck: def test_empty_tools_list_against_no_passthrough_does_not_error(self, patch_caps: Any) -> None: """``tools: []`` is a 'no tools' request; a provider with nothing to diff --git a/tests/test_providers/test_factory.py b/tests/test_providers/test_factory.py index d3164a22..6e3b7775 100644 --- a/tests/test_providers/test_factory.py +++ b/tests/test_providers/test_factory.py @@ -48,6 +48,26 @@ async def test_create_openai_provider_raises(self) -> None: assert exc_info.value.suggestion is not None assert "copilot" in exc_info.value.suggestion + @pytest.mark.asyncio + async def test_openai_provider_receives_default_reasoning_effort(self) -> None: + """default_reasoning_effort reaches the OpenAI provider.""" + from conductor.providers.openai import OpenAIProvider + + provider_settings = ProviderSettings(name="openai", api_key=SecretStr("sk-test")) + with ( + patch("conductor.providers.factory.OPENAI_SDK_AVAILABLE", True), + patch("openai.AsyncOpenAI"), + patch.object(OpenAIProvider, "_initialize_client"), + ): + provider = await create_provider( + "openai", + validate=False, + default_reasoning_effort="high", + provider_settings=provider_settings, + ) + assert isinstance(provider, OpenAIProvider) + assert provider._default_reasoning_effort == "high" + @pytest.mark.asyncio async def test_copilot_provider_receives_default_context_tier(self) -> None: """default_context_tier is threaded into the Copilot provider.""" diff --git a/tests/test_providers/test_registry.py b/tests/test_providers/test_registry.py index d365e812..43af9efb 100644 --- a/tests/test_providers/test_registry.py +++ b/tests/test_providers/test_registry.py @@ -318,6 +318,7 @@ async def test_runtime_config_passed_to_provider(self, mock_create: MagicMock) - temperature=0.7, max_tokens=4096, timeout=60.0, + default_reasoning_effort="high", ), ), agents=[AgentDef(name="agent1", prompt="test")], @@ -339,6 +340,7 @@ async def test_runtime_config_passed_to_provider(self, mock_create: MagicMock) - timeout=60.0, max_session_seconds=None, max_agent_iterations=None, + default_reasoning_effort="high", provider_settings=config.workflow.runtime.provider, tool_output=config.workflow.runtime.tool_output, ) From 5d11c7aa842b7c5a0a7a57c9e643af4841056d83 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 01:40:30 +0300 Subject: [PATCH 06/14] test(providers): add HTTP-stub pipeline tests for real OpenAI backend Drive the real OpenAIChatModel + AsyncOpenAI through run_agent_pipeline with httpx.MockTransport. Covers success usage mapping, 400 non-retryable (one request), and 429 retryable then success (two requests). --- tests/test_providers/test_openai_http_stub.py | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tests/test_providers/test_openai_http_stub.py diff --git a/tests/test_providers/test_openai_http_stub.py b/tests/test_providers/test_openai_http_stub.py new file mode 100644 index 00000000..e3bf47f3 --- /dev/null +++ b/tests/test_providers/test_openai_http_stub.py @@ -0,0 +1,215 @@ +"""HTTP-stub integration tests for the OpenAI Pydantic AI pipeline. + +These tests drive the *real* ``OpenAIChatModel`` and ``AsyncOpenAI`` client +through the shared ``run_agent_pipeline`` helper with no external network. +Responses are served via ``httpx.MockTransport`` so we can assert exact +request counts and retry behavior while using the same code paths a live +workflow would exercise. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +from pydantic_ai.exceptions import ModelHTTPError + +from conductor.config.schema import AgentDef, ToolOutputConfig +from conductor.exceptions import ProviderError +from conductor.providers._pydantic_ai.agent_builder import build_agent +from conductor.providers._pydantic_ai.retry import RetryConfig +from conductor.providers._pydantic_ai.runner import run_agent_pipeline + + +def _make_openai_success_sse(content: str) -> str: + """Return a realistic Chat Completions SSE stream for ``httpx.MockTransport``. + + Pydantic AI's ``run_with_interrupt`` streams responses, so a plain JSON body + is not accepted. The stream emits the assistant role, the content delta, a + finish chunk, and a final usage-only chunk, followed by ``[DONE]``. + """ + content_json = json.dumps(content) + chunks = [ + '{"id":"chatcmpl-test-1","object":"chat.completion.chunk","model":"gpt-5-mini","created":1,"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}', + f'{{"id":"chatcmpl-test-1","object":"chat.completion.chunk","model":"gpt-5-mini","created":1,"choices":[{{"index":0,"delta":{{"content":{content_json}}},"finish_reason":null}}]}}', + '{"id":"chatcmpl-test-1","object":"chat.completion.chunk","model":"gpt-5-mini","created":1,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', + '{"id":"chatcmpl-test-1","object":"chat.completion.chunk","model":"gpt-5-mini","created":1,"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}', + ] + return "".join(f"data: {chunk}\n\n" for chunk in chunks) + "data: [DONE]\n\n" + + +def _make_openai_400_response(reasoning_param: str = "openai_reasoning_effort") -> dict[str, Any]: + """Return a realistic OpenAI 400 error body for an unsupported reasoning parameter.""" + return { + "error": { + "message": f"Unsupported parameter: '{reasoning_param}'", + "type": "invalid_request_error", + "param": reasoning_param, + "code": None, + } + } + + +def _make_openai_429_response() -> dict[str, Any]: + """Return a realistic OpenAI 429 rate-limit error body.""" + return { + "error": { + "message": "Rate limit reached for requests", + "type": "rate_limit_error", + "param": None, + "code": "rate_limit", + } + } + + +def _build_mock_transport( + responses: list[tuple[int, dict[str, Any] | str]], + captured: dict[str, Any], +) -> httpx.MockTransport: + """Build an ``httpx.MockTransport`` that serves a sequence of stub responses. + + Args: + responses: Ordered list of ``(status_code, body)`` tuples. ``body`` may + be a ``dict`` for a JSON error response or a ``str`` for an SSE body. + captured: Dictionary that receives request diagnostics for assertions. + """ + call_index: list[int] = [0] + + def handler(request: httpx.Request) -> httpx.Response: + call_index[0] += 1 + captured.setdefault("urls", []).append(str(request.url)) + captured.setdefault("bodies", []).append(request.content) + + status, body = responses[call_index[0] - 1] + if isinstance(body, str): + return httpx.Response(status, text=body, headers={"content-type": "text/event-stream"}) + return httpx.Response(status, json=body) + + return httpx.MockTransport(handler) + + +def _build_pipeline_runner( + agent: AgentDef, + responses: list[tuple[int, dict[str, Any] | str]], + retry_config: RetryConfig | None = None, +) -> tuple[Callable[[], Any], dict[str, Any]]: + """Assemble a callable that executes ``run_agent_pipeline`` against stub responses. + + Returns a ``(coroutine_factory, captured)`` pair. The factory is suitable for + passing to ``asyncio.run`` in a test and uses a real ``OpenAIChatModel`` + backed by ``httpx.MockTransport``. + """ + captured: dict[str, Any] = {} + transport = _build_mock_transport(responses, captured) + http_client = httpx.AsyncClient(transport=transport) + + default_retry = retry_config or RetryConfig( + max_attempts=3, + base_delay=0.0, + max_delay=0.0, + jitter=0.0, + backoff="fixed", + ) + + def build_agent_fn(toolsets: list[Any], *, max_parse_recovery_attempts: int) -> Any: + """Return a pre-built OpenAI-backed Pydantic AI agent.""" + return build_agent( + agent=agent, + system_prompt=agent.system_prompt or "", + rendered_prompt="", + backend="openai", + http_client=http_client, + api_key="sk-test", + default_model="gpt-5-mini", + default_temperature=0.5, + default_max_tokens=1024, + toolsets=toolsets, + max_parse_recovery_attempts=max_parse_recovery_attempts, + ) + + async def _run() -> Any: + return await run_agent_pipeline( + agent=agent, + rendered_prompt="say hello", + mcp_manager=None, + tools=[], + tool_output_config=ToolOutputConfig(), + retry_config=default_retry, + interrupt_signal=None, + event_callback=None, + max_agent_iterations=10, + max_session_seconds=None, + default_model="gpt-5-mini", + retry_history=[], + build_agent_fn=build_agent_fn, + ) + + return _run, captured + + +@pytest.mark.asyncio +async def test_openai_pipeline_success_maps_usage() -> None: + """A successful streaming Chat Completions response maps tokens and content.""" + agent = AgentDef(name="greeter", model="gpt-5-mini", prompt="say hi") + run, captured = _build_pipeline_runner( + agent, + responses=[(200, _make_openai_success_sse("hello"))], + ) + + output = await run() + + assert output.content == {"result": "hello"} + assert output.model == "gpt-5-mini" + assert output.tokens_used == 8 + assert output.input_tokens == 5 + assert output.output_tokens == 3 + assert output.partial is False + assert len(captured["urls"]) == 1 + + +@pytest.mark.asyncio +async def test_openai_pipeline_400_reasoning_effort_is_fatal_one_request() -> None: + """A 400 from ``openai_reasoning_effort`` is non-retryable and one request.""" + agent = AgentDef( + name="reasoner", + model="gpt-5-mini", + prompt="think", + reasoning={"effort": "low"}, # type: ignore[dict-item] + ) + run, captured = _build_pipeline_runner( + agent, + responses=[(400, _make_openai_400_response("openai_reasoning_effort"))], + ) + + with pytest.raises(ProviderError) as exc_info: + await run() + + error = exc_info.value + assert error.is_retryable is False + cause = error.__cause__ + assert cause is not None + assert isinstance(cause, ModelHTTPError) + assert cause.status_code == 400 + assert len(captured["urls"]) == 1 + + +@pytest.mark.asyncio +async def test_openai_pipeline_429_retries_then_succeeds_with_two_requests() -> None: + """A 429 followed by a 200 retries once and results in exactly two Chat Completions requests.""" + agent = AgentDef(name="greeter", model="gpt-5-mini", prompt="say hi") + run, captured = _build_pipeline_runner( + agent, + responses=[ + (429, _make_openai_429_response()), + (200, _make_openai_success_sse("hello")), + ], + ) + + output = await run() + + assert output.content == {"result": "hello"} + assert output.model == "gpt-5-mini" + assert len(captured["urls"]) == 2 From 41d58fc43c99192664e00ba2934ac14b8ce34a74 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 02:02:46 +0300 Subject: [PATCH 07/14] test(providers): add OpenAI HTTP-stub and retry integration tests --- .../test_providers/test_pydantic_ai_retry.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/test_providers/test_pydantic_ai_retry.py b/tests/test_providers/test_pydantic_ai_retry.py index 60a08047..8e4d6d65 100644 --- a/tests/test_providers/test_pydantic_ai_retry.py +++ b/tests/test_providers/test_pydantic_ai_retry.py @@ -311,6 +311,71 @@ def callback(event_type: str, payload: dict[str, Any]) -> None: assert events[0][1]["delay"] == 3.0 mock_sleep.assert_called_once_with(3.0) + @pytest.mark.asyncio + async def test_real_openai_rate_limit_error_retries_once_then_succeeds(self) -> None: + """Requirement: openai.RateLimitError with Retry-After header flows through + execute_with_retry, emits an agent_retry event, and succeeds on the next call.""" + events: list[tuple[str, dict[str, Any]]] = [] + + def callback(event_type: str, payload: dict[str, Any]) -> None: + events.append((event_type, payload)) + + config = RetryConfig(max_attempts=2, base_delay=1.0, jitter=0.0) + factory = _make_factory([_make_openai_rate_limit_error("2"), "success"]) + + with patch("conductor.providers._pydantic_ai.retry.asyncio.sleep") as mock_sleep: + result = await execute_with_retry( + factory, + retry_config=config, + event_callback=callback, + agent_name="openai-retryer", + ) + + assert result == "success" + assert len(events) == 1 + assert events[0][0] == "agent_retry" + assert events[0][1] == { + "agent_name": "openai-retryer", + "attempt": 1, + "max_attempts": 2, + "error": "rate limited", + "error_type": "RateLimitError", + "delay": 2.0, + } + mock_sleep.assert_called_once_with(2.0) + + @pytest.mark.asyncio + async def test_real_openai_bad_request_error_is_not_retried(self) -> None: + """Requirement: openai.BadRequestError is fatal and must not be retried; + it propagates as a non-retryable ProviderError.""" + events: list[tuple[str, dict[str, Any]]] = [] + + def callback(event_type: str, payload: dict[str, Any]) -> None: + events.append((event_type, payload)) + + request = _make_http_request() + response = httpx.Response(400, text="bad request", request=request) + bad_request = openai.BadRequestError("bad request", response=response, body=None) + config = RetryConfig(max_attempts=3, base_delay=0.0, jitter=0.0) + factory = _make_factory([bad_request]) + + with ( + patch("conductor.providers._pydantic_ai.retry.asyncio.sleep") as mock_sleep, + pytest.raises(ProviderError) as exc_info, + ): + await execute_with_retry( + factory, + retry_config=config, + event_callback=callback, + agent_name="openai-bad-request", + ) + + assert exc_info.value.is_retryable is False + assert exc_info.value.status_code == 400 + assert "bad request" in str(exc_info.value) + assert events == [] + mock_sleep.assert_not_called() + class TestRetryConfig: """Tests for retry configuration resolution.""" From 33cec31d3ebbfa77a9c349f20807138b539a3261 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 02:53:06 +0300 Subject: [PATCH 08/14] test(providers): cover openai registration, validation and retry integration Add todo-11 test coverage for the openai provider: - factory: string + structured construction, YAML api_key precedence over env, missing API key raises ValidationError naming OPENAI_API_KEY - validate-level: runtime/provider: openai, per-agent provider: openai, reasoning.effort: max static rejection, tools allowlist, mcp_servers, skills - retry: existing openai.RateLimitError/BadRequestError tests already cover the requirement; no new code needed - SecretStr redaction for openai ProviderSettings - temperature: schema-level 1.5 valid / 2.5 invalid, validator-level mixed default=openai + per-agent/for_each inline claude override fails naming temperature and claude Also exclude openai-named example files from the copilot backward-compatibility helper so the existing copilot-only assertion does not trip over examples/openai-compatible.yaml. Verification: uv run pytest tests/test_config tests/test_providers/test_factory.py tests/test_providers/test_pydantic_ai_retry.py -x (1253 passed, 9 skipped); make check clean. --- .../test_backward_compatibility.py | 2 + tests/test_config/test_provider_settings.py | 21 +++ .../test_validator_capabilities.py | 141 +++++++++++++++++- tests/test_providers/test_factory.py | 60 +++++++- 4 files changed, 222 insertions(+), 2 deletions(-) diff --git a/tests/test_config/test_backward_compatibility.py b/tests/test_config/test_backward_compatibility.py index 96eba8ac..89221b46 100644 --- a/tests/test_config/test_backward_compatibility.py +++ b/tests/test_config/test_backward_compatibility.py @@ -50,6 +50,8 @@ def get_copilot_example_files() -> list[Path]: continue if "aca" in example.name.lower(): continue + if "openai" in example.name.lower(): + continue copilot_examples.append(example) diff --git a/tests/test_config/test_provider_settings.py b/tests/test_config/test_provider_settings.py index fad427a9..9fea83d7 100644 --- a/tests/test_config/test_provider_settings.py +++ b/tests/test_config/test_provider_settings.py @@ -104,6 +104,27 @@ def test_openai_with_base_url_only_accepted(self) -> None: s = ProviderSettings(name="openai", base_url="http://localhost:11434/v1") assert s.base_url == "http://localhost:11434/v1" + def test_openai_temperature_within_range_accepted(self) -> None: + """Requirement: ``RuntimeConfig(provider={name: openai}, temperature=1.5)`` validates.""" + rc = RuntimeConfig(provider={"name": "openai"}, temperature=1.5) + assert rc.provider.name == "openai" + assert rc.temperature == 1.5 + + def test_openai_temperature_out_of_range_rejected(self) -> None: + """Requirement: ``RuntimeConfig(provider={name: openai}, temperature=2.5)`` raises.""" + with pytest.raises(ValidationError) as exc_info: + RuntimeConfig(provider={"name": "openai"}, temperature=2.5) + errors = exc_info.value.errors() + assert any("temperature" in str(e.get("loc", [])) for e in errors) + + def test_openai_api_key_redacted_in_json_dump(self) -> None: + """Requirement: openai ProviderSettings api_key redacts to '**********' in + ``model_dump(mode='json')``.""" + s = ProviderSettings(name="openai", api_key="sk-x") + dumped = s.model_dump(mode="json") + assert dumped["api_key"] == "**********" + assert "sk-x" not in str(dumped) + def test_claude_with_base_url_accepted(self) -> None: s = ProviderSettings(name="claude", base_url="https://my-gateway.example.com/api/v1") assert s.base_url == "https://my-gateway.example.com/api/v1" diff --git a/tests/test_config/test_validator_capabilities.py b/tests/test_config/test_validator_capabilities.py index 725a502e..134272e7 100644 --- a/tests/test_config/test_validator_capabilities.py +++ b/tests/test_config/test_validator_capabilities.py @@ -52,8 +52,9 @@ def _build_workflow( tools: list[str] | None = None, skills: list[str] | None = None, temperature: float | None = None, + provider: str = "copilot", ) -> WorkflowConfig: - runtime_kwargs: dict[str, Any] = {"provider": "copilot"} + runtime_kwargs: dict[str, Any] = {"provider": provider} if mcp_servers is not None: runtime_kwargs["mcp_servers"] = mcp_servers if skills is not None: @@ -165,6 +166,104 @@ def test_per_agent_provider_override_against_mcp_errors(self, patch_caps: Any) - validate_workflow_config(config) +class TestOpenAIProviderCrossCheck: + """Requirement: ``runtime.provider: openai`` and per-agent ``provider: openai`` + workflows pass ``conductor validate`` for features the provider supports. + """ + + def test_runtime_provider_openai_validates(self, patch_caps: Any) -> None: + """Requirement: a workflow with ``runtime.provider: openai`` validates cleanly.""" + from conductor.providers.openai import OpenAIProvider + + patch_caps({"openai": OpenAIProvider.CAPABILITIES}) + config = WorkflowConfig( + workflow=WorkflowDef( + name="t", + entry_point="a", + runtime=RuntimeConfig(provider="openai"), + ), + agents=[AgentDef(name="a", prompt="hi")], + ) + validate_workflow_config(config) # no raise + + def test_per_agent_provider_openai_validates(self, patch_caps: Any) -> None: + """Requirement: a per-agent ``provider: openai`` override validates cleanly.""" + from conductor.providers.openai import OpenAIProvider + + patch_caps({"copilot": _caps(), "openai": OpenAIProvider.CAPABILITIES}) + config = _build_workflow( + agents=[AgentDef(name="a", prompt="hi", provider="openai")], + ) + validate_workflow_config(config) # no raise + + def test_max_effort_rejected_against_real_openai_capabilities(self, patch_caps: Any) -> None: + """Requirement: ``reasoning.effort: max`` on ``openai`` is rejected statically + via the real capability descriptor (which excludes ``max``). + """ + from conductor.providers.openai import OpenAIProvider + + patch_caps({"openai": OpenAIProvider.CAPABILITIES}) + config = _build_workflow( + agents=[ + AgentDef( + name="a", + prompt="hi", + provider="openai", + reasoning=ReasoningConfig(effort="max"), + ), + ], + ) + with pytest.raises(ConfigurationError, match="supports only.*low.*medium.*high.*xhigh"): + validate_workflow_config(config) + + def test_non_empty_tools_allowlist_passes(self, patch_caps: Any) -> None: + """Requirement: openai honors per-agent tool allowlists, so + ``tools: [...]`` validates.""" + from conductor.providers.openai import OpenAIProvider + + patch_caps({"openai": OpenAIProvider.CAPABILITIES}) + config = _build_workflow( + agents=[AgentDef(name="a", prompt="hi", provider="openai", tools=["search"])], + tools=["search"], + ) + validate_workflow_config(config) # no raise + + def test_mcp_servers_with_openai_passes(self, patch_caps: Any) -> None: + """Requirement: ``runtime.mcp_servers`` with default provider ``openai`` + validates.""" + from conductor.providers.openai import OpenAIProvider + + patch_caps({"openai": OpenAIProvider.CAPABILITIES}) + config = WorkflowConfig( + workflow=WorkflowDef( + name="t", + entry_point="a", + runtime=RuntimeConfig( + provider="openai", + mcp_servers={"docs": MCPServerDef(command="docs-server")}, + ), + ), + agents=[AgentDef(name="a", prompt="hi")], + ) + validate_workflow_config(config) # no raise + + def test_skills_with_openai_passes(self, patch_caps: Any) -> None: + """Requirement: declared skills with provider ``openai`` validate + (openai supports skills).""" + from conductor.providers.openai import OpenAIProvider + + patch_caps({"openai": OpenAIProvider.CAPABILITIES}) + config = WorkflowConfig( + workflow=WorkflowDef( + name="t", + entry_point="a", + runtime=RuntimeConfig(provider="openai"), + ), + agents=[AgentDef(name="a", prompt="hi", skills=["conductor"])], + ) + validate_workflow_config(config) # no raise + + class TestTemperatureCrossCheck: def test_temperature_above_one_with_openai_default_passes(self, patch_caps: Any) -> None: patch_caps({"openai": _caps()}) @@ -224,6 +323,46 @@ def test_temperature_above_one_for_each_inline_copilot_errors(self, patch_caps: with pytest.raises(ConfigurationError, match="temperature.*1.5.*copilot"): validate_workflow_config(config) + def test_temperature_above_one_with_claude_per_agent_override_errors( + self, patch_caps: Any + ) -> None: + """Requirement: workflow default=openai + temperature=1.5 with a per-agent + provider='claude' override fails validation, naming temperature and claude.""" + patch_caps({"openai": _caps(), "claude": _caps()}) + config = _build_workflow( + provider="openai", + temperature=1.5, + agents=[ + AgentDef(name="a", prompt="hi", provider="claude"), + AgentDef(name="b", prompt="hi", provider="openai"), + ], + ) + with pytest.raises(ConfigurationError, match="temperature.*1.5.*claude"): + validate_workflow_config(config) + + def test_temperature_above_one_with_for_each_inline_claude_override_errors( + self, patch_caps: Any + ) -> None: + """Requirement: workflow default=openai + temperature=1.5 with a for_each + inline provider='claude' override fails validation, naming temperature and claude.""" + patch_caps({"openai": _caps(), "claude": _caps()}) + config = _build_workflow( + provider="openai", + temperature=1.5, + agents=[AgentDef(name="entry", prompt="hi", output={"items": {"type": "array"}})], + for_each=[ + ForEachDef( + name="loop", + type="for_each", + source="entry.output.items", + **{"as": "item"}, + agent=AgentDef(name="inline", prompt="{{ item }}", provider="claude"), + ) + ], + ) + with pytest.raises(ConfigurationError, match="temperature.*1.5.*claude"): + validate_workflow_config(config) + def test_temperature_at_one_allowed_for_all_providers(self, patch_caps: Any) -> None: patch_caps({"copilot": _caps()}) config = _build_workflow( diff --git a/tests/test_providers/test_factory.py b/tests/test_providers/test_factory.py index 6e3b7775..cbcf063b 100644 --- a/tests/test_providers/test_factory.py +++ b/tests/test_providers/test_factory.py @@ -7,7 +7,7 @@ from pydantic import SecretStr from conductor.config.schema import ProviderSettings, ToolOutputConfig -from conductor.exceptions import ProviderError +from conductor.exceptions import ProviderError, ValidationError from conductor.providers.claude import ClaudeProvider from conductor.providers.copilot import CopilotProvider from conductor.providers.factory import create_provider @@ -48,6 +48,64 @@ async def test_create_openai_provider_raises(self) -> None: assert exc_info.value.suggestion is not None assert "copilot" in exc_info.value.suggestion + @pytest.mark.asyncio + async def test_create_openai_provider_from_string(self) -> None: + """Requirement: factory creates OpenAIProvider from the string 'openai'.""" + from conductor.providers.openai import OpenAIProvider + + with ( + patch("conductor.providers.factory.OPENAI_SDK_AVAILABLE", True), + patch.object(OpenAIProvider, "_initialize_client"), + ): + provider = await create_provider("openai", validate=False) + assert isinstance(provider, OpenAIProvider) + + @pytest.mark.asyncio + async def test_create_openai_provider_from_structured_settings(self) -> None: + """Requirement: factory creates OpenAIProvider from structured + ProviderSettings(name='openai').""" + from conductor.providers.openai import OpenAIProvider + + settings = ProviderSettings(name="openai", api_key=SecretStr("sk-test")) + with ( + patch("conductor.providers.factory.OPENAI_SDK_AVAILABLE", True), + patch.object(OpenAIProvider, "_initialize_client"), + ): + provider = await create_provider("openai", validate=False, provider_settings=settings) + assert isinstance(provider, OpenAIProvider) + assert provider._api_key == "sk-test" + + @pytest.mark.asyncio + async def test_openai_yaml_api_key_precedence_over_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: YAML api_key for name='openai' takes precedence over + OPENAI_API_KEY env var.""" + from conductor.providers.openai import OpenAIProvider + + monkeypatch.setenv("OPENAI_API_KEY", "env-key") + settings = ProviderSettings(name="openai", api_key=SecretStr("yaml-key")) + with ( + patch("conductor.providers.factory.OPENAI_SDK_AVAILABLE", True), + patch.object(OpenAIProvider, "_initialize_client"), + ): + provider = await create_provider("openai", validate=False, provider_settings=settings) + assert isinstance(provider, OpenAIProvider) + assert provider._api_key == "yaml-key" + + @pytest.mark.asyncio + async def test_openai_missing_api_key_raises_validation_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: missing YAML api_key and missing OPENAI_API_KEY raises + ValidationError naming OPENAI_API_KEY.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + with ( + patch("conductor.providers.factory.OPENAI_SDK_AVAILABLE", True), + pytest.raises(ValidationError, match="OPENAI_API_KEY"), + ): + await create_provider("openai", validate=False) + @pytest.mark.asyncio async def test_openai_provider_receives_default_reasoning_effort(self) -> None: """default_reasoning_effort reaches the OpenAI provider.""" From 0a9bdf1e8715351ad2dcad621b98cfad6a61b21f Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 02:57:51 +0300 Subject: [PATCH 09/14] docs(providers): add OpenAI provider documentation and comparison entries --- AGENTS.md | 13 ++ README.md | 30 ++-- docs/providers/comparison.md | 66 +++++--- docs/providers/openai.md | 260 ++++++++++++++++++++++++++++++++ examples/README.md | 15 ++ examples/openai-compatible.yaml | 90 +++++++++++ 6 files changed, 444 insertions(+), 30 deletions(-) create mode 100644 docs/providers/openai.md create mode 100644 examples/openai-compatible.yaml diff --git a/AGENTS.md b/AGENTS.md index f9672483..ecea2390 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -388,6 +388,19 @@ All providers must maintain feature parity where applicable. Any change to one p When modifying any provider, check all other providers for the same change. The dashboard, JSONL logger, console subscriber, and workflow engine all depend on consistent behavior across providers. +#### `openai.py` parity notes + +The OpenAI provider (`openai.py`) implements `AgentProvider` on the shared +Pydantic AI execution loop (`src/conductor/providers/_pydantic_ai/runner.py::run_agent_pipeline`). +It shares toolset bridges, event callbacks, interrupts, and retry contracts with `ClaudeProvider`, +with the following backend specifics: + +- **Shared runner**: Delegates `execute()` to `run_agent_pipeline()` with an `OpenAIChatModel` backend. +- **Env-resolution rule**: YAML `api_key` and `base_url` take precedence over `OPENAI_API_KEY` and `OPENAI_BASE_URL`. Ambient env vars never reroute an unconfigured provider (e.g. `provider: copilot` is never diverted by `OPENAI_*` variables). If an explicit `base_url` is provided without `api_key`, the key must be in `OPENAI_API_KEY` or passed explicitly. +- **Chat-Completions-only**: Exclusively uses the OpenAI Chat Completions API; the OpenAI Responses API is not supported. +- **Reasoning max rejection**: Supports `low`, `medium`, `high`, `xhigh` effort on reasoning models (`o1`, `o3-mini`), but rejects `max` with a `ValidationError`. +- **Temperature 0..2**: Validates temperature in the `0.0..2.0` range and is exempt from the `1.0` cap enforced on Claude and Copilot providers. + #### `claude_agent_sdk.py` parity notes The Claude Agent SDK provider (`claude_agent_sdk.py`) is the canonical diff --git a/README.md b/README.md index 92f5eb66..8306380e 100644 --- a/README.md +++ b/README.md @@ -346,14 +346,14 @@ See [docs/fleet.md](docs/fleet.md) for every screen, key binding, the status voc Conductor supports multiple AI providers. Choose based on your needs: -| Feature | Copilot | Claude | Claude Agent SDK | Hermes | ACA | -|---------|---------|--------|------------------|--------|-----| -| **Tier** | Stable | Stable | Experimental | Experimental | Experimental | -| **Pricing** | Subscription | Pay-per-token | Subscription | Pay-per-token (via hermes) | Subscription + ACA compute | -| **Context Window** | Per-model | Per-model | Per-model | Per-model | Per-model (inner Copilot) | -| **Tool Support (MCP)** | Yes | Yes (stdio) | Yes (built-in) | No (hermes internal tools) | Yes (always forwarded, not allowlisted) | -| **Streaming** | Yes | Yes | Yes | No | Yes | -| **Best For** | Heavy usage, tools | Large context, pay-per-use | Full Claude Code toolset | Multi-provider model access | Untrusted/isolation-sensitive agents | +| Feature | Copilot | OpenAI | Claude | Claude Agent SDK | Hermes | ACA | +|---------|---------|--------|--------|------------------|--------|-----| +| **Tier** | Stable | Stable | Stable | Experimental | Experimental | Experimental | +| **Pricing** | Subscription | Pay-per-token | Pay-per-token | Subscription | Pay-per-token (via hermes) | Subscription + ACA compute | +| **Context Window** | Per-model | Per-model | Per-model | Per-model | Per-model | Per-model (inner Copilot) | +| **Tool Support (MCP)** | Yes | Yes (stdio) | Yes (stdio) | Yes (built-in) | No (hermes internal tools) | Yes (always forwarded, not allowlisted) | +| **Streaming** | Yes | Yes | Yes | Yes | No | Yes | +| **Best For** | Heavy usage, tools | OpenAI ecosystem, pay-per-use | Large context, pay-per-use | Full Claude Code toolset | Multi-provider model access | Untrusted/isolation-sensitive agents | ### Using Copilot @@ -366,6 +366,17 @@ workflow: Copilot is the default provider — `runtime.provider` can be omitted entirely. Requires an active GitHub Copilot subscription and the GitHub CLI authenticated (`gh auth login`). +### Using OpenAI + +```yaml +workflow: + runtime: + provider: openai + default_model: gpt-5-mini +``` + +Set your API key: `export OPENAI_API_KEY=sk-...` + ### Using Claude ```yaml @@ -415,7 +426,7 @@ workflow: The `aca` provider delegates an agent's entire agentic loop, tools, and MCP calls to a remote **Azure Container Apps dynamic-sessions sandbox** instead of running it on the host — useful for untrusted or isolation-sensitive agents (e.g. running arbitrary generated code). Unlike the other providers, `aca` requires the structured `provider:` form with a `pool_endpoint` pointing at an operator-provisioned ACA session pool (`scripts/aca/provision-pool.sh`) and `azure-identity` for authentication. Resolves its inner Copilot credential automatically via `COPILOT_PROVIDER_BASE_URL` → `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN` → `gh auth token`, so a `gh`-authenticated operator needs no ACA-specific setup. See [`examples/aca-coding-agent.yaml`](examples/aca-coding-agent.yaml) for a full end-to-end example. -**See also:** [Claude Documentation](docs/providers/claude.md) | [Hermes Documentation](docs/providers/hermes.md) | [ACA Documentation](docs/providers/aca.md) | [Provider Comparison](docs/providers/comparison.md) | [Migration Guide](docs/providers/migration.md) +**See also:** [OpenAI Documentation](docs/providers/openai.md) | [Claude Documentation](docs/providers/claude.md) | [Hermes Documentation](docs/providers/hermes.md) | [ACA Documentation](docs/providers/aca.md) | [Provider Comparison](docs/providers/comparison.md) | [Migration Guide](docs/providers/migration.md) ### Using a Local / Custom LLM Endpoint (Ollama, vLLM, Azure OpenAI, ...) @@ -550,6 +561,7 @@ See the [`examples/`](./examples/) directory for complete workflows: | [Fleet Manager](./docs/fleet.md) | `conductor fleet` TUI: screens, key bindings, gate resolvability, retention | | [Parallel Execution](./docs/parallel-execution.md) | Static parallel groups | | [Dynamic Parallel](./docs/dynamic-parallel.md) | For-each groups and array processing | +| [OpenAI Provider](./docs/providers/openai.md) | OpenAI setup and configuration | | [Claude Provider](./docs/providers/claude.md) | Claude setup and configuration | | [Hermes Provider](./docs/providers/hermes.md) | Hermes setup and configuration | | [ACA Provider](./docs/providers/aca.md) | Azure Container Apps sandboxed execution setup and configuration | diff --git a/docs/providers/comparison.md b/docs/providers/comparison.md index 0229175d..b5599263 100644 --- a/docs/providers/comparison.md +++ b/docs/providers/comparison.md @@ -1,28 +1,28 @@ -# Provider Comparison: Copilot vs Claude vs Claude Agent SDK vs Hermes +# Provider Comparison: Copilot vs OpenAI vs Claude vs Claude Agent SDK vs Hermes -This guide helps you choose between GitHub Copilot, Anthropic Claude, Claude Agent SDK, and NousResearch Hermes providers for your workflows. +This guide helps you choose between GitHub Copilot, OpenAI, Anthropic Claude, Claude Agent SDK, and NousResearch Hermes providers for your workflows. ## Quick Comparison -| Feature | Copilot | Claude | Claude Agent SDK | Hermes | -|---------|---------|--------|------------------|--------| -| **Tier** | Stable | Stable | Experimental | Experimental | -| **Context Window** | per-model (SDK-reported) | per-model (SDK-reported) | 200K | per-model | -| **Pricing Model** | Subscription ($10-39/mo) | Pay-per-token | Via Claude Code CLI | Pay-per-token (via hermes) | -| **Setup** | GitHub auth | API key | `claude` CLI auth | API key (model-provider's key) | -| **Model Selection** | GPT-5.2, o1 | Haiku, Sonnet, Opus | Haiku, Sonnet, Opus | Any OpenRouter-style model | -| **Streaming** | Yes | Yes | Yes | Yes | -| **Tool Support** | Yes (MCP, all types) | Yes (MCP, stdio only) | Yes (MCP + built-in preset) | Yes (hermes toolsets) | -| **MCP Servers** | Yes | Yes (stdio) | Yes (all types) | No | -| **Reasoning / Extended Thinking** | Yes (`reasoning_effort` on session) | Yes (extended `thinking` budget) | Inherits from CLI config | Yes (`reasoning_config`) | -| **Speed** | Fast | Fast | Fast | Depends on model | -| **Output Quality** | Excellent | Excellent | Excellent | Depends on model | -| **Cost Predictability** | High (flat rate) | Variable (usage-based) | Variable | Variable (usage-based) | -| **Multi-provider** | No | Yes (via Conductor) | No | Yes (native) | -| **Agentic Loop** | SDK-managed | SDK-managed (Pydantic AI) | SDK-managed (delegated to CLI) | SDK-managed (delegated to hermes) | -| **Structured Output** | Prompt injection | Native | Prompt injection | Prompt injection | -| **Session Resume** | Yes | No | No | Yes | -| **Tool Output Limits** | native SDK spill (large_output) | conductor-side truncation+spill | native CLI env var | N/A | +| Feature | Copilot | OpenAI | Claude | Claude Agent SDK | Hermes | +|---------|---------|--------|--------|------------------|--------| +| **Tier** | Stable | Stable | Stable | Experimental | Experimental | +| **Context Window** | per-model (SDK-reported) | per-model (SDK-reported) | per-model (SDK-reported) | 200K | per-model | +| **Pricing Model** | Subscription ($10-39/mo) | Pay-per-token | Pay-per-token | Via Claude Code CLI | Pay-per-token (via hermes) | +| **Setup** | GitHub auth | API key | API key | `claude` CLI auth | API key (model-provider's key) | +| **Model Selection** | GPT-5.2, o1 | GPT-4o, GPT-5-mini, o1, o3-mini | Haiku, Sonnet, Opus | Haiku, Sonnet, Opus | Any OpenRouter-style model | +| **Streaming** | Yes | Yes | Yes | Yes | Yes | +| **Tool Support** | Yes (MCP, all types) | Yes (MCP, stdio only) | Yes (MCP, stdio only) | Yes (MCP + built-in preset) | Yes (hermes toolsets) | +| **MCP Servers** | Yes | Yes (stdio) | Yes (stdio) | Yes (all types) | No | +| **Reasoning / Extended Thinking** | Yes (`reasoning_effort` on session) | Yes (`reasoning_effort` on o1/o3) | Yes (extended `thinking` budget) | Inherits from CLI config | Yes (`reasoning_config`) | +| **Speed** | Fast | Fast | Fast | Fast | Depends on model | +| **Output Quality** | Excellent | Excellent | Excellent | Excellent | Depends on model | +| **Cost Predictability** | High (flat rate) | Variable (usage-based) | Variable (usage-based) | Variable | Variable (usage-based) | +| **Multi-provider** | No | Yes (via custom base_url) | Yes (via Conductor) | No | Yes (native) | +| **Agentic Loop** | SDK-managed | SDK-managed (Pydantic AI) | SDK-managed (Pydantic AI) | SDK-managed (delegated to CLI) | SDK-managed (delegated to hermes) | +| **Structured Output** | Prompt injection | Native | Native | Prompt injection | Prompt injection | +| **Session Resume** | Yes | No | No | No | Yes | +| **Tool Output Limits** | native SDK spill (large_output) | conductor-side truncation+spill | conductor-side truncation+spill | native CLI env var | N/A | > **About the experimental tier.** `claude-agent-sdk` and `hermes` declare > specific capability carve-outs (e.g. no per-agent tools allowlist). `conductor validate` @@ -71,6 +71,30 @@ agents: prompt: "Research {{ topic }} using web search" ``` +## When to Use OpenAI + +### ✅ Choose OpenAI if: + +1. **You work primarily with OpenAI models or custom endpoints** — GPT-4o, GPT-5-mini, o1, o3-mini, or compatible proxies (OpenRouter, Ollama, vLLM) +2. **You need full temperature control** — OpenAI supports temperatures from 0.0 up to 2.0 +3. **You want pay-per-token usage with native schema enforcement** — Built on Pydantic AI's forced tool call structured output +4. **You use custom gateways** — Point `base_url` to local or corporate OpenAI-compatible endpoints + +### Example OpenAI Workflow + +```yaml +workflow: + name: openai-workflow + runtime: + provider: openai + default_model: gpt-5-mini + temperature: 0.7 + +agents: + - name: analyzer + prompt: "Analyze the following text: {{ workflow.input.text }}" +``` + ## When to Use Claude ### ✅ Choose Claude if: diff --git a/docs/providers/openai.md b/docs/providers/openai.md new file mode 100644 index 00000000..051bdf9a --- /dev/null +++ b/docs/providers/openai.md @@ -0,0 +1,260 @@ +# OpenAI Provider Documentation + +The OpenAI provider enables Conductor workflows to execute agents using OpenAI's models via Pydantic AI (`pydantic-ai` package, `OpenAIChatModel`). + +## Table of Contents + +- [Quick Start](#quick-start) +- [Architecture & Internal Design](#architecture--internal-design) +- [API Key Setup & Precedence](#api-key-setup--precedence) +- [Custom Endpoints and Gateways](#custom-endpoints-and-gateways) +- [Disambiguation: `provider: openai` vs Copilot `type: openai`](#disambiguation-provider-openai-vs-copilots-type-openai) +- [Model Selection & Runtime Configuration](#model-selection--runtime-configuration) +- [Reasoning Effort Matrix](#reasoning-effort-matrix) +- [MCP Tools Support](#mcp-tools-support) +- [Troubleshooting](#troubleshooting) + +## Quick Start + +### 1. Install the OpenAI SDK + +```bash +# Using uv (recommended) +uv add 'openai>=1.0.0' + +# Using pip +pip install 'openai>=1.0.0' +``` + +### 2. Set up your API key + +```bash +export OPENAI_API_KEY=sk-... +``` + +### 3. Update your workflow + +```yaml +workflow: + name: my-openai-workflow + runtime: + provider: openai + default_model: gpt-5-mini + +agents: + - name: assistant + model: gpt-5-mini + prompt: | + Answer the following question: {{ workflow.input.question }} + output: + answer: + type: string + routes: + - to: $end +``` + +### 4. Run your workflow + +```bash +conductor run my-openai-workflow.yaml --input question="What is Python?" +``` + +## Architecture & Internal Design + +The OpenAI provider uses the shared Pydantic AI execution loop (`src/conductor/providers/_pydantic_ai/runner.py`). `OpenAIProvider` in `src/conductor/providers/openai.py` implements the `AgentProvider` interface and delegates agent execution to `run_agent_pipeline()`. + +Key architectural properties: +- **Chat Completions API Only**: Speaks Chat Completions endpoint. There is no support for the OpenAI Responses API. +- **Shared Runner**: Utilizes the shared Pydantic AI runner pipeline (`run_agent_pipeline`), sharing toolset bridges, event callbacks, interrupts, and retry contracts with `ClaudeProvider`. +- **Eager Skill Injection**: OpenAI's API has no native skill-directory surface; skill files (`SKILL.md` and references) are eagerly injected into the prompt envelope by `AgentExecutor`. +- **No Native Plugins**: `plugins: False` capability. Subagents and plugin MCP surfaces are not natively supported. + +## API Key Setup & Precedence + +### Setting the API Key + +You can supply the API key via environment variable or YAML: + +```bash +export OPENAI_API_KEY=sk-... +``` + +Or in YAML: + +```yaml +workflow: + runtime: + provider: + name: openai + api_key: "${OPENAI_API_KEY}" +``` + +### Precedence Rules + +1. **YAML over Environment**: Values explicitly configured in YAML (`api_key`, `base_url`) override environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`). +2. **Environment Fallback**: When omitted in YAML, `OPENAI_API_KEY` and `OPENAI_BASE_URL` are read from the environment. +3. **No Ambient Rerouting**: Ambient environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`) never divert an unconfigured provider. If a workflow specifies `provider: copilot` (or omits provider), ambient `OPENAI_*` variables have zero effect and will not reroute execution. + +## Custom Endpoints and Gateways + +The OpenAI provider can route requests to any OpenAI-compatible API gateway, local model server, or proxy. + +### Provider Configuration Schema + +```yaml +workflow: + runtime: + provider: + name: openai + base_url: "http://localhost:11434/v1" + api_key: "ollama" +``` + +| Field | Description | Env Fallback | +|-------|-------------|--------------| +| `base_url` | Custom OpenAI-compatible base URL (typically ending in `/v1`) | `OPENAI_BASE_URL` | +| `api_key` | API key for authentication | `OPENAI_API_KEY` | + +### Recipe: Omniroute-style Proxy + +```yaml +workflow: + name: omniroute-workflow + runtime: + provider: + name: openai + base_url: "https://omniroute.example.com/v1" + api_key: "${OMNIROUTE_API_KEY}" + default_model: gpt-4o +``` + +### Recipe: OpenRouter + +```yaml +workflow: + name: openrouter-workflow + runtime: + provider: + name: openai + base_url: "https://openrouter.ai/api/v1" + api_key: "${OPENROUTER_API_KEY}" + default_model: meta-llama/llama-3.3-70b-instruct +``` + +### Recipe: Ollama + +```yaml +workflow: + name: ollama-workflow + runtime: + provider: + name: openai + base_url: "http://localhost:11434/v1" + api_key: "ollama" # Required string; Ollama ignores value but SDK expects one + default_model: llama3.1 +``` + +### Recipe: vLLM or LM Studio + +```yaml +workflow: + name: local-llm-workflow + runtime: + provider: + name: openai + # vLLM default: http://localhost:8000/v1 + # LM Studio default: http://localhost:1234/v1 + base_url: "http://localhost:8000/v1" + api_key: "vllm" + default_model: mistralai/Mistral-7B-Instruct-v0.3 +``` + +## Disambiguation: `provider: openai` vs Copilot `type: openai` + +Conductor offers two ways to use OpenAI-compatible models. They target different runtime engines: + +> **Important Difference:** +> +> 1. **Native OpenAI Provider (`provider: openai` or `name: openai`)**: +> Executes directly against the OpenAI Chat Completions API using Pydantic AI and Python's `openai` SDK. Supports full temperature range (0.0 to 2.0) and uses Conductor's shared Pydantic AI runner. +> +> 2. **Copilot Custom Routing (`provider: { name: copilot, type: openai, ... }`)**: +> Routes the GitHub Copilot SDK to an OpenAI-compatible wire endpoint. Uses GitHub Copilot as the underlying agentic engine. + +## Model Selection & Runtime Configuration + +### Chat Completions Only + +The OpenAI provider strictly uses the OpenAI Chat Completions endpoint. The OpenAI Responses API is not supported. + +### Temperature Range (0.0 – 2.0) + +Unlike the Claude and Copilot providers which cap temperature at `1.0`, the OpenAI provider accepts temperatures from `0.0` to `2.0`. + +```yaml +workflow: + runtime: + provider: openai + default_model: gpt-5-mini + temperature: 1.5 # Valid for OpenAI (0.0 to 2.0 range) +``` + +`conductor validate` enforces provider-aware temperature bounds: values > 1.0 raise validation errors for Claude/Copilot but are permitted for OpenAI. + +## Reasoning Effort Matrix + +OpenAI reasoning models (such as `o1`, `o3-mini`) support the `reasoning.effort` setting. + +| Effort Level | Supported by OpenAI | Note | +|--------------|----------------------|------| +| `low` | Yes | Fast reasoning | +| `medium` | Yes | Balanced reasoning | +| `high` | Yes | Deep reasoning | +| `xhigh` | Yes | Extra high reasoning | +| `max` | **No (Rejected)** | Raises `ValidationError` | + +```yaml +workflow: + runtime: + provider: openai + default_model: o3-mini + default_reasoning_effort: medium # low, medium, high, xhigh +``` + +Attempting to set `reasoning.effort: max` with the OpenAI provider will be rejected during validation or execution. + +## MCP Tools Support + +The OpenAI provider supports stdio MCP servers via Conductor's `MCPManager`. + +```yaml +workflow: + runtime: + provider: openai + default_model: gpt-5-mini + mcp_servers: + fetch: + command: uvx + args: ["mcp-server-fetch"] +``` + +HTTP and SSE MCP server types are not supported by the OpenAI provider (`stdio` only). + +## Troubleshooting + +### Missing OpenAI SDK + +If `openai` package is not installed: + +```text +ProviderError: OpenAI SDK not installed +Suggestion: Install with: uv add 'openai>=1.0.0' +``` + +### Missing API Key + +If no API key is found in environment or YAML: + +```text +ValidationError: OPENAI_API_KEY environment variable is not set and no api_key was provided +``` diff --git a/examples/README.md b/examples/README.md index 39b4f085..faf5431f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -228,6 +228,21 @@ conductor run examples/copilot-local-llm.yaml --input question="What is Python?" See [Configuration → Custom Provider Routing](../docs/configuration.md#custom-provider-routing-ollama--vllm--azure-openai) for env-var fallbacks, validator rules, and the security rationale. +### openai-compatible.yaml + +Route the native `openai` provider at any OpenAI-compatible Chat Completions endpoint (Ollama, vLLM, OpenRouter, LM Studio, etc.). Demonstrates: +- Object form of `runtime.provider` for the `openai` provider +- `base_url` / `api_key` forwarded to the OpenAI client +- Secret hygiene via `${OPENAI_BASE_URL:-...}` and `${OPENAI_API_KEY:-...}` interpolation with placeholder defaults +- Commented alternates for OpenRouter, Ollama, and omniroute gateways + +```bash +# Requires an OpenAI-compatible endpoint; defaults point at Ollama on localhost +conductor run examples/openai-compatible.yaml --input question="What is Python?" +``` + +See [Configuration → OpenAI-compatible Provider Routing](../docs/configuration.md#openai-compatible-provider-routing) for env-var fallbacks, validator rules, and the security rationale. + ### claude-custom-endpoint.yaml Route the Claude provider through a custom Anthropic-compatible endpoint or proxy. Demonstrates: diff --git a/examples/openai-compatible.yaml b/examples/openai-compatible.yaml new file mode 100644 index 00000000..4cba8ddf --- /dev/null +++ b/examples/openai-compatible.yaml @@ -0,0 +1,90 @@ +# OpenAI-compatible provider workflow +# +# Demonstrates the native ``openai`` provider pointed at any +# OpenAI-compatible Chat Completions endpoint. +# +# NEVER commit real API keys. Use environment-variable interpolation +# (``${VAR:-default}``) so secrets are resolved at load time and never +# appear in checkpoints, dashboard events, or the workflow file. +# +# IMPORTANT: the ``openai`` provider uses the Chat Completions API only. +# The ``Responses`` API is not supported. The endpoint you supply must +# expose ``/v1/chat/completions`` and the model name must match a model +# that endpoint actually serves. +# +# The placeholder defaults (``:-...``) exist only so this example passes +# static validation without any environment variables set. They are not +# valid credentials and will not run against a real endpoint. +# +# Usage (Ollama running on http://localhost:11434): +# +# conductor run examples/openai-compatible.yaml --input question="What is Python?" +# +# Uncomment one of the alternate blocks below for OpenRouter, a local +# Ollama default, or the omniroute gateway. + +workflow: + name: openai-compatible + description: Single-agent Q&A using the native OpenAI-compatible provider + version: "1.0.0" + entry_point: answerer + + runtime: + provider: + name: openai + base_url: ${OPENAI_BASE_URL:-http://localhost:11434/v1} + api_key: ${OPENAI_API_KEY:-ollama} + default_model: llama3.1 + + input: + question: + type: string + required: true + description: The question to answer + +agents: + - name: answerer + description: Answers the user's question via the configured endpoint + prompt: | + You are a helpful assistant. Please answer the following question + clearly and concisely: + + Question: {{ workflow.input.question }} + + Provide a direct answer without unnecessary preamble. + output: + answer: + type: string + description: The answer to the question + routes: + - to: $end + +output: + answer: "{{ answerer.output.answer }}" + +# Alternate: OpenRouter (commented out): +# +# runtime: +# provider: +# name: openai +# base_url: ${OPENAI_BASE_URL:-https://openrouter.ai/api/v1} +# api_key: ${OPENAI_API_KEY:-sk-or-placeholder-replace-me} +# default_model: openai/gpt-4o-mini + +# Alternate: Ollama default without env vars (commented out): +# +# runtime: +# provider: +# name: openai +# base_url: ${OPENAI_BASE_URL:-http://localhost:11434/v1} +# api_key: ${OPENAI_API_KEY:-ollama} +# default_model: llama3.1 + +# Alternate: omniroute gateway (commented out): +# +# runtime: +# provider: +# name: openai +# base_url: ${OPENAI_BASE_URL:-https://api.omniroute.example.com/v1} +# api_key: ${OPENAI_API_KEY:-sk-omniroute-placeholder-replace-me} +# default_model: gpt-4o-mini From 21e850d36fc2712b08c51dc71e2f50075851b79e Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 03:55:56 +0300 Subject: [PATCH 10/14] docs: add Unreleased CHANGELOG entry for native OpenAI provider --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 922ebb9d..d1edc31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -555,6 +555,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Native OpenAI provider** — new stable `openai` provider built on the shared + Pydantic AI runtime. Works with the real OpenAI API and any OpenAI-compatible + Chat Completions endpoint (Ollama, vLLM, LM Studio, OpenRouter, corporate + proxies). Supports MCP tools, structured output, interrupts, reasoning effort + (`low`/`medium`/`high`/`xhigh`), and full temperature range `0.0`–`2.0`. + See `docs/providers/openai.md` and `examples/openai-compatible.yaml`. - **Mid-run guidance for `--web` and `--web-bg` runs** (#400). The dashboard previously offered only Stop, Resume, and Kill — there was no way to correct a run's course without stopping it first. `conductor guide --text From 9e59bce5f3cba4a0f59bed37d6fb2ca63080dba1 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 04:29:00 +0300 Subject: [PATCH 11/14] fix(providers): validate openai reasoning effort at runtime and honor OPENAI_BASE_URL env fallback --- docs/workflow-syntax.md | 1 + src/conductor/providers/openai.py | 33 ++++++++++- tests/test_providers/test_openai.py | 85 +++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/docs/workflow-syntax.md b/docs/workflow-syntax.md index d742b94c..b99e51ca 100644 --- a/docs/workflow-syntax.md +++ b/docs/workflow-syntax.md @@ -1844,6 +1844,7 @@ skill* — but the mechanism and its cost differ: | `claude-agent-sdk` | owning plugin registered, skill enabled by `:` | progressive | | `claude` | eager injection into the rendered prompt | **full body on every call** | | `hermes` | eager injection into the rendered prompt | **full body on every call** | +| `openai` | eager injection into the rendered prompt | **full body on every call** | | `aca` | not supported (`skills=False`) — rejected by `conductor validate` and by the executor at run time | n/a | Two consequences worth knowing: diff --git a/src/conductor/providers/openai.py b/src/conductor/providers/openai.py index 765c16eb..1885e33d 100644 --- a/src/conductor/providers/openai.py +++ b/src/conductor/providers/openai.py @@ -34,7 +34,7 @@ ModelCapabilityInfo, ) from conductor.providers.capabilities import ProviderCapabilities -from conductor.providers.reasoning import ReasoningEffort +from conductor.providers.reasoning import ReasoningEffort, resolve_reasoning_effort if TYPE_CHECKING: from conductor.engine.pricing import ModelPricing @@ -224,6 +224,9 @@ def __init__( self._mcp_managers: dict[str, MCPManager] = {} self._mcp_manager_locks: dict[str, asyncio.Lock] = {} + if self._base_url is None: + self._base_url = os.environ.get("OPENAI_BASE_URL") + self._initialize_client() def _initialize_client(self) -> None: @@ -485,6 +488,18 @@ async def execute_dialog_turn( model_settings["max_tokens"] = max_tokens if self._default_reasoning_effort is not None: + assert self.CAPABILITIES is not None + supported = self.CAPABILITIES.reasoning_effort + if supported is None or self._default_reasoning_effort not in supported: + supported_list = sorted(supported) if supported else [] + raise ValidationError( + f"Default reasoning effort {self._default_reasoning_effort!r} is not supported " + f"by the OpenAI provider. Supported efforts: {supported_list}.", + suggestion=( + "Choose a supported reasoning effort level, or use the " + "Copilot or Claude provider for 'max'." + ), + ) model_settings["openai_reasoning_effort"] = self._default_reasoning_effort try: @@ -560,6 +575,22 @@ async def execute( ValidationError: If output doesn't match schema. """ del skill_directories, custom_agents, extra_mcp_servers + + effort = resolve_reasoning_effort(agent, self._default_reasoning_effort) + if effort is not None: + assert self.CAPABILITIES is not None + supported = self.CAPABILITIES.reasoning_effort + if supported is None or effort not in supported: + raise ValidationError( + f"Agent {agent.name!r} resolves to reasoning.effort={effort!r}, " + f"but the OpenAI provider supports only " + f"{sorted(supported) if supported else []}.", + suggestion=( + "Choose a supported reasoning effort level, or use the " + "Copilot or Claude provider for 'max'." + ), + ) + from conductor.providers._pydantic_ai.agent_builder import build_agent from conductor.providers._pydantic_ai.retry import RetryConfig as PydanticRetryConfig from conductor.providers._pydantic_ai.runner import run_agent_pipeline diff --git a/tests/test_providers/test_openai.py b/tests/test_providers/test_openai.py index e84247b0..70a61312 100644 --- a/tests/test_providers/test_openai.py +++ b/tests/test_providers/test_openai.py @@ -57,6 +57,28 @@ def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: with pytest.raises(ValidationError, match="OPENAI_API_KEY"): OpenAIProvider() + def test_base_url_env_fallback_used_when_yaml_missing( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: explicit env resolution (OPENAI_API_KEY, OPENAI_BASE_URL read in code, + never delegated to SDK ambient fallback). + + Verify OPENAI_BASE_URL environment variable is used as a fallback for base_url + when not explicitly provided in the YAML config, and that explicit parameters + still win over the environment variable. + """ + monkeypatch.setenv("OPENAI_BASE_URL", "http://env-fallback:1234/v1") + + p = OpenAIProvider(api_key="test-key") + assert p._base_url == "http://env-fallback:1234/v1" + assert p._client is not None + assert str(p._client.base_url) == "http://env-fallback:1234/v1/" + + p_explicit = OpenAIProvider(api_key="test-key", base_url="http://explicit-url:5678/v1") + assert p_explicit._base_url == "http://explicit-url:5678/v1" + assert p_explicit._client is not None + assert str(p_explicit._client.base_url) == "http://explicit-url:5678/v1/" + def test_temperature_validation_accepts_range(self) -> None: """OpenAI accepts temperatures up to 2.0.""" p = OpenAIProvider(api_key="test-key", temperature=2.0) @@ -191,6 +213,56 @@ class AnswerModel(BaseModel): assert output.content == {"answer": "hello"} + async def test_execute_rejects_max_reasoning_effort_at_runtime( + self, provider: OpenAIProvider, no_mcp_manager: Any + ) -> None: + """Requirement: reasoning.effort='max' raises a ValidationError at runtime. + + Verify that an agent with reasoning.effort set to 'max' is rejected at runtime. + """ + # Requirement: Verify 'max' effort raises ValidationError at runtime. + from conductor.config.schema import ReasoningConfig + + agent = AgentDef( + name="test_agent", + model="gpt-5", + prompt="hi", + reasoning=ReasoningConfig(effort="max"), + ) + with pytest.raises(ValidationError, match="resolves to reasoning.effort='max'"): + await provider.execute(agent, {}, "say hi") + + async def test_execute_accepts_supported_reasoning_effort( + self, provider: OpenAIProvider, no_mcp_manager: Any + ) -> None: + """Requirement: reasoning.effort='xhigh' is accepted at runtime. + + Verify that a supported effort level is allowed and passed down correctly. + """ + # Requirement: Verify supported reasoning effort levels are accepted. + from conductor.config.schema import ReasoningConfig + + agent = AgentDef( + name="test_agent", + model="gpt-5", + prompt="hi", + reasoning=ReasoningConfig(effort="xhigh"), + ) + captured_kwargs: dict[str, Any] = {} + + def spy_build_agent(*args: Any, **kwargs: Any) -> Agent[Any, Any]: + captured_kwargs.update(kwargs) + return _build_text_agent("hello") + + with patch( + "conductor.providers._pydantic_ai.agent_builder.build_agent", + side_effect=spy_build_agent, + ): + await provider.execute(agent, {}, "say hi") + + assert agent.reasoning is not None + assert agent.reasoning.effort == "xhigh" + class TestExecuteDialogTurn: """Tests for execute_dialog_turn().""" @@ -224,6 +296,19 @@ class FakeResult: assert kwargs.get("api_key") == "test-key" assert kwargs.get("timeout") == 600.0 + async def test_dialog_turn_rejects_unsupported_reasoning_effort(self) -> None: + """Requirement: execute_dialog_turn() rejects unsupported reasoning effort. + + Verify that a ValidationError is raised when default_reasoning_effort is set to + an unsupported value like 'max'. + """ + # Requirement: Rejects unsupported reasoning efforts during dialog turns. + provider = OpenAIProvider(api_key="test-key", default_reasoning_effort="max") + with pytest.raises( + ValidationError, match="Default reasoning effort 'max' is not supported" + ): + await provider.execute_dialog_turn("system prompt", "user message") + class TestConnectionHelpers: """Tests for validate_connection/list_models/get_model_capabilities.""" From c5cefb35e5f1cc82d8a831b8b08630dd54dfb75a Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Wed, 12 Aug 2026 18:04:11 +0300 Subject: [PATCH 12/14] fix(providers): complete Claude dialog agent limits --- src/conductor/providers/claude.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/conductor/providers/claude.py b/src/conductor/providers/claude.py index 2948dcf5..2299ed4e 100644 --- a/src/conductor/providers/claude.py +++ b/src/conductor/providers/claude.py @@ -826,6 +826,10 @@ async def execute_dialog_turn( name="dialog_agent", model=resolved_model, prompt="", + max_depth=None, + timeout_seconds=None, + max_session_seconds=None, + max_agent_iterations=None, ) pydantic_model = _resolve_anthropic_model( agent=dummy_agent, From a0aa16bc948b84c276314d3a9f85c86a28dd6d93 Mon Sep 17 00:00:00 2001 From: Genadij Blinov Date: Thu, 20 Aug 2026 02:52:45 +0300 Subject: [PATCH 13/14] fix(providers): address review findings on the OpenAI provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address every review comment on #421: - openai.py: never forward an ambient OPENAI_API_KEY to a custom base_url; the env-resolved key no longer overwrites self._api_key, so the agent_builder guard against custom endpoints without an explicit key is reachable in production, not just in direct unit tests. - openai.py: declare agent_reasoning_events=False — Chat Completions never returns reasoning content from api.openai.com; only DeepSeek/Moonshot-style proxies echo reasoning_content. - openai.py: narrow reasoning_effort to low/medium/high; xhigh arrived with the GPT-5.1-Codex-Max generation and the o-series models named in the docs (o1, o3-mini, o4-mini) 400 on it. - agent_builder.py: validate reasoning effort against the resolved model via pydantic-ai's model profile (shared helper _openai_model_supports_reasoning), mirroring the Anthropic thinking check, instead of trusting the provider-level tuple for every model. - openai.py: drop the invented default max_tokens=4096; an output cap on a reasoning model can be entirely consumed by reasoning tokens and surfaces as a misleading structured-output retry failure. - openai.py: validate_connection now mirrors ClaudeProvider's inconclusive-probe pattern (401/403/connection errors fail, other HTTP statuses warn and proceed with _connection_probe_note), and reports a mistyped default model against the fetched model list. - openai.py: get_model_capabilities no longer uses the startswith("o") heuristic (it misclassified the provider's own default gpt-5-mini and any "openai/..."-prefixed OpenRouter id); it consults the pydantic-ai model profile and returns None for unknowns. - schema/factory/registry: remove the never-implemented openai-agents provider name and unify the drifted provider-name Literals behind a single ProviderName alias in config/schema.py. - Temperature ceilings are now capability-driven: ProviderCapabilities gains max_temperature (1.0 for copilot/claude/hermes/aca/ claude-agent-sdk), the static validator reads it instead of hardcoding provider names, and create_provider enforces it so run/resume are covered too, restoring the 1.1-for-claude rejection test. - diagnostics.py: drop OPENAI_BASE_URL from openai's credential spec — an unset base URL must not render as a missing credential. - pyproject.toml: widen the openai pin to >=2.48.0,<3.0.0. - docs/providers/openai.md: remove the unreachable "install the SDK" instructions and troubleshooting entry, correct the reasoning-effort matrix (xhigh rejected), and renumber Quick Start. - CHANGELOG: note the pydantic-ai -> pydantic-ai-slim dependency swap, the openai-agents removal, and the temperature enforcement move. - AGENTS.md: restore the Run/Resume Parity tail truncated in a rebase, and document the new temperature capability. - tests: new real-API integration test (tests/test_integration/ test_openai_real_api.py, pytest.mark.real_api) exercised against an OpenAI-compatible endpoint; satisfies the stable-tier real-API bar. Co-Authored-By: Sisyphus (OhMyOpenCode) --- AGENTS.md | 2 +- CHANGELOG.md | 9 +- docs/cli-reference.md | 19 +- docs/providers/experimental.md | 1 + docs/providers/openai.md | 35 +-- .../skills/conductor/references/authoring.md | 2 +- .../skills/conductor/references/execution.md | 6 +- .../conductor/references/yaml-schema.md | 4 +- pyproject.toml | 2 +- src/conductor/config/schema.py | 14 +- src/conductor/config/validator.py | 21 +- .../providers/_pydantic_ai/agent_builder.py | 35 ++- src/conductor/providers/_pydantic_ai/retry.py | 12 +- src/conductor/providers/aca.py | 5 +- src/conductor/providers/capabilities.py | 15 +- src/conductor/providers/claude.py | 1 + src/conductor/providers/claude_agent_sdk.py | 1 + src/conductor/providers/copilot.py | 1 + src/conductor/providers/diagnostics.py | 21 +- src/conductor/providers/factory.py | 50 ++-- src/conductor/providers/hermes.py | 1 + src/conductor/providers/openai.py | 174 +++++++++-- src/conductor/providers/registry.py | 13 +- .../test_backward_compatibility.py | 3 +- tests/test_config/test_loader.py | 4 +- tests/test_config/test_provider_settings.py | 8 +- tests/test_config/test_schema.py | 9 +- .../test_validator_capabilities.py | 47 ++- tests/test_fleet/test_tui_drilldown.py | 2 +- .../test_integration/test_openai_real_api.py | 140 +++++++++ tests/test_providers/test_diagnostics.py | 6 +- tests/test_providers/test_factory.py | 23 +- tests/test_providers/test_openai.py | 283 ++++++++++++++++-- .../test_pydantic_ai_agent_builder.py | 74 ++++- uv.lock | 76 ++++- 35 files changed, 906 insertions(+), 213 deletions(-) create mode 100644 tests/test_integration/test_openai_real_api.py diff --git a/AGENTS.md b/AGENTS.md index ecea2390..87c26424 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -649,7 +649,7 @@ Implementation parity rules: - The async helpers (`run_workflow_async` and `resume_workflow_async` in `cli/run.py`) must wire up the same event emitter, JSONL event log subscriber, console event subscriber, and `WebDashboard` lifecycle. - The `WorkflowEngine` constructor receives the same kwargs in both paths (`event_emitter`, `web_dashboard`, `run_context`, `interrupt_event`, `keyboard_listener`, `instructions_preamble`). - Background-process forking lives in `cli/bg_runner.py`. `run --web-bg` calls `launch_background()` and `resume --web-bg` calls `launch_background_resume()`. Both must forward equivalent options and write a PID file via `cli/pid.py`. -- Note: on resume, the dashboard is seeded with prior events before it starts accepting clients. The CLI prepends a fresh `workflow_started` event built from the **current** workflow YAML (via `WorkflowEngine.build_workflow_started_data()`) so historical events apply to the correct topology; it then either replays the original JSONL event log (`WebDashboard.replay_events_from_jsonl()` — when the checkpoint records an `event_log_path` and the file exists) or synthesises minimal `*_started` / `*_completed` pairs from the restored `WorkflowContext` (`replay_synthetic_from_context()`). The resumed engine's own `workflow_started` emit is suppressed via `engine.suppress_workflow_started_emit()` so the dashboard sees exactly one root `workflow_started` (no `wfDepth` double-count). Two disjoint sets of events are filtered on replay. `_REPLAY_ROOT_SKIP_TYPES` (`workflow_started` / `workflow_completed` / `workflow_failed` / `checkpoint_saved` / `checkpoint_save_failed`) is filtered **only at root depth** — subworkflow-level lifecycle events are preserved so frontend `wfDepth` stays balanced. `_REPLAY_INTERACTIVE_SKIP_TYPES` (`agent_paused` / `agent_resumed` / `iteration_limit_reached` / `iteration_limit_resolved` / `dialog_started` / `dialog_completed`) is filtered at **every depth**, because the control channel is the root dashboard's `resume_event` / `kill_event` / gate id no matter which engine emitted the event. Each of those sets a *global* store latch (`isPaused`, `iterationLimitGate`, `activeDialog`) that only its counterpart event can clear — plus, for `isPaused`/`iterationLimitGate` only, a root terminal event, which the first set filters — so replaying the opening half of an unresolved pair latches it on for the whole resumed run. Concretely, a run stopped then killed from the dashboard logs `agent_paused` with no `agent_resumed`, so replaying it renders Resume/Kill instead of Stop on the live resumed run, hiding the only graceful stop behind a Kill that hard-stops a healthy workflow. +- Note: on resume, the dashboard is seeded with prior events before it starts accepting clients. The CLI prepends a fresh `workflow_started` event built from the **current** workflow YAML (via `WorkflowEngine.build_workflow_started_data()`) so historical events apply to the correct topology; it then either replays the original JSONL event log (`WebDashboard.replay_events_from_jsonl()` — when the checkpoint records an `event_log_path` and the file exists) or synthesises minimal `*_started` / `*_completed` pairs from the restored `WorkflowContext` (`replay_synthetic_from_context()`). The resumed engine's own `workflow_started` emit is suppressed via `engine.suppress_workflow_started_emit()` so the dashboard sees exactly one root `workflow_started` (no `wfDepth` double-count). Two disjoint sets of events are filtered on replay. `_REPLAY_ROOT_SKIP_TYPES` (`workflow_started` / `workflow_completed` / `workflow_failed` / `checkpoint_saved` / `checkpoint_save_failed`) is filtered **only at root depth** — subworkflow-level lifecycle events are preserved so frontend `wfDepth` stays balanced. `_REPLAY_INTERACTIVE_SKIP_TYPES` (`agent_paused` / `agent_resumed` / `iteration_limit_reached` / `iteration_limit_resolved` / `dialog_started` / `dialog_completed`) is filtered at **every depth**, because the control channel is the root dashboard's `resume_event` / `kill_event` / gate id no matter which engine emitted the event. Each of those sets a *global* store latch (`isPaused`, `iterationLimitGate`, `activeDialog`) that only its counterpart event can clear — plus, for `isPaused`/`iterationLimitGate` only, a root terminal event, which the first set filters — so replaying the opening half of an unresolved pair latches it on for the whole resumed run. Concretely, a run stopped then killed from the dashboard logs `agent_paused` with no `agent_resumed`, so replaying it renders Resume/Kill instead of Stop on the live resumed run, hiding the only graceful stop behind a Kill that hard-stops a healthy workflow. A pause or gate the resumed run genuinely re-enters emits its own fresh event; `dialog_message` is left unfiltered only because it is *inert* once `dialog_started` is filtered — both renderers of `node.dialog_messages` (`DialogEngagementPrompt` via `DetailPanel`, `DialogDetail` via `DialogOverlay`) gate on `dialog_active`/`activeDialog`, which only `dialog_started` sets, so replayed dialog transcripts are **not** visible on a resumed dashboard. `components/layout/Header.tsx` additionally hides all live-control buttons when `replayMode` is set, since `ReplayDashboard` serves no `/api/stop`|`/api/resume`|`/api/kill`; `replayMode` is latched in `hooks/use-replay.ts` on mount (that hook only mounts once `App`'s `/api/replay/info` probe confirmed replay) rather than when `/api/state` resolves, so a slow or failed event fetch cannot leave the live controls rendered. The resumed `EventLogSubscriber` opens the original JSONL in append mode (when available) so a multi-resume session produces one continuous log file and `run_id` stays stable for log-correlation tools. ## Pydantic AI Runner Extraction diff --git a/CHANGELOG.md b/CHANGELOG.md index d1edc31b..ba4ee677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -560,7 +560,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Chat Completions endpoint (Ollama, vLLM, LM Studio, OpenRouter, corporate proxies). Supports MCP tools, structured output, interrupts, reasoning effort (`low`/`medium`/`high`/`xhigh`), and full temperature range `0.0`–`2.0`. - See `docs/providers/openai.md` and `examples/openai-compatible.yaml`. + The Pydantic AI dependency was narrowed from the full `pydantic-ai` package + to `pydantic-ai-slim[anthropic,openai]`; this drops the bundled + `pydantic_ai.mcp` module, which Conductor replaces with its own toolset + bridge, so the change is transparent to users. + The previously reserved `openai-agents` provider name has been removed from + the schema and factory; workflows that named it now fail at schema load time + rather than at the first agent execution. See `docs/providers/openai.md` and + `examples/openai-compatible.yaml`. - **Mid-run guidance for `--web` and `--web-bg` runs** (#400). The dashboard previously offered only Stop, Resume, and Kill — there was no way to correct a run's course without stopping it first. `conductor guide --text diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a849e29c..e578a18a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -903,11 +903,10 @@ in the `env` section (cache-first, short timeout, silent, and skipped when - **env** — Conductor version, Python version, OS/platform, and update availability. - **providers** — for each known provider (`copilot`, `claude`, - `claude-agent-sdk`, `hermes`, `openai-agents`): whether the SDK is - installed, the capability tier (`stable` / `experimental`), which - credential environment variables are **present** (presence only — values - are never printed), and — with `--check` / `--models` — connection status - and a model count. `openai-agents` is surfaced as "not yet implemented". + `claude-agent-sdk`, `hermes`, `openai`): whether the SDK is installed, + the capability tier (`stable` / `experimental`), which credential environment + variables are **present** (presence only — values are never printed), and + — with `--check` / `--models` — connection status and a model count. - **registries** — configured workflow registries and which is the default (see [`conductor registry`](#conductor-registry)). @@ -940,10 +939,10 @@ Coverage varies by provider — every field degrades independently to `n/a` / none) and reports only `Prompt` (via the Anthropic API's `max_input_tokens`) — `Output` and `Context` are always `—` and `Default` is always `—` (Anthropic has no per-model default-effort concept). -- **`claude-agent-sdk`**, **`hermes`**, and **`openai-agents`** don't - implement model enumeration (`list_models`) at all, so `--models` shows - `n/a` for them in the Providers table and they get **no** Models detail - table — there is nothing to detail. +- **`claude-agent-sdk`** and **`hermes`** don't implement model + enumeration (`list_models`) at all, so `--models` shows `n/a` for them in + the Providers table and they get **no** Models detail table — there is + nothing to detail. In `--json`, each provider's `models` field is a list of objects (not plain id strings): @@ -973,7 +972,7 @@ values are never read or printed. Detected variables per provider: | `claude` | `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` | required (direct Anthropic API) | | `claude-agent-sdk` | `ANTHROPIC_API_KEY` | optional override — authenticates via `claude login` | | `hermes` | *(none — endpoint / API key are passed explicitly)* | — | -| `openai-agents` | *(none — not yet implemented)* | — | +| `openai` | `OPENAI_API_KEY` | required (direct OpenAI API) | For **`copilot`** and **`claude-agent-sdk`**, these env vars are *optional overrides*: both providers authenticate primarily via an on-disk CLI login diff --git a/docs/providers/experimental.md b/docs/providers/experimental.md index 76e8fbb0..5a93482a 100644 --- a/docs/providers/experimental.md +++ b/docs/providers/experimental.md @@ -52,6 +52,7 @@ validator so the operator can plan accordingly. | `checkpoint_resume` | Provider session state does not survive `conductor resume` (re-runs the agent from scratch). | | `working_dir` | Provider does not apply the resolved working directory to its session/subprocess cwd. Workflows that set `working_dir` against this provider fail validation. | | `session_continuity` | Provider does not honor a per-agent `session_key`; every execution starts a fresh session. Agents that set `session_key` fail validation. | +| `max_temperature` | Provider caps the effective temperature below the schema's `0..2` range. `runtime.temperature` or a per-agent `temperature` above the cap fails validation. | ## Non-negotiable rules diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 051bdf9a..f40ebc9c 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -16,23 +16,13 @@ The OpenAI provider enables Conductor workflows to execute agents using OpenAI's ## Quick Start -### 1. Install the OpenAI SDK - -```bash -# Using uv (recommended) -uv add 'openai>=1.0.0' - -# Using pip -pip install 'openai>=1.0.0' -``` - -### 2. Set up your API key +### 1. Set up your API key ```bash export OPENAI_API_KEY=sk-... ``` -### 3. Update your workflow +### 2. Update your workflow ```yaml workflow: @@ -53,7 +43,7 @@ agents: - to: $end ``` -### 4. Run your workflow +### 3. Run your workflow ```bash conductor run my-openai-workflow.yaml --input question="What is Python?" @@ -207,12 +197,14 @@ OpenAI reasoning models (such as `o1`, `o3-mini`) support the `reasoning.effort` | Effort Level | Supported by OpenAI | Note | |--------------|----------------------|------| -| `low` | Yes | Fast reasoning | -| `medium` | Yes | Balanced reasoning | -| `high` | Yes | Deep reasoning | -| `xhigh` | Yes | Extra high reasoning | +| `low` | Yes (reasoning models) | Fast reasoning | +| `medium` | Yes (reasoning models) | Balanced reasoning | +| `high` | Yes (reasoning models) | Deep reasoning | +| `xhigh` | **No (Rejected)** | Only `low`/`medium`/`high` are accepted by the GPT-5.1-Codex-Max generation and by `o1`/`o3-mini`/`o4-mini`; `xhigh` is rejected. | | `max` | **No (Rejected)** | Raises `ValidationError` | +Only reasoning models accept `reasoning.effort`; on a non-reasoning model such as `gpt-4o` the setting is validated against the model and also raises `ValidationError`. + ```yaml workflow: runtime: @@ -242,15 +234,6 @@ HTTP and SSE MCP server types are not supported by the OpenAI provider (`stdio` ## Troubleshooting -### Missing OpenAI SDK - -If `openai` package is not installed: - -```text -ProviderError: OpenAI SDK not installed -Suggestion: Install with: uv add 'openai>=1.0.0' -``` - ### Missing API Key If no API key is found in environment or YAML: diff --git a/plugins/conductor/skills/conductor/references/authoring.md b/plugins/conductor/skills/conductor/references/authoring.md index df18d2c2..e866b89d 100644 --- a/plugins/conductor/skills/conductor/references/authoring.md +++ b/plugins/conductor/skills/conductor/references/authoring.md @@ -12,7 +12,7 @@ workflow: entry_point: first_agent # Required: starting agent, parallel group, or for-each group runtime: - provider: copilot # copilot (default), claude, claude-agent-sdk, hermes (experimental), or openai-agents + provider: copilot # copilot (default), openai, claude, claude-agent-sdk, hermes (experimental) default_model: gpt-5.2 # Default model for agents temperature: 0.7 # 0.0-1.0 (optional) max_tokens: 4096 # Max output tokens per response (optional) diff --git a/plugins/conductor/skills/conductor/references/execution.md b/plugins/conductor/skills/conductor/references/execution.md index a9044ef3..5902ab92 100644 --- a/plugins/conductor/skills/conductor/references/execution.md +++ b/plugins/conductor/skills/conductor/references/execution.md @@ -17,7 +17,7 @@ conductor run [OPTIONS] | `--input`, `-i NAME=VALUE` | Workflow input (repeatable) | | `--input.NAME=VALUE` | Alternative input syntax | | `--metadata`, `-m KEY=VALUE` | Workflow metadata, merged on top of YAML `metadata:` (repeatable; values stay strings) | -| `--provider`, `-p PROVIDER` | Override provider (`copilot`, `claude`, `claude-agent-sdk`, `hermes`, `openai-agents`) | +| `--provider`, `-p PROVIDER` | Override provider (`copilot`, `openai`, `claude`, `claude-agent-sdk`, `hermes`) | | `--dry-run` | Show execution plan only | | `--skip-gates` | Auto-select first option at human gates | | `--web` | Start real-time web dashboard | @@ -623,7 +623,7 @@ If the workflow file has changed since the checkpoint was saved, a warning is di conductor run workflow.yaml -p claude # Use Claude for all agents conductor run workflow.yaml -p copilot # Use Copilot (default) conductor run workflow.yaml -p hermes # Use Hermes (NousResearch agent SDK) -conductor run workflow.yaml -p openai-agents # Use OpenAI Agents SDK +conductor run workflow.yaml -p openai # Use OpenAI provider ``` ### Per-Agent Provider Override @@ -667,7 +667,7 @@ conductor run workflow.yaml --input q="test" | jq '.answer' |----------|-------------| | `GITHUB_TOKEN` | GitHub Copilot authentication | | `ANTHROPIC_API_KEY` | Claude provider API key | -| `OPENAI_API_KEY` | OpenAI Agents provider API key (when `provider: openai-agents`) | +| `OPENAI_API_KEY` | OpenAI provider API key (when `provider: openai`) | | `CONDUCTOR_LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | | `CONDUCTOR_NO_UPDATE_CHECK` | Set to `1` to suppress the passive update-check hint | diff --git a/plugins/conductor/skills/conductor/references/yaml-schema.md b/plugins/conductor/skills/conductor/references/yaml-schema.md index 55865be6..31b43505 100644 --- a/plugins/conductor/skills/conductor/references/yaml-schema.md +++ b/plugins/conductor/skills/conductor/references/yaml-schema.md @@ -27,7 +27,7 @@ workflow: # Runtime configuration runtime: - provider: string | object # "copilot" (default), "claude", "claude-agent-sdk", "hermes", or "openai-agents" + provider: string | object # "copilot" (default), "claude", "claude-agent-sdk", "hermes", or "openai" # — or a ProviderSettings object (see below) default_model: string # Default model for all agents temperature: float # 0.0-1.0, controls randomness (optional, copilot/claude/hermes) @@ -811,7 +811,7 @@ endpoints (Ollama, vLLM, LM Studio, Azure OpenAI, etc.). ```yaml runtime: provider: - name: string # "copilot" (default), "claude", "hermes", "openai-agents" + name: string # "copilot" (default), "openai", "claude", "claude-agent-sdk", "hermes" type: string # "openai" | "azure" | "anthropic" (Copilot-only) wire_api: string # "completions" | "responses" (Copilot-only) base_url: string # Endpoint base URL (copilot + hermes) diff --git a/pyproject.toml b/pyproject.toml index 85e42ce3..c31955d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "github-copilot-sdk>=1.0.9", "anthropic>=0.77.0,<1.0.0", "pydantic-ai-slim[anthropic,openai]>=1.44.0", - "openai>=2.48.0,<2.49.0", + "openai>=2.48.0,<3.0.0", "mcp>=1.28.1", "fastapi>=0.115.0", "uvicorn>=0.30.0", diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index 9ded2fd5..c9a8a891 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -1136,6 +1136,14 @@ def _validate_skill_entries(entries: list[str]) -> list[str]: return entries +ProviderName = Literal["copilot", "openai", "claude", "claude-agent-sdk", "hermes", "aca"] +"""Canonical set of supported agent provider names. + +Used by :attr:`AgentDef.provider` and :attr:`ProviderSettings.name` so the +schema, factory, and registry cannot drift out of sync. +""" + + class AgentDef(BaseModel): """Definition for a single agent in the workflow. @@ -1191,7 +1199,7 @@ class AgentDef(BaseModel): ) = None """Agent type. Defaults to 'agent' if not specified.""" - provider: Literal["copilot", "openai", "claude", "claude-agent-sdk", "hermes"] | None = None + provider: ProviderName | None = None """Provider override for this agent. If None (default), the agent uses the workflow.runtime.provider. @@ -2602,9 +2610,7 @@ class ProviderSettings(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - name: Literal[ - "copilot", "openai", "openai-agents", "claude", "claude-agent-sdk", "hermes", "aca" - ] = "copilot" + name: ProviderName = "copilot" """SDK provider to use for agent execution.""" type: Literal["openai", "azure", "anthropic"] | None = None diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index 7dc05cfb..9143fd29 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -2525,21 +2525,24 @@ def _check_agent_capabilities( f"or remove the workflow-level skills." ) - if runtime_temperature is not None and runtime_temperature > 1.0: - providers_over_one: dict[str, list[str]] = {} + if runtime_temperature is not None: + providers_over_ceiling: dict[str, list[str]] = {} for agent in all_llm_agents: pname = _resolved_provider_name(agent, default_provider) - if pname == "openai": + pcaps = _caps_for(pname) + if pcaps is None or pcaps.max_temperature is None: continue - providers_over_one.setdefault(pname, []).append(agent.name) - for pname, agent_names in providers_over_one.items(): + if runtime_temperature > pcaps.max_temperature: + providers_over_ceiling.setdefault(pname, []).append(agent.name) + for pname, agent_names in providers_over_ceiling.items(): + pcaps = _caps_for(pname) + ceiling = pcaps.max_temperature if pcaps is not None else 1.0 errors.append( f"Workflow declares 'runtime.temperature'={runtime_temperature!r} " - f"but provider '{pname}' only supports temperatures up to 1.0 " + f"but provider '{pname}' only supports temperatures up to {ceiling!r} " f"and is used by agent(s): {sorted(agent_names)!r}. " - f"Override these agents to provider 'openai', lower the " - f"temperature to 1.0 or below, or remove the workflow-level " - f"temperature." + f"Override these agents to a provider with a higher temperature ceiling, " + f"lower the temperature, or remove the workflow-level temperature." ) # ----- Per-agent checks ----- diff --git a/src/conductor/providers/_pydantic_ai/agent_builder.py b/src/conductor/providers/_pydantic_ai/agent_builder.py index fde0fcbb..d8c348fe 100644 --- a/src/conductor/providers/_pydantic_ai/agent_builder.py +++ b/src/conductor/providers/_pydantic_ai/agent_builder.py @@ -335,18 +335,37 @@ def _coerce_for_thinking( return 1.0, effective_max_tokens +def _openai_model_supports_reasoning(model_name: str) -> bool | None: + """Return whether ``model_name`` supports OpenAI ``reasoning_effort``. + + Uses pydantic-ai's model profile when available. Returns ``None`` when the + profile attribute is missing (older pydantic-ai), so callers can skip the + check rather than guess. + """ + from pydantic_ai.profiles.openai import openai_model_profile + + try: + profile = openai_model_profile(model_name) + except Exception: # noqa: BLE001 - profile lookup is a best-effort capability probe + return None + return getattr(profile, "openai_supports_reasoning", None) + + def _build_openai_model_settings( agent: AgentDef, default_temperature: float | None, default_max_tokens: int | None, default_reasoning_effort: ReasoningEffort | None, + default_model: str | None = None, timeout: float | None = None, ) -> OpenAIChatModelSettings: """Build ``OpenAIChatModelSettings`` from the agent and runtime defaults. OpenAI reasoning effort is forwarded directly as the ``openai_reasoning_effort`` field; unlike Anthropic extended thinking it - does not require coercion of temperature or max_tokens. + does not require coercion of temperature or max_tokens. When reasoning is + requested, the model is checked via pydantic-ai's profile so a non-reasoning + model fails fast instead of producing a 400 at runtime. """ effort = resolve_reasoning_effort(agent, default_reasoning_effort) @@ -355,6 +374,19 @@ def _build_openai_model_settings( agent_max_tokens = getattr(agent, "max_tokens", None) max_tokens = agent_max_tokens if agent_max_tokens is not None else default_max_tokens + if effort is not None: + model_name = (agent.model or default_model) or DEFAULT_OPENAI_MODEL + supports_reasoning = _openai_model_supports_reasoning(model_name) + if supports_reasoning is False: + raise ValidationError( + f"Model {model_name!r} does not support reasoning.effort, but " + f"reasoning.effort={effort!r} was requested for agent {agent.name!r}.", + suggestion=( + "Use a reasoning-capable model (e.g. o-series, gpt-5-mini) or " + "remove the reasoning config." + ), + ) + settings: OpenAIChatModelSettings = OpenAIChatModelSettings() if temperature is not None: settings["temperature"] = temperature @@ -488,6 +520,7 @@ def build_agent( default_temperature, default_max_tokens, default_reasoning_effort, + default_model=default_model, timeout=timeout, ) else: diff --git a/src/conductor/providers/_pydantic_ai/retry.py b/src/conductor/providers/_pydantic_ai/retry.py index 17404c50..5b176098 100644 --- a/src/conductor/providers/_pydantic_ai/retry.py +++ b/src/conductor/providers/_pydantic_ai/retry.py @@ -40,6 +40,11 @@ except ImportError: anthropic = None # type: ignore[assignment] +try: + import openai +except ImportError: + openai = None # type: ignore[assignment] + from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError from conductor.config.schema import RetryPolicy @@ -153,7 +158,7 @@ def _is_retryable_error(exception: Exception) -> bool: # translation. Issue #454. if isinstance(exception, ModelHTTPError): code = exception.status_code - return code == 429 or 500 <= code < 600 + return code == 429 or code == 408 or 500 <= code < 600 # A bare ModelAPIError is what APIConnectionError/APITimeoutError become — # a transport failure, retryable for the same reason the SDK names below # are. Checked with `type(...) is ...` rather than isinstance: ModelHTTPError @@ -184,6 +189,11 @@ def _is_retryable_error(exception: Exception) -> bool: is_api_status = isinstance(exception, anthropic.APIStatusError) except TypeError: is_api_status = error_type_name in ("APIStatusError", "MockAPIStatusError") + + if not is_api_status and openai is not None: + with contextlib.suppress(TypeError, AttributeError): + is_api_status = isinstance(exception, openai.APIStatusError) + if not is_api_status: is_api_status = error_type_name in ("APIStatusError", "MockAPIStatusError") diff --git a/src/conductor/providers/aca.py b/src/conductor/providers/aca.py index cb35bb05..9bcdccdb 100644 --- a/src/conductor/providers/aca.py +++ b/src/conductor/providers/aca.py @@ -265,9 +265,10 @@ class AcaRuntimeProvider(AgentProvider): # `aca`-backed agent rather than silently dropping it. skills=False, # No plugin surface for the same reason: subagent definitions and - # MCP declarations live in host-filesystem directories the - # in-sandbox runner cannot read. + # MCP declarations live in host-filesystem directories the in-sandbox + # runner cannot read. plugins=False, + max_temperature=1.0, upstream_pin="azure-identity>=1.19.0", maintainer=None, ) diff --git a/src/conductor/providers/capabilities.py b/src/conductor/providers/capabilities.py index 94db26ec..fe87734b 100644 --- a/src/conductor/providers/capabilities.py +++ b/src/conductor/providers/capabilities.py @@ -190,12 +190,15 @@ class ProviderCapabilities(BaseModel): to ``False``.""" session_continuity: bool = False - """``True`` when the provider honors an agent's ``session_key``, reusing - one provider session across every execution tagged with that key. + """``True`` when the provider supports per-agent ``session_key``.""" - Agents that set ``session_key:`` against a provider with - ``session_continuity=False`` fail validation, rather than silently losing - the context the author asked to keep. Defaults to ``False``.""" + max_temperature: float | None = None + """Highest temperature the provider accepts. + + ``None`` means no provider-specific cap (the schema bound ``0..2`` + applies). The validator enforces this statically; ``create_provider`` + enforces it at construction so ``run`` / ``resume`` are covered too. + """ upstream_pin: str | None = None """Upstream package pin surfaced in the experimental banner, e.g. @@ -296,7 +299,7 @@ def declared_limitations(self) -> list[str]: # the validator does NOT pre-empt the factory's "not yet implemented" # error — the workflow author should see one clear failure at run time, # not a misleading "no capabilities declared" error at validate time. -_NOT_YET_IMPLEMENTED_PROVIDERS: Final[frozenset[str]] = frozenset({"openai-agents"}) +_NOT_YET_IMPLEMENTED_PROVIDERS: Final[frozenset[str]] = frozenset() def _build_unimplemented_placeholder() -> ProviderCapabilities: diff --git a/src/conductor/providers/claude.py b/src/conductor/providers/claude.py index 2299ed4e..8404dc48 100644 --- a/src/conductor/providers/claude.py +++ b/src/conductor/providers/claude.py @@ -152,6 +152,7 @@ class ClaudeProvider(AgentProvider): # dispatch to, and a plugin that loaded only its skills would be # exactly the partial load ``plugins:`` exists to prevent. plugins=False, + max_temperature=1.0, upstream_pin=None, maintainer="@microsoft/conductor", ) diff --git a/src/conductor/providers/claude_agent_sdk.py b/src/conductor/providers/claude_agent_sdk.py index fa6c4315..bbf7452d 100644 --- a/src/conductor/providers/claude_agent_sdk.py +++ b/src/conductor/providers/claude_agent_sdk.py @@ -648,6 +648,7 @@ class ClaudeAgentSdkProvider(AgentProvider): # ``session_key`` is honored: executions sharing a key resume one # Claude session, and the map is persisted across ``conductor resume``. session_continuity=True, + max_temperature=1.0, upstream_pin="claude-agent-sdk>=0.2.82", maintainer="@lesandiz (best-effort)", ) diff --git a/src/conductor/providers/copilot.py b/src/conductor/providers/copilot.py index 82f2f34e..ca9fbbd4 100644 --- a/src/conductor/providers/copilot.py +++ b/src/conductor/providers/copilot.py @@ -271,6 +271,7 @@ class CopilotProvider(AgentProvider): # ``mcp_servers`` for MCP. The SDK's ``plugin_directories`` is # deliberately unused — see conductor.plugins for why. plugins=True, + max_temperature=1.0, upstream_pin=None, maintainer="@microsoft/conductor", ) diff --git a/src/conductor/providers/diagnostics.py b/src/conductor/providers/diagnostics.py index f3d2ac28..f3a88fbe 100644 --- a/src/conductor/providers/diagnostics.py +++ b/src/conductor/providers/diagnostics.py @@ -52,7 +52,9 @@ # Provider names that are known to the schema/factory but not yet implemented. # Surfaced as an informational note, not an error. -_NOT_IMPLEMENTED: frozenset[str] = frozenset({"openai-agents"}) +_NOT_IMPLEMENTED: frozenset[str] = frozenset() + +_REMOVED_PROVIDER_NAMES: frozenset[str] = frozenset({"openai-agents"}) # One-shot latch so a systemically-raising get_model_pricing hook logs once # per process rather than once per model (mirrors @@ -107,9 +109,8 @@ def optional(self) -> bool: "authenticates via `claude login`; ANTHROPIC_API_KEY is an optional override" ), ), - "openai": _CredentialSpec(env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL")), + "openai": _CredentialSpec(env_vars=("OPENAI_API_KEY",)), "hermes": _CredentialSpec(), - "openai-agents": _CredentialSpec(), } # Update-check opt-out env var (mirrors cli/update.py so diagnostics does not @@ -572,10 +573,17 @@ async def gather_provider( Returns: A fully-populated :class:`ProviderDiagnostic`. """ - implemented = name not in _NOT_IMPLEMENTED + implemented = name not in _NOT_IMPLEMENTED and name not in _REMOVED_PROVIDER_NAMES installed = _sdk_available(name) if implemented else False spec = _CREDENTIAL_SPECS.get(name, _CredentialSpec()) + if name in _REMOVED_PROVIDER_NAMES: + note = "removed" + elif not implemented: + note = "not yet implemented" + else: + note = spec.optional_auth_note + diag = ProviderDiagnostic( name=name, installed=installed, @@ -583,10 +591,7 @@ async def gather_provider( tier=_provider_tier(name), credential_env_vars=_credential_env_vars(spec), credentials_optional=spec.optional, - # "not yet implemented" always wins over a provider's own credential - # note — there is nothing useful to say about a provider's auth path - # when it can't be instantiated at all. - note="not yet implemented" if not implemented else spec.optional_auth_note, + note=note, ) do_check = check or list_models diff --git a/src/conductor/providers/factory.py b/src/conductor/providers/factory.py index 994e6d3e..e82f2549 100644 --- a/src/conductor/providers/factory.py +++ b/src/conductor/providers/factory.py @@ -6,13 +6,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any -from conductor.config.schema import ToolOutputConfig -from conductor.exceptions import ProviderError +from conductor.config.schema import ProviderName, ToolOutputConfig +from conductor.exceptions import ProviderError, ValidationError from conductor.install_hint import install_command from conductor.providers.aca import AZURE_IDENTITY_AVAILABLE, AcaRuntimeProvider from conductor.providers.base import AgentProvider +from conductor.providers.capabilities import get_capabilities from conductor.providers.claude import ANTHROPIC_SDK_AVAILABLE, ClaudeProvider from conductor.providers.claude_agent_sdk import ( CLAUDE_AGENT_SDK_AVAILABLE, @@ -28,15 +29,24 @@ from conductor.config.schema import ProviderSettings -ProviderType = Literal[ - "copilot", - "openai", - "openai-agents", - "claude", - "claude-agent-sdk", - "hermes", - "aca", -] +ProviderType = ProviderName + + +def _enforce_temperature(provider_type: str, temperature: float | None) -> None: + """Raise if ``temperature`` exceeds the provider's declared ceiling.""" + if temperature is None: + return + try: + caps = get_capabilities(provider_type) + except (KeyError, AttributeError): + return + if caps.max_temperature is not None and temperature > caps.max_temperature: + raise ValidationError( + f"Provider {provider_type!r} only supports temperatures up to " + f"{caps.max_temperature}; received {temperature!r}. " + "Lower the temperature or use a provider that accepts higher values.", + suggestion="Set `runtime.temperature` to a value within the provider's range.", + ) async def create_provider( @@ -98,6 +108,8 @@ async def create_provider( >>> # Use provider for agent execution >>> await provider.close() """ + _enforce_temperature(provider_type, temperature) + match provider_type: case "copilot": idle_recovery_config = None @@ -141,11 +153,7 @@ async def create_provider( default_reasoning_effort=default_reasoning_effort, tool_output=tool_output, ) - case "openai-agents": - raise ProviderError( - "OpenAI Agents provider not yet implemented", - suggestion="Use 'copilot' provider for now", - ) + case "claude": if not ANTHROPIC_SDK_AVAILABLE: raise ProviderError( @@ -266,8 +274,7 @@ async def create_provider( raise ProviderError( f"Unknown provider: {provider_type}", suggestion=( - "Valid providers are: copilot, openai, openai-agents, claude, " - "claude-agent-sdk, hermes, aca" + "Valid providers are: copilot, openai, claude, claude-agent-sdk, hermes, aca" ), ) @@ -309,9 +316,10 @@ async def create_provider( ProviderError: If provider creation or validation fails. """ provider_settings = getattr(runtime_config, "provider", None) + provider_type: ProviderType # Support both the new ProviderSettings object and any legacy # string-typed mock that test code might still pass in. - if hasattr(provider_settings, "name"): + if provider_settings is not None and hasattr(provider_settings, "name"): provider_type = provider_settings.name elif isinstance(provider_settings, str): provider_type = provider_settings @@ -330,6 +338,8 @@ async def create_provider( default_context_tier = getattr(runtime_config, "default_context_tier", None) tool_output = getattr(runtime_config, "tool_output", None) + _enforce_temperature(provider_type, temperature) + return await create_provider( provider_type=provider_type, validate=validate, diff --git a/src/conductor/providers/hermes.py b/src/conductor/providers/hermes.py index e3eb1765..68c34b91 100644 --- a/src/conductor/providers/hermes.py +++ b/src/conductor/providers/hermes.py @@ -116,6 +116,7 @@ class HermesProvider(AgentProvider): # Hermes runs its own internal toolsets (mcp_tools=False); a # per-agent working directory has no meaning for the session. working_dir=False, + max_temperature=1.0, upstream_pin="hermes-agent", maintainer="(community contribution)", ) diff --git a/src/conductor/providers/openai.py b/src/conductor/providers/openai.py index 1885e33d..9e1a20ee 100644 --- a/src/conductor/providers/openai.py +++ b/src/conductor/providers/openai.py @@ -57,7 +57,7 @@ def _import_openai_sdk() -> tuple[bool, Any]: logger = logging.getLogger(__name__) -_OPENAI_REASONING_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "xhigh") +_OPENAI_REASONING_EFFORTS: tuple[str, ...] = ("low", "medium", "high") class RetryConfig(BaseModel): @@ -106,11 +106,14 @@ class OpenAIProvider(AgentProvider): # Per-agent ``tools:`` allowlists are passed through to the Pydantic AI agent. workflow_tools_passthrough=True, streaming_events=True, - # Reasoning content is surfaced as ``agent_reasoning`` events when the model - # returns it. - agent_reasoning_events=True, - # OpenAI supports reasoning_effort on its reasoning models (o-series, etc.). - reasoning_effort=("low", "medium", "high", "xhigh"), + # Chat Completions never returns reasoning content from api.openai.com; only + # third-party proxies echoing ``reasoning_content`` would surface it. This + # becomes ``True`` only on an OpenAIResponsesModel backend. + agent_reasoning_events=False, + # OpenAI's reasoning models accept low/medium/high. ``xhigh`` arrived with the + # GPT-5.1-Codex-Max generation and upstream support is unverified for arbitrary + # endpoints, so declare the narrower tuple. + reasoning_effort=("low", "medium", "high"), # Tool-based structured output: the schema is enforced via a forced tool call. structured_output="native", # ``interrupt_signal`` is monitored by the shared Pydantic AI interrupt helper. @@ -166,13 +169,17 @@ def __init__( """Initialize the OpenAI provider. Args: - api_key: OpenAI API key. If None, falls back to ``OPENAI_API_KEY`` env var. - base_url: Optional custom API endpoint. If set, ``api_key`` must be provided - explicitly — Conductor does not rely on ambient ``OPENAI_API_KEY`` for - authenticated custom endpoints. + api_key: OpenAI API key. If ``None`` and no custom ``base_url`` is set, + falls back to ``OPENAI_API_KEY``. A custom ``base_url`` requires an + explicit ``api_key`` because Conductor will not forward an ambient + ``OPENAI_API_KEY`` to a non-OpenAI endpoint. + base_url: Optional custom API endpoint. Resolves from ``OPENAI_BASE_URL`` + when not passed explicitly. When set, ``api_key`` must also be provided + explicitly. model: Default model to use. Defaults to ``gpt-5-mini``. temperature: Default temperature (0.0-2.0). - max_tokens: Maximum output tokens. Defaults to 4096. + max_tokens: Maximum output tokens. ``None`` leaves the parameter unset so + the server applies its own default. timeout: Request timeout in seconds. Defaults to 600s. retry_config: Optional retry configuration. Uses default if not provided. mcp_servers: Optional MCP server configurations for tool support. @@ -207,7 +214,7 @@ def __init__( if max_tokens is not None: self._validate_max_tokens(max_tokens) - self._default_max_tokens = max_tokens or 4096 + self._default_max_tokens = max_tokens self._timeout = timeout self._sdk_version: str | None = None @@ -227,6 +234,11 @@ def __init__( if self._base_url is None: self._base_url = os.environ.get("OPENAI_BASE_URL") + # Set when validate_connection()'s models.list() probe is inconclusive + # (see _connection_probe_verdict) rather than a confirmed success. + # diagnostics.py surfaces this note instead of silently claiming "connected". + self._connection_probe_note: str | None = None + self._initialize_client() def _initialize_client(self) -> None: @@ -241,19 +253,28 @@ def _initialize_client(self) -> None: from openai import AsyncOpenAI client_kwargs: dict[str, Any] = {"timeout": self._timeout, "max_retries": 0} - effective_api_key = self._api_key - if effective_api_key is None: - effective_api_key = os.environ.get("OPENAI_API_KEY") - if not effective_api_key: + # A custom base_url must be paired with an explicit api_key. Conductor does not + # forward an ambient OPENAI_API_KEY to a non-OpenAI endpoint. + if self._base_url is not None and self._api_key is None: raise ValidationError( - "OPENAI_API_KEY environment variable is not set and no api_key was provided", - suggestion="Set OPENAI_API_KEY or pass api_key to the provider.", + "A custom base_url requires an explicit api_key.", + suggestion="Pass api_key in the provider config; Conductor will not forward " + "an ambient OPENAI_API_KEY to a non-OpenAI endpoint.", ) - self._api_key = effective_api_key + if self._api_key is not None: + client_kwargs["api_key"] = self._api_key + else: + # Only fall back to the ambient key when no custom base_url was requested. + effective_api_key = os.environ.get("OPENAI_API_KEY") + if not effective_api_key: + raise ValidationError( + "OPENAI_API_KEY environment variable is not set and no api_key was provided", + suggestion="Set OPENAI_API_KEY or pass api_key to the provider.", + ) + client_kwargs["api_key"] = effective_api_key - client_kwargs["api_key"] = effective_api_key if self._base_url is not None: client_kwargs["base_url"] = self._base_url @@ -304,19 +325,93 @@ def get_retry_history(self) -> list[dict[str, Any]]: async def validate_connection(self) -> bool: """Verify the provider can connect to the OpenAI API. + ``models.list()`` is not implemented by every OpenAI-compatible endpoint + (Ollama returns 404, some LiteLLM/Databricks gateways return other non-auth + status codes while ``/v1/chat/completions`` works), so a non-connection, + non-credential HTTP failure from this probe is treated as inconclusive + rather than fatal: the workflow proceeds and credentials are verified at + the first agent execution instead. Only an unreachable host, rejected + credentials (401/403), or a non-HTTP error still fail startup. + Returns: - True if connection successful, False otherwise. + True if connection successful (or probe inconclusive), False otherwise. """ if self._client is None: return False try: - await self._client.models.list() + models_page = await self._client.models.list() + self._report_available_models(models_page) + self._connection_probe_note = None return True except Exception as e: - logger.error(f"Connection validation failed: {e}") + return self._connection_probe_verdict(e) + + def _connection_probe_verdict(self, exc: Exception) -> bool: + """Classify a ``models.list()`` failure as fatal or merely inconclusive. + + Args: + exc: The exception raised by ``client.models.list()``. + + Returns: + False when the failure indicates an unreachable host, rejected credentials, + or a non-HTTP error. True when the endpoint returned some other HTTP status + (it likely doesn't implement model listing) — startup proceeds and + credentials are verified on the first agent call. + """ + if isinstance(exc, openai.APIConnectionError): + logger.error(f"Connection validation failed: {exc}") + return False + + if isinstance(exc, openai.APIStatusError): + # APIStatusError exposes status_code as a typed attribute. + status_code: int | None = getattr(exc, "status_code", None) + else: + # Fall back for a duck-typed error (e.g. a gateway-layer httpx.HTTPStatusError) + # that carries a status code without being an APIStatusError itself. + status_code = getattr(exc, "status_code", None) + if status_code is None: + status_code = getattr(getattr(exc, "response", None), "status_code", None) + # A duck-typed status_code may be a non-int (e.g. a stringified "401" from + # a proxy wrapper) or an auto-created Mock attribute; neither is usable for + # the 401/403 check below, so route it into the fail-closed arm. `bool` is + # excluded explicitly since ``isinstance(True, int)`` is ``True``. + if not isinstance(status_code, int) or isinstance(status_code, bool): + status_code = None + + if status_code is None: + logger.error(f"Connection validation failed: {exc}") + return False + + if status_code in (401, 403): + logger.error(f"Connection validation failed: {exc}") return False + logger.warning( + f"Could not verify connection via models.list() (HTTP {status_code}): {exc}. " + "This endpoint may not implement /v1/models. Continuing startup; credentials " + "will be verified on the first agent call." + ) + self._connection_probe_note = f"unverified (HTTP {status_code})" + return True + + def _report_available_models(self, models_page: Any) -> None: + """Log available models, warn if default model is unavailable. + + Args: + models_page: The result of ``client.models.list()``. + """ + available_models = [model.id for model in models_page.data] + logger.info(f"Available OpenAI models: {', '.join(available_models)}") + + if self._default_model not in available_models: + logger.warning( + f"Requested model '{self._default_model}' is not in the list of " + f"available models. API calls may fail. Available: {available_models}" + ) + else: + logger.debug(f"Default model '{self._default_model}' verified in available models") + async def list_models(self) -> list[str] | None: """Return the model ids advertised by the OpenAI API. @@ -343,15 +438,21 @@ async def get_max_prompt_tokens(self, model: str) -> int | None: async def get_model_capabilities(self, model: str) -> ModelCapabilityInfo | None: """Return reasoning-effort support and prompt-token limits for ``model``. - OpenAI does not expose token limits or reasoning defaults through its model - listing, so only the reasoning-effort capability is inferred from the model - id: models that are part of the OpenAI reasoning family advertise the - provider's supported effort tuple; other models advertise an empty list - ("supports none"). Unknown fields remain ``None``. + OpenAI does not expose token limits through its model listing, so only + reasoning-effort capability is inferred from the pydantic-ai model profile. + Returns ``None`` when the profile cannot be queried. """ - lowered = model.lower() - is_reasoning_model = lowered.startswith("o") or "reasoning" in lowered - supported = list(_OPENAI_REASONING_EFFORTS) if is_reasoning_model else [] + from pydantic_ai.profiles.openai import openai_model_profile + + try: + profile = openai_model_profile(model) + except Exception: # noqa: BLE001 - profile lookup is a best-effort capability probe + return None + + supports_reasoning = getattr(profile, "openai_supports_reasoning", None) + if supports_reasoning is None: + return None + supported = list(_OPENAI_REASONING_EFFORTS) if supports_reasoning else [] return ModelCapabilityInfo( supported_reasoning_efforts=supported, default_reasoning_effort=None, @@ -469,6 +570,7 @@ async def execute_dialog_turn( from pydantic_ai.models.openai import OpenAIChatModelSettings from conductor.providers._pydantic_ai.agent_builder import ( + _openai_model_supports_reasoning, _resolve_openai_model, ) @@ -500,6 +602,16 @@ async def execute_dialog_turn( "Copilot or Claude provider for 'max'." ), ) + supports_reasoning = _openai_model_supports_reasoning(resolved_model) + if supports_reasoning is False: + raise ValidationError( + f"Model {resolved_model!r} does not support reasoning.effort, but " + f"default_reasoning_effort={self._default_reasoning_effort!r} was requested.", + suggestion=( + "Use a reasoning-capable model (e.g. o-series, gpt-5-mini) or " + "remove the reasoning config." + ), + ) model_settings["openai_reasoning_effort"] = self._default_reasoning_effort try: diff --git a/src/conductor/providers/registry.py b/src/conductor/providers/registry.py index 1b46e7e6..cddd7a7f 100644 --- a/src/conductor/providers/registry.py +++ b/src/conductor/providers/registry.py @@ -6,8 +6,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any +from conductor.config.schema import ProviderName from conductor.providers.base import AgentProvider from conductor.providers.factory import create_provider @@ -15,15 +16,7 @@ from conductor.config.schema import AgentDef, WorkflowConfig -ProviderType = Literal[ - "copilot", - "openai", - "openai-agents", - "claude", - "claude-agent-sdk", - "hermes", - "aca", -] +ProviderType = ProviderName class ProviderRegistry: diff --git a/tests/test_config/test_backward_compatibility.py b/tests/test_config/test_backward_compatibility.py index 89221b46..2308fe51 100644 --- a/tests/test_config/test_backward_compatibility.py +++ b/tests/test_config/test_backward_compatibility.py @@ -91,9 +91,8 @@ def test_load_existing_copilot_workflows(self, example_file: Path): assert config.workflow.runtime is not None assert isinstance(config.workflow.runtime, RuntimeConfig) - # Verify provider is copilot (or not explicitly set to claude) if config.workflow.runtime.provider.name: - assert config.workflow.runtime.provider.name in ["copilot", "openai-agents"] + assert config.workflow.runtime.provider.name in ["copilot", "openai"] def test_copilot_workflow_with_new_schema_no_validation_errors(self): """Test that Copilot workflows load without validation errors with new schema. diff --git a/tests/test_config/test_loader.py b/tests/test_config/test_loader.py index 74130c95..46814a9e 100644 --- a/tests/test_config/test_loader.py +++ b/tests/test_config/test_loader.py @@ -153,7 +153,7 @@ def test_load_env_vars_resolved(self, fixtures_dir: Path) -> None: with patch.dict( os.environ, { - "PROVIDER": "openai-agents", + "PROVIDER": "openai", "DEFAULT_MODEL": "gpt-4-turbo", "AGENT_MODEL": "gpt-3.5", "API_KEY": "secret123", @@ -162,7 +162,7 @@ def test_load_env_vars_resolved(self, fixtures_dir: Path) -> None: loader = ConfigLoader() config = loader.load(fixtures_dir / "valid_env_vars.yaml") - assert config.workflow.runtime.provider.name == "openai-agents" + assert config.workflow.runtime.provider.name == "openai" assert config.workflow.runtime.default_model == "gpt-4-turbo" assert config.agents[0].model == "gpt-3.5" assert "secret123" in config.agents[0].prompt diff --git a/tests/test_config/test_provider_settings.py b/tests/test_config/test_provider_settings.py index 9fea83d7..a85f28e3 100644 --- a/tests/test_config/test_provider_settings.py +++ b/tests/test_config/test_provider_settings.py @@ -90,9 +90,11 @@ def test_openai_headers_rejected(self) -> None: with pytest.raises(ValidationError, match="only supported when name='copilot'"): ProviderSettings(name="openai", headers={"X-Foo": "1"}) - def test_non_copilot_with_base_url_rejected(self) -> None: - with pytest.raises(ValidationError, match="not yet implemented"): - ProviderSettings(name="openai-agents", base_url="http://some-proxy/v1") + def test_openai_wire_api_rejected(self) -> None: + with pytest.raises( + ValidationError, match=r"Provider fields \['wire_api'\] are Copilot-only" + ): + ProviderSettings(name="openai", wire_api="completions") def test_openai_with_base_url_and_api_key_accepted(self) -> None: s = ProviderSettings(name="openai", base_url="https://api.openai.com/v1", api_key="sk-xxx") diff --git a/tests/test_config/test_schema.py b/tests/test_config/test_schema.py index 88f5a657..fd693a5f 100644 --- a/tests/test_config/test_schema.py +++ b/tests/test_config/test_schema.py @@ -682,10 +682,15 @@ def test_default_values(self) -> None: def test_custom_provider(self) -> None: """Test custom provider setting.""" - config = RuntimeConfig(provider="openai-agents", default_model="gpt-4") - assert config.provider.name == "openai-agents" + config = RuntimeConfig(provider="openai", default_model="gpt-4") + assert config.provider.name == "openai" assert config.default_model == "gpt-4" + def test_openai_agents_rejected_by_schema(self) -> None: + """The removed `openai-agents` provider name now fails schema validation.""" + with pytest.raises(ValidationError): + RuntimeConfig(provider="openai-agents", default_model="gpt-4") + def test_working_dir_defaults_to_none(self) -> None: """Requirement: runtime.working_dir is an optional workflow-wide default.""" assert RuntimeConfig().working_dir is None diff --git a/tests/test_config/test_validator_capabilities.py b/tests/test_config/test_validator_capabilities.py index 134272e7..f0ff0fd8 100644 --- a/tests/test_config/test_validator_capabilities.py +++ b/tests/test_config/test_validator_capabilities.py @@ -38,6 +38,7 @@ def _caps(**overrides: object) -> ProviderCapabilities: "usage_tracking": True, "concurrent_safe": True, "skills": True, + "max_temperature": 1.0, } base.update(overrides) return ProviderCapabilities(**base) # type: ignore[arg-type] @@ -213,7 +214,7 @@ def test_max_effort_rejected_against_real_openai_capabilities(self, patch_caps: ), ], ) - with pytest.raises(ConfigurationError, match="supports only.*low.*medium.*high.*xhigh"): + with pytest.raises(ConfigurationError, match="supports only.*low.*medium.*high"): validate_workflow_config(config) def test_non_empty_tools_allowlist_passes(self, patch_caps: Any) -> None: @@ -266,7 +267,7 @@ def test_skills_with_openai_passes(self, patch_caps: Any) -> None: class TestTemperatureCrossCheck: def test_temperature_above_one_with_openai_default_passes(self, patch_caps: Any) -> None: - patch_caps({"openai": _caps()}) + patch_caps({"openai": _caps(max_temperature=2.0)}) config = _build_workflow( temperature=1.5, agents=[AgentDef(name="a", prompt="hi", provider="openai")], @@ -283,7 +284,7 @@ def test_temperature_above_one_with_copilot_default_errors(self, patch_caps: Any validate_workflow_config(config) def test_temperature_above_one_agent_override_to_openai_passes(self, patch_caps: Any) -> None: - patch_caps({"copilot": _caps(), "openai": _caps()}) + patch_caps({"copilot": _caps(), "openai": _caps(max_temperature=2.0)}) config = _build_workflow( temperature=1.5, agents=[ @@ -294,7 +295,7 @@ def test_temperature_above_one_agent_override_to_openai_passes(self, patch_caps: validate_workflow_config(config) # no raise def test_temperature_above_one_mixed_providers_errors(self, patch_caps: Any) -> None: - patch_caps({"copilot": _caps(), "openai": _caps()}) + patch_caps({"copilot": _caps(), "openai": _caps(max_temperature=2.0)}) config = _build_workflow( temperature=1.5, agents=[ @@ -328,7 +329,7 @@ def test_temperature_above_one_with_claude_per_agent_override_errors( ) -> None: """Requirement: workflow default=openai + temperature=1.5 with a per-agent provider='claude' override fails validation, naming temperature and claude.""" - patch_caps({"openai": _caps(), "claude": _caps()}) + patch_caps({"openai": _caps(max_temperature=2.0), "claude": _caps()}) config = _build_workflow( provider="openai", temperature=1.5, @@ -345,7 +346,7 @@ def test_temperature_above_one_with_for_each_inline_claude_override_errors( ) -> None: """Requirement: workflow default=openai + temperature=1.5 with a for_each inline provider='claude' override fails validation, naming temperature and claude.""" - patch_caps({"openai": _caps(), "claude": _caps()}) + patch_caps({"openai": _caps(max_temperature=2.0), "claude": _caps()}) config = _build_workflow( provider="openai", temperature=1.5, @@ -704,7 +705,7 @@ def test_max_effort_rejected_against_real_hermes_capabilities(self, patch_caps: ), ], ) - with pytest.raises(ConfigurationError, match="supports only.*low.*medium.*high.*xhigh"): + with pytest.raises(ConfigurationError, match="supports only.*low.*medium.*high"): validate_workflow_config(config) def test_xhigh_effort_passes_against_real_hermes_capabilities(self, patch_caps: Any) -> None: @@ -980,27 +981,25 @@ def test_per_agent_reasoning_overrides_workflow_default(self, patch_caps: Any) - ) validate_workflow_config(config) # must not raise - def test_openai_agents_placeholder_does_not_error_at_validate( + def test_openai_agents_rejected_by_schema_at_validate( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """Known-but-unimplemented providers return a permissive placeholder. + """The removed `openai-agents` provider name now fails at schema load time. - Previously `openai-agents` would surface "no declared - ProviderCapabilities" at validate time — overriding the factory's - authoritative "not yet implemented" error at runtime. + Removing the placeholder ensures a workflow naming it fails early with + a schema validation error instead of a confusing runtime message. """ - # Use the REAL resolver (don't monkeypatch) so the placeholder path - # is exercised end-to-end. - config = WorkflowConfig( - workflow=WorkflowDef( - name="t", - entry_point="a", - runtime=RuntimeConfig(provider="openai-agents"), - ), - agents=[AgentDef(name="a", prompt="hi")], - ) - # No raise expected — placeholder permits everything. - validate_workflow_config(config) + from pydantic import ValidationError as PydanticValidationError + + with pytest.raises(PydanticValidationError, match="openai-agents"): + WorkflowConfig( + workflow=WorkflowDef( + name="t", + entry_point="a", + runtime=RuntimeConfig(provider="openai-agents"), + ), + agents=[AgentDef(name="a", prompt="hi")], + ) class TestConcurrencyOverrides: diff --git a/tests/test_fleet/test_tui_drilldown.py b/tests/test_fleet/test_tui_drilldown.py index 7e8ab1f3..34d2845c 100644 --- a/tests/test_fleet/test_tui_drilldown.py +++ b/tests/test_fleet/test_tui_drilldown.py @@ -327,7 +327,7 @@ async def test_expanding_unimplemented_provider_shows_terminal_state( "conductor.fleet.tui.screens.providers.gather", new=AsyncMock( return_value=_FakeReport( - [_diag("openai-agents", implemented=False, installed=False)] + [_diag("openai", implemented=False, installed=False)] ) ), ), diff --git a/tests/test_integration/test_openai_real_api.py b/tests/test_integration/test_openai_real_api.py new file mode 100644 index 00000000..d08cfdc0 --- /dev/null +++ b/tests/test_integration/test_openai_real_api.py @@ -0,0 +1,140 @@ +"""Real API integration tests for the native OpenAI provider. + +These tests require: +- OPENAI_API_KEY environment variable (or a reachable OpenAI-compatible endpoint) +- Network connectivity to the configured endpoint + +Run with: pytest -m real_api +Skip with: pytest -m "not real_api" (default) + +An OpenAI-compatible custom endpoint (Ollama, vLLM, a local proxy, ...) can be +exercised instead of api.openai.com by setting CONDUCTOR_TEST_OPENAI_BASE_URL +and CONDUCTOR_TEST_OPENAI_MODEL alongside OPENAI_API_KEY: + + export CONDUCTOR_TEST_OPENAI_BASE_URL=http://localhost:20128/v1/ + export CONDUCTOR_TEST_OPENAI_MODEL=deepseekai/DeepSeek-V4-Flash-0731 + export OPENAI_API_KEY=sk-... +""" + +import os + +import pytest + +from conductor.config.schema import ( + AgentDef, + OutputField, + RouteDef, + RuntimeConfig, + WorkflowConfig, + WorkflowDef, +) +from conductor.engine.workflow import WorkflowEngine +from conductor.providers.openai import OpenAIProvider + +# A custom endpoint requires an explicit api_key (the provider refuses to +# forward an ambient OPENAI_API_KEY to a non-OpenAI endpoint), so read both. +_TEST_BASE_URL = os.getenv("CONDUCTOR_TEST_OPENAI_BASE_URL") +_TEST_MODEL = os.getenv("CONDUCTOR_TEST_OPENAI_MODEL", "gpt-5-mini") + + +@pytest.mark.real_api +class TestOpenAIRealAPI: + """Real API tests (require OPENAI_API_KEY).""" + + @pytest.fixture + def provider_kwargs(self) -> dict[str, str | None]: + """Resolve the endpoint and key for the provider under test. + + Skips when no credential is available. When a custom base URL is + configured the API key is passed explicitly, satisfying the + "custom base_url requires an explicit api_key" rule. + """ + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + pytest.skip("OPENAI_API_KEY not set - skipping real API test") + kwargs: dict[str, str | None] = {"api_key": api_key} + if _TEST_BASE_URL: + kwargs["base_url"] = _TEST_BASE_URL + return kwargs + + @pytest.mark.asyncio + async def test_real_simple_qa(self, provider_kwargs: dict[str, str | None]) -> None: + """Test real API call with simple Q&A workflow.""" + workflow = WorkflowConfig( + workflow=WorkflowDef( + name="real-qa-test", + description="Real API Q&A test", + entry_point="qa_agent", + runtime=RuntimeConfig(provider={"name": "openai"}), + ), + agents=[ + AgentDef( + name="qa_agent", + model=_TEST_MODEL, + prompt="Answer this question concisely: {{ workflow.input.question }}", + output={"answer": OutputField(type="string")}, + routes=[RouteDef(to="$end")], + ) + ], + output={"qa_answer": "{{ qa_agent.output.answer }}"}, + ) + + provider = OpenAIProvider(**provider_kwargs) # type: ignore[arg-type] + + # Verify connection before running workflow + is_connected = await provider.validate_connection() + assert is_connected, "Failed to connect to OpenAI API" + + engine = WorkflowEngine(workflow, provider) + + result = await engine.run({"question": "What is 2+2?"}) + + # Verify result + assert "qa_answer" in result + answer = result["qa_answer"].lower() + assert "4" in answer or "four" in answer + + # Cleanup + await provider.close() + + @pytest.mark.asyncio + async def test_real_structured_output(self, provider_kwargs: dict[str, str | None]) -> None: + """Test structured output parsing with real API.""" + workflow = WorkflowConfig( + workflow=WorkflowDef( + name="real-structured-test", + description="Real API structured output test", + entry_point="classifier", + runtime=RuntimeConfig(provider={"name": "openai"}), + ), + agents=[ + AgentDef( + name="classifier", + model=_TEST_MODEL, + prompt=( + "Classify the sentiment of this text as positive, negative, or neutral: " + "{{ workflow.input.text }}" + ), + output={ + "sentiment": OutputField(type="string"), + "confidence": OutputField(type="number"), + }, + routes=[RouteDef(to="$end")], + ) + ], + output={"sentiment": "{{ classifier.output.sentiment }}"}, + ) + + provider = OpenAIProvider(**provider_kwargs) # type: ignore[arg-type] + engine = WorkflowEngine(workflow, provider) + + result = await engine.run({"text": "I absolutely love this product!"}) + + assert "sentiment" in result + assert result["sentiment"].lower() in ( + "positive", + "negative", + "neutral", + ) + + await provider.close() diff --git a/tests/test_providers/test_diagnostics.py b/tests/test_providers/test_diagnostics.py index 95dad496..afb70e12 100644 --- a/tests/test_providers/test_diagnostics.py +++ b/tests/test_providers/test_diagnostics.py @@ -159,11 +159,11 @@ async def test_not_installed(self, monkeypatch: pytest.MonkeyPatch) -> None: diag = await d.gather_provider("hermes") assert diag.installed is False - async def test_openai_agents_not_implemented(self) -> None: + async def test_removed_provider_rejected(self) -> None: diag = await d.gather_provider("openai-agents") assert diag.implemented is False assert diag.installed is False - assert diag.note == "not yet implemented" + assert "removed" in (diag.note or "").lower() async def test_credential_presence(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") @@ -731,7 +731,7 @@ async def test_all_sections(self, monkeypatch: pytest.MonkeyPatch) -> None: assert report.providers is not None assert report.registries is not None names = {p.name for p in report.providers} - assert {"copilot", "claude", "openai-agents"} <= names + assert {"copilot", "claude", "openai"} <= names async def test_single_section(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("CONDUCTOR_NO_UPDATE_CHECK", "1") diff --git a/tests/test_providers/test_factory.py b/tests/test_providers/test_factory.py index cbcf063b..cdeb8a87 100644 --- a/tests/test_providers/test_factory.py +++ b/tests/test_providers/test_factory.py @@ -40,14 +40,27 @@ async def test_create_copilot_provider_no_validation(self) -> None: await provider.close() @pytest.mark.asyncio - async def test_create_openai_provider_raises(self) -> None: - """Test that OpenAI provider raises ProviderError (not implemented).""" + async def test_create_openai_agents_provider_raises(self) -> None: + """Test that the removed openai-agents provider name raises ProviderError.""" with pytest.raises(ProviderError) as exc_info: - await create_provider("openai-agents") - assert "not yet implemented" in str(exc_info.value) + await create_provider("openai-agents") # type: ignore + assert "Unknown provider" in str(exc_info.value) + assert "openai-agents" in str(exc_info.value) assert exc_info.value.suggestion is not None assert "copilot" in exc_info.value.suggestion + @pytest.mark.asyncio + async def test_create_openai_temperature_above_ceiling_raises(self) -> None: + """OpenAI accepts up to 2.0; values above it are rejected before construction.""" + with pytest.raises(ValidationError, match="temperature"): + await create_provider("openai", validate=False, temperature=2.5) + + @pytest.mark.asyncio + async def test_create_claude_temperature_above_ceiling_raises(self) -> None: + """Claude's cap is 1.0; 1.1 is rejected before construction.""" + with pytest.raises(ValidationError, match="temperature"): + await create_provider("claude", validate=False, temperature=1.1) + @pytest.mark.asyncio async def test_create_openai_provider_from_string(self) -> None: """Requirement: factory creates OpenAIProvider from the string 'openai'.""" @@ -367,7 +380,7 @@ async def test_provider_error_includes_valid_providers(self) -> None: suggestion = exc_info.value.suggestion assert suggestion is not None assert "copilot" in suggestion - assert "openai-agents" in suggestion + assert "openai" in suggestion assert "claude" in suggestion diff --git a/tests/test_providers/test_openai.py b/tests/test_providers/test_openai.py index 70a61312..20e54cd1 100644 --- a/tests/test_providers/test_openai.py +++ b/tests/test_providers/test_openai.py @@ -9,6 +9,8 @@ from typing import Any from unittest.mock import MagicMock, patch +import httpx +import openai import pytest from pydantic import SecretStr from pydantic_ai import Agent @@ -60,12 +62,9 @@ def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_base_url_env_fallback_used_when_yaml_missing( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """Requirement: explicit env resolution (OPENAI_API_KEY, OPENAI_BASE_URL read in code, - never delegated to SDK ambient fallback). - - Verify OPENAI_BASE_URL environment variable is used as a fallback for base_url - when not explicitly provided in the YAML config, and that explicit parameters - still win over the environment variable. + """Requirement: OPENAI_BASE_URL environment variable is used as a fallback for base_url + when not explicitly provided in the YAML config, and explicit parameters still win over + the environment variable. """ monkeypatch.setenv("OPENAI_BASE_URL", "http://env-fallback:1234/v1") @@ -79,6 +78,46 @@ def test_base_url_env_fallback_used_when_yaml_missing( assert p_explicit._client is not None assert str(p_explicit._client.base_url) == "http://explicit-url:5678/v1/" + def test_custom_base_url_requires_explicit_api_key_with_ambient_key_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: ambient OPENAI_API_KEY is never forwarded to a custom endpoint. + + When base_url is set (even via env) and no explicit api_key is passed, construction + must raise ValidationError even if OPENAI_API_KEY is present. + """ + monkeypatch.setenv("OPENAI_API_KEY", "sk-ambient") + monkeypatch.setenv("OPENAI_BASE_URL", "http://custom:1234/v1") + + with pytest.raises(ValidationError, match="custom base_url requires an explicit api_key"): + OpenAIProvider(base_url="http://custom:1234/v1") + + def test_custom_base_url_with_explicit_api_key_succeeds( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: custom base_url is allowed when api_key is passed explicitly.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + p = OpenAIProvider(api_key="sk-explicit", base_url="http://custom:1234/v1") + assert p._api_key == "sk-explicit" + assert p._base_url == "http://custom:1234/v1" + assert p._client is not None + + def test_api_key_stays_none_when_only_ambient_key_is_used( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: the builder guard stays reachable when only an ambient key is used. + + With no custom base_url and OPENAI_API_KEY set, construction must succeed and + self._api_key must remain None so the builder can apply its own env fallback. + """ + monkeypatch.setenv("OPENAI_API_KEY", "sk-ambient") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + p = OpenAIProvider() + assert p._api_key is None + assert p._client is not None + def test_temperature_validation_accepts_range(self) -> None: """OpenAI accepts temperatures up to 2.0.""" p = OpenAIProvider(api_key="test-key", temperature=2.0) @@ -90,14 +129,19 @@ def test_temperature_validation_rejects_out_of_range(self) -> None: OpenAIProvider(api_key="test-key", temperature=2.5) def test_capabilities_are_stable(self) -> None: - """CAPABILITIES declares the expected stable-provider contract.""" + """Requirement: CAPABILITIES reflects the reviewed OpenAI contract. + + agent_reasoning_events is False because Chat Completions does not surface + reasoning content from api.openai.com, and reasoning_effort omits xhigh + until GPT-5.1-Codex-Max support is verified for arbitrary endpoints. + """ caps = OpenAIProvider.CAPABILITIES assert caps.tier == "stable" assert caps.mcp_tools is True assert caps.workflow_tools_passthrough is True assert caps.streaming_events is True - assert caps.agent_reasoning_events is True - assert caps.reasoning_effort == ("low", "medium", "high", "xhigh") + assert caps.agent_reasoning_events is False + assert caps.reasoning_effort == ("low", "medium", "high") assert caps.structured_output == "native" assert caps.interrupt is True assert caps.max_session_seconds is True @@ -235,7 +279,7 @@ async def test_execute_rejects_max_reasoning_effort_at_runtime( async def test_execute_accepts_supported_reasoning_effort( self, provider: OpenAIProvider, no_mcp_manager: Any ) -> None: - """Requirement: reasoning.effort='xhigh' is accepted at runtime. + """Requirement: reasoning.effort='high' is accepted at runtime. Verify that a supported effort level is allowed and passed down correctly. """ @@ -244,9 +288,9 @@ async def test_execute_accepts_supported_reasoning_effort( agent = AgentDef( name="test_agent", - model="gpt-5", + model="gpt-5-mini", prompt="hi", - reasoning=ReasoningConfig(effort="xhigh"), + reasoning=ReasoningConfig(effort="high"), ) captured_kwargs: dict[str, Any] = {} @@ -261,7 +305,26 @@ def spy_build_agent(*args: Any, **kwargs: Any) -> Agent[Any, Any]: await provider.execute(agent, {}, "say hi") assert agent.reasoning is not None - assert agent.reasoning.effort == "xhigh" + assert agent.reasoning.effort == "high" + + async def test_execute_rejects_xhigh_reasoning_effort( + self, provider: OpenAIProvider, no_mcp_manager: Any + ) -> None: + """Requirement: reasoning.effort='xhigh' is rejected at the provider level. + + xhigh is no longer in the OpenAI provider's supported reasoning_effort tuple. + """ + # Requirement: Verify xhigh is rejected by CAPABILITIES membership. + from conductor.config.schema import ReasoningConfig + + agent = AgentDef( + name="test_agent", + model="gpt-5-mini", + prompt="hi", + reasoning=ReasoningConfig(effort="xhigh"), + ) + with pytest.raises(ValidationError, match="resolves to reasoning.effort='xhigh'"): + await provider.execute(agent, {}, "say hi") class TestExecuteDialogTurn: @@ -296,6 +359,58 @@ class FakeResult: assert kwargs.get("api_key") == "test-key" assert kwargs.get("timeout") == 600.0 + async def test_dialog_turn_accepts_reasoning_on_reasoning_model( + self, provider: OpenAIProvider + ) -> None: + """Requirement: a supported effort on a reasoning-capable model succeeds.""" + provider = OpenAIProvider(api_key="test-key", default_reasoning_effort="high") + + async def fake_run(*args: Any, **kwargs: Any) -> Any: + class FakeResult: + output = "dialog reply" + + return FakeResult() + + with ( + patch( + "conductor.providers._pydantic_ai.agent_builder._resolve_openai_model" + ) as mock_resolve_model, + patch( + "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", + return_value=True, + ), + patch("pydantic_ai.Agent") as mock_agent_cls, + ): + mock_agent = mock_agent_cls.return_value + mock_agent.run = fake_run + result = await provider.execute_dialog_turn( + "system prompt", + "user message", + history=[{"role": "user", "content": "previous"}], + model="gpt-5-mini", + ) + + assert result == "dialog reply" + kwargs = mock_resolve_model.call_args.kwargs + assert kwargs.get("api_key") == "test-key" + assert kwargs.get("timeout") == 600.0 + + async def test_dialog_turn_rejects_reasoning_on_non_reasoning_model( + self, provider: OpenAIProvider + ) -> None: + """Requirement: a supported effort on a non-reasoning model is rejected. + + Per-model reasoning support is verified via the shared helper; a False result + must raise ValidationError before the request is sent. + """ + provider = OpenAIProvider(api_key="test-key", default_reasoning_effort="high") + + with patch( + "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", + return_value=False, + ), pytest.raises(ValidationError, match="does not support reasoning.effort"): + await provider.execute_dialog_turn("system prompt", "user message", model="gpt-4o") + async def test_dialog_turn_rejects_unsupported_reasoning_effort(self) -> None: """Requirement: execute_dialog_turn() rejects unsupported reasoning effort. @@ -315,27 +430,94 @@ class TestConnectionHelpers: @pytest.mark.asyncio async def test_validate_connection_returns_true_when_list_succeeds( - self, provider: OpenAIProvider + self, provider: OpenAIProvider, caplog: pytest.LogCaptureFixture ) -> None: - """validate_connection() returns True when models.list() succeeds.""" + """Requirement: validate_connection() succeeds, logs models, and clears note.""" from unittest.mock import AsyncMock mock_client = MagicMock() - mock_client.models.list = AsyncMock(return_value=MagicMock(data=[])) + mock_client.models.list = AsyncMock( + return_value=MagicMock(data=[MagicMock(id="gpt-5-mini")]) + ) provider._client = mock_client # type: ignore[assignment] - assert await provider.validate_connection() is True + with caplog.at_level("INFO"): + assert await provider.validate_connection() is True + assert provider._connection_probe_note is None + assert "Available OpenAI models: gpt-5-mini" in caplog.text + + @pytest.mark.asyncio + async def test_validate_connection_warns_when_default_model_missing( + self, provider: OpenAIProvider, caplog: pytest.LogCaptureFixture + ) -> None: + """Requirement: a default model absent from the listing triggers a warning.""" + from unittest.mock import AsyncMock + + mock_client = MagicMock() + mock_client.models.list = AsyncMock(return_value=MagicMock(data=[MagicMock(id="other")])) + provider._client = mock_client # type: ignore[assignment] + with caplog.at_level("WARNING"): + assert await provider.validate_connection() is True + assert "Requested model 'gpt-5-mini' is not in the list" in caplog.text @pytest.mark.asyncio - async def test_validate_connection_returns_false_on_error( + async def test_validate_connection_returns_false_on_non_http_error( self, provider: OpenAIProvider ) -> None: - """validate_connection() returns False when models.list() fails.""" + """Requirement: a non-HTTP error from models.list() fails startup.""" from unittest.mock import AsyncMock mock_client = MagicMock() mock_client.models.list = AsyncMock(side_effect=RuntimeError("boom")) provider._client = mock_client # type: ignore[assignment] assert await provider.validate_connection() is False + assert provider._connection_probe_note is None + + @pytest.mark.asyncio + async def test_validate_connection_returns_false_on_api_connection_error( + self, provider: OpenAIProvider + ) -> None: + """Requirement: an unreachable host fails startup.""" + from unittest.mock import AsyncMock + + request = httpx.Request("GET", "http://custom/v1/models") + exc = openai.APIConnectionError(message="connection refused", request=request) + mock_client = MagicMock() + mock_client.models.list = AsyncMock(side_effect=exc) + provider._client = mock_client # type: ignore[assignment] + assert await provider.validate_connection() is False + assert provider._connection_probe_note is None + + @pytest.mark.asyncio + async def test_validate_connection_returns_false_on_auth_error( + self, provider: OpenAIProvider + ) -> None: + """Requirement: rejected credentials (401/403) fail startup.""" + from unittest.mock import AsyncMock + + request = httpx.Request("GET", "http://custom/v1/models") + response = httpx.Response(401, request=request) + exc = openai.APIStatusError("unauthorized", response=response, body=None) + mock_client = MagicMock() + mock_client.models.list = AsyncMock(side_effect=exc) + provider._client = mock_client # type: ignore[assignment] + assert await provider.validate_connection() is False + assert provider._connection_probe_note is None + + @pytest.mark.asyncio + async def test_validate_connection_returns_true_with_note_on_404( + self, provider: OpenAIProvider + ) -> None: + """Requirement: a non-auth HTTP failure is treated as inconclusive.""" + from unittest.mock import AsyncMock + + request = httpx.Request("GET", "http://custom/v1/models") + response = httpx.Response(404, request=request) + exc = openai.APIStatusError("not found", response=response, body=None) + mock_client = MagicMock() + mock_client.models.list = AsyncMock(side_effect=exc) + provider._client = mock_client # type: ignore[assignment] + assert await provider.validate_connection() is True + assert provider._connection_probe_note == "unverified (HTTP 404)" @pytest.mark.asyncio async def test_list_models_returns_ids(self, provider: OpenAIProvider) -> None: @@ -351,17 +533,68 @@ async def test_list_models_returns_ids(self, provider: OpenAIProvider) -> None: assert ids == ["gpt-5-mini", "gpt-5"] @pytest.mark.asyncio - async def test_get_model_capabilities_reasoning_models(self, provider: OpenAIProvider) -> None: - """Reasoning models advertise the full supported effort tuple.""" + async def test_get_model_capabilities_reasoning_models( + self, provider: OpenAIProvider, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: reasoning-capable models advertise the provider effort tuple.""" + + def _fake_profile(model: str) -> Any: + class Profile: + openai_supports_reasoning = True + + return Profile() + + monkeypatch.setattr("pydantic_ai.profiles.openai.openai_model_profile", _fake_profile) caps = await provider.get_model_capabilities("o3-mini") assert caps is not None - assert caps.supported_reasoning_efforts == ["low", "medium", "high", "xhigh"] + assert caps.supported_reasoning_efforts == ["low", "medium", "high"] @pytest.mark.asyncio async def test_get_model_capabilities_non_reasoning_models( - self, provider: OpenAIProvider + self, provider: OpenAIProvider, monkeypatch: pytest.MonkeyPatch ) -> None: - """Non-reasoning models advertise no supported reasoning efforts.""" - caps = await provider.get_model_capabilities("gpt-5-mini") + """Requirement: non-reasoning models advertise no supported reasoning efforts.""" + + def _fake_profile(model: str) -> Any: + class Profile: + openai_supports_reasoning = False + + return Profile() + + monkeypatch.setattr("pydantic_ai.profiles.openai.openai_model_profile", _fake_profile) + caps = await provider.get_model_capabilities("gpt-4o") + assert caps is not None + assert caps.supported_reasoning_efforts == [] + + @pytest.mark.asyncio + async def test_get_model_capabilities_openrouter_prefix_not_misclassified( + self, provider: OpenAIProvider, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: 'openai/gpt-4o-mini' must not be misclassified as reasoning.""" + + def _fake_profile(model: str) -> Any: + class Profile: + openai_supports_reasoning = False + + return Profile() + + monkeypatch.setattr("pydantic_ai.profiles.openai.openai_model_profile", _fake_profile) + caps = await provider.get_model_capabilities("openai/gpt-4o-mini") assert caps is not None assert caps.supported_reasoning_efforts == [] + + @pytest.mark.asyncio + async def test_get_model_capabilities_returns_none_when_profile_attr_missing( + self, provider: OpenAIProvider, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Requirement: missing profile support attribute yields unknown capabilities.""" + + def _fake_profile(model: str) -> Any: + class Profile: + pass + + return Profile() + + monkeypatch.setattr("pydantic_ai.profiles.openai.openai_model_profile", _fake_profile) + caps = await provider.get_model_capabilities("some-model") + assert caps is None diff --git a/tests/test_providers/test_pydantic_ai_agent_builder.py b/tests/test_providers/test_pydantic_ai_agent_builder.py index 53749e3e..f31cf099 100644 --- a/tests/test_providers/test_pydantic_ai_agent_builder.py +++ b/tests/test_providers/test_pydantic_ai_agent_builder.py @@ -9,6 +9,7 @@ from __future__ import annotations from typing import Any +from unittest.mock import patch import httpx import pytest @@ -183,22 +184,83 @@ def test_openai_sampling_settings(self) -> None: @pytest.mark.parametrize( ("effort",), - [("low",), ("medium",), ("high",), ("xhigh",), ("max",)], + [("low",), ("medium",), ("high",)], ) def test_openai_reasoning_effort_maps_to_openai_reasoning_effort(self, effort: str) -> None: - """Each reasoning effort level must be forwarded as openai_reasoning_effort.""" + """Each supported reasoning effort level must be forwarded as openai_reasoning_effort.""" agent_def = AgentDef( name="reasoner", - model="gpt-5", + model="gpt-5-mini", reasoning=ReasoningConfig(effort=effort), # type: ignore[arg-type] ) - pydantic_agent = build_agent( - agent_def, system_prompt="", rendered_prompt="", backend="openai" - ) + with patch( + "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", + return_value=True, + ): + pydantic_agent = build_agent( + agent_def, system_prompt="", rendered_prompt="", backend="openai" + ) assert pydantic_agent.model_settings.get("openai_reasoning_effort") == effort + def test_openai_reasoning_effort_rejected_on_non_reasoning_model(self) -> None: + """Requirement: reasoning effort on a non-reasoning model raises ValidationError. + + The shared helper reports False for the model, so build_agent must fail fast. + """ + agent_def = AgentDef( + name="reasoner", + model="gpt-4o", + reasoning=ReasoningConfig(effort="low"), + ) + + with patch( + "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", + return_value=False, + ), pytest.raises(ValidationError, match="does not support reasoning.effort"): + build_agent(agent_def, system_prompt="", rendered_prompt="", backend="openai") + + def test_openai_reasoning_effort_accepted_on_reasoning_model(self) -> None: + """Requirement: reasoning effort on a reasoning-capable model succeeds.""" + agent_def = AgentDef( + name="reasoner", + model="o3-mini", + reasoning=ReasoningConfig(effort="high"), + ) + + with patch( + "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", + return_value=True, + ): + pydantic_agent = build_agent( + agent_def, system_prompt="", rendered_prompt="", backend="openai" + ) + + assert pydantic_agent.model_settings.get("openai_reasoning_effort") == "high" + + def test_openai_reasoning_validated_against_default_model_when_agent_model_unset(self) -> None: + """Requirement: the reasoning-support check must use the workflow's default_model + when agent.model is unset, not the hardcoded library default.""" + agent_def = AgentDef( + name="reasoner", + reasoning=ReasoningConfig(effort="low"), + ) + + with patch( + "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", + return_value=True, + ): + pydantic_agent = build_agent( + agent_def, + system_prompt="", + rendered_prompt="", + backend="openai", + default_model="o3-mini", + ) + + assert pydantic_agent.model_settings.get("openai_reasoning_effort") == "low" + def test_openai_custom_base_url_without_key_raises( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/uv.lock b/uv.lock index e69c8463..c4d42309 100644 --- a/uv.lock +++ b/uv.lock @@ -391,7 +391,7 @@ wheels = [ [[package]] name = "conductor-cli" -version = "0.1.27" +version = "0.1.33" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -421,6 +421,9 @@ aca = [ claude-agent-sdk = [ { name = "claude-agent-sdk" }, ] +tui = [ + { name = "textual" }, +] [package.dev-dependencies] dev = [ @@ -428,6 +431,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "textual" }, { name = "ty" }, ] @@ -442,7 +446,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "mcp", specifier = ">=1.28.1" }, - { name = "openai", specifier = ">=2.48.0,<2.49.0" }, + { name = "openai", specifier = ">=2.48.0,<3.0.0" }, { name = "packaging", specifier = ">=21.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], specifier = ">=1.44.0" }, @@ -450,11 +454,12 @@ requires-dist = [ { name = "rich", specifier = ">=13.0.0" }, { name = "ruamel-yaml", specifier = ">=0.18.0" }, { name = "simpleeval", specifier = ">=1.0.0" }, + { name = "textual", marker = "extra == 'tui'", specifier = ">=8.0" }, { name = "typer", specifier = ">=0.12.0" }, { name = "uvicorn", specifier = ">=0.30.0" }, { name = "websockets", specifier = ">=12.0" }, ] -provides-extras = ["claude-agent-sdk", "aca"] +provides-extras = ["claude-agent-sdk", "aca", "tui"] [package.metadata.requires-dev] dev = [ @@ -462,6 +467,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.8.0" }, + { name = "textual", specifier = ">=8.0" }, { name = "ty", specifier = ">=0.0.1a0" }, ] @@ -946,6 +952,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "logfire-api" version = "4.39.0" @@ -967,6 +985,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1055,6 +1078,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1229,6 +1264,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1903,6 +1947,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + [[package]] name = "tiktoken" version = "0.13.0" @@ -2031,6 +2092,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From caa80f5886780d0ae4d58f52392a5a2cdf0e0847 Mon Sep 17 00:00:00 2001 From: Jason Robert Date: Sat, 22 Aug 2026 14:04:07 -0400 Subject: [PATCH 14/14] fix(providers): correct OpenAI provider docs, changelog placement and lint Follow-up to the review round on the native OpenAI provider. No behaviour changes; the provider code is untouched apart from two suggestion strings. - Run `ruff format` on the three files CI flagged. The formatter check was failing, which skipped every downstream job, so no test run had executed against this branch head. - Move the OpenAI provider CHANGELOG entry out of the released `[0.1.28]` section and into `[Unreleased]`, where it belongs. A rebase had carried it down as 0.1.29-0.1.33 were inserted above it, so it was editing the notes of a shipped release while the version that will actually carry the feature had no entry at all. Also record the retry-classification change (408/429/5xx are now retried on Claude as well) and the `default_reasoning_effort` forwarding fix, both of which alter existing provider behaviour. - Describe the credential rule the provider actually implements. The docs promised `OPENAI_API_KEY` and `OPENAI_BASE_URL` both work as environment fallbacks, but once a custom `base_url` is in effect the ambient key is refused and construction raises. That is the correct behaviour, and it is the configuration the recipes on the same page recommend, so the page needed to say so rather than send readers into a `ValidationError`. - Drop the remaining `xhigh` claims left over from narrowing the capability tuple to `("low", "medium", "high")`, including an example comment that contradicted the matrix ten lines above it. - Correct the comparison table's context-window cell: `get_max_prompt_tokens` returns `None` unconditionally, so the provider reports no window. - Point the "install the SDK" suggestions at `openai>=2.48.0` instead of `>=1.0.0`, two majors below the project's own floor. - Rewrite the AGENTS.md provider notes so the env-resolution, reasoning-effort and temperature bullets match the implementation, and record why `agent_reasoning_events` is `False` on a Chat Completions backend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 8 ++-- CHANGELOG.md | 47 ++++++++++++++----- docs/providers/comparison.md | 2 +- docs/providers/openai.md | 17 +++++-- src/conductor/providers/factory.py | 2 +- src/conductor/providers/openai.py | 2 +- tests/test_fleet/test_tui_drilldown.py | 4 +- tests/test_providers/test_openai.py | 11 +++-- .../test_pydantic_ai_agent_builder.py | 11 +++-- 9 files changed, 68 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87c26424..74c2c3ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,10 +396,10 @@ It shares toolset bridges, event callbacks, interrupts, and retry contracts with with the following backend specifics: - **Shared runner**: Delegates `execute()` to `run_agent_pipeline()` with an `OpenAIChatModel` backend. -- **Env-resolution rule**: YAML `api_key` and `base_url` take precedence over `OPENAI_API_KEY` and `OPENAI_BASE_URL`. Ambient env vars never reroute an unconfigured provider (e.g. `provider: copilot` is never diverted by `OPENAI_*` variables). If an explicit `base_url` is provided without `api_key`, the key must be in `OPENAI_API_KEY` or passed explicitly. -- **Chat-Completions-only**: Exclusively uses the OpenAI Chat Completions API; the OpenAI Responses API is not supported. -- **Reasoning max rejection**: Supports `low`, `medium`, `high`, `xhigh` effort on reasoning models (`o1`, `o3-mini`), but rejects `max` with a `ValidationError`. -- **Temperature 0..2**: Validates temperature in the `0.0..2.0` range and is exempt from the `1.0` cap enforced on Claude and Copilot providers. +- **Env-resolution rule**: YAML `api_key` and `base_url` take precedence over `OPENAI_API_KEY` and `OPENAI_BASE_URL`. Ambient env vars never reroute an unconfigured provider (e.g. `provider: copilot` is never diverted by `OPENAI_*` variables). Once a custom `base_url` is in effect — from YAML **or** from `OPENAI_BASE_URL` — the ambient `OPENAI_API_KEY` is **not** forwarded to it and construction raises `ValidationError` unless `api_key` was passed explicitly, so a personal credential never reaches an endpoint the author did not pair it with (the same reasoning behind `copilot.py` refusing ambient `OPENAI_API_KEY`). `self._api_key` is deliberately left `None` on the ambient path rather than being written back, which keeps `agent_builder.py`'s own guard reachable in production instead of only under its unit test. +- **Chat-Completions-only**: Exclusively uses the OpenAI Chat Completions API; the OpenAI Responses API is not supported. This is also why `CAPABILITIES.agent_reasoning_events` is `False`: pydantic-ai only builds a `ThinkingPart` from `reasoning`/`reasoning_content`, a DeepSeek/Moonshot field, so `api.openai.com` never emits `agent_reasoning` on this backend. It would become `True` on an `OpenAIResponsesModel` backend. +- **Reasoning effort**: Declares `("low", "medium", "high")` and rejects `xhigh`/`max` with a `ValidationError`. `xhigh` arrived with a later model generation than the o-series and is unverified against arbitrary OpenAI-compatible endpoints, so the provider declares the narrower tuple (matching the way `hermes.py` omits `max`). Effort is additionally validated *per model* at agent-build time via `pydantic_ai.profiles.openai.openai_model_profile(...).openai_supports_reasoning`, so a non-reasoning model like `gpt-4o` fails before the request instead of returning a 400 mid-run; an unavailable profile attribute yields `None`, which skips the check rather than guessing. +- **Temperature 0..2**: The per-provider ceiling lives on `ProviderCapabilities.max_temperature` (`1.0` for copilot/claude/hermes/aca/claude-agent-sdk, `None` for openai) and is enforced by `factory.py::_enforce_temperature`, the choke point `run`, `resume` and `validate` all share — the schema bound alone would not cover `conductor run`, which never calls the cross-reference validator. #### `claude_agent_sdk.py` parity notes diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4ee677..c6ab0141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/microsoft/conductor/compare/v0.1.33...HEAD) +### Added + +- **Native OpenAI provider** — new stable `openai` provider built on the shared + Pydantic AI runtime. Works with the real OpenAI API and any OpenAI-compatible + Chat Completions endpoint (Ollama, vLLM, LM Studio, OpenRouter, corporate + proxies). Supports MCP tools, structured output, interrupts, reasoning effort + (`low`/`medium`/`high`), and the full `0.0`–`2.0` temperature range, which is + now expressed as `ProviderCapabilities.max_temperature` and enforced in + `create_provider` so `conductor run` and `conductor resume` are covered rather + than only `conductor validate`. See `docs/providers/openai.md` and + `examples/openai-compatible.yaml`. + + A custom `base_url` requires an explicit `api_key`: an ambient `OPENAI_API_KEY` + is never forwarded to a non-OpenAI endpoint. + +### Changed + +- The Pydantic AI dependency was narrowed from the full `pydantic-ai` package to + `pydantic-ai-slim[anthropic,openai]`. This drops the bundled `pydantic_ai.mcp` + module, which Conductor replaces with its own toolset bridge, so the change is + transparent to users. +- The previously reserved `openai-agents` provider name has been removed from the + schema, factory, registry and diagnostics. Workflows that named it now fail at + schema load time rather than at the first agent execution. + +### Fixed + +- Retry classification now covers the `ModelHTTPError` and `ModelAPIError` types + pydantic-ai actually raises, so `408`, `429` and `5xx` responses are retried on + the Claude provider as well as the new OpenAI one. Previously they were treated + as fatal. +- `runtime.default_reasoning_effort` was silently dropped at run time for every + provider and is now forwarded through `ProviderRegistry`. + ## [0.1.33](https://github.com/microsoft/conductor/compare/v0.1.32...v0.1.33) - 2026-08-18 ### Added @@ -555,19 +589,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Native OpenAI provider** — new stable `openai` provider built on the shared - Pydantic AI runtime. Works with the real OpenAI API and any OpenAI-compatible - Chat Completions endpoint (Ollama, vLLM, LM Studio, OpenRouter, corporate - proxies). Supports MCP tools, structured output, interrupts, reasoning effort - (`low`/`medium`/`high`/`xhigh`), and full temperature range `0.0`–`2.0`. - The Pydantic AI dependency was narrowed from the full `pydantic-ai` package - to `pydantic-ai-slim[anthropic,openai]`; this drops the bundled - `pydantic_ai.mcp` module, which Conductor replaces with its own toolset - bridge, so the change is transparent to users. - The previously reserved `openai-agents` provider name has been removed from - the schema and factory; workflows that named it now fail at schema load time - rather than at the first agent execution. See `docs/providers/openai.md` and - `examples/openai-compatible.yaml`. - **Mid-run guidance for `--web` and `--web-bg` runs** (#400). The dashboard previously offered only Stop, Resume, and Kill — there was no way to correct a run's course without stopping it first. `conductor guide --text diff --git a/docs/providers/comparison.md b/docs/providers/comparison.md index b5599263..a406ac49 100644 --- a/docs/providers/comparison.md +++ b/docs/providers/comparison.md @@ -7,7 +7,7 @@ This guide helps you choose between GitHub Copilot, OpenAI, Anthropic Claude, Cl | Feature | Copilot | OpenAI | Claude | Claude Agent SDK | Hermes | |---------|---------|--------|--------|------------------|--------| | **Tier** | Stable | Stable | Stable | Experimental | Experimental | -| **Context Window** | per-model (SDK-reported) | per-model (SDK-reported) | per-model (SDK-reported) | 200K | per-model | +| **Context Window** | per-model (SDK-reported) | not reported | per-model (SDK-reported) | 200K | per-model | | **Pricing Model** | Subscription ($10-39/mo) | Pay-per-token | Pay-per-token | Via Claude Code CLI | Pay-per-token (via hermes) | | **Setup** | GitHub auth | API key | API key | `claude` CLI auth | API key (model-provider's key) | | **Model Selection** | GPT-5.2, o1 | GPT-4o, GPT-5-mini, o1, o3-mini | Haiku, Sonnet, Opus | Haiku, Sonnet, Opus | Any OpenRouter-style model | diff --git a/docs/providers/openai.md b/docs/providers/openai.md index f40ebc9c..14e5d119 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -82,8 +82,9 @@ workflow: ### Precedence Rules 1. **YAML over Environment**: Values explicitly configured in YAML (`api_key`, `base_url`) override environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`). -2. **Environment Fallback**: When omitted in YAML, `OPENAI_API_KEY` and `OPENAI_BASE_URL` are read from the environment. -3. **No Ambient Rerouting**: Ambient environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`) never divert an unconfigured provider. If a workflow specifies `provider: copilot` (or omits provider), ambient `OPENAI_*` variables have zero effect and will not reroute execution. +2. **Environment Fallback**: When omitted in YAML, `OPENAI_BASE_URL` is read from the environment, and `OPENAI_API_KEY` is read from the environment *only when no custom `base_url` is in effect*. +3. **A Custom `base_url` Requires an Explicit `api_key`**: Once a custom endpoint is in effect — from YAML **or** from `OPENAI_BASE_URL` — the ambient `OPENAI_API_KEY` is never forwarded to it, and construction fails with a `ValidationError` unless `api_key` is set in YAML. This keeps a personal OpenAI credential from reaching a third-party endpoint that the workflow author did not explicitly pair it with. Use `api_key: "${OPENAI_API_KEY}"` to opt in deliberately, as the recipes below do. +4. **No Ambient Rerouting**: Ambient environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`) never divert an unconfigured provider. If a workflow specifies `provider: copilot` (or omits provider), ambient `OPENAI_*` variables have zero effect and will not reroute execution. ## Custom Endpoints and Gateways @@ -103,7 +104,13 @@ workflow: | Field | Description | Env Fallback | |-------|-------------|--------------| | `base_url` | Custom OpenAI-compatible base URL (typically ending in `/v1`) | `OPENAI_BASE_URL` | -| `api_key` | API key for authentication | `OPENAI_API_KEY` | +| `api_key` | API key for authentication | `OPENAI_API_KEY`, but only when no custom `base_url` is in effect (see [Precedence Rules](#precedence-rules)) | + +> Setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` together in the environment is **not** a +> working configuration: the custom endpoint activates, the ambient key is refused, and +> construction raises `ValidationError`. Put the key in YAML — `api_key: "${OPENAI_API_KEY}"` +> interpolates it at load time, so the literal value still never lands in checkpoints or +> dashboard events. ### Recipe: Omniroute-style Proxy @@ -200,7 +207,7 @@ OpenAI reasoning models (such as `o1`, `o3-mini`) support the `reasoning.effort` | `low` | Yes (reasoning models) | Fast reasoning | | `medium` | Yes (reasoning models) | Balanced reasoning | | `high` | Yes (reasoning models) | Deep reasoning | -| `xhigh` | **No (Rejected)** | Only `low`/`medium`/`high` are accepted by the GPT-5.1-Codex-Max generation and by `o1`/`o3-mini`/`o4-mini`; `xhigh` is rejected. | +| `xhigh` | **No (Rejected)** | `o1`, `o3-mini` and `o4-mini` accept only `low`/`medium`/`high`; `xhigh` arrived with a later model generation and is not offered by this provider. | | `max` | **No (Rejected)** | Raises `ValidationError` | Only reasoning models accept `reasoning.effort`; on a non-reasoning model such as `gpt-4o` the setting is validated against the model and also raises `ValidationError`. @@ -210,7 +217,7 @@ workflow: runtime: provider: openai default_model: o3-mini - default_reasoning_effort: medium # low, medium, high, xhigh + default_reasoning_effort: medium # low, medium, high ``` Attempting to set `reasoning.effort: max` with the OpenAI provider will be rejected during validation or execution. diff --git a/src/conductor/providers/factory.py b/src/conductor/providers/factory.py index e82f2549..96154667 100644 --- a/src/conductor/providers/factory.py +++ b/src/conductor/providers/factory.py @@ -132,7 +132,7 @@ async def create_provider( if not OPENAI_SDK_AVAILABLE: raise ProviderError( "OpenAI provider requires the openai package", - suggestion="Install with: uv add 'openai>=1.0.0'", + suggestion="Install with: uv add 'openai>=2.48.0'", ) openai_api_key: str | None = None openai_base_url: str | None = None diff --git a/src/conductor/providers/openai.py b/src/conductor/providers/openai.py index 9e1a20ee..560b691e 100644 --- a/src/conductor/providers/openai.py +++ b/src/conductor/providers/openai.py @@ -200,7 +200,7 @@ def __init__( if not OPENAI_SDK_AVAILABLE: raise ProviderError( "OpenAI SDK not installed", - suggestion="Install with: uv add 'openai>=1.0.0'", + suggestion="Install with: uv add 'openai>=2.48.0'", ) self._client: AsyncOpenAI | None = None diff --git a/tests/test_fleet/test_tui_drilldown.py b/tests/test_fleet/test_tui_drilldown.py index 34d2845c..269eabfb 100644 --- a/tests/test_fleet/test_tui_drilldown.py +++ b/tests/test_fleet/test_tui_drilldown.py @@ -326,9 +326,7 @@ async def test_expanding_unimplemented_provider_shows_terminal_state( patch( "conductor.fleet.tui.screens.providers.gather", new=AsyncMock( - return_value=_FakeReport( - [_diag("openai", implemented=False, installed=False)] - ) + return_value=_FakeReport([_diag("openai", implemented=False, installed=False)]) ), ), patch("conductor.fleet.tui.screens.providers.gather_provider") as fake_gather_provider, diff --git a/tests/test_providers/test_openai.py b/tests/test_providers/test_openai.py index 20e54cd1..23e1d25c 100644 --- a/tests/test_providers/test_openai.py +++ b/tests/test_providers/test_openai.py @@ -405,10 +405,13 @@ async def test_dialog_turn_rejects_reasoning_on_non_reasoning_model( """ provider = OpenAIProvider(api_key="test-key", default_reasoning_effort="high") - with patch( - "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", - return_value=False, - ), pytest.raises(ValidationError, match="does not support reasoning.effort"): + with ( + patch( + "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", + return_value=False, + ), + pytest.raises(ValidationError, match="does not support reasoning.effort"), + ): await provider.execute_dialog_turn("system prompt", "user message", model="gpt-4o") async def test_dialog_turn_rejects_unsupported_reasoning_effort(self) -> None: diff --git a/tests/test_providers/test_pydantic_ai_agent_builder.py b/tests/test_providers/test_pydantic_ai_agent_builder.py index f31cf099..32206704 100644 --- a/tests/test_providers/test_pydantic_ai_agent_builder.py +++ b/tests/test_providers/test_pydantic_ai_agent_builder.py @@ -215,10 +215,13 @@ def test_openai_reasoning_effort_rejected_on_non_reasoning_model(self) -> None: reasoning=ReasoningConfig(effort="low"), ) - with patch( - "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", - return_value=False, - ), pytest.raises(ValidationError, match="does not support reasoning.effort"): + with ( + patch( + "conductor.providers._pydantic_ai.agent_builder._openai_model_supports_reasoning", + return_value=False, + ), + pytest.raises(ValidationError, match="does not support reasoning.effort"), + ): build_agent(agent_def, system_prompt="", rendered_prompt="", backend="openai") def test_openai_reasoning_effort_accepted_on_reasoning_model(self) -> None: