lighthouse-llm-registry 0.1.0__tar.gz

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.
Files changed (25) hide show
  1. lighthouse_llm_registry-0.1.0/.gitignore +3 -0
  2. lighthouse_llm_registry-0.1.0/.vscode/settings.json +3 -0
  3. lighthouse_llm_registry-0.1.0/LICENSE +21 -0
  4. lighthouse_llm_registry-0.1.0/PKG-INFO +182 -0
  5. lighthouse_llm_registry-0.1.0/README.md +147 -0
  6. lighthouse_llm_registry-0.1.0/pyproject.toml +45 -0
  7. lighthouse_llm_registry-0.1.0/src/llm_registry/__init__.py +28 -0
  8. lighthouse_llm_registry-0.1.0/src/llm_registry/base.py +9 -0
  9. lighthouse_llm_registry-0.1.0/src/llm_registry/config.py +9 -0
  10. lighthouse_llm_registry-0.1.0/src/llm_registry/errors.py +132 -0
  11. lighthouse_llm_registry-0.1.0/src/llm_registry/providers/__init__.py +1 -0
  12. lighthouse_llm_registry-0.1.0/src/llm_registry/providers/anthropic.py +20 -0
  13. lighthouse_llm_registry-0.1.0/src/llm_registry/providers/google.py +20 -0
  14. lighthouse_llm_registry-0.1.0/src/llm_registry/providers/groq.py +20 -0
  15. lighthouse_llm_registry-0.1.0/src/llm_registry/providers/local_llm.py +37 -0
  16. lighthouse_llm_registry-0.1.0/src/llm_registry/providers/openai.py +20 -0
  17. lighthouse_llm_registry-0.1.0/src/llm_registry/registry.py +143 -0
  18. lighthouse_llm_registry-0.1.0/src/llm_registry/result.py +13 -0
  19. lighthouse_llm_registry-0.1.0/src/llm_registry/tools/__init__.py +0 -0
  20. lighthouse_llm_registry-0.1.0/tests/chat_test.py +115 -0
  21. lighthouse_llm_registry-0.1.0/tests/local_llm.py +16 -0
  22. lighthouse_llm_registry-0.1.0/tests/test_groq.py +59 -0
  23. lighthouse_llm_registry-0.1.0/tests/test_local_env.py +57 -0
  24. lighthouse_llm_registry-0.1.0/tests/test_openai.py +61 -0
  25. lighthouse_llm_registry-0.1.0/tests/test_registry.py +136 -0
@@ -0,0 +1,3 @@
1
+ *.pyc
2
+ .env
3
+ dist/
@@ -0,0 +1,3 @@
1
+ {
2
+ "python-envs.defaultEnvManager": "ms-python.python:system"
3
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rohit Jagtap
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: lighthouse-llm-registry
3
+ Version: 0.1.0
4
+ Summary: A global reusable LLM Registry wrapper for LangChain models with unified error mapping for Lighthouse Agents Factory
5
+ Author-email: Rohit Jagtap <rohit.jagtap@lighthouseindia.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: ai,anthropic,gemini,groq,langchain,llm,openai,registry
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: langchain-core>=0.3.0
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Provides-Extra: all
22
+ Requires-Dist: langchain-anthropic; extra == 'all'
23
+ Requires-Dist: langchain-google-genai; extra == 'all'
24
+ Requires-Dist: langchain-groq; extra == 'all'
25
+ Requires-Dist: langchain-openai; extra == 'all'
26
+ Provides-Extra: anthropic
27
+ Requires-Dist: langchain-anthropic; extra == 'anthropic'
28
+ Provides-Extra: google
29
+ Requires-Dist: langchain-google-genai; extra == 'google'
30
+ Provides-Extra: groq
31
+ Requires-Dist: langchain-groq; extra == 'groq'
32
+ Provides-Extra: openai
33
+ Requires-Dist: langchain-openai; extra == 'openai'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # LLM Registry
37
+
38
+ A global, reusable LLM Registry wrapper for LangChain chat models. It enables unified model configuration management, lazy loading, and dynamic resolution of model providers (OpenAI, Groq, Anthropic, and Google Gemini).
39
+
40
+ ---
41
+
42
+ ## Installation & Setup
43
+
44
+ > [!NOTE]
45
+ > The distribution package name is **`lighthouse-llm-registry`** (hyphen), and the Python import package name is **`llm_registry`** (underscore).
46
+
47
+ ### 1. Installation
48
+
49
+ Install the core package:
50
+
51
+ ```bash
52
+ pip install lighthouse-llm-registry
53
+ ```
54
+
55
+ To install specific provider SDK dependencies:
56
+
57
+ ```bash
58
+ # OpenAI support
59
+ pip install "lighthouse-llm-registry[openai]"
60
+
61
+ # Groq support
62
+ pip install "lighthouse-llm-registry[groq]"
63
+
64
+ # Anthropic support
65
+ pip install "lighthouse-llm-registry[anthropic]"
66
+
67
+ # Google Gemini support
68
+ pip install "lighthouse-llm-registry[google]"
69
+
70
+ # Install all supported providers
71
+ pip install "lighthouse-llm-registry[all]"
72
+ ```
73
+
74
+ If installing from local source distribution:
75
+
76
+ ```bash
77
+ pip install ".[all]"
78
+ ```
79
+
80
+ ### 2. API Keys Configuration
81
+
82
+ Create a `.env` file in the root of your target project:
83
+
84
+ ```env
85
+ OPENAI_API_KEY=your-openai-key
86
+ GROQ_API_KEY=your-groq-key
87
+ ANTHROPIC_API_KEY=your-anthropic-key
88
+ GOOGLE_API_KEY=your-gemini-key
89
+ LOCAL_MODEL_LINK=http://192.168.101.88:11434/api/chat
90
+ LOCAL_MODEL=llama3.1:8b
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Usage Guide
96
+
97
+ ### 1. Registration (Application Startup)
98
+
99
+ Register your model configurations once at application startup (e.g., in your app's main entry point):
100
+
101
+ ```python
102
+ # main.py
103
+ from dotenv import load_dotenv
104
+ from llm_registry import global_registry, ModelConfig
105
+
106
+ # Load environment keys from .env
107
+ load_dotenv()
108
+
109
+ # Register OpenAI config (defaults to standard temperature/max_tokens rules)
110
+ global_registry.register_model_config(
111
+ "primary-chat",
112
+ ModelConfig(
113
+ provider="openai",
114
+ model_name="gpt-4o-mini",
115
+ temperature=0.7
116
+ )
117
+ )
118
+
119
+ # Register Groq config
120
+ global_registry.register_model_config(
121
+ "fast-chat",
122
+ ModelConfig(
123
+ provider="groq",
124
+ model_name="llama-3.1-8b-instant",
125
+ temperature=0.2
126
+ )
127
+ )
128
+
129
+ # Register Anthropic config (supports models like claude-opus-4-8)
130
+ global_registry.register_model_config(
131
+ "legacy-chat",
132
+ ModelConfig(
133
+ provider="anthropic",
134
+ model_name="claude-opus-4-8"
135
+ )
136
+ )
137
+
138
+ # Register Local Ollama config (falls back to LOCAL_MODEL_LINK and LOCAL_MODEL if parameters are omitted)
139
+ global_registry.register_model_config(
140
+ "local-chat",
141
+ ModelConfig(
142
+ provider="local",
143
+ model_name=""
144
+ )
145
+ )
146
+ ```
147
+
148
+ ### 2. Resolution (Anywhere in your code)
149
+
150
+ Resolve and query the model anywhere inside your application without importing specific provider SDKs:
151
+
152
+ ```python
153
+ # agents/agent.py
154
+ from llm_registry import global_registry
155
+
156
+ class SimpleAgent:
157
+ def __init__(self):
158
+ # Dynamically resolve model from the global registry
159
+ self.model = global_registry.get_model("fast-chat")
160
+
161
+ def respond(self, query: str) -> str:
162
+ # Standard LangChain invoke call
163
+ response = self.model.invoke(query)
164
+ return response.content
165
+
166
+ # Example for Local Ollama Model:
167
+ local_model = global_registry.get_model("local-chat")
168
+ local_response = local_model.invoke([
169
+ {"role": "user", "content": "How are you today?"}
170
+ ])
171
+ print(local_response) # Returns direct text output
172
+ ```
173
+
174
+ ---
175
+
176
+ ## Running Playground tests
177
+
178
+ An interactive chat test script is included under `tests/chat_test.py` to check your connections. Run it directly:
179
+
180
+ ```bash
181
+ python tests/chat_test.py
182
+ ```
@@ -0,0 +1,147 @@
1
+ # LLM Registry
2
+
3
+ A global, reusable LLM Registry wrapper for LangChain chat models. It enables unified model configuration management, lazy loading, and dynamic resolution of model providers (OpenAI, Groq, Anthropic, and Google Gemini).
4
+
5
+ ---
6
+
7
+ ## Installation & Setup
8
+
9
+ > [!NOTE]
10
+ > The distribution package name is **`lighthouse-llm-registry`** (hyphen), and the Python import package name is **`llm_registry`** (underscore).
11
+
12
+ ### 1. Installation
13
+
14
+ Install the core package:
15
+
16
+ ```bash
17
+ pip install lighthouse-llm-registry
18
+ ```
19
+
20
+ To install specific provider SDK dependencies:
21
+
22
+ ```bash
23
+ # OpenAI support
24
+ pip install "lighthouse-llm-registry[openai]"
25
+
26
+ # Groq support
27
+ pip install "lighthouse-llm-registry[groq]"
28
+
29
+ # Anthropic support
30
+ pip install "lighthouse-llm-registry[anthropic]"
31
+
32
+ # Google Gemini support
33
+ pip install "lighthouse-llm-registry[google]"
34
+
35
+ # Install all supported providers
36
+ pip install "lighthouse-llm-registry[all]"
37
+ ```
38
+
39
+ If installing from local source distribution:
40
+
41
+ ```bash
42
+ pip install ".[all]"
43
+ ```
44
+
45
+ ### 2. API Keys Configuration
46
+
47
+ Create a `.env` file in the root of your target project:
48
+
49
+ ```env
50
+ OPENAI_API_KEY=your-openai-key
51
+ GROQ_API_KEY=your-groq-key
52
+ ANTHROPIC_API_KEY=your-anthropic-key
53
+ GOOGLE_API_KEY=your-gemini-key
54
+ LOCAL_MODEL_LINK=http://192.168.101.88:11434/api/chat
55
+ LOCAL_MODEL=llama3.1:8b
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Usage Guide
61
+
62
+ ### 1. Registration (Application Startup)
63
+
64
+ Register your model configurations once at application startup (e.g., in your app's main entry point):
65
+
66
+ ```python
67
+ # main.py
68
+ from dotenv import load_dotenv
69
+ from llm_registry import global_registry, ModelConfig
70
+
71
+ # Load environment keys from .env
72
+ load_dotenv()
73
+
74
+ # Register OpenAI config (defaults to standard temperature/max_tokens rules)
75
+ global_registry.register_model_config(
76
+ "primary-chat",
77
+ ModelConfig(
78
+ provider="openai",
79
+ model_name="gpt-4o-mini",
80
+ temperature=0.7
81
+ )
82
+ )
83
+
84
+ # Register Groq config
85
+ global_registry.register_model_config(
86
+ "fast-chat",
87
+ ModelConfig(
88
+ provider="groq",
89
+ model_name="llama-3.1-8b-instant",
90
+ temperature=0.2
91
+ )
92
+ )
93
+
94
+ # Register Anthropic config (supports models like claude-opus-4-8)
95
+ global_registry.register_model_config(
96
+ "legacy-chat",
97
+ ModelConfig(
98
+ provider="anthropic",
99
+ model_name="claude-opus-4-8"
100
+ )
101
+ )
102
+
103
+ # Register Local Ollama config (falls back to LOCAL_MODEL_LINK and LOCAL_MODEL if parameters are omitted)
104
+ global_registry.register_model_config(
105
+ "local-chat",
106
+ ModelConfig(
107
+ provider="local",
108
+ model_name=""
109
+ )
110
+ )
111
+ ```
112
+
113
+ ### 2. Resolution (Anywhere in your code)
114
+
115
+ Resolve and query the model anywhere inside your application without importing specific provider SDKs:
116
+
117
+ ```python
118
+ # agents/agent.py
119
+ from llm_registry import global_registry
120
+
121
+ class SimpleAgent:
122
+ def __init__(self):
123
+ # Dynamically resolve model from the global registry
124
+ self.model = global_registry.get_model("fast-chat")
125
+
126
+ def respond(self, query: str) -> str:
127
+ # Standard LangChain invoke call
128
+ response = self.model.invoke(query)
129
+ return response.content
130
+
131
+ # Example for Local Ollama Model:
132
+ local_model = global_registry.get_model("local-chat")
133
+ local_response = local_model.invoke([
134
+ {"role": "user", "content": "How are you today?"}
135
+ ])
136
+ print(local_response) # Returns direct text output
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Running Playground tests
142
+
143
+ An interactive chat test script is included under `tests/chat_test.py` to check your connections. Run it directly:
144
+
145
+ ```bash
146
+ python tests/chat_test.py
147
+ ```
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lighthouse-llm-registry"
7
+ version = "0.1.0"
8
+ description = "A global reusable LLM Registry wrapper for LangChain models with unified error mapping for Lighthouse Agents Factory"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Rohit Jagtap", email = "rohit.jagtap@lighthouseindia.com"}
14
+ ]
15
+ keywords = ["llm", "langchain", "registry", "ai", "openai", "groq", "anthropic", "gemini"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ ]
27
+ dependencies = [
28
+ "langchain-core>=0.3.0",
29
+ "pydantic>=2.0.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ openai = ["langchain-openai"]
34
+ anthropic = ["langchain-anthropic"]
35
+ google = ["langchain-google-genai"]
36
+ groq = ["langchain-groq"]
37
+ all = [
38
+ "langchain-openai",
39
+ "langchain-anthropic",
40
+ "langchain-google-genai",
41
+ "langchain-groq"
42
+ ]
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/llm_registry"]
@@ -0,0 +1,28 @@
1
+ from llm_registry.registry import global_registry, LLMRegistry
2
+ from llm_registry.config import ModelConfig
3
+ from llm_registry.base import BaseLLMProvider
4
+ from llm_registry.errors import (
5
+ LLMErrorDetails,
6
+ LLMInvocationError,
7
+ LLMRegistryError,
8
+ ModelAliasNotFoundError,
9
+ ModelCreationError,
10
+ ProviderDependencyError,
11
+ ProviderNotRegisteredError,
12
+ )
13
+ from llm_registry.result import LLMResult
14
+
15
+ __all__ = [
16
+ "global_registry",
17
+ "LLMRegistry",
18
+ "ModelConfig",
19
+ "BaseLLMProvider",
20
+ "LLMErrorDetails",
21
+ "LLMInvocationError",
22
+ "LLMRegistryError",
23
+ "ModelAliasNotFoundError",
24
+ "ModelCreationError",
25
+ "ProviderDependencyError",
26
+ "ProviderNotRegisteredError",
27
+ "LLMResult",
28
+ ]
@@ -0,0 +1,9 @@
1
+ from abc import ABC, abstractmethod
2
+ from langchain_core.language_models import BaseChatModel
3
+ from llm_registry.config import ModelConfig
4
+
5
+ class BaseLLMProvider(ABC):
6
+ @abstractmethod
7
+ def create_model(self, config: ModelConfig) -> BaseChatModel:
8
+ """Instantiate and return a LangChain ChatModel based on the ModelConfig."""
9
+ pass
@@ -0,0 +1,9 @@
1
+ from typing import Dict, Any, Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+ class ModelConfig(BaseModel):
5
+ provider: str = Field(..., description="The LLM provider, e.g., 'openai', 'anthropic', 'google', 'groq'")
6
+ model_name: str = Field(..., description="The name of the model, e.g., 'gpt-4o', 'llama-3.1-8b-instant'")
7
+ temperature: Optional[float] = Field(None, ge=0.0, le=2.0, description="Temperature for sampling (optional)")
8
+ max_tokens: Optional[int] = Field(None, description="Max tokens to generate (optional)")
9
+ extra_params: Dict[str, Any] = Field(default_factory=dict, description="Any extra provider-specific parameter kwargs")
@@ -0,0 +1,132 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class LLMErrorDetails:
7
+ """Structured error details for provider and registry failures."""
8
+
9
+ provider: Optional[str]
10
+ model_name: Optional[str]
11
+ category: str
12
+ message: str
13
+ exception_type: str
14
+ status_code: Optional[int] = None
15
+ code: Optional[str] = None
16
+
17
+
18
+ class LLMRegistryError(Exception):
19
+ """Base exception for all llm_registry errors."""
20
+
21
+ def __init__(self, message: str, details: Optional[LLMErrorDetails] = None):
22
+ super().__init__(message)
23
+ self.details = details
24
+
25
+
26
+ class ModelAliasNotFoundError(LLMRegistryError, ValueError):
27
+ """Raised when an alias has not been registered."""
28
+
29
+
30
+ class ProviderNotRegisteredError(LLMRegistryError, ValueError):
31
+ """Raised when no provider implementation exists for a model config."""
32
+
33
+
34
+ class ProviderDependencyError(LLMRegistryError, ImportError):
35
+ """Raised when an optional provider dependency is not installed."""
36
+
37
+
38
+ class ModelCreationError(LLMRegistryError):
39
+ """Raised when a provider cannot create a model instance."""
40
+
41
+
42
+ class LLMInvocationError(LLMRegistryError):
43
+ """Raised when a provider API call fails."""
44
+
45
+
46
+ def normalize_exception(
47
+ exc: Exception,
48
+ *,
49
+ provider: Optional[str] = None,
50
+ model_name: Optional[str] = None,
51
+ ) -> LLMErrorDetails:
52
+ """Map provider-specific exceptions into stable package-level details."""
53
+
54
+ exception_type = exc.__class__.__name__
55
+ message = str(exc) or exception_type
56
+ status_code = _get_status_code(exc)
57
+ code = _get_code(exc)
58
+ searchable = f"{exception_type} {message} {code or ''}".lower()
59
+
60
+ if status_code in {401, 403} or _contains_any(
61
+ searchable,
62
+ ("authentication", "permissiondenied", "unauthorized", "invalid api key", "api key"),
63
+ ):
64
+ category = "authentication"
65
+ elif status_code == 429 or _contains_any(searchable, ("ratelimit", "rate limit", "too many requests")):
66
+ category = "rate_limit"
67
+ elif _contains_any(searchable, ("quota", "insufficient_quota", "billing")):
68
+ category = "quota"
69
+ elif status_code in {408, 504} or _contains_any(searchable, ("timeout", "deadlineexceeded", "deadline exceeded")):
70
+ category = "timeout"
71
+ elif status_code in {400, 404, 422} or _contains_any(
72
+ searchable,
73
+ ("badrequest", "invalidrequest", "validation", "notfound", "not found"),
74
+ ):
75
+ category = "bad_request"
76
+ elif _contains_any(searchable, ("context_length", "maximum context", "token limit", "too many tokens")):
77
+ category = "context_length"
78
+ elif status_code and status_code >= 500:
79
+ category = "provider_server"
80
+ elif _contains_any(
81
+ searchable,
82
+ ("connection", "connecterror", "api connection", "service unavailable", "temporarily unavailable"),
83
+ ):
84
+ category = "connection"
85
+ else:
86
+ category = "unknown"
87
+
88
+ return LLMErrorDetails(
89
+ provider=provider,
90
+ model_name=model_name,
91
+ category=category,
92
+ message=message,
93
+ exception_type=exception_type,
94
+ status_code=status_code,
95
+ code=code,
96
+ )
97
+
98
+
99
+ def _contains_any(value: str, needles: tuple[str, ...]) -> bool:
100
+ return any(needle in value for needle in needles)
101
+
102
+
103
+ def _get_status_code(exc: Exception) -> Optional[int]:
104
+ for attr in ("status_code", "status", "http_status", "http_status_code"):
105
+ value = getattr(exc, attr, None)
106
+ if isinstance(value, int):
107
+ return value
108
+
109
+ response = getattr(exc, "response", None)
110
+ value = getattr(response, "status_code", None)
111
+ if isinstance(value, int):
112
+ return value
113
+
114
+ return None
115
+
116
+
117
+ def _get_code(exc: Exception) -> Optional[str]:
118
+ for attr in ("code", "error_code", "type"):
119
+ value = getattr(exc, attr, None)
120
+ if value is not None:
121
+ return str(value)
122
+
123
+ body = getattr(exc, "body", None)
124
+ if isinstance(body, dict):
125
+ error = body.get("error", body)
126
+ if isinstance(error, dict):
127
+ for key in ("code", "type"):
128
+ value = error.get(key)
129
+ if value is not None:
130
+ return str(value)
131
+
132
+ return None
@@ -0,0 +1 @@
1
+ # Providers package initialization.
@@ -0,0 +1,20 @@
1
+ from langchain_core.language_models import BaseChatModel
2
+ from llm_registry.base import BaseLLMProvider
3
+ from llm_registry.config import ModelConfig
4
+
5
+ class AnthropicProvider(BaseLLMProvider):
6
+ def create_model(self, config: ModelConfig) -> BaseChatModel:
7
+ from langchain_anthropic import ChatAnthropic
8
+
9
+ kwargs = {
10
+ "model": config.model_name,
11
+ }
12
+ if config.temperature is not None:
13
+ kwargs["temperature"] = config.temperature
14
+ if config.max_tokens is not None:
15
+ kwargs["max_tokens"] = config.max_tokens
16
+
17
+ return ChatAnthropic(
18
+ **kwargs,
19
+ **config.extra_params
20
+ )
@@ -0,0 +1,20 @@
1
+ from langchain_core.language_models import BaseChatModel
2
+ from llm_registry.base import BaseLLMProvider
3
+ from llm_registry.config import ModelConfig
4
+
5
+ class GoogleProvider(BaseLLMProvider):
6
+ def create_model(self, config: ModelConfig) -> BaseChatModel:
7
+ from langchain_google_genai import ChatGoogleGenerativeAI
8
+
9
+ kwargs = {
10
+ "model": config.model_name,
11
+ }
12
+ if config.temperature is not None:
13
+ kwargs["temperature"] = config.temperature
14
+ if config.max_tokens is not None:
15
+ kwargs["max_tokens"] = config.max_tokens
16
+
17
+ return ChatGoogleGenerativeAI(
18
+ **kwargs,
19
+ **config.extra_params
20
+ )
@@ -0,0 +1,20 @@
1
+ from langchain_core.language_models import BaseChatModel
2
+ from llm_registry.base import BaseLLMProvider
3
+ from llm_registry.config import ModelConfig
4
+
5
+ class GroqProvider(BaseLLMProvider):
6
+ def create_model(self, config: ModelConfig) -> BaseChatModel:
7
+ from langchain_groq import ChatGroq
8
+
9
+ kwargs = {
10
+ "model_name": config.model_name,
11
+ }
12
+ if config.temperature is not None:
13
+ kwargs["temperature"] = config.temperature
14
+ if config.max_tokens is not None:
15
+ kwargs["max_tokens"] = config.max_tokens
16
+
17
+ return ChatGroq(
18
+ **kwargs,
19
+ **config.extra_params
20
+ )
@@ -0,0 +1,37 @@
1
+ import os
2
+ import requests
3
+ from llm_registry.base import BaseLLMProvider
4
+ from llm_registry.config import ModelConfig
5
+
6
+ class LocalLLM:
7
+ def __init__(self, model=None, api_url=None):
8
+ # Fall back to environment variables if parameters are not explicitly passed
9
+ self.api_url = api_url or os.getenv("LOCAL_MODEL_LINK")
10
+ self.model = model or os.getenv("LOCAL_MODEL")
11
+
12
+ if not self.api_url:
13
+ raise ValueError("LOCAL_MODEL_LINK not set in environment.")
14
+ if not self.model:
15
+ raise ValueError("LOCAL_MODEL not set in environment.")
16
+
17
+ def invoke(self, messages, **kwargs):
18
+ payload = {
19
+ "model": self.model,
20
+ "messages": messages,
21
+ "stream": False
22
+ }
23
+ payload.update(kwargs)
24
+ response = requests.post(self.api_url, json=payload)
25
+ response.raise_for_status()
26
+ data = response.json()
27
+ return data.get("message", {}).get("content", data)
28
+
29
+ class LocalProvider(BaseLLMProvider):
30
+ def create_model(self, config: ModelConfig):
31
+ # Extract custom parameters from config model_name & extra_params
32
+ api_url = config.extra_params.get("api_url")
33
+
34
+ return LocalLLM(
35
+ model=config.model_name,
36
+ api_url=api_url
37
+ )
@@ -0,0 +1,20 @@
1
+ from langchain_core.language_models import BaseChatModel
2
+ from llm_registry.base import BaseLLMProvider
3
+ from llm_registry.config import ModelConfig
4
+
5
+ class OpenAIProvider(BaseLLMProvider):
6
+ def create_model(self, config: ModelConfig) -> BaseChatModel:
7
+ from langchain_openai import ChatOpenAI
8
+
9
+ kwargs = {
10
+ "model": config.model_name,
11
+ }
12
+ if config.temperature is not None:
13
+ kwargs["temperature"] = config.temperature
14
+ if config.max_tokens is not None:
15
+ kwargs["max_tokens"] = config.max_tokens
16
+
17
+ return ChatOpenAI(
18
+ **kwargs,
19
+ **config.extra_params
20
+ )
@@ -0,0 +1,143 @@
1
+ from typing import Any, Dict
2
+ from langchain_core.language_models import BaseChatModel
3
+
4
+ from llm_registry.base import BaseLLMProvider
5
+ from llm_registry.config import ModelConfig
6
+ from llm_registry.errors import (
7
+ LLMErrorDetails,
8
+ LLMInvocationError,
9
+ LLMRegistryError,
10
+ ModelAliasNotFoundError,
11
+ ModelCreationError,
12
+ ProviderDependencyError,
13
+ ProviderNotRegisteredError,
14
+ normalize_exception,
15
+ )
16
+ from llm_registry.providers.openai import OpenAIProvider
17
+ from llm_registry.providers.groq import GroqProvider
18
+ from llm_registry.providers.anthropic import AnthropicProvider
19
+ from llm_registry.providers.google import GoogleProvider
20
+ from llm_registry.providers.local_llm import LocalProvider
21
+ from llm_registry.result import LLMResult
22
+
23
+ class LLMRegistry:
24
+ def __init__(self):
25
+ self._providers: Dict[str, BaseLLMProvider] = {}
26
+ self._configs: Dict[str, ModelConfig] = {}
27
+
28
+ ############### Register default providers #######################
29
+ self.register_provider("openai", OpenAIProvider())
30
+ self.register_provider("groq", GroqProvider())
31
+ self.register_provider("anthropic", AnthropicProvider())
32
+ self.register_provider("google", GoogleProvider())
33
+ self.register_provider("local", LocalProvider())
34
+
35
+ def register_provider(self, name: str, provider: BaseLLMProvider):
36
+ """Register an LLM provider."""
37
+ self._providers[name.lower()] = provider
38
+
39
+ def register_model_config(self, alias: str, config: ModelConfig):
40
+ """Register a model configuration under an alias."""
41
+ self._configs[alias] = config
42
+
43
+ def get_model(self, alias: str) -> BaseChatModel:
44
+ """Instantiate and return the model configured for the given alias."""
45
+ if alias not in self._configs:
46
+ details = LLMErrorDetails(
47
+ provider=None,
48
+ model_name=None,
49
+ category="model_alias_not_found",
50
+ message=f"Model alias '{alias}' is not registered in the LLM Registry.",
51
+ exception_type="ModelAliasNotFoundError",
52
+ )
53
+ raise ModelAliasNotFoundError(
54
+ details.message,
55
+ details,
56
+ )
57
+
58
+ config = self._configs[alias]
59
+ provider = self._providers.get(config.provider.lower())
60
+ if not provider:
61
+ details = LLMErrorDetails(
62
+ provider=config.provider,
63
+ model_name=config.model_name,
64
+ category="provider_not_registered",
65
+ message=f"No provider implementation found for '{config.provider}'.",
66
+ exception_type="ProviderNotRegisteredError",
67
+ )
68
+ raise ProviderNotRegisteredError(
69
+ details.message,
70
+ details,
71
+ )
72
+
73
+ try:
74
+ return provider.create_model(config)
75
+ except ImportError as exc:
76
+ details = normalize_exception(
77
+ exc,
78
+ provider=config.provider,
79
+ model_name=config.model_name,
80
+ )
81
+ raise ProviderDependencyError(
82
+ f"Missing dependency for provider '{config.provider}': {exc}",
83
+ details,
84
+ ) from exc
85
+ except Exception as exc:
86
+ details = normalize_exception(
87
+ exc,
88
+ provider=config.provider,
89
+ model_name=config.model_name,
90
+ )
91
+ raise ModelCreationError(
92
+ f"Failed to create model '{config.model_name}' for provider '{config.provider}': {exc}",
93
+ details,
94
+ ) from exc
95
+
96
+ def invoke(self, alias: str, input: Any, **kwargs: Any) -> Any:
97
+ """Invoke a registered model and raise normalized package exceptions on failure."""
98
+ config = self._get_config(alias)
99
+ try:
100
+ model = self.get_model(alias)
101
+ return model.invoke(input, **kwargs)
102
+ except LLMRegistryError:
103
+ raise
104
+ except Exception as exc:
105
+ details = normalize_exception(
106
+ exc,
107
+ provider=config.provider,
108
+ model_name=config.model_name,
109
+ )
110
+ raise LLMInvocationError(
111
+ f"LLM API call failed for provider '{config.provider}' and model '{config.model_name}': {exc}",
112
+ details,
113
+ ) from exc
114
+
115
+ def safe_invoke(self, alias: str, input: Any, **kwargs: Any) -> LLMResult:
116
+ """Invoke a registered model and return structured success/error data."""
117
+ try:
118
+ response = self.invoke(alias, input, **kwargs)
119
+ return LLMResult(
120
+ ok=True,
121
+ content=getattr(response, "content", None),
122
+ raw_response=response,
123
+ )
124
+ except LLMRegistryError as exc:
125
+ return LLMResult(ok=False, error=exc.details)
126
+
127
+ def _get_config(self, alias: str) -> ModelConfig:
128
+ if alias not in self._configs:
129
+ details = LLMErrorDetails(
130
+ provider=None,
131
+ model_name=None,
132
+ category="model_alias_not_found",
133
+ message=f"Model alias '{alias}' is not registered in the LLM Registry.",
134
+ exception_type="ModelAliasNotFoundError",
135
+ )
136
+ raise ModelAliasNotFoundError(
137
+ details.message,
138
+ details,
139
+ )
140
+ return self._configs[alias]
141
+
142
+ # Global registry singleton instance
143
+ global_registry = LLMRegistry()
@@ -0,0 +1,13 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Optional
3
+
4
+ from llm_registry.errors import LLMErrorDetails
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class LLMResult:
9
+ ok: bool
10
+ content: Optional[str] = None
11
+ raw_response: Optional[Any] = None
12
+ error: Optional[LLMErrorDetails] = None
13
+
@@ -0,0 +1,115 @@
1
+ import os
2
+ import sys
3
+ from dotenv import load_dotenv
4
+
5
+ # --- Dynamic Path Configuration ---
6
+ # Detects whether the script is run from /tests or the project root and appends /src
7
+ current_dir = os.path.dirname(os.path.abspath(__file__))
8
+ if os.path.basename(current_dir) == "tests":
9
+ project_root = os.path.dirname(current_dir)
10
+ else:
11
+ project_root = current_dir
12
+
13
+ sys.path.insert(0, os.path.join(project_root, "src"))
14
+
15
+ # Import from your llm_registry package
16
+ from llm_registry import global_registry, ModelConfig
17
+
18
+ # Load API keys from your local .env file
19
+ load_dotenv()
20
+
21
+ # Dictionary to map user choices to model config registrations
22
+ MODELS_MAP = {
23
+ "1": {
24
+ "alias": "openai-chat",
25
+ "name": "OpenAI (gpt-4o-mini)",
26
+ "config": ModelConfig(provider="openai", model_name="gpt-4o-mini", temperature=0.7),
27
+ "pip": "pip install langchain-openai"
28
+ },
29
+ "2": {
30
+ "alias": "groq-chat",
31
+ "name": "Groq (llama-3.1-8b-instant)",
32
+ "config": ModelConfig(provider="groq", model_name="llama-3.1-8b-instant", temperature=0.7),
33
+ "pip": "pip install langchain-groq"
34
+ },
35
+ "3": {
36
+ "alias": "anthropic-chat",
37
+ "name": "Anthropic (claude-opus-4-8)",
38
+ "config": ModelConfig(provider="anthropic", model_name="claude-opus-4-8"),
39
+ "pip": "pip install langchain-anthropic"
40
+ },
41
+ "4": {
42
+ "alias": "gemini-chat",
43
+ "name": "Google Gemini (gemini-2.5-flash)",
44
+ "config": ModelConfig(provider="google", model_name="gemini-2.5-flash", temperature=0.7),
45
+ "pip": "pip install langchain-google-genai"
46
+ }
47
+ }
48
+
49
+ def register_all_models():
50
+ """Register all configurations in the global registry."""
51
+ for key, model_info in MODELS_MAP.items():
52
+ global_registry.register_model_config(model_info["alias"], model_info["config"])
53
+
54
+ def run_playground():
55
+ register_all_models()
56
+
57
+ print("=" * 50)
58
+ print(" LLM REGISTRY PLAYGROUND")
59
+ print("=" * 50)
60
+ print("Select a provider/model to chat with:")
61
+
62
+ for key, model_info in MODELS_MAP.items():
63
+ print(f"{key}. {model_info['name']}")
64
+
65
+ choice = input("\nEnter choice (1-4): ").strip()
66
+
67
+ if choice not in MODELS_MAP:
68
+ print("Invalid selection. Exiting.")
69
+ return
70
+
71
+ model_info = MODELS_MAP[choice]
72
+ alias = model_info["alias"]
73
+
74
+ try:
75
+ # Resolve model from the registry
76
+ model = global_registry.get_model(alias)
77
+ print(f"\n[Success] Resolved model for: {model_info['name']}")
78
+ print("Type 'exit' or 'quit' to end session.\n")
79
+
80
+ while True:
81
+ user_input = input("You: ").strip()
82
+ if user_input.lower() in ["exit", "quit"]:
83
+ print("Exiting playground...")
84
+ break
85
+
86
+ if not user_input:
87
+ continue
88
+
89
+ print(f"[{alias} thinking...]")
90
+ result = global_registry.safe_invoke(alias, user_input)
91
+ if result.ok:
92
+ print(f"\nAI: {result.content}\n")
93
+ else:
94
+ error = result.error
95
+ print("\n[API Error]")
96
+ if error is None:
97
+ print("Message: Unknown registry error\n")
98
+ continue
99
+ print(f"Provider: {error.provider}")
100
+ print(f"Model: {error.model_name}")
101
+ print(f"Category: {error.category}")
102
+ if error.status_code is not None:
103
+ print(f"Status Code: {error.status_code}")
104
+ if error.code is not None:
105
+ print(f"Code: {error.code}")
106
+ print(f"Message: {error.message}\n")
107
+
108
+ except ImportError as imp_err:
109
+ print(f"\n[Import Error]: Missing SDK dependencies.")
110
+ print(f"To use this provider, please run: {model_info['pip']}")
111
+ except Exception as e:
112
+ print(f"\n[Error]: {e}")
113
+
114
+ if __name__ == "__main__":
115
+ run_playground()
@@ -0,0 +1,16 @@
1
+ from llm_registry import global_registry, ModelConfig
2
+
3
+ # Register the local model configuration
4
+ global_registry.register_model_config(
5
+ "ollama-chat",
6
+ ModelConfig(
7
+ provider="local",
8
+ model_name="llama3",
9
+ extra_params={"api_url": "http://localhost:11434/api/chat"}
10
+ )
11
+ )
12
+
13
+ # Resolve and invoke
14
+ local_model = global_registry.get_model("ollama-chat")
15
+ response = local_model.invoke("Hello, local model!")
16
+ print(response) # Outputs the direct text response
@@ -0,0 +1,59 @@
1
+ import os
2
+ import sys
3
+ import unittest
4
+ from dotenv import load_dotenv
5
+
6
+ # --- Dynamic Path Configuration ---
7
+ current_dir = os.path.dirname(os.path.abspath(__file__))
8
+ project_root = os.path.dirname(current_dir)
9
+ sys.path.insert(0, os.path.join(project_root, "src"))
10
+
11
+ from llm_registry import global_registry, ModelConfig
12
+ from langchain_core.language_models import BaseChatModel
13
+
14
+ # Load API keys from .env file
15
+ load_dotenv()
16
+
17
+ class TestGroqRegistry(unittest.TestCase):
18
+
19
+ def test_env_loaded(self):
20
+ """Test that the GROQ_API_KEY is successfully loaded from .env"""
21
+ api_key = os.getenv("GROQ_API_KEY")
22
+ self.assertIsNotNone(api_key, "GROQ_API_KEY is not defined in your .env file.")
23
+
24
+ def test_groq_registration_and_retrieval(self):
25
+ """Test that Groq is registered and resolves successfully from global_registry"""
26
+ config = ModelConfig(
27
+ provider="groq",
28
+ model_name="llama-3.1-8b-instant",
29
+ temperature=0.1
30
+ )
31
+ global_registry.register_model_config("my-groq-model", config)
32
+
33
+ model = global_registry.get_model("my-groq-model")
34
+
35
+ self.assertIsInstance(model, BaseChatModel, "The returned object is not a LangChain model.")
36
+ self.assertEqual(model.model_name, "llama-3.1-8b-instant")
37
+
38
+ def test_live_groq_api_call(self):
39
+ """Test a live request to Groq using your key"""
40
+ config = ModelConfig(
41
+ provider="groq",
42
+ model_name="llama-3.1-8b-instant",
43
+ temperature=0.0,
44
+ max_tokens=15
45
+ )
46
+ global_registry.register_model_config("groq-test-call", config)
47
+ model = global_registry.get_model("groq-test-call")
48
+
49
+ try:
50
+ response = model.invoke("Say the word 'success' and nothing else.")
51
+ response_text = response.content.strip().lower()
52
+
53
+ self.assertIn("success", response_text)
54
+ print(f"\n[Success] Live Groq API Connection verified. Model output: {response.content}")
55
+ except Exception as e:
56
+ self.fail(f"Live Groq API call failed with exception: {e}")
57
+
58
+ if __name__ == "__main__":
59
+ unittest.main()
@@ -0,0 +1,57 @@
1
+ import os
2
+ import sys
3
+ from dotenv import load_dotenv
4
+
5
+ # --- Dynamic Path Configuration ---
6
+ current_dir = os.path.dirname(os.path.abspath(__file__))
7
+ project_root = os.path.dirname(current_dir)
8
+ sys.path.insert(0, os.path.join(project_root, "src"))
9
+
10
+ from llm_registry import global_registry, ModelConfig
11
+
12
+ # 1. Load your API keys and local configs from .env
13
+ load_dotenv()
14
+
15
+ # Setup simulated env parameters if not already present in the environment/dotenv file
16
+ if not os.getenv("LOCAL_MODEL_LINK"):
17
+ os.environ["LOCAL_MODEL_LINK"] = "http://192.168.101.88:11434/api/chat"
18
+ if not os.getenv("LOCAL_MODEL"):
19
+ os.environ["LOCAL_MODEL"] = "llama3.1:8b"
20
+
21
+ def run_local_model_test():
22
+ print("=" * 50)
23
+ print(" LOCAL OLLAMA CONNECTION TEST")
24
+ print("=" * 50)
25
+
26
+ print(f"Env LOCAL_MODEL_LINK: {os.getenv('LOCAL_MODEL_LINK')}")
27
+ print(f"Env LOCAL_MODEL: {os.getenv('LOCAL_MODEL')}")
28
+
29
+ # Register the local provider config.
30
+ # By passing empty strings/None, it falls back dynamically to your .env variables!
31
+ global_registry.register_model_config(
32
+ "local-ollama",
33
+ ModelConfig(
34
+ provider="local",
35
+ model_name="", # Falls back to os.getenv("LOCAL_MODEL")
36
+ extra_params={} # Falls back to os.getenv("LOCAL_MODEL_LINK")
37
+ )
38
+ )
39
+
40
+ try:
41
+ # Resolve the model
42
+ model = global_registry.get_model("local-ollama")
43
+ print("\n[Success] Local model resolved successfully!")
44
+
45
+ # Invoke a simple prompt (role/content layout for Ollama api/chat)
46
+ test_messages = [{"role": "user", "content": "Tell me what is UI"}]
47
+ print(f"Sending request to {model.api_url}...")
48
+
49
+ response = model.invoke(test_messages)
50
+ print(f"\nResponse from Local LLM:\n{response}")
51
+
52
+ except Exception as e:
53
+ print(f"\n[Connection Error]: {e}")
54
+ print("Please check that your Ollama server is running and accessible at the specified IP/port.")
55
+
56
+ if __name__ == "__main__":
57
+ run_local_model_test()
@@ -0,0 +1,61 @@
1
+ import os
2
+ import sys
3
+ import unittest
4
+ from dotenv import load_dotenv
5
+
6
+ # --- Dynamic Path Configuration ---
7
+ # This adds the 'src/' folder to Python's search path so it finds 'llm_registry'
8
+ current_dir = os.path.dirname(os.path.abspath(__file__))
9
+ project_root = os.path.dirname(current_dir)
10
+ sys.path.insert(0, os.path.join(project_root, "src"))
11
+
12
+ # Now these imports will work successfully!
13
+ from llm_registry.providers.openai import OpenAIProvider
14
+ from llm_registry.config import ModelConfig
15
+ from langchain_core.language_models import BaseChatModel
16
+
17
+ # Load API keys from your local .env file
18
+ load_dotenv()
19
+
20
+ class TestOpenAIProvider(unittest.TestCase):
21
+
22
+ def test_env_loaded(self):
23
+ """Test that the OpenAI API key is successfully loaded from .env"""
24
+ api_key = os.getenv("OPENAI_API_KEY")
25
+ self.assertIsNotNone(api_key, "OPENAI_API_KEY is not defined in your .env file.")
26
+
27
+ def test_provider_initialization(self):
28
+ """Test that OpenAIProvider instantiates a valid LangChain ChatModel"""
29
+ config = ModelConfig(
30
+ provider="openai",
31
+ model_name="gpt-4o-mini",
32
+ temperature=0.0
33
+ )
34
+ provider = OpenAIProvider()
35
+ model = provider.create_model(config)
36
+
37
+ self.assertIsInstance(model, BaseChatModel, "The returned object is not a LangChain model.")
38
+ self.assertEqual(model.model_name, "gpt-4o-mini")
39
+
40
+ def test_live_api_call(self):
41
+ """Test a live request to OpenAI using your key"""
42
+ config = ModelConfig(
43
+ provider="openai",
44
+ model_name="gpt-4.1-mini",
45
+ temperature=0.0,
46
+ max_tokens=200
47
+ )
48
+ provider = OpenAIProvider()
49
+ model = provider.create_model(config)
50
+
51
+ try:
52
+ response = model.invoke("Say the word 'success' and nothing else.")
53
+ response_text = response.content.strip().lower()
54
+
55
+ self.assertIn("success", response_text)
56
+ print(f"\n[Success] Live API Connection verified. Model output: {response.content}")
57
+ except Exception as e:
58
+ self.fail(f"Live API call failed with exception: {e}")
59
+
60
+ if __name__ == "__main__":
61
+ unittest.main()
@@ -0,0 +1,136 @@
1
+ import os
2
+ import sys
3
+ import unittest
4
+
5
+ # --- Dynamic Path Configuration ---
6
+ # Detects whether the script is run from /tests or the project root and appends /src
7
+ current_dir = os.path.dirname(os.path.abspath(__file__))
8
+ if os.path.basename(current_dir) == "tests":
9
+ project_root = os.path.dirname(current_dir)
10
+ else:
11
+ project_root = current_dir
12
+
13
+ sys.path.insert(0, os.path.join(project_root, "src"))
14
+
15
+ from llm_registry import ( # noqa: E402
16
+ BaseLLMProvider,
17
+ LLMInvocationError,
18
+ LLMRegistry,
19
+ ModelAliasNotFoundError,
20
+ ModelConfig,
21
+ ProviderDependencyError,
22
+ ProviderNotRegisteredError,
23
+ )
24
+
25
+
26
+ class FakeResponse:
27
+ content = "success"
28
+
29
+
30
+ class FakeModel:
31
+ def __init__(self, exc=None):
32
+ self.exc = exc
33
+
34
+ def invoke(self, input, **kwargs):
35
+ if self.exc:
36
+ raise self.exc
37
+ return FakeResponse()
38
+
39
+
40
+ class FakeProvider(BaseLLMProvider):
41
+ def __init__(self, model=None, exc=None):
42
+ self.model = model or FakeModel()
43
+ self.exc = exc
44
+
45
+ def create_model(self, config):
46
+ if self.exc:
47
+ raise self.exc
48
+ return self.model
49
+
50
+
51
+ class FakeRateLimitError(Exception):
52
+ status_code = 429
53
+ code = "rate_limit_exceeded"
54
+
55
+
56
+ class TestRegistryErrorHandling(unittest.TestCase):
57
+ def test_unknown_alias_raises_typed_error(self):
58
+ registry = LLMRegistry()
59
+
60
+ with self.assertRaises(ModelAliasNotFoundError) as ctx:
61
+ registry.get_model("missing")
62
+
63
+ self.assertEqual(ctx.exception.details.category, "model_alias_not_found")
64
+
65
+ def test_unknown_provider_raises_typed_error(self):
66
+ registry = LLMRegistry()
67
+ registry.register_model_config(
68
+ "bad-provider",
69
+ ModelConfig(provider="missing", model_name="x"),
70
+ )
71
+
72
+ with self.assertRaises(ProviderNotRegisteredError) as ctx:
73
+ registry.get_model("bad-provider")
74
+
75
+ self.assertEqual(ctx.exception.details.provider, "missing")
76
+ self.assertEqual(ctx.exception.details.category, "provider_not_registered")
77
+
78
+ def test_missing_dependency_is_normalized(self):
79
+ registry = LLMRegistry()
80
+ registry.register_provider("fake", FakeProvider(exc=ImportError("fake-sdk")))
81
+ registry.register_model_config(
82
+ "fake",
83
+ ModelConfig(provider="fake", model_name="fake-model"),
84
+ )
85
+
86
+ with self.assertRaises(ProviderDependencyError) as ctx:
87
+ registry.get_model("fake")
88
+
89
+ self.assertEqual(ctx.exception.details.provider, "fake")
90
+ self.assertEqual(ctx.exception.details.model_name, "fake-model")
91
+
92
+ def test_invoke_wraps_provider_api_error(self):
93
+ registry = LLMRegistry()
94
+ registry.register_provider("fake", FakeProvider(model=FakeModel(FakeRateLimitError("slow down"))))
95
+ registry.register_model_config(
96
+ "fake",
97
+ ModelConfig(provider="fake", model_name="fake-model"),
98
+ )
99
+
100
+ with self.assertRaises(LLMInvocationError) as ctx:
101
+ registry.invoke("fake", "hello")
102
+
103
+ self.assertEqual(ctx.exception.details.category, "rate_limit")
104
+ self.assertEqual(ctx.exception.details.status_code, 429)
105
+
106
+ def test_safe_invoke_returns_structured_result(self):
107
+ registry = LLMRegistry()
108
+ registry.register_provider("fake", FakeProvider(model=FakeModel(FakeRateLimitError("slow down"))))
109
+ registry.register_model_config(
110
+ "fake",
111
+ ModelConfig(provider="fake", model_name="fake-model"),
112
+ )
113
+
114
+ result = registry.safe_invoke("fake", "hello")
115
+
116
+ self.assertFalse(result.ok)
117
+ self.assertEqual(result.error.category, "rate_limit")
118
+ self.assertIsNone(result.content)
119
+
120
+ def test_safe_invoke_returns_success_result(self):
121
+ registry = LLMRegistry()
122
+ registry.register_provider("fake", FakeProvider())
123
+ registry.register_model_config(
124
+ "fake",
125
+ ModelConfig(provider="fake", model_name="fake-model"),
126
+ )
127
+
128
+ result = registry.safe_invoke("fake", "hello")
129
+
130
+ self.assertTrue(result.ok)
131
+ self.assertEqual(result.content, "success")
132
+ self.assertIsInstance(result.raw_response, FakeResponse)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ unittest.main()