diff --git a/AGENTS.md b/AGENTS.md index f393c57f..74c2c3ed 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). 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 The Claude Agent SDK provider (`claude_agent_sdk.py`) is the canonical @@ -637,3 +650,20 @@ Implementation parity rules: - 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. + +## 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. + +## 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/CHANGELOG.md b/CHANGELOG.md index c9d54ef5..d2cee27c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,39 @@ 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`. - **MCP tool discovery and structured tool results no longer break with MCP 2.0** (#419). MCP 2.0 renamed the Python field on `mcp.types.Tool` from `inputSchema` to `input_schema` and on `mcp.types.CallToolResult` from 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/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/comparison.md b/docs/providers/comparison.md index 0229175d..a406ac49 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) | 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 | +| **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/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 new file mode 100644 index 00000000..14e5d119 --- /dev/null +++ b/docs/providers/openai.md @@ -0,0 +1,250 @@ +# 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. Set up your API key + +```bash +export OPENAI_API_KEY=sk-... +``` + +### 2. 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 +``` + +### 3. 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_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 + +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`, 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 + +```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 (reasoning models) | Fast reasoning | +| `medium` | Yes (reasoning models) | Balanced reasoning | +| `high` | Yes (reasoning models) | Deep reasoning | +| `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`. + +```yaml +workflow: + runtime: + provider: openai + default_model: o3-mini + default_reasoning_effort: medium # low, medium, high +``` + +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 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/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/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 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 63f32ae3..c31955d8 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,<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 889b51c4..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", "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-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 @@ -2810,12 +2816,25 @@ 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'. " "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( @@ -3338,8 +3357,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..9143fd29 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,26 @@ def _check_agent_capabilities( f"or remove the workflow-level skills." ) + 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) + pcaps = _caps_for(pname) + if pcaps is None or pcaps.max_temperature is None: + continue + 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 {ceiling!r} " + f"and is used by agent(s): {sorted(agent_names)!r}. " + 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 ----- for agent in config.agents: if not _is_llm_agent(agent): 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/_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/agent_builder.py b/src/conductor/providers/_pydantic_ai/agent_builder.py index ffde883c..d8c348fe 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,71 @@ def _coerce_for_thinking( return 1.0, effective_max_tokens -def _build_model_settings( +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. 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) + + 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 + + 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 + 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 +475,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 +490,56 @@ 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, + default_model=default_model, + 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/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/_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/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 097cb184..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. @@ -284,6 +287,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", @@ -295,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 bbb32573..8404dc48 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, @@ -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", ) @@ -826,6 +827,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, @@ -898,67 +903,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 +954,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/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 3dd0d5c4..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,8 +109,8 @@ 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(), } # Update-check opt-out env var (mirrors cli/update.py so diagnostics does not @@ -326,6 +328,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 @@ -567,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, @@ -578,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 3fa96279..96154667 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, @@ -21,13 +22,31 @@ 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 = 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( @@ -89,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 @@ -107,11 +128,32 @@ async def create_provider( provider_settings=provider_settings, tool_output=tool_output, ) - case "openai-agents": - raise ProviderError( - "OpenAI Agents provider not yet implemented", - suggestion="Use 'copilot' provider for now", + case "openai": + if not OPENAI_SDK_AVAILABLE: + raise ProviderError( + "OpenAI provider requires the openai package", + suggestion="Install with: uv add 'openai>=2.48.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 "claude": if not ANTHROPIC_SDK_AVAILABLE: raise ProviderError( @@ -232,8 +274,7 @@ async def create_provider( raise ProviderError( f"Unknown provider: {provider_type}", suggestion=( - "Valid providers are: copilot, openai-agents, claude, " - "claude-agent-sdk, hermes, aca" + "Valid providers are: copilot, openai, claude, claude-agent-sdk, hermes, aca" ), ) @@ -275,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 @@ -296,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 new file mode 100644 index 00000000..560b691e --- /dev/null +++ b/src/conductor/providers/openai.py @@ -0,0 +1,772 @@ +"""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, resolve_reasoning_effort + +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") + + +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, + # 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. + 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`` 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. ``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. + 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>=2.48.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 + + 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] = {} + + 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: + """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} + + # 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( + "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.", + ) + + 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 + + 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. + + ``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 (or probe inconclusive), False otherwise. + """ + if self._client is None: + return False + + try: + models_page = await self._client.models.list() + self._report_available_models(models_page) + self._connection_probe_note = None + return True + except Exception as 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. + + 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 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. + """ + 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, + 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 ( + _openai_model_supports_reasoning, + _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: + 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'." + ), + ) + 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: + 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 + + 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 + + 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..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,7 +16,7 @@ from conductor.config.schema import AgentDef, WorkflowConfig -ProviderType = Literal["copilot", "openai-agents", "claude", "claude-agent-sdk", "hermes"] +ProviderType = ProviderName class ProviderRegistry: @@ -124,6 +125,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_backward_compatibility.py b/tests/test_config/test_backward_compatibility.py index 96eba8ac..2308fe51 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) @@ -89,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_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_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 882a048a..a85f28e3 100644 --- a/tests/test_config/test_provider_settings.py +++ b/tests/test_config/test_provider_settings.py @@ -72,9 +72,60 @@ 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_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_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_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") + 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_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") diff --git a/tests/test_config/test_schema.py b/tests/test_config/test_schema.py index 1498cfb6..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 @@ -709,29 +714,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..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] @@ -51,12 +52,16 @@ def _build_workflow( mcp_servers: dict[str, MCPServerDef] | None = None, 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: 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 +167,212 @@ 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"): + 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(max_temperature=2.0)}) + 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(max_temperature=2.0)}) + 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(max_temperature=2.0)}) + 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_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(max_temperature=2.0), "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(max_temperature=2.0), "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( + 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 @@ -494,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: @@ -770,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..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-agents", 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_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_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_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/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 d3164a22..cdeb8a87 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 @@ -40,14 +40,105 @@ 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'.""" + 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 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.""" @@ -289,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 new file mode 100644 index 00000000..23e1d25c --- /dev/null +++ b/tests/test_providers/test_openai.py @@ -0,0 +1,603 @@ +"""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 httpx +import openai +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_base_url_env_fallback_used_when_yaml_missing( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """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") + + 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_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) + 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: + """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 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 + 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"} + + 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='high' 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-mini", + prompt="hi", + reasoning=ReasoningConfig(effort="high"), + ) + 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 == "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: + """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 + + 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. + + 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.""" + + @pytest.mark.asyncio + async def test_validate_connection_returns_true_when_list_succeeds( + self, provider: OpenAIProvider, caplog: pytest.LogCaptureFixture + ) -> None: + """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=[MagicMock(id="gpt-5-mini")]) + ) + provider._client = mock_client # type: ignore[assignment] + 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_non_http_error( + self, provider: OpenAIProvider + ) -> None: + """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: + """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, 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"] + + @pytest.mark.asyncio + async def test_get_model_capabilities_non_reasoning_models( + self, provider: OpenAIProvider, monkeypatch: pytest.MonkeyPatch + ) -> None: + """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_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 diff --git a/tests/test_providers/test_pydantic_ai_agent_builder.py b/tests/test_providers/test_pydantic_ai_agent_builder.py index 81559304..32206704 100644 --- a/tests/test_providers/test_pydantic_ai_agent_builder.py +++ b/tests/test_providers/test_pydantic_ai_agent_builder.py @@ -9,7 +9,9 @@ from __future__ import annotations from typing import Any +from unittest.mock import patch +import httpx import pytest from pydantic import BaseModel from pydantic_ai import Agent @@ -17,26 +19,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 +67,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 +106,225 @@ 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",)], + ) + def test_openai_reasoning_effort_maps_to_openai_reasoning_effort(self, effort: str) -> None: + """Each supported reasoning effort level must be forwarded as openai_reasoning_effort.""" + agent_def = AgentDef( + name="reasoner", + model="gpt-5-mini", + reasoning=ReasoningConfig(effort=effort), # type: ignore[arg-type] + ) + + 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: + """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.""" diff --git a/tests/test_providers/test_pydantic_ai_retry.py b/tests/test_providers/test_pydantic_ai_retry.py index 23b73f47..8e4d6d65 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,187 @@ 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) + + @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.""" 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, ) diff --git a/uv.lock b/uv.lock index 6f122dc2..c4d42309 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" @@ -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" }, @@ -518,9 +446,10 @@ 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,<3.0.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" }, @@ -675,15 +604,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 +613,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 +629,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 +744,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 +845,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 +925,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,23 +952,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" @@ -1228,29 +964,6 @@ 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" @@ -1386,15 +1099,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 +1255,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" @@ -1671,11 +1266,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.0" +version = "4.11.3" 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" } +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/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, + { 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]] @@ -1687,18 +1282,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 +1376,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 +1400,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 +1422,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 +1498,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 +1550,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 +1639,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 +1885,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,15 +1947,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" @@ -2748,15 +2123,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 +2168,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"