llmswitch-sdk 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.
- llmswitch/__init__.py +47 -0
- llmswitch/base.py +39 -0
- llmswitch/config.py +115 -0
- llmswitch/constants.py +1 -0
- llmswitch/core/__init__.py +7 -0
- llmswitch/core/async_client.py +132 -0
- llmswitch/core/common.py +122 -0
- llmswitch/core/registry.py +66 -0
- llmswitch/core/sync_client.py +131 -0
- llmswitch/errors.py +333 -0
- llmswitch/models.py +343 -0
- llmswitch/providers/_shared.py +208 -0
- llmswitch/providers/anthropic_provider.py +1000 -0
- llmswitch/providers/azurefoundry_provider.py +749 -0
- llmswitch/providers/azureopenai_provider.py +599 -0
- llmswitch/providers/openai_provider.py +589 -0
- llmswitch/providers/vertexai_provider.py +817 -0
- llmswitch/py.typed +0 -0
- llmswitch/structured.py +246 -0
- llmswitch_sdk-0.1.0.dist-info/METADATA +214 -0
- llmswitch_sdk-0.1.0.dist-info/RECORD +24 -0
- llmswitch_sdk-0.1.0.dist-info/WHEEL +5 -0
- llmswitch_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- llmswitch_sdk-0.1.0.dist-info/top_level.txt +1 -0
llmswitch/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
llmswitch
|
|
3
|
+
|
|
4
|
+
One client interface for chat completions and embeddings across OpenAI, Azure
|
|
5
|
+
OpenAI, Azure AI Foundry, Anthropic and Vertex AI.
|
|
6
|
+
|
|
7
|
+
from llmswitch import LLMSwitch
|
|
8
|
+
"""
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
14
|
+
|
|
15
|
+
from .core import AsyncLLMSwitch, LLMSwitch # noqa: E402
|
|
16
|
+
from .errors import ( # noqa: E402
|
|
17
|
+
APIConnectionError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
ChatCompletionError,
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
ContentFilterError,
|
|
22
|
+
EmbeddingGenerationError,
|
|
23
|
+
InvalidResponseError,
|
|
24
|
+
LLMSwitchError,
|
|
25
|
+
ModelNotFoundError,
|
|
26
|
+
RateLimitExceededError,
|
|
27
|
+
TimeOutError,
|
|
28
|
+
)
|
|
29
|
+
from .models import ProviderExtensions # noqa: E402
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"__version__",
|
|
33
|
+
"LLMSwitch",
|
|
34
|
+
"AsyncLLMSwitch",
|
|
35
|
+
"LLMSwitchError",
|
|
36
|
+
"ConfigurationError",
|
|
37
|
+
"ModelNotFoundError",
|
|
38
|
+
"APIConnectionError",
|
|
39
|
+
"AuthenticationError",
|
|
40
|
+
"RateLimitExceededError",
|
|
41
|
+
"ChatCompletionError",
|
|
42
|
+
"EmbeddingGenerationError",
|
|
43
|
+
"InvalidResponseError",
|
|
44
|
+
"ContentFilterError",
|
|
45
|
+
"TimeOutError",
|
|
46
|
+
"ProviderExtensions",
|
|
47
|
+
]
|
llmswitch/base.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaseChatProvider(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Abstract base class for chat providers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def create(self, messages, **kwargs):
|
|
11
|
+
"""
|
|
12
|
+
Create a chat completion.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
messages: List of message dicts or objects.
|
|
16
|
+
**kwargs: Additional parameters for provider.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
ChatCompletion or generator of ChatCompletionChunk.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseEmbeddingProvider(ABC):
|
|
24
|
+
"""
|
|
25
|
+
Abstract base class for embedding providers.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def create(self, inputs, **kwargs):
|
|
30
|
+
"""
|
|
31
|
+
Create embeddings for the given inputs.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
inputs: List of input strings or objects.
|
|
35
|
+
**kwargs: Additional parameters for provider.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Embedding response object.
|
|
39
|
+
"""
|
llmswitch/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
config.py
|
|
3
|
+
|
|
4
|
+
Pydantic configuration models, one per supported provider. ``CONFIG_REGISTRY``
|
|
5
|
+
maps each provider name to its configuration class.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
|
11
|
+
|
|
12
|
+
# Native-SDK client arguments that the unified constructor sets itself. They cannot
|
|
13
|
+
# be overridden through ``client_options``; pass them as the unified arguments instead.
|
|
14
|
+
RESERVED_CLIENT_OPTIONS = frozenset({
|
|
15
|
+
"api_key", "api_version", "azure_endpoint", "azure_deployment", "base_url", "endpoint",
|
|
16
|
+
"organization", "project", "timeout", "credential", "credentials", "location",
|
|
17
|
+
"vertexai", "http_options",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseLLMConfig(BaseModel):
|
|
22
|
+
"""Fields shared by every provider. Provider configs inherit from this class."""
|
|
23
|
+
|
|
24
|
+
# The unified constructor accepts the union of provider-specific fields;
|
|
25
|
+
# each provider reads what it needs and ignores the rest.
|
|
26
|
+
model_config = ConfigDict(extra="allow", str_strip_whitespace=True)
|
|
27
|
+
|
|
28
|
+
provider: str = Field(..., min_length=1, description="Provider name (openai, azure_openai, ...)")
|
|
29
|
+
model: Optional[str] = Field(default=None, description="Model name or ID")
|
|
30
|
+
deployment_name: Optional[str] = Field(
|
|
31
|
+
default=None,
|
|
32
|
+
description="Deployment or model alias; when set it takes precedence over the per-call model",
|
|
33
|
+
)
|
|
34
|
+
timeout: Optional[float] = Field(default=None, gt=0, allow_inf_nan=False,
|
|
35
|
+
description="Request timeout in seconds")
|
|
36
|
+
client_options: Optional[dict[str, Any]] = Field(
|
|
37
|
+
default=None,
|
|
38
|
+
description="Extra keyword arguments passed to the native SDK client constructor, "
|
|
39
|
+
"for example default_headers or max_retries",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@field_validator("client_options")
|
|
43
|
+
@classmethod
|
|
44
|
+
def reject_reserved_client_options(cls, value: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
reserved = sorted(RESERVED_CLIENT_OPTIONS.intersection(value))
|
|
48
|
+
if reserved:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"client_options cannot set {', '.join(reserved)}; use the unified constructor arguments instead"
|
|
51
|
+
)
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
@field_validator("provider", mode="before")
|
|
55
|
+
@classmethod
|
|
56
|
+
def required_string_fields(cls, v, info: ValidationInfo):
|
|
57
|
+
if v is None or (isinstance(v, str) and not v.strip()):
|
|
58
|
+
raise ValueError(f"{info.field_name} is required and cannot be empty")
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AzureOpenAIConfig(BaseLLMConfig):
|
|
63
|
+
"""Azure OpenAI: API key, endpoint, API version and deployment name are required."""
|
|
64
|
+
|
|
65
|
+
api_key: str = Field(..., min_length=1, description="API key for authentication")
|
|
66
|
+
endpoint: str = Field(..., min_length=1, description="Azure OpenAI resource endpoint")
|
|
67
|
+
api_version: str = Field(..., min_length=1, description="Azure OpenAI API version")
|
|
68
|
+
deployment_name: str = Field(..., min_length=1, description="Azure OpenAI deployment name")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AzureFoundryConfig(BaseLLMConfig):
|
|
72
|
+
"""Azure AI Foundry: API key, endpoint, API version and deployment name are required."""
|
|
73
|
+
|
|
74
|
+
api_key: str = Field(..., min_length=1, description="API key for authentication")
|
|
75
|
+
endpoint: str = Field(..., min_length=1, description="Azure AI Foundry endpoint")
|
|
76
|
+
deployment_name: str = Field(..., min_length=1, description="Deployment or model name")
|
|
77
|
+
api_version: str = Field(..., min_length=1, description="Azure AI Foundry API version")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class VertexAIConfig(BaseLLMConfig):
|
|
81
|
+
"""Vertex AI: location and service-account credentials are required."""
|
|
82
|
+
|
|
83
|
+
location: str = Field(..., min_length=1, description="GCP location (e.g. us-central1)")
|
|
84
|
+
credentials_path: str = Field(..., min_length=1, description="Path to GCP credentials JSON file")
|
|
85
|
+
scopes: list[str] = Field(default_factory=lambda: ["https://www.googleapis.com/auth/cloud-platform"],
|
|
86
|
+
description="OAuth scopes for GCP authentication")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class OpenAIConfig(BaseLLMConfig):
|
|
90
|
+
"""Native OpenAI: API key is required; endpoint, organization and project are optional."""
|
|
91
|
+
|
|
92
|
+
api_key: str = Field(..., min_length=1, description="OpenAI API key")
|
|
93
|
+
endpoint: Optional[str] = Field(default=None, description="Custom base URL for compatible APIs")
|
|
94
|
+
organization: Optional[str] = Field(default=None, description="OpenAI organization ID")
|
|
95
|
+
project: Optional[str] = Field(default=None, description="OpenAI project ID")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AnthropicConfig(BaseLLMConfig):
|
|
99
|
+
"""Anthropic (Claude): API key is required. Prompt caching is on by default."""
|
|
100
|
+
|
|
101
|
+
api_key: str = Field(..., min_length=1, description="Anthropic API key")
|
|
102
|
+
endpoint: Optional[str] = Field(default=None, description="Custom base URL for an Anthropic-compatible gateway")
|
|
103
|
+
enable_prompt_caching: bool = Field(
|
|
104
|
+
default=True,
|
|
105
|
+
description="Inject cache_control on system messages and tool definitions",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
CONFIG_REGISTRY = {
|
|
110
|
+
"azure_openai": AzureOpenAIConfig,
|
|
111
|
+
"azure_foundry": AzureFoundryConfig,
|
|
112
|
+
"vertexai": VertexAIConfig,
|
|
113
|
+
"openai": OpenAIConfig,
|
|
114
|
+
"anthropic": AnthropicConfig,
|
|
115
|
+
}
|
llmswitch/constants.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-5"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core.async_client
|
|
3
|
+
|
|
4
|
+
Asynchronous ``AsyncLLMSwitch`` client. Chat completions use each provider's
|
|
5
|
+
native async client. Embedding providers are synchronous, so they run in a
|
|
6
|
+
worker thread to keep the event loop free.
|
|
7
|
+
|
|
8
|
+
Native async chat is available for azure_openai, azure_foundry, openai and
|
|
9
|
+
anthropic. vertexai is sync-only in this release.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..errors import LLMSwitchError
|
|
18
|
+
from .common import (
|
|
19
|
+
build_config,
|
|
20
|
+
map_chat_error,
|
|
21
|
+
map_embedding_error,
|
|
22
|
+
normalize_embedding_input,
|
|
23
|
+
provider_kwargs,
|
|
24
|
+
resolve_model,
|
|
25
|
+
validate_messages,
|
|
26
|
+
)
|
|
27
|
+
from .registry import ASYNC_CHAT_PROVIDER_REGISTRY, EMBEDDING_PROVIDER_REGISTRY
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _AsyncChatCompletions:
|
|
33
|
+
def __init__(self, client: "AsyncLLMSwitch") -> None:
|
|
34
|
+
self._client = client
|
|
35
|
+
|
|
36
|
+
async def create(self, messages: list[dict[str, Any]], stream: bool = False, **kwargs: Any) -> Any:
|
|
37
|
+
"""Async counterpart of ``LLMSwitch.chat.completions.create``.
|
|
38
|
+
|
|
39
|
+
With ``stream=True`` the awaited result is an async generator of chunks.
|
|
40
|
+
"""
|
|
41
|
+
config = self._client.config
|
|
42
|
+
validate_messages(messages)
|
|
43
|
+
provider_cls = ASYNC_CHAT_PROVIDER_REGISTRY.get(config.provider)
|
|
44
|
+
if provider_cls is None:
|
|
45
|
+
raise LLMSwitchError(f"Unsupported provider for async chat completion: {config.provider}",
|
|
46
|
+
provider=config.provider)
|
|
47
|
+
if stream:
|
|
48
|
+
kwargs.setdefault("stream_options", {"include_usage": True})
|
|
49
|
+
resolve_model(config, kwargs)
|
|
50
|
+
provider = self._client._provider(provider_cls)
|
|
51
|
+
try:
|
|
52
|
+
return await provider.create(messages=messages, stream=stream, **kwargs)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
raise map_chat_error(exc, config) from exc
|
|
55
|
+
|
|
56
|
+
async def parse(self, messages: list[dict[str, Any]], response_format: Any, **kwargs: Any) -> Any:
|
|
57
|
+
"""Async structured output. See ``LLMSwitch.chat.completions.parse``."""
|
|
58
|
+
return await self.create(messages, stream=False, response_format=response_format, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _AsyncEmbeddings:
|
|
62
|
+
def __init__(self, client: "AsyncLLMSwitch") -> None:
|
|
63
|
+
self._client = client
|
|
64
|
+
|
|
65
|
+
async def create(self, input: str | list[str], **kwargs: Any) -> Any: # noqa: A002
|
|
66
|
+
"""Create embeddings without blocking the event loop."""
|
|
67
|
+
config = self._client.config
|
|
68
|
+
inputs = normalize_embedding_input(input)
|
|
69
|
+
provider_cls = EMBEDDING_PROVIDER_REGISTRY.get(config.provider)
|
|
70
|
+
if provider_cls is None:
|
|
71
|
+
raise LLMSwitchError(f"Unsupported provider for embeddings: {config.provider}",
|
|
72
|
+
provider=config.provider)
|
|
73
|
+
resolve_model(config, kwargs)
|
|
74
|
+
provider = self._client._provider(provider_cls)
|
|
75
|
+
try:
|
|
76
|
+
return await asyncio.to_thread(provider.create, inputs, **kwargs)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise map_embedding_error(exc, config) from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AsyncLLMSwitch:
|
|
82
|
+
"""
|
|
83
|
+
Unified asynchronous client, mirroring ``LLMSwitch``.
|
|
84
|
+
|
|
85
|
+
Usage::
|
|
86
|
+
|
|
87
|
+
client = AsyncLLMSwitch(provider="openai", api_key="...", model="gpt-4o")
|
|
88
|
+
reply = await client.chat.completions.create(messages=[{"role": "user", "content": "Hi"}])
|
|
89
|
+
vectors = await client.embeddings.create(["hello", "world"])
|
|
90
|
+
await client.aclose()
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
94
|
+
self.config = build_config(kwargs)
|
|
95
|
+
self._providers: dict[type, Any] = {}
|
|
96
|
+
self.chat = _AsyncChat(self)
|
|
97
|
+
self.embeddings = _AsyncEmbeddings(self)
|
|
98
|
+
|
|
99
|
+
def __repr__(self) -> str:
|
|
100
|
+
return f"AsyncLLMSwitch(provider={self.config.provider!r}, model={self.config.model!r})"
|
|
101
|
+
|
|
102
|
+
async def __aenter__(self) -> "AsyncLLMSwitch":
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
106
|
+
await self.aclose()
|
|
107
|
+
|
|
108
|
+
def _provider(self, provider_cls: type) -> Any:
|
|
109
|
+
"""Return the cached provider client for ``provider_cls``, building it on first use."""
|
|
110
|
+
provider = self._providers.get(provider_cls)
|
|
111
|
+
if provider is None:
|
|
112
|
+
provider = provider_cls(**provider_kwargs(self.config))
|
|
113
|
+
self._providers[provider_cls] = provider
|
|
114
|
+
return provider
|
|
115
|
+
|
|
116
|
+
async def aclose(self) -> None:
|
|
117
|
+
"""Close the provider clients this client created, awaiting async close hooks."""
|
|
118
|
+
for provider in self._providers.values():
|
|
119
|
+
close_fn = getattr(provider, "aclose", None) or getattr(provider, "close", None)
|
|
120
|
+
if close_fn is None:
|
|
121
|
+
close_fn = getattr(getattr(provider, "client", None), "aclose", None) or getattr(
|
|
122
|
+
getattr(provider, "client", None), "close", None)
|
|
123
|
+
if callable(close_fn):
|
|
124
|
+
result = close_fn()
|
|
125
|
+
if asyncio.iscoroutine(result):
|
|
126
|
+
await result
|
|
127
|
+
self._providers.clear()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _AsyncChat:
|
|
131
|
+
def __init__(self, client: AsyncLLMSwitch) -> None:
|
|
132
|
+
self.completions = _AsyncChatCompletions(client)
|
llmswitch/core/common.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core.common
|
|
3
|
+
|
|
4
|
+
Helpers shared by the synchronous and asynchronous clients: configuration
|
|
5
|
+
loading, model resolution, input validation, provider construction and error
|
|
6
|
+
translation.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from ..config import CONFIG_REGISTRY
|
|
14
|
+
from ..constants import DEFAULT_ANTHROPIC_MODEL
|
|
15
|
+
from ..errors import (
|
|
16
|
+
APIConnectionError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
ChatCompletionError,
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
EmbeddingGenerationError,
|
|
21
|
+
LLMSwitchError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_config(kwargs: dict[str, Any]) -> Any:
|
|
28
|
+
"""Validate constructor kwargs against the provider's configuration model."""
|
|
29
|
+
provider = kwargs.get("provider")
|
|
30
|
+
if not isinstance(provider, str):
|
|
31
|
+
raise LLMSwitchError(f"Unsupported provider config: {provider}")
|
|
32
|
+
config_cls = CONFIG_REGISTRY.get(provider)
|
|
33
|
+
if config_cls is None:
|
|
34
|
+
raise LLMSwitchError(f"Unsupported provider config: {provider}")
|
|
35
|
+
try:
|
|
36
|
+
return config_cls(**kwargs)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
if hasattr(exc, "errors"):
|
|
39
|
+
errors = exc.errors()
|
|
40
|
+
reasons = "; ".join(str(error["msg"]).removeprefix("Value error, ") for error in errors)
|
|
41
|
+
raise ConfigurationError(f"Invalid switch configuration: {reasons}", details=errors) from exc
|
|
42
|
+
raise ConfigurationError(f"Invalid switch configuration: {exc}", details=str(exc)) from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_model(config: Any, kwargs: dict[str, Any]) -> Optional[str]:
|
|
46
|
+
"""Pick the effective model and write it into ``kwargs``.
|
|
47
|
+
|
|
48
|
+
A configured deployment wins over everything. Otherwise a per-call ``model``
|
|
49
|
+
overrides the configured model. Anthropic falls back to a default model.
|
|
50
|
+
"""
|
|
51
|
+
deployment_name = getattr(config, "deployment_name", None)
|
|
52
|
+
model = deployment_name or kwargs.get("model") or getattr(config, "model", None)
|
|
53
|
+
if model is None and config.provider == "anthropic":
|
|
54
|
+
model = DEFAULT_ANTHROPIC_MODEL
|
|
55
|
+
if model is not None:
|
|
56
|
+
kwargs["model"] = model
|
|
57
|
+
return model
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_messages(messages: Any) -> None:
|
|
61
|
+
if not isinstance(messages, list) or not messages:
|
|
62
|
+
raise ValueError("messages must be a non-empty list of message dicts")
|
|
63
|
+
if not all(isinstance(message, dict) for message in messages):
|
|
64
|
+
raise ValueError("every message must be a dict")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_embedding_input(input: Any) -> list[str]: # noqa: A002
|
|
68
|
+
inputs = [input] if isinstance(input, str) else list(input or [])
|
|
69
|
+
if not inputs:
|
|
70
|
+
raise ValueError("Embedding input must contain at least one string")
|
|
71
|
+
if not all(isinstance(text, str) and text for text in inputs):
|
|
72
|
+
raise ValueError("Embedding input must be a non-empty string or a list of non-empty strings")
|
|
73
|
+
return inputs
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def provider_kwargs(config: Any) -> dict[str, Any]:
|
|
77
|
+
"""Union of constructor kwargs; each provider reads the ones it understands."""
|
|
78
|
+
endpoint = getattr(config, "endpoint", None)
|
|
79
|
+
deployment_name = getattr(config, "deployment_name", None)
|
|
80
|
+
return {
|
|
81
|
+
"api_key": getattr(config, "api_key", None),
|
|
82
|
+
"endpoint": endpoint,
|
|
83
|
+
"azure_endpoint": endpoint,
|
|
84
|
+
"api_version": getattr(config, "api_version", None),
|
|
85
|
+
"deployment_name": deployment_name,
|
|
86
|
+
"azure_deployment": deployment_name,
|
|
87
|
+
"model": getattr(config, "model", None),
|
|
88
|
+
"location": getattr(config, "location", None),
|
|
89
|
+
"credentials_path": getattr(config, "credentials_path", None),
|
|
90
|
+
"scopes": getattr(config, "scopes", None),
|
|
91
|
+
"organization": getattr(config, "organization", None),
|
|
92
|
+
"project": getattr(config, "project", None),
|
|
93
|
+
"timeout": getattr(config, "timeout", None),
|
|
94
|
+
"enable_prompt_caching": getattr(config, "enable_prompt_caching", None),
|
|
95
|
+
"client_options": getattr(config, "client_options", None),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def map_chat_error(exc: Exception, config: Any) -> LLMSwitchError:
|
|
100
|
+
"""Translate a provider exception into the public error hierarchy."""
|
|
101
|
+
if isinstance(exc, LLMSwitchError):
|
|
102
|
+
return exc
|
|
103
|
+
if isinstance(exc, ValueError):
|
|
104
|
+
return ChatCompletionError(str(exc), status_code=400, provider=config.provider)
|
|
105
|
+
if isinstance(exc, ConnectionError):
|
|
106
|
+
return APIConnectionError(config.endpoint, provider=config.provider)
|
|
107
|
+
if isinstance(exc, PermissionError):
|
|
108
|
+
return AuthenticationError(provider=config.provider)
|
|
109
|
+
return ChatCompletionError(str(exc), status_code=getattr(exc, "status_code", 500), provider=config.provider)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def map_embedding_error(exc: Exception, config: Any) -> LLMSwitchError:
|
|
113
|
+
if isinstance(exc, LLMSwitchError):
|
|
114
|
+
return exc
|
|
115
|
+
if isinstance(exc, ValueError):
|
|
116
|
+
return EmbeddingGenerationError(str(exc), status_code=400, provider=config.provider)
|
|
117
|
+
if isinstance(exc, ConnectionError):
|
|
118
|
+
return APIConnectionError(config.endpoint, provider=config.provider)
|
|
119
|
+
if isinstance(exc, PermissionError):
|
|
120
|
+
return AuthenticationError(provider=config.provider)
|
|
121
|
+
return EmbeddingGenerationError(str(exc), status_code=getattr(exc, "status_code", 500),
|
|
122
|
+
provider=config.provider)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core.registry
|
|
3
|
+
|
|
4
|
+
Maps provider names to their chat, async chat and embedding provider classes.
|
|
5
|
+
Each provider SDK is an optional extra, so every import is guarded: a missing
|
|
6
|
+
SDK leaves that provider out of the registry instead of breaking the package.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
CHAT_PROVIDER_REGISTRY: dict[str, type] = {}
|
|
11
|
+
ASYNC_CHAT_PROVIDER_REGISTRY: dict[str, type] = {}
|
|
12
|
+
EMBEDDING_PROVIDER_REGISTRY: dict[str, type] = {}
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from ..providers.openai_provider import (
|
|
16
|
+
AsyncOpenAIChatProvider,
|
|
17
|
+
OpenAIChatProvider,
|
|
18
|
+
OpenAIEmbeddingProvider,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
CHAT_PROVIDER_REGISTRY["openai"] = OpenAIChatProvider
|
|
22
|
+
ASYNC_CHAT_PROVIDER_REGISTRY["openai"] = AsyncOpenAIChatProvider
|
|
23
|
+
EMBEDDING_PROVIDER_REGISTRY["openai"] = OpenAIEmbeddingProvider
|
|
24
|
+
except ImportError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from ..providers.azureopenai_provider import (
|
|
29
|
+
AsyncAzureOpenAIChatProvider,
|
|
30
|
+
AzureOpenAIChatProvider,
|
|
31
|
+
AzureOpenAIEmbeddingProvider,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
CHAT_PROVIDER_REGISTRY["azure_openai"] = AzureOpenAIChatProvider
|
|
35
|
+
ASYNC_CHAT_PROVIDER_REGISTRY["azure_openai"] = AsyncAzureOpenAIChatProvider
|
|
36
|
+
EMBEDDING_PROVIDER_REGISTRY["azure_openai"] = AzureOpenAIEmbeddingProvider
|
|
37
|
+
except ImportError:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from ..providers.azurefoundry_provider import (
|
|
42
|
+
AsyncAzureFoundryChatProvider,
|
|
43
|
+
AzureFoundryChatProvider,
|
|
44
|
+
AzureFoundryEmbeddingProvider,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
CHAT_PROVIDER_REGISTRY["azure_foundry"] = AzureFoundryChatProvider
|
|
48
|
+
ASYNC_CHAT_PROVIDER_REGISTRY["azure_foundry"] = AsyncAzureFoundryChatProvider
|
|
49
|
+
EMBEDDING_PROVIDER_REGISTRY["azure_foundry"] = AzureFoundryEmbeddingProvider
|
|
50
|
+
except ImportError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from ..providers.anthropic_provider import AnthropicChatProvider, AsyncAnthropicChatProvider
|
|
55
|
+
|
|
56
|
+
CHAT_PROVIDER_REGISTRY["anthropic"] = AnthropicChatProvider
|
|
57
|
+
ASYNC_CHAT_PROVIDER_REGISTRY["anthropic"] = AsyncAnthropicChatProvider
|
|
58
|
+
except ImportError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
from ..providers.vertexai_provider import VertexAIChatProvider
|
|
63
|
+
|
|
64
|
+
CHAT_PROVIDER_REGISTRY["vertexai"] = VertexAIChatProvider
|
|
65
|
+
except ImportError:
|
|
66
|
+
pass
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core.sync_client
|
|
3
|
+
|
|
4
|
+
Synchronous ``LLMSwitch`` client with an OpenAI-style ``chat.completions`` and
|
|
5
|
+
``embeddings`` surface.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..errors import LLMSwitchError
|
|
13
|
+
from .common import (
|
|
14
|
+
build_config,
|
|
15
|
+
map_chat_error,
|
|
16
|
+
map_embedding_error,
|
|
17
|
+
normalize_embedding_input,
|
|
18
|
+
provider_kwargs,
|
|
19
|
+
resolve_model,
|
|
20
|
+
validate_messages,
|
|
21
|
+
)
|
|
22
|
+
from .registry import CHAT_PROVIDER_REGISTRY, EMBEDDING_PROVIDER_REGISTRY
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ChatCompletions:
|
|
28
|
+
def __init__(self, client: "LLMSwitch") -> None:
|
|
29
|
+
self._client = client
|
|
30
|
+
|
|
31
|
+
def create(self, messages: list[dict[str, Any]], stream: bool = False, **kwargs: Any) -> Any:
|
|
32
|
+
"""
|
|
33
|
+
Create a chat completion.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
messages: Message dicts. ``content`` may be a string or a list of content parts.
|
|
37
|
+
stream: Return a generator of ``ChatCompletionChunk`` instead of a ``ChatCompletion``.
|
|
38
|
+
**kwargs: Pass-through provider parameters (``response_format``, ``tools``,
|
|
39
|
+
``temperature``, ``max_tokens``, ...). ``model`` overrides the configured model
|
|
40
|
+
unless a deployment name is configured.
|
|
41
|
+
"""
|
|
42
|
+
config = self._client.config
|
|
43
|
+
validate_messages(messages)
|
|
44
|
+
provider_cls = CHAT_PROVIDER_REGISTRY.get(config.provider)
|
|
45
|
+
if provider_cls is None:
|
|
46
|
+
raise LLMSwitchError(f"Unsupported provider for chat completion: {config.provider}",
|
|
47
|
+
provider=config.provider)
|
|
48
|
+
if stream:
|
|
49
|
+
kwargs.setdefault("stream_options", {"include_usage": True})
|
|
50
|
+
resolve_model(config, kwargs)
|
|
51
|
+
provider = self._client._provider(provider_cls)
|
|
52
|
+
try:
|
|
53
|
+
return provider.create(messages=messages, stream=stream, **kwargs)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
raise map_chat_error(exc, config) from exc
|
|
56
|
+
|
|
57
|
+
def parse(self, messages: list[dict[str, Any]], response_format: Any, **kwargs: Any) -> Any:
|
|
58
|
+
"""
|
|
59
|
+
Structured output. Pass a Pydantic model class as ``response_format``; the parsed
|
|
60
|
+
instance is available on ``choice.message.parsed``. Always non-streaming.
|
|
61
|
+
"""
|
|
62
|
+
return self.create(messages, stream=False, response_format=response_format, **kwargs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Embeddings:
|
|
66
|
+
def __init__(self, client: "LLMSwitch") -> None:
|
|
67
|
+
self._client = client
|
|
68
|
+
|
|
69
|
+
def create(self, input: str | list[str], **kwargs: Any) -> Any: # noqa: A002
|
|
70
|
+
"""Create embeddings for one string or a list of strings."""
|
|
71
|
+
config = self._client.config
|
|
72
|
+
inputs = normalize_embedding_input(input)
|
|
73
|
+
provider_cls = EMBEDDING_PROVIDER_REGISTRY.get(config.provider)
|
|
74
|
+
if provider_cls is None:
|
|
75
|
+
raise LLMSwitchError(f"Unsupported provider for embeddings: {config.provider}",
|
|
76
|
+
provider=config.provider)
|
|
77
|
+
resolve_model(config, kwargs)
|
|
78
|
+
provider = self._client._provider(provider_cls)
|
|
79
|
+
try:
|
|
80
|
+
return provider.create(inputs, **kwargs)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
raise map_embedding_error(exc, config) from exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LLMSwitch:
|
|
86
|
+
"""
|
|
87
|
+
Unified synchronous client. Construct it with a ``provider`` name plus that
|
|
88
|
+
provider's credentials and settings; the same call shape works across providers.
|
|
89
|
+
|
|
90
|
+
Usage::
|
|
91
|
+
|
|
92
|
+
client = LLMSwitch(provider="openai", api_key="...", model="gpt-4o")
|
|
93
|
+
reply = client.chat.completions.create(messages=[{"role": "user", "content": "Hi"}])
|
|
94
|
+
vectors = client.embeddings.create(["hello", "world"])
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
98
|
+
self.config = build_config(kwargs)
|
|
99
|
+
self._providers: dict[type, Any] = {}
|
|
100
|
+
self.chat = _Chat(self)
|
|
101
|
+
self.embeddings = _Embeddings(self)
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"LLMSwitch(provider={self.config.provider!r}, model={self.config.model!r})"
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> "LLMSwitch":
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
110
|
+
self.close()
|
|
111
|
+
|
|
112
|
+
def _provider(self, provider_cls: type) -> Any:
|
|
113
|
+
"""Return the cached provider client for ``provider_cls``, building it on first use."""
|
|
114
|
+
provider = self._providers.get(provider_cls)
|
|
115
|
+
if provider is None:
|
|
116
|
+
provider = provider_cls(**provider_kwargs(self.config))
|
|
117
|
+
self._providers[provider_cls] = provider
|
|
118
|
+
return provider
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
"""Close the provider clients this client created."""
|
|
122
|
+
for provider in self._providers.values():
|
|
123
|
+
close_fn = getattr(provider, "close", None) or getattr(getattr(provider, "client", None), "close", None)
|
|
124
|
+
if callable(close_fn):
|
|
125
|
+
close_fn()
|
|
126
|
+
self._providers.clear()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _Chat:
|
|
130
|
+
def __init__(self, client: LLMSwitch) -> None:
|
|
131
|
+
self.completions = _ChatCompletions(client)
|