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.
- pyagent_providers/__init__.py +30 -0
- pyagent_providers/adapters/__init__.py +27 -0
- pyagent_providers/adapters/anthropic.py +106 -0
- pyagent_providers/adapters/litellm.py +90 -0
- pyagent_providers/adapters/mock.py +72 -0
- pyagent_providers/adapters/openai.py +90 -0
- pyagent_providers/base.py +95 -0
- pyagent_providers/cost.py +113 -0
- pyagent_providers/fallback.py +126 -0
- pyagent_providers/negotiation.py +180 -0
- pyagent_providers/py.typed +0 -0
- pyagent_providers/registry.py +166 -0
- pyagent_providers/router.py +182 -0
- pyagent_providers-0.1.0.dist-info/METADATA +347 -0
- pyagent_providers-0.1.0.dist-info/RECORD +16 -0
- pyagent_providers-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|