Alibrown commited on
Commit
86c46c5
Β·
verified Β·
1 Parent(s): e3b0257

Update app/providers.py

Browse files
Files changed (1) hide show
  1. app/providers.py +172 -49
app/providers.py CHANGED
@@ -1,5 +1,6 @@
1
  # =============================================================================
2
- # # app/providers.py
 
3
  # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
4
  # Copyright 2026 - Volkan KΓΌcΓΌkbudak
5
  # Apache License V. 2 + ESOL 1.1
@@ -10,77 +11,99 @@
10
  # NO direct access to fundaments/*, .env, or Guardian (main.py).
11
  # All config comes from app/.pyfun via app/config.py.
12
  #
13
- #
14
- # TOOL REGISTRATION PRINCIPLE:
15
- # Tools are registered via providers.py and models.py .
16
  # No key = no provider = no tool = no crash.
17
- # Adding a new provider = update .pyfun + providers.py only. Never touch mcp.py!
 
 
 
 
 
 
18
  #
19
  # DEPENDENCY CHAIN (app/* only, no fundaments!):
20
  # config.py β†’ parses app/.pyfun β€” single source of truth
21
- # providers.py β†’ LLM + Search provider registry + fallback chain
22
- # models.py β†’ model limits, costs, capabilities from .pyfun [MODELS]
23
- # db_sync.py β†’ internal SQLite IPC (app/* state) β€” NOT postgresql.py!
24
- # mcp.py β†’ registers tools only, delegates all logic to providers/*
25
  # =============================================================================
26
 
27
- from . import config
28
  import os
29
- import httpx
30
  import logging
 
 
 
31
 
32
  logger = logging.getLogger("providers")
33
 
 
34
  # =============================================================================
35
- # Base Provider β€” gemeinsame Logic EINMAL
 
36
  # =============================================================================
 
37
  class BaseProvider:
 
 
 
 
38
  def __init__(self, name: str, cfg: dict):
39
- self.name = name
40
- self.key = os.getenv(cfg.get("env_key", ""))
41
- self.base_url = cfg.get("base_url", "")
42
- self.fallback = cfg.get("fallback_to", "")
43
- self.timeout = int(config.get_limits().get("REQUEST_TIMEOUT_SEC", "60"))
44
- self.model = cfg.get("default_model", "")
45
 
46
  async def complete(self, prompt: str, model: str, max_tokens: int) -> str:
 
47
  raise NotImplementedError
48
 
49
  async def _post(self, url: str, headers: dict, payload: dict) -> dict:
50
- """EINMAL β€” alle Provider nutzen das!"""
 
 
 
51
  async with httpx.AsyncClient() as client:
52
  r = await client.post(
53
  url,
54
  headers=headers,
55
  json=payload,
56
- timeout=self.timeout
57
  )
58
  r.raise_for_status()
59
  return r.json()
60
 
 
61
  # =============================================================================
62
- # Provider Implementierungen β€” nur parse logic verschieden
 
63
  # =============================================================================
 
64
  class AnthropicProvider(BaseProvider):
 
 
65
  async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
66
- cfg = config.get_active_llm_providers().get("anthropic", {})
67
  data = await self._post(
68
  f"{self.base_url}/messages",
69
  headers={
70
- "x-api-key": self.key,
71
- "anthropic-version": cfg.get("api_version_header", "2023-06-01"),
72
- "content-type": "application/json",
73
  },
74
  payload={
75
  "model": model or self.model,
76
  "max_tokens": max_tokens,
77
  "messages": [{"role": "user", "content": prompt}],
78
- }
79
  )
80
  return data["content"][0]["text"]
81
 
82
 
83
  class GeminiProvider(BaseProvider):
 
 
84
  async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
85
  m = model or self.model
86
  async with httpx.AsyncClient() as client:
@@ -88,16 +111,18 @@ class GeminiProvider(BaseProvider):
88
  f"{self.base_url}/models/{m}:generateContent",
89
  params={"key": self.key},
90
  json={
91
- "contents": [{"parts": [{"text": prompt}]}],
92
- "generationConfig":{"maxOutputTokens": max_tokens},
93
  },
94
- timeout=self.timeout
95
  )
96
  r.raise_for_status()
97
  return r.json()["candidates"][0]["content"]["parts"][0]["text"]
98
 
99
 
100
  class OpenRouterProvider(BaseProvider):
 
 
101
  async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
102
  data = await self._post(
103
  f"{self.base_url}/chat/completions",
@@ -107,17 +132,19 @@ class OpenRouterProvider(BaseProvider):
107
  "content-type": "application/json",
108
  },
109
  payload={
110
- "model": model or self.model,
111
  "max_tokens": max_tokens,
112
- "messages": [{"role": "user", "content": prompt}],
113
- }
114
  )
115
  return data["choices"][0]["message"]["content"]
116
 
117
 
118
  class HuggingFaceProvider(BaseProvider):
 
 
119
  async def complete(self, prompt: str, model: str = None, max_tokens: int = 512) -> str:
120
- m = model or self.model
121
  data = await self._post(
122
  f"{self.base_url}/{m}/v1/chat/completions",
123
  headers={
@@ -128,14 +155,17 @@ class HuggingFaceProvider(BaseProvider):
128
  "model": m,
129
  "max_tokens": max_tokens,
130
  "messages": [{"role": "user", "content": prompt}],
131
- }
132
  )
133
  return data["choices"][0]["message"]["content"]
134
 
135
 
136
  # =============================================================================
137
- # Provider Registry β€” gebaut aus .pyfun
 
 
138
  # =============================================================================
 
139
  _PROVIDER_CLASSES = {
140
  "anthropic": AnthropicProvider,
141
  "gemini": GeminiProvider,
@@ -145,8 +175,13 @@ _PROVIDER_CLASSES = {
145
 
146
  _registry: dict = {}
147
 
 
148
  def initialize() -> None:
149
- """Build provider registry from .pyfun β€” called by app.py"""
 
 
 
 
150
  global _registry
151
  active = config.get_active_llm_providers()
152
 
@@ -163,20 +198,35 @@ def initialize() -> None:
163
  logger.info(f"Provider registered: {name}")
164
 
165
 
166
- async def complete(
 
 
 
 
167
  prompt: str,
168
  provider_name: str = None,
169
  model: str = None,
170
- max_tokens: int = 1024
171
  ) -> str:
172
  """
173
- Complete with fallback chain from .pyfun.
174
- anthropic β†’ fails β†’ openrouter β†’ fails β†’ error
 
 
 
 
 
 
 
 
 
 
 
175
  """
176
- # default provider aus [TOOL.llm_complete] β†’ default_provider
177
  if not provider_name:
178
- tools = config.get_active_tools()
179
- provider_name = tools.get("llm_complete", {}).get("default_provider", "anthropic")
180
 
181
  visited = set()
182
  current = provider_name
@@ -193,18 +243,91 @@ async def complete(
193
  except Exception as e:
194
  logger.warning(f"Provider '{current}' failed: {e} β€” trying fallback.")
195
 
196
- # Fallback aus .pyfun
197
- cfg = config.get_active_llm_providers().get(current, {})
198
  current = cfg.get("fallback_to", "")
199
 
200
  raise RuntimeError("All providers failed β€” no fallback available.")
201
 
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  def get(name: str) -> BaseProvider:
204
- """Get a specific provider by name."""
 
 
 
 
 
 
 
 
205
  return _registry.get(name)
206
 
207
 
208
- def list_active() -> list:
209
- """List all active provider names."""
210
- return list(_registry.keys())
 
 
 
 
1
  # =============================================================================
2
+ # app/providers.py
3
+ # LLM + Search Provider Registry + Fallback Chain
4
  # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
5
  # Copyright 2026 - Volkan KΓΌcΓΌkbudak
6
  # Apache License V. 2 + ESOL 1.1
 
11
  # NO direct access to fundaments/*, .env, or Guardian (main.py).
12
  # All config comes from app/.pyfun via app/config.py.
13
  #
14
+ # PROVIDER PRINCIPLE:
 
 
15
  # No key = no provider = no tool = no crash.
16
+ # Server always starts, just with fewer providers.
17
+ # Adding a new provider = update .pyfun + add class here. Never touch mcp.py!
18
+ #
19
+ # FALLBACK CHAIN:
20
+ # Defined in .pyfun per provider via fallback_to field.
21
+ # anthropic β†’ fails β†’ openrouter β†’ fails β†’ RuntimeError
22
+ # Visited set prevents infinite loops.
23
  #
24
  # DEPENDENCY CHAIN (app/* only, no fundaments!):
25
  # config.py β†’ parses app/.pyfun β€” single source of truth
26
+ # providers.py β†’ LLM + Search registry + fallback chain
27
+ # tools.py β†’ calls providers.llm_complete() / providers.search()
28
+ # mcp.py β†’ calls providers.list_active_llm() / list_active_search()
 
29
  # =============================================================================
30
 
 
31
  import os
 
32
  import logging
33
+ import httpx
34
+
35
+ from . import config
36
 
37
  logger = logging.getLogger("providers")
38
 
39
+
40
  # =============================================================================
41
+ # SECTION 1 β€” Base Provider
42
+ # Shared HTTP logic β€” implemented ONCE, reused by all providers.
43
  # =============================================================================
44
+
45
  class BaseProvider:
46
+ """
47
+ Base class for all LLM providers.
48
+ Subclasses only implement complete() β€” HTTP logic lives here.
49
+ """
50
  def __init__(self, name: str, cfg: dict):
51
+ self.name = name
52
+ self.key = os.getenv(cfg.get("env_key", ""))
53
+ self.base_url = cfg.get("base_url", "")
54
+ self.fallback = cfg.get("fallback_to", "")
55
+ self.timeout = int(config.get_limits().get("REQUEST_TIMEOUT_SEC", "60"))
56
+ self.model = cfg.get("default_model", "")
57
 
58
  async def complete(self, prompt: str, model: str, max_tokens: int) -> str:
59
+ """Override in each provider subclass."""
60
  raise NotImplementedError
61
 
62
  async def _post(self, url: str, headers: dict, payload: dict) -> dict:
63
+ """
64
+ Shared HTTP POST β€” used by all providers.
65
+ Raises httpx.HTTPStatusError on non-2xx responses.
66
+ """
67
  async with httpx.AsyncClient() as client:
68
  r = await client.post(
69
  url,
70
  headers=headers,
71
  json=payload,
72
+ timeout=self.timeout,
73
  )
74
  r.raise_for_status()
75
  return r.json()
76
 
77
+
78
  # =============================================================================
79
+ # SECTION 2 β€” LLM Provider Implementations
80
+ # Only the API-specific parsing logic differs per provider.
81
  # =============================================================================
82
+
83
  class AnthropicProvider(BaseProvider):
84
+ """Anthropic Claude API β€” Messages endpoint."""
85
+
86
  async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
87
+ cfg = config.get_active_llm_providers().get("anthropic", {})
88
  data = await self._post(
89
  f"{self.base_url}/messages",
90
  headers={
91
+ "x-api-key": self.key,
92
+ "anthropic-version": cfg.get("api_version_header", "2023-06-01"),
93
+ "content-type": "application/json",
94
  },
95
  payload={
96
  "model": model or self.model,
97
  "max_tokens": max_tokens,
98
  "messages": [{"role": "user", "content": prompt}],
99
+ },
100
  )
101
  return data["content"][0]["text"]
102
 
103
 
104
  class GeminiProvider(BaseProvider):
105
+ """Google Gemini API β€” generateContent endpoint."""
106
+
107
  async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
108
  m = model or self.model
109
  async with httpx.AsyncClient() as client:
 
111
  f"{self.base_url}/models/{m}:generateContent",
112
  params={"key": self.key},
113
  json={
114
+ "contents": [{"parts": [{"text": prompt}]}],
115
+ "generationConfig": {"maxOutputTokens": max_tokens},
116
  },
117
+ timeout=self.timeout,
118
  )
119
  r.raise_for_status()
120
  return r.json()["candidates"][0]["content"]["parts"][0]["text"]
121
 
122
 
123
  class OpenRouterProvider(BaseProvider):
124
+ """OpenRouter API β€” OpenAI-compatible chat completions endpoint."""
125
+
126
  async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
127
  data = await self._post(
128
  f"{self.base_url}/chat/completions",
 
132
  "content-type": "application/json",
133
  },
134
  payload={
135
+ "model": model or self.model,
136
  "max_tokens": max_tokens,
137
+ "messages": [{"role": "user", "content": prompt}],
138
+ },
139
  )
140
  return data["choices"][0]["message"]["content"]
141
 
142
 
143
  class HuggingFaceProvider(BaseProvider):
144
+ """HuggingFace Inference API β€” chat completions endpoint."""
145
+
146
  async def complete(self, prompt: str, model: str = None, max_tokens: int = 512) -> str:
147
+ m = model or self.model
148
  data = await self._post(
149
  f"{self.base_url}/{m}/v1/chat/completions",
150
  headers={
 
155
  "model": m,
156
  "max_tokens": max_tokens,
157
  "messages": [{"role": "user", "content": prompt}],
158
+ },
159
  )
160
  return data["choices"][0]["message"]["content"]
161
 
162
 
163
  # =============================================================================
164
+ # SECTION 3 β€” Provider Registry
165
+ # Built from .pyfun [LLM_PROVIDERS] at initialize().
166
+ # Maps provider names to classes β€” add new providers here.
167
  # =============================================================================
168
+
169
  _PROVIDER_CLASSES = {
170
  "anthropic": AnthropicProvider,
171
  "gemini": GeminiProvider,
 
175
 
176
  _registry: dict = {}
177
 
178
+
179
  def initialize() -> None:
180
+ """
181
+ Build provider registry from .pyfun [LLM_PROVIDERS].
182
+ Called once by mcp.py during startup sequence.
183
+ Skips providers with missing ENV keys β€” no crash, just fewer tools.
184
+ """
185
  global _registry
186
  active = config.get_active_llm_providers()
187
 
 
198
  logger.info(f"Provider registered: {name}")
199
 
200
 
201
+ # =============================================================================
202
+ # SECTION 4 β€” LLM Execution + Fallback Chain
203
+ # =============================================================================
204
+
205
+ async def llm_complete(
206
  prompt: str,
207
  provider_name: str = None,
208
  model: str = None,
209
+ max_tokens: int = 1024,
210
  ) -> str:
211
  """
212
+ Send prompt to LLM provider with automatic fallback chain.
213
+ Fallback order is defined in .pyfun via fallback_to field.
214
+ Raises RuntimeError if all providers in the chain fail.
215
+
216
+ Args:
217
+ prompt: Input text to send to the model.
218
+ provider_name: Provider name override. Defaults to default_provider
219
+ from .pyfun [TOOL.llm_complete].
220
+ model: Model name override. Defaults to provider's default_model.
221
+ max_tokens: Max tokens in response. Default: 1024.
222
+
223
+ Returns:
224
+ Model response as plain text string.
225
  """
226
+ # Default provider from .pyfun [TOOL.llm_complete] β†’ default_provider
227
  if not provider_name:
228
+ tools_cfg = config.get_active_tools()
229
+ provider_name = tools_cfg.get("llm_complete", {}).get("default_provider", "anthropic")
230
 
231
  visited = set()
232
  current = provider_name
 
243
  except Exception as e:
244
  logger.warning(f"Provider '{current}' failed: {e} β€” trying fallback.")
245
 
246
+ # Next in fallback chain from .pyfun
247
+ cfg = config.get_active_llm_providers().get(current, {})
248
  current = cfg.get("fallback_to", "")
249
 
250
  raise RuntimeError("All providers failed β€” no fallback available.")
251
 
252
 
253
+ # Alias β€” used internally by tools.py
254
+ complete = llm_complete
255
+
256
+
257
+ # =============================================================================
258
+ # SECTION 5 β€” Search Execution
259
+ # Search providers not yet implemented β€” returns placeholder.
260
+ # Add BraveProvider, TavilyProvider here when ready.
261
+ # =============================================================================
262
+
263
+ async def search(
264
+ query: str,
265
+ provider_name: str = None,
266
+ max_results: int = 5,
267
+ ) -> str:
268
+ """
269
+ Search the web via configured search provider.
270
+ Search providers not yet implemented β€” placeholder until BraveProvider ready.
271
+
272
+ Args:
273
+ query: Search query string.
274
+ provider_name: Provider name override (e.g. 'brave', 'tavily').
275
+ max_results: Maximum number of results. Default: 5.
276
+
277
+ Returns:
278
+ Formatted search results as plain text string.
279
+ """
280
+ # TODO: implement BraveProvider, TavilyProvider
281
+ # Same pattern as LLM providers β€” add class + register in _SEARCH_REGISTRY
282
+ logger.info(f"web_search called β€” query: '{query}' β€” search providers not yet active.")
283
+ return f"Search not yet implemented. Query was: {query}"
284
+
285
+
286
+ # =============================================================================
287
+ # SECTION 6 β€” Registry Helpers
288
+ # Used by mcp.py for tool registration decisions.
289
+ # =============================================================================
290
+
291
+ def list_active_llm() -> list:
292
+ """
293
+ List all active LLM provider names.
294
+ Used by mcp.py to decide whether to register llm_complete tool.
295
+
296
+ Returns:
297
+ List of active LLM provider name strings.
298
+ """
299
+ return list(_registry.keys())
300
+
301
+
302
+ def list_active_search() -> list:
303
+ """
304
+ List all active search provider names.
305
+ Used by mcp.py to decide whether to register web_search tool.
306
+ Returns empty list until search providers are implemented.
307
+
308
+ Returns:
309
+ List of active search provider name strings.
310
+ """
311
+ # TODO: return list(_search_registry.keys()) when search providers are ready
312
+ return []
313
+
314
+
315
  def get(name: str) -> BaseProvider:
316
+ """
317
+ Get a specific provider instance by name.
318
+
319
+ Args:
320
+ name: Provider name (e.g. 'anthropic', 'huggingface').
321
+
322
+ Returns:
323
+ Provider instance, or None if not registered.
324
+ """
325
  return _registry.get(name)
326
 
327
 
328
+ # =============================================================================
329
+ # Direct execution guard
330
+ # =============================================================================
331
+
332
+ if __name__ == "__main__":
333
+ print("WARNING: Run via main.py β†’ app.py, not directly.")