pyagent-providers 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ """PyAgent Providers — multi-provider abstraction with capability negotiation and fallback chains."""
2
+
3
+ from pyagent_providers.base import (
4
+ HealthStatus,
5
+ ProviderCapabilities,
6
+ ProviderInfo,
7
+ ProviderProtocol,
8
+ )
9
+ from pyagent_providers.cost import CostOptimizer, ProviderCostEstimate
10
+ from pyagent_providers.fallback import FallbackChain, FallbackResult
11
+ from pyagent_providers.negotiation import CapabilityNegotiator, NegotiationResult
12
+ from pyagent_providers.registry import ProviderRegistry
13
+ from pyagent_providers.router import ProviderRouter, RoutingStrategy
14
+
15
+ __all__ = [
16
+ "CapabilityNegotiator",
17
+ "CostOptimizer",
18
+ "FallbackChain",
19
+ "FallbackResult",
20
+ "HealthStatus",
21
+ "NegotiationResult",
22
+ "ProviderCapabilities",
23
+ "ProviderCostEstimate",
24
+ "ProviderInfo",
25
+ "ProviderProtocol",
26
+ "ProviderRegistry",
27
+ "ProviderRouter",
28
+ "RoutingStrategy",
29
+ ]
30
+ __version__ = "0.1.0"
@@ -0,0 +1,27 @@
1
+ """Provider adapters — concrete implementations of ProviderProtocol."""
2
+
3
+ from pyagent_providers.adapters.mock import MockProvider
4
+
5
+ __all__ = ["MockProvider"]
6
+
7
+ # Optional adapters — import fails gracefully if deps not installed
8
+ try:
9
+ from pyagent_providers.adapters.openai import OpenAIProvider
10
+
11
+ __all__.append("OpenAIProvider")
12
+ except ImportError:
13
+ pass
14
+
15
+ try:
16
+ from pyagent_providers.adapters.anthropic import AnthropicProvider
17
+
18
+ __all__.append("AnthropicProvider")
19
+ except ImportError:
20
+ pass
21
+
22
+ try:
23
+ from pyagent_providers.adapters.litellm import LiteLLMProvider
24
+
25
+ __all__.append("LiteLLMProvider")
26
+ except ImportError:
27
+ pass
@@ -0,0 +1,106 @@
1
+ """AnthropicProvider: wraps the Anthropic Python client as a ProviderProtocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import anthropic
6
+
7
+ from pyagent_patterns.base import Message, Role
8
+ from pyagent_providers.base import HealthStatus, ProviderCapabilities
9
+ from pyagent_router.selector import Capability
10
+
11
+
12
+ class AnthropicProvider:
13
+ """Anthropic provider implementing ``ProviderProtocol``.
14
+
15
+ Requires: ``pip install pyagent-providers[anthropic]``
16
+
17
+ Args:
18
+ api_key: Anthropic API key. Falls back to ``ANTHROPIC_API_KEY`` env var.
19
+ models: Models this provider exposes.
20
+ default_model: Model to use when none is specified.
21
+ name: Provider identifier. Defaults to ``"anthropic"``.
22
+ max_tokens: Default max tokens for completions.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: str | None = None,
28
+ models: list[str] | None = None,
29
+ default_model: str = "claude-sonnet-4-20250514",
30
+ name: str = "anthropic",
31
+ max_tokens: int = 4096,
32
+ ) -> None:
33
+ self._client = anthropic.AsyncAnthropic(api_key=api_key)
34
+ self._default_model = default_model
35
+ self._name = name
36
+ self._max_tokens = max_tokens
37
+ self._models = models or [
38
+ "claude-haiku-3-5-20241022",
39
+ "claude-sonnet-4-20250514",
40
+ ]
41
+ self._capabilities = ProviderCapabilities(
42
+ models=self._models,
43
+ capabilities={
44
+ Capability.GENERAL,
45
+ Capability.CODE,
46
+ Capability.REASONING,
47
+ Capability.CREATIVE,
48
+ Capability.VISION,
49
+ },
50
+ max_context=200_000,
51
+ supports_streaming=True,
52
+ supports_tools=True,
53
+ supports_vision=True,
54
+ )
55
+
56
+ @property
57
+ def name(self) -> str:
58
+ return self._name
59
+
60
+ @property
61
+ def capabilities(self) -> ProviderCapabilities:
62
+ return self._capabilities
63
+
64
+ async def health(self) -> HealthStatus:
65
+ """Check connectivity by counting available tokens (lightweight call)."""
66
+ try:
67
+ await self._client.messages.count_tokens(
68
+ model=self._default_model,
69
+ messages=[{"role": "user", "content": "ping"}],
70
+ )
71
+ return HealthStatus.HEALTHY
72
+ except Exception:
73
+ return HealthStatus.UNHEALTHY
74
+
75
+ async def complete(self, messages: list[Message], model: str | None = None) -> str:
76
+ """Generate a completion using the Anthropic Messages API.
77
+
78
+ Anthropic requires system messages to be passed separately from the
79
+ conversation history.
80
+
81
+ Args:
82
+ messages: Conversation history.
83
+ model: Model override. Uses ``default_model`` if ``None``.
84
+
85
+ Returns:
86
+ The assistant response text.
87
+ """
88
+ system = next(
89
+ (m.content for m in messages if m.role == Role.SYSTEM),
90
+ "You are a helpful assistant.",
91
+ )
92
+ chat_msgs = [
93
+ {"role": m.role.value, "content": m.content}
94
+ for m in messages
95
+ if m.role != Role.SYSTEM
96
+ ]
97
+ response = await self._client.messages.create(
98
+ model=model or self._default_model,
99
+ max_tokens=self._max_tokens,
100
+ system=system,
101
+ messages=chat_msgs,
102
+ )
103
+ return response.content[0].text
104
+
105
+ async def __call__(self, messages: list[Message]) -> str:
106
+ return await self.complete(messages)
@@ -0,0 +1,90 @@
1
+ """LiteLLMProvider: wraps LiteLLM as a ProviderProtocol (100+ model providers)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import litellm
6
+
7
+ from pyagent_patterns.base import Message
8
+ from pyagent_providers.base import HealthStatus, ProviderCapabilities
9
+ from pyagent_router.selector import Capability
10
+
11
+
12
+ class LiteLLMProvider:
13
+ """LiteLLM provider implementing ``ProviderProtocol``.
14
+
15
+ LiteLLM proxies 100+ model providers (OpenAI, Anthropic, Gemini, Ollama,
16
+ Bedrock, Azure, etc.) through a unified interface.
17
+
18
+ Requires: ``pip install pyagent-providers[litellm]``
19
+
20
+ Args:
21
+ models: List of LiteLLM model strings this provider exposes.
22
+ default_model: Model to use when none is specified.
23
+ name: Provider identifier. Defaults to ``"litellm"``.
24
+ capabilities: Capability set. Defaults to broad general capabilities.
25
+ max_context: Max context window.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ models: list[str] | None = None,
31
+ default_model: str = "gpt-4o-mini",
32
+ name: str = "litellm",
33
+ capabilities: set[Capability] | None = None,
34
+ max_context: int = 128_000,
35
+ ) -> None:
36
+ self._default_model = default_model
37
+ self._name = name
38
+ self._models = models or [
39
+ "gpt-4o-mini",
40
+ "gpt-4o",
41
+ "anthropic/claude-sonnet-4-20250514",
42
+ "gemini/gemini-2.5-flash",
43
+ ]
44
+ self._capabilities = ProviderCapabilities(
45
+ models=self._models,
46
+ capabilities=capabilities
47
+ or {Capability.GENERAL, Capability.CODE, Capability.REASONING},
48
+ max_context=max_context,
49
+ supports_streaming=True,
50
+ supports_tools=True,
51
+ )
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ return self._name
56
+
57
+ @property
58
+ def capabilities(self) -> ProviderCapabilities:
59
+ return self._capabilities
60
+
61
+ async def health(self) -> HealthStatus:
62
+ """Verify LiteLLM can reach at least one provider."""
63
+ try:
64
+ response = await litellm.acompletion(
65
+ model=self._default_model,
66
+ messages=[{"role": "user", "content": "ping"}],
67
+ max_tokens=1,
68
+ )
69
+ return HealthStatus.HEALTHY
70
+ except Exception:
71
+ return HealthStatus.DEGRADED
72
+
73
+ async def complete(self, messages: list[Message], model: str | None = None) -> str:
74
+ """Generate a completion using LiteLLM's unified API.
75
+
76
+ Args:
77
+ messages: Conversation history.
78
+ model: LiteLLM model string override. Uses ``default_model`` if ``None``.
79
+
80
+ Returns:
81
+ The assistant response text.
82
+ """
83
+ response = await litellm.acompletion(
84
+ model=model or self._default_model,
85
+ messages=[{"role": m.role.value, "content": m.content} for m in messages],
86
+ )
87
+ return response.choices[0].message.content or ""
88
+
89
+ async def __call__(self, messages: list[Message]) -> str:
90
+ return await self.complete(messages)
@@ -0,0 +1,72 @@
1
+ """MockProvider: wraps MockLLM with ProviderProtocol for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyagent_patterns.base import Message, MockLLM
6
+ from pyagent_providers.base import HealthStatus, ProviderCapabilities
7
+ from pyagent_router.selector import Capability
8
+
9
+
10
+ class MockProvider:
11
+ """A mock provider for testing that wraps ``MockLLM``.
12
+
13
+ Implements the full ``ProviderProtocol`` interface with configurable
14
+ capabilities and health status.
15
+
16
+ Args:
17
+ name: Unique provider name.
18
+ responses: Canned responses passed to ``MockLLM``.
19
+ models: List of model names this mock exposes.
20
+ capabilities: Capability set.
21
+ max_context: Max context window.
22
+ health_status: Fixed health status to return.
23
+ delay: Simulated latency in seconds.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ name: str = "mock",
29
+ responses: list[str] | None = None,
30
+ models: list[str] | None = None,
31
+ capabilities: set[Capability] | None = None,
32
+ max_context: int = 128_000,
33
+ health_status: HealthStatus = HealthStatus.HEALTHY,
34
+ delay: float = 0.0,
35
+ ) -> None:
36
+ self._name = name
37
+ self._llm = MockLLM(responses=responses, delay=delay)
38
+ self._capabilities = ProviderCapabilities(
39
+ models=models or ["mock-model"],
40
+ capabilities=capabilities or {Capability.GENERAL},
41
+ max_context=max_context,
42
+ )
43
+ self._health_status = health_status
44
+
45
+ @property
46
+ def name(self) -> str:
47
+ return self._name
48
+
49
+ @property
50
+ def capabilities(self) -> ProviderCapabilities:
51
+ return self._capabilities
52
+
53
+ async def health(self) -> HealthStatus:
54
+ return self._health_status
55
+
56
+ async def complete(self, messages: list[Message], model: str | None = None) -> str:
57
+ return await self._llm(messages)
58
+
59
+ async def __call__(self, messages: list[Message]) -> str:
60
+ return await self.complete(messages)
61
+
62
+ def set_health(self, status: HealthStatus) -> None:
63
+ """Change health status (for test scenarios)."""
64
+ self._health_status = status
65
+
66
+ @property
67
+ def call_count(self) -> int:
68
+ return self._llm.call_count
69
+
70
+ @property
71
+ def call_log(self) -> list[list[Message]]:
72
+ return self._llm.call_log
@@ -0,0 +1,90 @@
1
+ """OpenAIProvider: wraps the OpenAI Python client as a ProviderProtocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from openai import AsyncOpenAI
6
+
7
+ from pyagent_patterns.base import Message, Role
8
+ from pyagent_providers.base import HealthStatus, ProviderCapabilities
9
+ from pyagent_router.selector import Capability
10
+
11
+
12
+ class OpenAIProvider:
13
+ """OpenAI provider implementing ``ProviderProtocol``.
14
+
15
+ Requires: ``pip install pyagent-providers[openai]``
16
+
17
+ Args:
18
+ api_key: OpenAI API key. Falls back to ``OPENAI_API_KEY`` env var.
19
+ models: Models this provider exposes. Defaults to common GPT models.
20
+ default_model: Model to use when none is specified in ``complete()``.
21
+ name: Provider identifier. Defaults to ``"openai"``.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ api_key: str | None = None,
27
+ models: list[str] | None = None,
28
+ default_model: str = "gpt-4o-mini",
29
+ name: str = "openai",
30
+ ) -> None:
31
+ self._client = AsyncOpenAI(api_key=api_key)
32
+ self._default_model = default_model
33
+ self._name = name
34
+ self._models = models or [
35
+ "gpt-4.1-nano",
36
+ "gpt-4o-mini",
37
+ "gpt-4.1-mini",
38
+ "gpt-4o",
39
+ "gpt-4.1",
40
+ "o3-mini",
41
+ ]
42
+ self._capabilities = ProviderCapabilities(
43
+ models=self._models,
44
+ capabilities={
45
+ Capability.GENERAL,
46
+ Capability.CODE,
47
+ Capability.REASONING,
48
+ Capability.CREATIVE,
49
+ Capability.VISION,
50
+ },
51
+ max_context=1_000_000,
52
+ supports_streaming=True,
53
+ supports_tools=True,
54
+ supports_vision=True,
55
+ )
56
+
57
+ @property
58
+ def name(self) -> str:
59
+ return self._name
60
+
61
+ @property
62
+ def capabilities(self) -> ProviderCapabilities:
63
+ return self._capabilities
64
+
65
+ async def health(self) -> HealthStatus:
66
+ """Ping the models endpoint to verify connectivity."""
67
+ try:
68
+ await self._client.models.list()
69
+ return HealthStatus.HEALTHY
70
+ except Exception:
71
+ return HealthStatus.UNHEALTHY
72
+
73
+ async def complete(self, messages: list[Message], model: str | None = None) -> str:
74
+ """Generate a completion using the OpenAI Chat API.
75
+
76
+ Args:
77
+ messages: Conversation history.
78
+ model: Model override. Uses ``default_model`` if ``None``.
79
+
80
+ Returns:
81
+ The assistant response text.
82
+ """
83
+ response = await self._client.chat.completions.create(
84
+ model=model or self._default_model,
85
+ messages=[{"role": m.role.value, "content": m.content} for m in messages],
86
+ )
87
+ return response.choices[0].message.content or ""
88
+
89
+ async def __call__(self, messages: list[Message]) -> str:
90
+ return await self.complete(messages)
@@ -0,0 +1,95 @@
1
+ """Core types: ProviderProtocol, ProviderCapabilities, HealthStatus, ProviderInfo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from typing import Any, Protocol, runtime_checkable
8
+
9
+ from pyagent_patterns.base import Message
10
+ from pyagent_router.selector import Capability
11
+
12
+
13
+ class HealthStatus(StrEnum):
14
+ """Health status of a provider."""
15
+
16
+ HEALTHY = "healthy"
17
+ DEGRADED = "degraded"
18
+ UNHEALTHY = "unhealthy"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ProviderCapabilities:
23
+ """Declares what a provider can do.
24
+
25
+ Attributes:
26
+ models: List of model identifiers this provider exposes.
27
+ capabilities: Set of capability tags (reuses pyagent_router.Capability).
28
+ max_context: Maximum context window in tokens across all models.
29
+ supports_streaming: Whether the provider supports token-level streaming.
30
+ supports_tools: Whether the provider supports tool/function calling.
31
+ supports_vision: Whether the provider supports image inputs.
32
+ """
33
+
34
+ models: list[str]
35
+ capabilities: set[Capability] = field(default_factory=lambda: {Capability.GENERAL})
36
+ max_context: int = 128_000
37
+ supports_streaming: bool = True
38
+ supports_tools: bool = False
39
+ supports_vision: bool = False
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ProviderInfo:
44
+ """Snapshot of provider state returned by the registry.
45
+
46
+ Attributes:
47
+ name: Provider name.
48
+ capabilities: Provider capabilities.
49
+ health: Current health status.
50
+ metadata: Arbitrary extra metadata (e.g. region, version).
51
+ """
52
+
53
+ name: str
54
+ capabilities: ProviderCapabilities
55
+ health: HealthStatus
56
+ metadata: dict[str, Any] = field(default_factory=dict)
57
+
58
+
59
+ @runtime_checkable
60
+ class ProviderProtocol(Protocol):
61
+ """Interface that every LLM provider must implement.
62
+
63
+ Also satisfies ``LLMCallable`` via ``__call__`` which delegates to
64
+ ``complete`` with a default model.
65
+ """
66
+
67
+ @property
68
+ def name(self) -> str:
69
+ """Unique identifier for this provider."""
70
+ ...
71
+
72
+ @property
73
+ def capabilities(self) -> ProviderCapabilities:
74
+ """Declared capabilities."""
75
+ ...
76
+
77
+ async def health(self) -> HealthStatus:
78
+ """Check current health of the provider endpoint."""
79
+ ...
80
+
81
+ async def complete(self, messages: list[Message], model: str | None = None) -> str:
82
+ """Generate a completion.
83
+
84
+ Args:
85
+ messages: Conversation history.
86
+ model: Optional model override. Uses provider default if ``None``.
87
+
88
+ Returns:
89
+ The assistant response text.
90
+ """
91
+ ...
92
+
93
+ async def __call__(self, messages: list[Message]) -> str:
94
+ """LLMCallable-compatible entry point — delegates to ``complete``."""
95
+ ...
@@ -0,0 +1,113 @@
1
+ """CostOptimizer: multi-provider cost comparison wrapping CostEstimator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from pyagent_providers.base import ProviderProtocol
8
+ from pyagent_providers.registry import ProviderRegistry
9
+ from pyagent_router.estimator import CostEstimate, CostEstimator
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ProviderCostEstimate:
14
+ """Cost estimate for a specific provider + model pair.
15
+
16
+ Attributes:
17
+ provider_name: Provider that would serve this request.
18
+ model: Model within that provider.
19
+ estimate: The underlying cost estimate.
20
+ """
21
+
22
+ provider_name: str
23
+ model: str
24
+ estimate: CostEstimate
25
+
26
+
27
+ class CostOptimizer:
28
+ """Compare costs across all registered providers.
29
+
30
+ Wraps ``CostEstimator`` from ``pyagent-router`` and iterates over every
31
+ provider's model list to find the cheapest option.
32
+
33
+ Args:
34
+ registry: Provider registry to draw models from.
35
+ cost_estimator: Optional ``CostEstimator`` instance.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ registry: ProviderRegistry,
41
+ cost_estimator: CostEstimator | None = None,
42
+ ) -> None:
43
+ self._registry = registry
44
+ self._estimator = cost_estimator or CostEstimator()
45
+
46
+ def compare(
47
+ self,
48
+ task: str,
49
+ *,
50
+ healthy_only: bool = True,
51
+ limit: int = 10,
52
+ ) -> list[ProviderCostEstimate]:
53
+ """Estimate cost for every provider + model pair and rank cheapest first.
54
+
55
+ Args:
56
+ task: The task text to estimate.
57
+ healthy_only: If ``True`` (default), only include healthy providers.
58
+ limit: Maximum number of results to return.
59
+
60
+ Returns:
61
+ Sorted list of ``ProviderCostEstimate`` objects, cheapest first.
62
+ """
63
+ candidates = self._registry.discover(healthy_only=healthy_only)
64
+ results: list[ProviderCostEstimate] = []
65
+
66
+ for provider in candidates:
67
+ for model_name in provider.capabilities.models:
68
+ try:
69
+ est = self._estimator.estimate_from_text(model_name, task)
70
+ results.append(
71
+ ProviderCostEstimate(
72
+ provider_name=provider.name,
73
+ model=model_name,
74
+ estimate=est,
75
+ )
76
+ )
77
+ except KeyError:
78
+ continue
79
+
80
+ results.sort(key=lambda x: x.estimate.total_cost)
81
+ return results[:limit]
82
+
83
+ def cheapest(
84
+ self,
85
+ task: str,
86
+ *,
87
+ healthy_only: bool = True,
88
+ ) -> ProviderCostEstimate | None:
89
+ """Return the single cheapest provider + model for a task.
90
+
91
+ Returns:
92
+ The cheapest option, or ``None`` if no estimates could be computed.
93
+ """
94
+ estimates = self.compare(task, healthy_only=healthy_only, limit=1)
95
+ return estimates[0] if estimates else None
96
+
97
+ def cheapest_provider(
98
+ self,
99
+ task: str,
100
+ *,
101
+ healthy_only: bool = True,
102
+ ) -> tuple[ProviderProtocol, str] | None:
103
+ """Return the cheapest provider object + model name.
104
+
105
+ Convenience method for passing directly to ``Agent`` or patterns.
106
+ """
107
+ est = self.cheapest(task, healthy_only=healthy_only)
108
+ if est is None:
109
+ return None
110
+ provider = self._registry.get(est.provider_name)
111
+ if provider is None:
112
+ return None
113
+ return provider, est.model