model-router-cli 1.0.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.
- app/analytics/service.py +119 -0
- app/analyzer/analyzer.py +67 -0
- app/analyzer/heuristics.py +192 -0
- app/api/routes.py +589 -0
- app/budgets/manager.py +39 -0
- app/cli/main.py +287 -0
- app/config/settings.py +43 -0
- app/experiments/service.py +85 -0
- app/fallback/handler.py +105 -0
- app/models/schemas.py +127 -0
- app/observability/events.py +43 -0
- app/providers/base.py +46 -0
- app/providers/external_providers.py +321 -0
- app/providers/mock_provider.py +108 -0
- app/providers/ollama_provider.py +141 -0
- app/providers/registry.py +35 -0
- app/router/engine.py +150 -0
- app/router/rules_engine.py +73 -0
- app/router/scoring.py +154 -0
- app/static/assets/index-CQFztymk.js +63 -0
- app/static/assets/index-DWa3sE4Y.css +2 -0
- app/static/favicon.png +0 -0
- app/static/favicon.svg +1 -0
- app/static/icons.svg +24 -0
- app/static/index.html +17 -0
- app/static/logo.png +0 -0
- app/storage/database.py +366 -0
- app/storage/models.py +202 -0
- model_router_cli-1.0.0.dist-info/METADATA +343 -0
- model_router_cli-1.0.0.dist-info/RECORD +38 -0
- model_router_cli-1.0.0.dist-info/WHEEL +5 -0
- model_router_cli-1.0.0.dist-info/entry_points.txt +2 -0
- model_router_cli-1.0.0.dist-info/licenses/LICENSE +22 -0
- model_router_cli-1.0.0.dist-info/top_level.txt +2 -0
- tests/test_analyzer.py +41 -0
- tests/test_e2e.py +127 -0
- tests/test_providers.py +21 -0
- tests/test_router.py +78 -0
app/providers/base.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import time
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import AsyncGenerator, Dict, Any, Optional
|
|
5
|
+
from app.models.schemas import ProviderResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelProvider(abc.ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract Base Class for all Model Providers (Mock, Ollama, OpenAI, Anthropic, Gemini, etc.)
|
|
11
|
+
Ensures complete provider decoupling from routing logic.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, provider_id: str, name: str, base_url: Optional[str] = None):
|
|
15
|
+
self.provider_id = provider_id
|
|
16
|
+
self.name = name
|
|
17
|
+
self.base_url = base_url
|
|
18
|
+
|
|
19
|
+
@abc.abstractmethod
|
|
20
|
+
async def generate(
|
|
21
|
+
self,
|
|
22
|
+
prompt: str,
|
|
23
|
+
model_id: str,
|
|
24
|
+
system_prompt: Optional[str] = None,
|
|
25
|
+
temperature: float = 0.7,
|
|
26
|
+
max_tokens: Optional[int] = None,
|
|
27
|
+
) -> ProviderResponse:
|
|
28
|
+
"""Execute a text generation call against the model provider."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abc.abstractmethod
|
|
32
|
+
async def stream(
|
|
33
|
+
self,
|
|
34
|
+
prompt: str,
|
|
35
|
+
model_id: str,
|
|
36
|
+
system_prompt: Optional[str] = None,
|
|
37
|
+
temperature: float = 0.7,
|
|
38
|
+
max_tokens: Optional[int] = None,
|
|
39
|
+
) -> AsyncGenerator[str, None]:
|
|
40
|
+
"""Stream generated response chunks."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@abc.abstractmethod
|
|
44
|
+
async def check_health(self) -> Dict[str, Any]:
|
|
45
|
+
"""Check provider connectivity, status, and loaded models."""
|
|
46
|
+
pass
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import AsyncGenerator, Dict, Any, Optional
|
|
4
|
+
from app.providers.base import ModelProvider
|
|
5
|
+
from app.models.schemas import ProviderResponse
|
|
6
|
+
from app.config.settings import get_settings
|
|
7
|
+
|
|
8
|
+
settings = get_settings()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAIProvider(ModelProvider):
|
|
12
|
+
"""
|
|
13
|
+
OpenAI Provider Adapter.
|
|
14
|
+
Uses OPENAI_API_KEY from environment variables. Secrets are never exposed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
18
|
+
super().__init__(provider_id="openai", name="OpenAI", base_url="https://api.openai.com/v1")
|
|
19
|
+
self.api_key = api_key or settings.OPENAI_API_KEY
|
|
20
|
+
|
|
21
|
+
async def generate(
|
|
22
|
+
self,
|
|
23
|
+
prompt: str,
|
|
24
|
+
model_id: str,
|
|
25
|
+
system_prompt: Optional[str] = None,
|
|
26
|
+
temperature: float = 0.7,
|
|
27
|
+
max_tokens: Optional[int] = None,
|
|
28
|
+
) -> ProviderResponse:
|
|
29
|
+
if not self.api_key:
|
|
30
|
+
return ProviderResponse(
|
|
31
|
+
content="",
|
|
32
|
+
finish_reason="error",
|
|
33
|
+
error="OPENAI_API_KEY not configured in environment.",
|
|
34
|
+
is_mock=False,
|
|
35
|
+
model=model_id,
|
|
36
|
+
provider="openai",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
endpoint = f"{self.base_url}/chat/completions"
|
|
40
|
+
headers = {
|
|
41
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
}
|
|
44
|
+
messages = []
|
|
45
|
+
if system_prompt:
|
|
46
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
47
|
+
messages.append({"role": "user", "content": prompt})
|
|
48
|
+
|
|
49
|
+
payload: Dict[str, Any] = {
|
|
50
|
+
"model": model_id,
|
|
51
|
+
"messages": messages,
|
|
52
|
+
"temperature": temperature,
|
|
53
|
+
}
|
|
54
|
+
if max_tokens:
|
|
55
|
+
payload["max_tokens"] = max_tokens
|
|
56
|
+
|
|
57
|
+
start_time = time.perf_counter()
|
|
58
|
+
try:
|
|
59
|
+
async with httpx.AsyncClient(timeout=settings.PROVIDER_TIMEOUT_SECONDS) as client:
|
|
60
|
+
resp = await client.post(endpoint, json=payload, headers=headers)
|
|
61
|
+
resp.raise_for_status()
|
|
62
|
+
data = resp.json()
|
|
63
|
+
|
|
64
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
65
|
+
choice = data["choices"][0]
|
|
66
|
+
usage = data.get("usage", {})
|
|
67
|
+
|
|
68
|
+
return ProviderResponse(
|
|
69
|
+
content=choice["message"]["content"],
|
|
70
|
+
finish_reason=choice.get("finish_reason", "stop"),
|
|
71
|
+
input_tokens=usage.get("prompt_tokens", 0),
|
|
72
|
+
output_tokens=usage.get("completion_tokens", 0),
|
|
73
|
+
total_tokens=usage.get("total_tokens", 0),
|
|
74
|
+
provider_latency_ms=round(elapsed_ms, 2),
|
|
75
|
+
is_mock=False,
|
|
76
|
+
model=model_id,
|
|
77
|
+
provider="openai",
|
|
78
|
+
)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
return ProviderResponse(
|
|
81
|
+
content="",
|
|
82
|
+
finish_reason="error",
|
|
83
|
+
error=f"OpenAI error: {str(exc)}",
|
|
84
|
+
is_mock=False,
|
|
85
|
+
model=model_id,
|
|
86
|
+
provider="openai",
|
|
87
|
+
provider_latency_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def stream(
|
|
91
|
+
self,
|
|
92
|
+
prompt: str,
|
|
93
|
+
model_id: str,
|
|
94
|
+
system_prompt: Optional[str] = None,
|
|
95
|
+
temperature: float = 0.7,
|
|
96
|
+
max_tokens: Optional[int] = None,
|
|
97
|
+
) -> AsyncGenerator[str, None]:
|
|
98
|
+
if not self.api_key:
|
|
99
|
+
yield "[OpenAI Error: API key not configured]"
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
endpoint = f"{self.base_url}/chat/completions"
|
|
103
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
104
|
+
messages = []
|
|
105
|
+
if system_prompt:
|
|
106
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
107
|
+
messages.append({"role": "user", "content": prompt})
|
|
108
|
+
|
|
109
|
+
payload = {
|
|
110
|
+
"model": model_id,
|
|
111
|
+
"messages": messages,
|
|
112
|
+
"temperature": temperature,
|
|
113
|
+
"stream": True,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
async with httpx.AsyncClient(timeout=settings.PROVIDER_TIMEOUT_SECONDS) as client:
|
|
118
|
+
async with client.stream("POST", endpoint, json=payload, headers=headers) as resp:
|
|
119
|
+
async for line in resp.aiter_lines():
|
|
120
|
+
if line.startswith("data: ") and not line.endswith("[DONE]"):
|
|
121
|
+
import json
|
|
122
|
+
data = json.loads(line[6:])
|
|
123
|
+
delta = data["choices"][0].get("delta", {}).get("content", "")
|
|
124
|
+
if delta:
|
|
125
|
+
yield delta
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
yield f"\n[OpenAI Streaming Error: {str(exc)}]"
|
|
128
|
+
|
|
129
|
+
async def check_health(self) -> Dict[str, Any]:
|
|
130
|
+
if not self.api_key:
|
|
131
|
+
return {"provider": "openai", "status": "NOT_CONFIGURED", "message": "API key not set in .env"}
|
|
132
|
+
return {"provider": "openai", "status": "CONNECTED", "message": "API key configured."}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class AnthropicProvider(ModelProvider):
|
|
136
|
+
"""
|
|
137
|
+
Anthropic Provider Adapter.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
141
|
+
super().__init__(provider_id="anthropic", name="Anthropic", base_url="https://api.anthropic.com/v1")
|
|
142
|
+
self.api_key = api_key or settings.ANTHROPIC_API_KEY
|
|
143
|
+
|
|
144
|
+
async def generate(
|
|
145
|
+
self,
|
|
146
|
+
prompt: str,
|
|
147
|
+
model_id: str,
|
|
148
|
+
system_prompt: Optional[str] = None,
|
|
149
|
+
temperature: float = 0.7,
|
|
150
|
+
max_tokens: Optional[int] = None,
|
|
151
|
+
) -> ProviderResponse:
|
|
152
|
+
if not self.api_key:
|
|
153
|
+
return ProviderResponse(
|
|
154
|
+
content="",
|
|
155
|
+
finish_reason="error",
|
|
156
|
+
error="ANTHROPIC_API_KEY not configured in environment.",
|
|
157
|
+
is_mock=False,
|
|
158
|
+
model=model_id,
|
|
159
|
+
provider="anthropic",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
endpoint = f"{self.base_url}/messages"
|
|
163
|
+
headers = {
|
|
164
|
+
"x-api-key": self.api_key,
|
|
165
|
+
"anthropic-version": "2023-06-01",
|
|
166
|
+
"Content-Type": "application/json",
|
|
167
|
+
}
|
|
168
|
+
payload: Dict[str, Any] = {
|
|
169
|
+
"model": model_id,
|
|
170
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
171
|
+
"max_tokens": max_tokens or 2048,
|
|
172
|
+
"temperature": temperature,
|
|
173
|
+
}
|
|
174
|
+
if system_prompt:
|
|
175
|
+
payload["system"] = system_prompt
|
|
176
|
+
|
|
177
|
+
start_time = time.perf_counter()
|
|
178
|
+
try:
|
|
179
|
+
async with httpx.AsyncClient(timeout=settings.PROVIDER_TIMEOUT_SECONDS) as client:
|
|
180
|
+
resp = await client.post(endpoint, json=payload, headers=headers)
|
|
181
|
+
resp.raise_for_status()
|
|
182
|
+
data = resp.json()
|
|
183
|
+
|
|
184
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
185
|
+
content = data["content"][0]["text"]
|
|
186
|
+
usage = data.get("usage", {})
|
|
187
|
+
|
|
188
|
+
return ProviderResponse(
|
|
189
|
+
content=content,
|
|
190
|
+
finish_reason=data.get("stop_reason", "stop"),
|
|
191
|
+
input_tokens=usage.get("input_tokens", 0),
|
|
192
|
+
output_tokens=usage.get("output_tokens", 0),
|
|
193
|
+
total_tokens=usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
|
|
194
|
+
provider_latency_ms=round(elapsed_ms, 2),
|
|
195
|
+
is_mock=False,
|
|
196
|
+
model=model_id,
|
|
197
|
+
provider="anthropic",
|
|
198
|
+
)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
return ProviderResponse(
|
|
201
|
+
content="",
|
|
202
|
+
finish_reason="error",
|
|
203
|
+
error=f"Anthropic error: {str(exc)}",
|
|
204
|
+
is_mock=False,
|
|
205
|
+
model=model_id,
|
|
206
|
+
provider="anthropic",
|
|
207
|
+
provider_latency_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def stream(
|
|
211
|
+
self,
|
|
212
|
+
prompt: str,
|
|
213
|
+
model_id: str,
|
|
214
|
+
system_prompt: Optional[str] = None,
|
|
215
|
+
temperature: float = 0.7,
|
|
216
|
+
max_tokens: Optional[int] = None,
|
|
217
|
+
) -> AsyncGenerator[str, None]:
|
|
218
|
+
resp = await self.generate(prompt, model_id, system_prompt, temperature, max_tokens)
|
|
219
|
+
if resp.error:
|
|
220
|
+
yield f"[{resp.error}]"
|
|
221
|
+
return
|
|
222
|
+
for word in resp.content.split(" "):
|
|
223
|
+
yield word + " "
|
|
224
|
+
|
|
225
|
+
async def check_health(self) -> Dict[str, Any]:
|
|
226
|
+
if not self.api_key:
|
|
227
|
+
return {"provider": "anthropic", "status": "NOT_CONFIGURED", "message": "API key not set in .env"}
|
|
228
|
+
return {"provider": "anthropic", "status": "CONNECTED", "message": "API key configured."}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class GeminiProvider(ModelProvider):
|
|
232
|
+
"""
|
|
233
|
+
Google Gemini Provider Adapter.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
237
|
+
super().__init__(provider_id="gemini", name="Google Gemini", base_url="https://generativelanguage.googleapis.com/v1beta")
|
|
238
|
+
self.api_key = api_key or settings.GEMINI_API_KEY
|
|
239
|
+
|
|
240
|
+
async def generate(
|
|
241
|
+
self,
|
|
242
|
+
prompt: str,
|
|
243
|
+
model_id: str,
|
|
244
|
+
system_prompt: Optional[str] = None,
|
|
245
|
+
temperature: float = 0.7,
|
|
246
|
+
max_tokens: Optional[int] = None,
|
|
247
|
+
) -> ProviderResponse:
|
|
248
|
+
if not self.api_key:
|
|
249
|
+
return ProviderResponse(
|
|
250
|
+
content="",
|
|
251
|
+
finish_reason="error",
|
|
252
|
+
error="GEMINI_API_KEY not configured in environment.",
|
|
253
|
+
is_mock=False,
|
|
254
|
+
model=model_id,
|
|
255
|
+
provider="gemini",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
endpoint = f"{self.base_url}/models/{model_id}:generateContent?key={self.api_key}"
|
|
259
|
+
contents = []
|
|
260
|
+
if system_prompt:
|
|
261
|
+
contents.append({"role": "user", "parts": [{"text": f"System context: {system_prompt}"}]})
|
|
262
|
+
contents.append({"role": "user", "parts": [{"text": prompt}]})
|
|
263
|
+
|
|
264
|
+
payload = {
|
|
265
|
+
"contents": contents,
|
|
266
|
+
"generationConfig": {"temperature": temperature},
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
start_time = time.perf_counter()
|
|
270
|
+
try:
|
|
271
|
+
async with httpx.AsyncClient(timeout=settings.PROVIDER_TIMEOUT_SECONDS) as client:
|
|
272
|
+
resp = await client.post(endpoint, json=payload)
|
|
273
|
+
resp.raise_for_status()
|
|
274
|
+
data = resp.json()
|
|
275
|
+
|
|
276
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
277
|
+
candidate = data["candidates"][0]
|
|
278
|
+
text = candidate["content"]["parts"][0]["text"]
|
|
279
|
+
usage = data.get("usageMetadata", {})
|
|
280
|
+
|
|
281
|
+
return ProviderResponse(
|
|
282
|
+
content=text,
|
|
283
|
+
finish_reason=candidate.get("finishReason", "stop"),
|
|
284
|
+
input_tokens=usage.get("promptTokenCount", 0),
|
|
285
|
+
output_tokens=usage.get("candidatesTokenCount", 0),
|
|
286
|
+
total_tokens=usage.get("totalTokenCount", 0),
|
|
287
|
+
provider_latency_ms=round(elapsed_ms, 2),
|
|
288
|
+
is_mock=False,
|
|
289
|
+
model=model_id,
|
|
290
|
+
provider="gemini",
|
|
291
|
+
)
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
return ProviderResponse(
|
|
294
|
+
content="",
|
|
295
|
+
finish_reason="error",
|
|
296
|
+
error=f"Gemini error: {str(exc)}",
|
|
297
|
+
is_mock=False,
|
|
298
|
+
model=model_id,
|
|
299
|
+
provider="gemini",
|
|
300
|
+
provider_latency_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def stream(
|
|
304
|
+
self,
|
|
305
|
+
prompt: str,
|
|
306
|
+
model_id: str,
|
|
307
|
+
system_prompt: Optional[str] = None,
|
|
308
|
+
temperature: float = 0.7,
|
|
309
|
+
max_tokens: Optional[int] = None,
|
|
310
|
+
) -> AsyncGenerator[str, None]:
|
|
311
|
+
resp = await self.generate(prompt, model_id, system_prompt, temperature, max_tokens)
|
|
312
|
+
if resp.error:
|
|
313
|
+
yield f"[{resp.error}]"
|
|
314
|
+
return
|
|
315
|
+
for word in resp.content.split(" "):
|
|
316
|
+
yield word + " "
|
|
317
|
+
|
|
318
|
+
async def check_health(self) -> Dict[str, Any]:
|
|
319
|
+
if not self.api_key:
|
|
320
|
+
return {"provider": "gemini", "status": "NOT_CONFIGURED", "message": "API key not set in .env"}
|
|
321
|
+
return {"provider": "gemini", "status": "CONNECTED", "message": "API key configured."}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import AsyncGenerator, Dict, Any, Optional
|
|
4
|
+
from app.providers.base import ModelProvider
|
|
5
|
+
from app.models.schemas import ProviderResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MockProvider(ModelProvider):
|
|
9
|
+
"""
|
|
10
|
+
Mock Provider for development, testing, and zero-cost simulation.
|
|
11
|
+
All outputs are explicitly labeled as DEMO/MOCK.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__(provider_id="mock", name="Mock / Demo Provider")
|
|
16
|
+
|
|
17
|
+
async def generate(
|
|
18
|
+
self,
|
|
19
|
+
prompt: str,
|
|
20
|
+
model_id: str,
|
|
21
|
+
system_prompt: Optional[str] = None,
|
|
22
|
+
temperature: float = 0.7,
|
|
23
|
+
max_tokens: Optional[int] = None,
|
|
24
|
+
) -> ProviderResponse:
|
|
25
|
+
start_time = time.perf_counter()
|
|
26
|
+
|
|
27
|
+
# Simulate realistic latency based on tier
|
|
28
|
+
tier_delay = 0.08 if "fast" in model_id else (0.18 if "balanced" in model_id else 0.35)
|
|
29
|
+
await asyncio.sleep(tier_delay)
|
|
30
|
+
|
|
31
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
32
|
+
|
|
33
|
+
# Calculate realistic token estimations
|
|
34
|
+
input_tokens = max(1, len(prompt.split()) * 2)
|
|
35
|
+
|
|
36
|
+
# Response tailored to task keywords
|
|
37
|
+
prompt_lower = prompt.lower()
|
|
38
|
+
if "python" in prompt_lower or "code" in prompt_lower or "def " in prompt_lower or "function" in prompt_lower:
|
|
39
|
+
content = (
|
|
40
|
+
f"[DEMO/MOCK RESPONSE from {model_id}]\n\n"
|
|
41
|
+
f"```python\n"
|
|
42
|
+
f"# Mock implementation generated for: {prompt[:60]}...\n"
|
|
43
|
+
f"def solve_problem(*args, **kwargs):\n"
|
|
44
|
+
f" \"\"\"Simulated response from Model Router Demo Engine.\"\"\"\n"
|
|
45
|
+
f" result = [x for x in args if x is not None]\n"
|
|
46
|
+
f" return {{'status': 'success', 'data': result, 'model': '{model_id}'}}\n"
|
|
47
|
+
f"```\n\n"
|
|
48
|
+
f"This code was returned by the simulated {model_id} engine."
|
|
49
|
+
)
|
|
50
|
+
elif "debug" in prompt_lower or "error" in prompt_lower or "bug" in prompt_lower:
|
|
51
|
+
content = (
|
|
52
|
+
f"[DEMO/MOCK RESPONSE from {model_id}]\n\n"
|
|
53
|
+
f"### Root Cause Analysis:\n"
|
|
54
|
+
f"1. **Potential Bottleneck**: Asynchronous task scheduling without proper semaphore limits.\n"
|
|
55
|
+
f"2. **Mitigation**: Introduce bounded concurrency worker pools.\n"
|
|
56
|
+
f"3. **Verification**: Checked against {model_id} simulated reasoning engine."
|
|
57
|
+
)
|
|
58
|
+
elif "summary" in prompt_lower or "summarize" in prompt_lower:
|
|
59
|
+
content = (
|
|
60
|
+
f"[DEMO/MOCK RESPONSE from {model_id}]\n\n"
|
|
61
|
+
f"**Executive Summary**:\n"
|
|
62
|
+
f"• Main point: {prompt[:80]}...\n"
|
|
63
|
+
f"• Key takeaway: High routing accuracy achieved with minimal latency overhead."
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
content = (
|
|
67
|
+
f"[DEMO/MOCK RESPONSE from {model_id}]\n\n"
|
|
68
|
+
f"Processed request: \"{prompt[:100]}\"\n\n"
|
|
69
|
+
f"This is a simulated response generated locally by Model Router Mock Engine without external API calls."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
output_tokens = max(1, len(content.split()) * 2)
|
|
73
|
+
total_tokens = input_tokens + output_tokens
|
|
74
|
+
|
|
75
|
+
return ProviderResponse(
|
|
76
|
+
content=content,
|
|
77
|
+
finish_reason="stop",
|
|
78
|
+
input_tokens=input_tokens,
|
|
79
|
+
output_tokens=output_tokens,
|
|
80
|
+
total_tokens=total_tokens,
|
|
81
|
+
provider_latency_ms=round(elapsed_ms, 2),
|
|
82
|
+
time_to_first_token_ms=round(tier_delay * 500, 2),
|
|
83
|
+
is_mock=True,
|
|
84
|
+
model=model_id,
|
|
85
|
+
provider="mock",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def stream(
|
|
89
|
+
self,
|
|
90
|
+
prompt: str,
|
|
91
|
+
model_id: str,
|
|
92
|
+
system_prompt: Optional[str] = None,
|
|
93
|
+
temperature: float = 0.7,
|
|
94
|
+
max_tokens: Optional[int] = None,
|
|
95
|
+
) -> AsyncGenerator[str, None]:
|
|
96
|
+
resp = await self.generate(prompt, model_id, system_prompt, temperature, max_tokens)
|
|
97
|
+
chunks = resp.content.split(" ")
|
|
98
|
+
for chunk in chunks:
|
|
99
|
+
await asyncio.sleep(0.02)
|
|
100
|
+
yield chunk + " "
|
|
101
|
+
|
|
102
|
+
async def check_health(self) -> Dict[str, Any]:
|
|
103
|
+
return {
|
|
104
|
+
"provider": "mock",
|
|
105
|
+
"status": "CONNECTED",
|
|
106
|
+
"message": "Mock provider active (Demo Mode). Zero API keys required.",
|
|
107
|
+
"models_available": ["mock-fast", "mock-balanced", "mock-power"],
|
|
108
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import AsyncGenerator, Dict, Any, Optional
|
|
4
|
+
from app.providers.base import ModelProvider
|
|
5
|
+
from app.models.schemas import ProviderResponse
|
|
6
|
+
from app.config.settings import get_settings
|
|
7
|
+
|
|
8
|
+
settings = get_settings()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OllamaProvider(ModelProvider):
|
|
12
|
+
"""
|
|
13
|
+
Local Ollama Provider adapter.
|
|
14
|
+
Executes real inference against locally running Ollama instance without external API keys.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, base_url: Optional[str] = None):
|
|
18
|
+
url = base_url or settings.OLLAMA_BASE_URL
|
|
19
|
+
super().__init__(provider_id="ollama", name="Ollama (Local)", base_url=url)
|
|
20
|
+
|
|
21
|
+
def _strip_model_prefix(self, model_id: str) -> str:
|
|
22
|
+
# e.g. "ollama-qwen2.5-coder" -> "qwen2.5-coder"
|
|
23
|
+
if model_id.startswith("ollama-"):
|
|
24
|
+
return model_id[len("ollama-"):]
|
|
25
|
+
return model_id
|
|
26
|
+
|
|
27
|
+
async def generate(
|
|
28
|
+
self,
|
|
29
|
+
prompt: str,
|
|
30
|
+
model_id: str,
|
|
31
|
+
system_prompt: Optional[str] = None,
|
|
32
|
+
temperature: float = 0.7,
|
|
33
|
+
max_tokens: Optional[int] = None,
|
|
34
|
+
) -> ProviderResponse:
|
|
35
|
+
real_model = self._strip_model_prefix(model_id)
|
|
36
|
+
endpoint = f"{self.base_url.rstrip('/')}/api/generate"
|
|
37
|
+
|
|
38
|
+
payload: Dict[str, Any] = {
|
|
39
|
+
"model": real_model,
|
|
40
|
+
"prompt": prompt,
|
|
41
|
+
"stream": False,
|
|
42
|
+
"options": {
|
|
43
|
+
"temperature": temperature,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if system_prompt:
|
|
47
|
+
payload["system"] = system_prompt
|
|
48
|
+
if max_tokens:
|
|
49
|
+
payload["options"]["num_predict"] = max_tokens
|
|
50
|
+
|
|
51
|
+
start_time = time.perf_counter()
|
|
52
|
+
try:
|
|
53
|
+
async with httpx.AsyncClient(timeout=settings.PROVIDER_TIMEOUT_SECONDS) as client:
|
|
54
|
+
resp = await client.post(endpoint, json=payload)
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
data = resp.json()
|
|
57
|
+
|
|
58
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
59
|
+
|
|
60
|
+
content = data.get("response", "")
|
|
61
|
+
input_tokens = data.get("prompt_eval_count", max(1, len(prompt.split()) * 2))
|
|
62
|
+
output_tokens = data.get("eval_count", max(1, len(content.split()) * 2))
|
|
63
|
+
|
|
64
|
+
# Nanoseconds to ms if provided by ollama
|
|
65
|
+
eval_duration_ms = data.get("eval_duration", 0) / 1_000_000.0 if "eval_duration" in data else elapsed_ms
|
|
66
|
+
|
|
67
|
+
return ProviderResponse(
|
|
68
|
+
content=content,
|
|
69
|
+
finish_reason="stop" if data.get("done") else "length",
|
|
70
|
+
input_tokens=input_tokens,
|
|
71
|
+
output_tokens=output_tokens,
|
|
72
|
+
total_tokens=input_tokens + output_tokens,
|
|
73
|
+
provider_latency_ms=round(elapsed_ms, 2),
|
|
74
|
+
time_to_first_token_ms=round(data.get("prompt_eval_duration", 0) / 1_000_000.0, 2) or None,
|
|
75
|
+
is_mock=False,
|
|
76
|
+
model=model_id,
|
|
77
|
+
provider="ollama",
|
|
78
|
+
)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
return ProviderResponse(
|
|
81
|
+
content="",
|
|
82
|
+
finish_reason="error",
|
|
83
|
+
error=f"Ollama connection error ({str(exc)}). Is Ollama running on {self.base_url}?",
|
|
84
|
+
is_mock=False,
|
|
85
|
+
model=model_id,
|
|
86
|
+
provider="ollama",
|
|
87
|
+
provider_latency_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def stream(
|
|
91
|
+
self,
|
|
92
|
+
prompt: str,
|
|
93
|
+
model_id: str,
|
|
94
|
+
system_prompt: Optional[str] = None,
|
|
95
|
+
temperature: float = 0.7,
|
|
96
|
+
max_tokens: Optional[int] = None,
|
|
97
|
+
) -> AsyncGenerator[str, None]:
|
|
98
|
+
real_model = self._strip_model_prefix(model_id)
|
|
99
|
+
endpoint = f"{self.base_url.rstrip('/')}/api/generate"
|
|
100
|
+
payload = {
|
|
101
|
+
"model": real_model,
|
|
102
|
+
"prompt": prompt,
|
|
103
|
+
"stream": True,
|
|
104
|
+
"options": {"temperature": temperature},
|
|
105
|
+
}
|
|
106
|
+
if system_prompt:
|
|
107
|
+
payload["system"] = system_prompt
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
async with httpx.AsyncClient(timeout=settings.PROVIDER_TIMEOUT_SECONDS) as client:
|
|
111
|
+
async with client.stream("POST", endpoint, json=payload) as resp:
|
|
112
|
+
async for line in resp.aiter_lines():
|
|
113
|
+
if line:
|
|
114
|
+
import json
|
|
115
|
+
data = json.loads(line)
|
|
116
|
+
chunk = data.get("response", "")
|
|
117
|
+
if chunk:
|
|
118
|
+
yield chunk
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
yield f"\n[Ollama Streaming Error: {str(exc)}]"
|
|
121
|
+
|
|
122
|
+
async def check_health(self) -> Dict[str, Any]:
|
|
123
|
+
try:
|
|
124
|
+
async with httpx.AsyncClient(timeout=3.0) as client:
|
|
125
|
+
resp = await client.get(f"{self.base_url.rstrip('/')}/api/tags")
|
|
126
|
+
if resp.status_code == 200:
|
|
127
|
+
models = [m.get("name") for m in resp.json().get("models", [])]
|
|
128
|
+
return {
|
|
129
|
+
"provider": "ollama",
|
|
130
|
+
"status": "CONNECTED",
|
|
131
|
+
"base_url": self.base_url,
|
|
132
|
+
"models_available": models,
|
|
133
|
+
}
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
pass
|
|
136
|
+
return {
|
|
137
|
+
"provider": "ollama",
|
|
138
|
+
"status": "NOT_CONNECTED",
|
|
139
|
+
"base_url": self.base_url,
|
|
140
|
+
"message": "Ollama service unreachable on configured port. Start via 'ollama serve'.",
|
|
141
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Dict, Optional
|
|
2
|
+
from app.providers.base import ModelProvider
|
|
3
|
+
from app.providers.mock_provider import MockProvider
|
|
4
|
+
from app.providers.ollama_provider import OllamaProvider
|
|
5
|
+
from app.providers.external_providers import OpenAIProvider, AnthropicProvider, GeminiProvider
|
|
6
|
+
from app.config.settings import get_settings
|
|
7
|
+
|
|
8
|
+
settings = get_settings()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProviderRegistry:
|
|
12
|
+
"""
|
|
13
|
+
Central registry managing provider instances.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self._providers: Dict[str, ModelProvider] = {
|
|
18
|
+
"mock": MockProvider(),
|
|
19
|
+
"ollama": OllamaProvider(base_url=settings.OLLAMA_BASE_URL),
|
|
20
|
+
"openai": OpenAIProvider(api_key=settings.OPENAI_API_KEY),
|
|
21
|
+
"anthropic": AnthropicProvider(api_key=settings.ANTHROPIC_API_KEY),
|
|
22
|
+
"gemini": GeminiProvider(api_key=settings.GEMINI_API_KEY),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def get_provider(self, provider_id: str) -> Optional[ModelProvider]:
|
|
26
|
+
return self._providers.get(provider_id.lower())
|
|
27
|
+
|
|
28
|
+
def register_provider(self, provider_id: str, provider: ModelProvider):
|
|
29
|
+
self._providers[provider_id.lower()] = provider
|
|
30
|
+
|
|
31
|
+
def list_providers(self) -> Dict[str, ModelProvider]:
|
|
32
|
+
return self._providers
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
provider_registry = ProviderRegistry()
|