aisbf 0.2.2__py3-none-any.whl → 0.2.4__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.
- aisbf/config.py +57 -1
- aisbf/handlers.py +314 -33
- aisbf/providers.py +164 -9
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf/config.py +57 -1
- aisbf-0.2.4.data/data/share/aisbf/aisbf/handlers.py +664 -0
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf/providers.py +164 -9
- aisbf-0.2.4.data/data/share/aisbf/main.py +421 -0
- {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/METADATA +1 -1
- aisbf-0.2.4.dist-info/RECORD +24 -0
- aisbf-0.2.2.data/data/share/aisbf/aisbf/handlers.py +0 -383
- aisbf-0.2.2.data/data/share/aisbf/main.py +0 -214
- aisbf-0.2.2.dist-info/RECORD +0 -24
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf/__init__.py +0 -0
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf/models.py +0 -0
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf.sh +0 -0
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/autoselect.json +0 -0
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/autoselect.md +0 -0
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/providers.json +0 -0
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/requirements.txt +0 -0
- {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/rotations.json +0 -0
- {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/WHEEL +0 -0
- {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/entry_points.txt +0 -0
- {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/licenses/LICENSE.txt +0 -0
- {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/top_level.txt +0 -0
|
@@ -62,14 +62,56 @@ class BaseProviderHandler:
|
|
|
62
62
|
self.last_request_time = time.time()
|
|
63
63
|
|
|
64
64
|
def record_failure(self):
|
|
65
|
+
import logging
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
65
68
|
self.error_tracking['failures'] += 1
|
|
66
69
|
self.error_tracking['last_failure'] = time.time()
|
|
70
|
+
|
|
71
|
+
failure_count = self.error_tracking['failures']
|
|
72
|
+
logger.warning(f"=== PROVIDER FAILURE RECORDED ===")
|
|
73
|
+
logger.warning(f"Provider: {self.provider_id}")
|
|
74
|
+
logger.warning(f"Failure count: {failure_count}/3")
|
|
75
|
+
logger.warning(f"Last failure time: {self.error_tracking['last_failure']}")
|
|
76
|
+
|
|
67
77
|
if self.error_tracking['failures'] >= 3:
|
|
68
78
|
self.error_tracking['disabled_until'] = time.time() + 300 # 5 minutes
|
|
79
|
+
disabled_until_time = self.error_tracking['disabled_until']
|
|
80
|
+
cooldown_remaining = int(disabled_until_time - time.time())
|
|
81
|
+
logger.error(f"!!! PROVIDER DISABLED !!!")
|
|
82
|
+
logger.error(f"Provider: {self.provider_id}")
|
|
83
|
+
logger.error(f"Reason: 3 consecutive failures reached")
|
|
84
|
+
logger.error(f"Disabled until: {disabled_until_time}")
|
|
85
|
+
logger.error(f"Cooldown period: {cooldown_remaining} seconds (5 minutes)")
|
|
86
|
+
logger.error(f"Provider will be automatically re-enabled after cooldown")
|
|
87
|
+
else:
|
|
88
|
+
remaining_failures = 3 - failure_count
|
|
89
|
+
logger.warning(f"Provider still active. {remaining_failures} more failure(s) will disable it")
|
|
90
|
+
logger.warning(f"=== END FAILURE RECORDING ===")
|
|
69
91
|
|
|
70
92
|
def record_success(self):
|
|
93
|
+
import logging
|
|
94
|
+
logger = logging.getLogger(__name__)
|
|
95
|
+
|
|
96
|
+
was_disabled = self.error_tracking['disabled_until'] is not None
|
|
97
|
+
previous_failures = self.error_tracking['failures']
|
|
98
|
+
|
|
71
99
|
self.error_tracking['failures'] = 0
|
|
72
100
|
self.error_tracking['disabled_until'] = None
|
|
101
|
+
|
|
102
|
+
logger.info(f"=== PROVIDER SUCCESS RECORDED ===")
|
|
103
|
+
logger.info(f"Provider: {self.provider_id}")
|
|
104
|
+
logger.info(f"Previous failure count: {previous_failures}")
|
|
105
|
+
logger.info(f"Failure count reset to: 0")
|
|
106
|
+
|
|
107
|
+
if was_disabled:
|
|
108
|
+
logger.info(f"!!! PROVIDER RE-ENABLED !!!")
|
|
109
|
+
logger.info(f"Provider: {self.provider_id}")
|
|
110
|
+
logger.info(f"Reason: Successful request after cooldown period")
|
|
111
|
+
logger.info(f"Provider is now active and available for requests")
|
|
112
|
+
else:
|
|
113
|
+
logger.info(f"Provider remains active")
|
|
114
|
+
logger.info(f"=== END SUCCESS RECORDING ===")
|
|
73
115
|
|
|
74
116
|
class GoogleProviderHandler(BaseProviderHandler):
|
|
75
117
|
def __init__(self, provider_id: str, api_key: str):
|
|
@@ -249,30 +291,122 @@ class AnthropicProviderHandler(BaseProviderHandler):
|
|
|
249
291
|
]
|
|
250
292
|
|
|
251
293
|
class OllamaProviderHandler(BaseProviderHandler):
|
|
252
|
-
def __init__(self, provider_id: str):
|
|
253
|
-
super().__init__(provider_id)
|
|
254
|
-
|
|
294
|
+
def __init__(self, provider_id: str, api_key: Optional[str] = None):
|
|
295
|
+
super().__init__(provider_id, api_key)
|
|
296
|
+
# Increase timeout for Ollama requests (especially for cloud models)
|
|
297
|
+
# Using separate timeouts for connect, read, write, and pool
|
|
298
|
+
timeout = httpx.Timeout(
|
|
299
|
+
connect=60.0, # 60 seconds to establish connection
|
|
300
|
+
read=300.0, # 5 minutes to read response
|
|
301
|
+
write=60.0, # 60 seconds to write request
|
|
302
|
+
pool=60.0 # 60 seconds for pool acquisition
|
|
303
|
+
)
|
|
304
|
+
self.client = httpx.AsyncClient(base_url=config.providers[provider_id].endpoint, timeout=timeout)
|
|
255
305
|
|
|
256
306
|
async def handle_request(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
|
|
257
307
|
temperature: Optional[float] = 1.0, stream: Optional[bool] = False) -> Dict:
|
|
308
|
+
import logging
|
|
309
|
+
import json
|
|
310
|
+
logger = logging.getLogger(__name__)
|
|
311
|
+
logger.info(f"=== OllamaProviderHandler.handle_request START ===")
|
|
312
|
+
logger.info(f"Provider ID: {self.provider_id}")
|
|
313
|
+
logger.info(f"Endpoint: {self.client.base_url}")
|
|
314
|
+
logger.info(f"Model: {model}")
|
|
315
|
+
logger.info(f"Messages count: {len(messages)}")
|
|
316
|
+
logger.info(f"Max tokens: {max_tokens}")
|
|
317
|
+
logger.info(f"Temperature: {temperature}")
|
|
318
|
+
logger.info(f"Stream: {stream}")
|
|
319
|
+
logger.info(f"API key provided: {bool(self.api_key)}")
|
|
320
|
+
|
|
258
321
|
if self.is_rate_limited():
|
|
322
|
+
logger.error("Provider is rate limited")
|
|
259
323
|
raise Exception("Provider rate limited")
|
|
260
324
|
|
|
261
325
|
try:
|
|
326
|
+
# Test connection first
|
|
327
|
+
logger.info("Testing Ollama connection...")
|
|
328
|
+
try:
|
|
329
|
+
health_response = await self.client.get("/api/tags", timeout=10.0)
|
|
330
|
+
logger.info(f"Ollama health check passed: {health_response.status_code}")
|
|
331
|
+
logger.info(f"Available models: {health_response.json().get('models', [])}")
|
|
332
|
+
except Exception as e:
|
|
333
|
+
logger.error(f"Ollama health check failed: {str(e)}")
|
|
334
|
+
logger.error(f"Cannot connect to Ollama at {self.client.base_url}")
|
|
335
|
+
logger.error(f"Please ensure Ollama is running and accessible")
|
|
336
|
+
raise Exception(f"Cannot connect to Ollama at {self.client.base_url}: {str(e)}")
|
|
337
|
+
|
|
262
338
|
# Apply rate limiting
|
|
339
|
+
logger.info("Applying rate limiting...")
|
|
263
340
|
await self.apply_rate_limit()
|
|
341
|
+
logger.info("Rate limiting applied")
|
|
264
342
|
|
|
265
|
-
|
|
343
|
+
prompt = "\n\n".join([f"{msg['role']}: {msg['content']}" for msg in messages])
|
|
344
|
+
logger.info(f"Prompt length: {len(prompt)} characters")
|
|
345
|
+
|
|
346
|
+
request_data = {
|
|
266
347
|
"model": model,
|
|
267
|
-
"prompt":
|
|
348
|
+
"prompt": prompt,
|
|
268
349
|
"options": {
|
|
269
350
|
"temperature": temperature,
|
|
270
351
|
"num_predict": max_tokens
|
|
271
|
-
}
|
|
272
|
-
|
|
352
|
+
},
|
|
353
|
+
"stream": False # Explicitly disable streaming for non-streaming requests
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Add API key to headers if provided (for Ollama cloud models)
|
|
357
|
+
headers = {}
|
|
358
|
+
if self.api_key:
|
|
359
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
360
|
+
logger.info("API key added to request headers for Ollama cloud")
|
|
361
|
+
|
|
362
|
+
logger.info(f"Sending POST request to {self.client.base_url}/api/generate")
|
|
363
|
+
logger.info(f"Request data: {request_data}")
|
|
364
|
+
logger.info(f"Request headers: {headers}")
|
|
365
|
+
logger.info(f"Client timeout: {self.client.timeout}")
|
|
366
|
+
|
|
367
|
+
response = await self.client.post("/api/generate", json=request_data, headers=headers)
|
|
368
|
+
logger.info(f"Response status code: {response.status_code}")
|
|
369
|
+
logger.info(f"Response content type: {response.headers.get('content-type')}")
|
|
370
|
+
logger.info(f"Response content length: {len(response.content)} bytes")
|
|
371
|
+
logger.info(f"Raw response content (first 500 chars): {response.text[:500]}")
|
|
273
372
|
response.raise_for_status()
|
|
373
|
+
|
|
374
|
+
# Ollama may return multiple JSON objects, parse them all
|
|
375
|
+
content = response.text
|
|
376
|
+
logger.info(f"Attempting to parse response as JSON...")
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
# Try parsing as single JSON first
|
|
380
|
+
response_json = response.json()
|
|
381
|
+
logger.info(f"Response parsed as single JSON: {response_json}")
|
|
382
|
+
except json.JSONDecodeError as e:
|
|
383
|
+
# If that fails, try parsing multiple JSON objects
|
|
384
|
+
logger.warning(f"Failed to parse as single JSON: {e}")
|
|
385
|
+
logger.info(f"Attempting to parse as multiple JSON objects...")
|
|
386
|
+
|
|
387
|
+
# Parse multiple JSON objects (one per line)
|
|
388
|
+
responses = []
|
|
389
|
+
for line in content.strip().split('\n'):
|
|
390
|
+
if line.strip():
|
|
391
|
+
try:
|
|
392
|
+
obj = json.loads(line)
|
|
393
|
+
responses.append(obj)
|
|
394
|
+
except json.JSONDecodeError as line_error:
|
|
395
|
+
logger.error(f"Failed to parse line: {line}")
|
|
396
|
+
logger.error(f"Error: {line_error}")
|
|
397
|
+
|
|
398
|
+
if not responses:
|
|
399
|
+
raise Exception("No valid JSON objects found in response")
|
|
400
|
+
|
|
401
|
+
# Combine responses - take the last complete response
|
|
402
|
+
# Ollama sends multiple chunks, we want the final one
|
|
403
|
+
response_json = responses[-1]
|
|
404
|
+
logger.info(f"Parsed {len(responses)} JSON objects, using last one: {response_json}")
|
|
405
|
+
|
|
406
|
+
logger.info(f"Final response: {response_json}")
|
|
274
407
|
self.record_success()
|
|
275
|
-
|
|
408
|
+
logger.info(f"=== OllamaProviderHandler.handle_request END ===")
|
|
409
|
+
return response_json
|
|
276
410
|
except Exception as e:
|
|
277
411
|
self.record_failure()
|
|
278
412
|
raise e
|
|
@@ -294,8 +428,29 @@ PROVIDER_HANDLERS = {
|
|
|
294
428
|
}
|
|
295
429
|
|
|
296
430
|
def get_provider_handler(provider_id: str, api_key: Optional[str] = None) -> BaseProviderHandler:
|
|
431
|
+
import logging
|
|
432
|
+
logger = logging.getLogger(__name__)
|
|
433
|
+
logger.info(f"=== get_provider_handler START ===")
|
|
434
|
+
logger.info(f"Provider ID: {provider_id}")
|
|
435
|
+
logger.info(f"API key provided: {bool(api_key)}")
|
|
436
|
+
|
|
297
437
|
provider_config = config.get_provider(provider_id)
|
|
438
|
+
logger.info(f"Provider config: {provider_config}")
|
|
439
|
+
logger.info(f"Provider type: {provider_config.type}")
|
|
440
|
+
logger.info(f"Provider endpoint: {provider_config.endpoint}")
|
|
441
|
+
|
|
298
442
|
handler_class = PROVIDER_HANDLERS.get(provider_config.type)
|
|
443
|
+
logger.info(f"Handler class: {handler_class.__name__ if handler_class else 'None'}")
|
|
444
|
+
logger.info(f"Available handler types: {list(PROVIDER_HANDLERS.keys())}")
|
|
445
|
+
|
|
299
446
|
if not handler_class:
|
|
447
|
+
logger.error(f"Unsupported provider type: {provider_config.type}")
|
|
300
448
|
raise ValueError(f"Unsupported provider type: {provider_config.type}")
|
|
301
|
-
|
|
449
|
+
|
|
450
|
+
# All handlers now accept api_key as optional parameter
|
|
451
|
+
logger.info(f"Creating handler with provider_id and optional api_key")
|
|
452
|
+
handler = handler_class(provider_id, api_key)
|
|
453
|
+
|
|
454
|
+
logger.info(f"Handler created: {handler.__class__.__name__}")
|
|
455
|
+
logger.info(f"=== get_provider_handler END ===")
|
|
456
|
+
return handler
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyleft (C) 2026 Stefy Lanza <stefy@nexlab.net>
|
|
3
|
+
|
|
4
|
+
AISBF - AI Service Broker Framework || AI Should Be Free
|
|
5
|
+
|
|
6
|
+
Main application for AISBF.
|
|
7
|
+
|
|
8
|
+
This program is free software: you can redistribute it and/or modify
|
|
9
|
+
it under the terms of the GNU General Public License as published by
|
|
10
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
11
|
+
(at your option) any later version.
|
|
12
|
+
|
|
13
|
+
This program is distributed in the hope that it will be useful,
|
|
14
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
15
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
16
|
+
GNU General Public License for more details.
|
|
17
|
+
|
|
18
|
+
You should have received a copy of the GNU General Public License
|
|
19
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
20
|
+
|
|
21
|
+
Why did the programmer quit his job? Because he didn't get arrays!
|
|
22
|
+
|
|
23
|
+
Main application for AISBF.
|
|
24
|
+
"""
|
|
25
|
+
from fastapi import FastAPI, HTTPException, Request, status
|
|
26
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
27
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
28
|
+
from aisbf.models import ChatCompletionRequest, ChatCompletionResponse
|
|
29
|
+
from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler
|
|
30
|
+
from aisbf.config import config
|
|
31
|
+
import time
|
|
32
|
+
import logging
|
|
33
|
+
import sys
|
|
34
|
+
import os
|
|
35
|
+
from logging.handlers import RotatingFileHandler
|
|
36
|
+
from datetime import datetime, timedelta
|
|
37
|
+
from collections import defaultdict
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
def setup_logging():
|
|
41
|
+
"""Setup logging with rotating file handlers"""
|
|
42
|
+
# Determine log directory based on user
|
|
43
|
+
if os.geteuid() == 0:
|
|
44
|
+
# Running as root - use /var/log/aisbf
|
|
45
|
+
log_dir = Path('/var/log/aisbf')
|
|
46
|
+
else:
|
|
47
|
+
# Running as user - use ~/.local/var/log/aisbf
|
|
48
|
+
log_dir = Path.home() / '.local' / 'var' / 'log' / 'aisbf'
|
|
49
|
+
|
|
50
|
+
# Create log directory if it doesn't exist
|
|
51
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# Setup rotating file handler for general logs
|
|
54
|
+
log_file = log_dir / 'aisbf.log'
|
|
55
|
+
file_handler = RotatingFileHandler(
|
|
56
|
+
log_file,
|
|
57
|
+
maxBytes=50*1024*1024, # 50 MB
|
|
58
|
+
backupCount=5,
|
|
59
|
+
encoding='utf-8'
|
|
60
|
+
)
|
|
61
|
+
file_handler.setLevel(logging.DEBUG)
|
|
62
|
+
file_formatter = logging.Formatter(
|
|
63
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
64
|
+
)
|
|
65
|
+
file_handler.setFormatter(file_formatter)
|
|
66
|
+
|
|
67
|
+
# Setup rotating file handler for error logs
|
|
68
|
+
error_log_file = log_dir / 'aisbf_error.log'
|
|
69
|
+
error_handler = RotatingFileHandler(
|
|
70
|
+
error_log_file,
|
|
71
|
+
maxBytes=50*1024*1024, # 50 MB
|
|
72
|
+
backupCount=5,
|
|
73
|
+
encoding='utf-8'
|
|
74
|
+
)
|
|
75
|
+
error_handler.setLevel(logging.ERROR)
|
|
76
|
+
error_handler.setFormatter(file_formatter)
|
|
77
|
+
|
|
78
|
+
# Setup console handler
|
|
79
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
80
|
+
console_handler.setLevel(logging.INFO)
|
|
81
|
+
console_formatter = logging.Formatter(
|
|
82
|
+
'%(asctime)s - %(levelname)s - %(message)s'
|
|
83
|
+
)
|
|
84
|
+
console_handler.setFormatter(console_formatter)
|
|
85
|
+
|
|
86
|
+
# Configure root logger
|
|
87
|
+
root_logger = logging.getLogger()
|
|
88
|
+
root_logger.setLevel(logging.DEBUG)
|
|
89
|
+
root_logger.addHandler(file_handler)
|
|
90
|
+
root_logger.addHandler(error_handler)
|
|
91
|
+
root_logger.addHandler(console_handler)
|
|
92
|
+
|
|
93
|
+
# Redirect stderr to error log
|
|
94
|
+
sys.stderr = open(log_dir / 'aisbf_stderr.log', 'a')
|
|
95
|
+
|
|
96
|
+
return logging.getLogger(__name__)
|
|
97
|
+
|
|
98
|
+
# Configure logging
|
|
99
|
+
logger = setup_logging()
|
|
100
|
+
|
|
101
|
+
# Initialize handlers
|
|
102
|
+
request_handler = RequestHandler()
|
|
103
|
+
rotation_handler = RotationHandler()
|
|
104
|
+
autoselect_handler = AutoselectHandler()
|
|
105
|
+
|
|
106
|
+
app = FastAPI(title="AI Proxy Server")
|
|
107
|
+
|
|
108
|
+
# CORS middleware
|
|
109
|
+
app.add_middleware(
|
|
110
|
+
CORSMiddleware,
|
|
111
|
+
allow_origins=["*"],
|
|
112
|
+
allow_credentials=True,
|
|
113
|
+
allow_methods=["*"],
|
|
114
|
+
allow_headers=["*"],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@app.get("/")
|
|
118
|
+
async def root():
|
|
119
|
+
return {
|
|
120
|
+
"message": "AI Proxy Server is running",
|
|
121
|
+
"providers": list(config.providers.keys()),
|
|
122
|
+
"rotations": list(config.rotations.keys()),
|
|
123
|
+
"autoselect": list(config.autoselect.keys())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@app.get("/api/rotations")
|
|
127
|
+
async def list_rotations():
|
|
128
|
+
"""List all available rotations"""
|
|
129
|
+
logger.info("=== LIST ROTATIONS REQUEST ===")
|
|
130
|
+
rotations_info = {}
|
|
131
|
+
for rotation_id, rotation_config in config.rotations.items():
|
|
132
|
+
models = []
|
|
133
|
+
for provider in rotation_config.providers:
|
|
134
|
+
for model in provider['models']:
|
|
135
|
+
models.append({
|
|
136
|
+
"name": model['name'],
|
|
137
|
+
"provider_id": provider['provider_id'],
|
|
138
|
+
"weight": model['weight'],
|
|
139
|
+
"rate_limit": model.get('rate_limit')
|
|
140
|
+
})
|
|
141
|
+
rotations_info[rotation_id] = {
|
|
142
|
+
"model_name": rotation_config.model_name,
|
|
143
|
+
"models": models
|
|
144
|
+
}
|
|
145
|
+
logger.info(f"Available rotations: {list(rotations_info.keys())}")
|
|
146
|
+
return rotations_info
|
|
147
|
+
|
|
148
|
+
@app.post("/api/rotations/chat/completions")
|
|
149
|
+
async def rotation_chat_completions(request: Request, body: ChatCompletionRequest):
|
|
150
|
+
"""Handle chat completions for rotations using model name to select rotation"""
|
|
151
|
+
logger.info(f"=== ROTATION CHAT COMPLETION REQUEST START ===")
|
|
152
|
+
logger.info(f"Request path: {request.url.path}")
|
|
153
|
+
logger.info(f"Model requested: {body.model}")
|
|
154
|
+
logger.info(f"Request headers: {dict(request.headers)}")
|
|
155
|
+
logger.info(f"Request body: {body}")
|
|
156
|
+
logger.info(f"Available rotations: {list(config.rotations.keys())}")
|
|
157
|
+
|
|
158
|
+
body_dict = body.model_dump()
|
|
159
|
+
|
|
160
|
+
# Check if the model name corresponds to a rotation
|
|
161
|
+
if body.model not in config.rotations:
|
|
162
|
+
logger.error(f"Model '{body.model}' not found in rotations")
|
|
163
|
+
logger.error(f"Available rotations: {list(config.rotations.keys())}")
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=400,
|
|
166
|
+
detail=f"Model '{body.model}' not found. Available rotations: {list(config.rotations.keys())}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
logger.info(f"Model '{body.model}' found in rotations")
|
|
170
|
+
logger.debug("Handling rotation request")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
if body.stream:
|
|
174
|
+
logger.debug("Handling streaming rotation request")
|
|
175
|
+
return await rotation_handler.handle_rotation_request(body.model, body_dict)
|
|
176
|
+
else:
|
|
177
|
+
logger.debug("Handling non-streaming rotation request")
|
|
178
|
+
result = await rotation_handler.handle_rotation_request(body.model, body_dict)
|
|
179
|
+
logger.debug(f"Rotation response result: {result}")
|
|
180
|
+
return result
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Error handling rotation chat_completions: {str(e)}", exc_info=True)
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
@app.get("/api/rotations/models")
|
|
186
|
+
async def list_rotation_models():
|
|
187
|
+
"""List all models across all rotations"""
|
|
188
|
+
logger.info("=== LIST ROTATION MODELS REQUEST ===")
|
|
189
|
+
all_models = []
|
|
190
|
+
for rotation_id, rotation_config in config.rotations.items():
|
|
191
|
+
for provider in rotation_config.providers:
|
|
192
|
+
for model in provider['models']:
|
|
193
|
+
all_models.append({
|
|
194
|
+
"id": f"{rotation_id}/{model['name']}",
|
|
195
|
+
"name": rotation_id, # Use rotation name as the model name for selection
|
|
196
|
+
"object": "model",
|
|
197
|
+
"owned_by": provider['provider_id'],
|
|
198
|
+
"rotation_id": rotation_id,
|
|
199
|
+
"actual_model": model['name'],
|
|
200
|
+
"provider_id": provider['provider_id'],
|
|
201
|
+
"weight": model['weight'],
|
|
202
|
+
"rate_limit": model.get('rate_limit')
|
|
203
|
+
})
|
|
204
|
+
logger.info(f"Total rotation models available: {len(all_models)}")
|
|
205
|
+
return {"data": all_models}
|
|
206
|
+
|
|
207
|
+
@app.get("/api/autoselect")
|
|
208
|
+
async def list_autoselect():
|
|
209
|
+
"""List all available autoselect configurations"""
|
|
210
|
+
logger.info("=== LIST AUTOSELECT REQUEST ===")
|
|
211
|
+
autoselect_info = {}
|
|
212
|
+
for autoselect_id, autoselect_config in config.autoselect.items():
|
|
213
|
+
autoselect_info[autoselect_id] = {
|
|
214
|
+
"model_name": autoselect_config.model_name,
|
|
215
|
+
"description": autoselect_config.description,
|
|
216
|
+
"fallback": autoselect_config.fallback,
|
|
217
|
+
"available_models": [
|
|
218
|
+
{
|
|
219
|
+
"model_id": m.model_id,
|
|
220
|
+
"description": m.description
|
|
221
|
+
}
|
|
222
|
+
for m in autoselect_config.available_models
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
logger.info(f"Available autoselect: {list(autoselect_info.keys())}")
|
|
226
|
+
return autoselect_info
|
|
227
|
+
|
|
228
|
+
@app.post("/api/autoselect/chat/completions")
|
|
229
|
+
async def autoselect_chat_completions(request: Request, body: ChatCompletionRequest):
|
|
230
|
+
"""Handle chat completions for autoselect using model name to select autoselect configuration"""
|
|
231
|
+
logger.info(f"=== AUTOSELECT CHAT COMPLETION REQUEST START ===")
|
|
232
|
+
logger.info(f"Request path: {request.url.path}")
|
|
233
|
+
logger.info(f"Model requested: {body.model}")
|
|
234
|
+
logger.info(f"Request headers: {dict(request.headers)}")
|
|
235
|
+
logger.info(f"Request body: {body}")
|
|
236
|
+
logger.info(f"Available autoselect: {list(config.autoselect.keys())}")
|
|
237
|
+
|
|
238
|
+
body_dict = body.model_dump()
|
|
239
|
+
|
|
240
|
+
# Check if the model name corresponds to an autoselect configuration
|
|
241
|
+
if body.model not in config.autoselect:
|
|
242
|
+
logger.error(f"Model '{body.model}' not found in autoselect")
|
|
243
|
+
logger.error(f"Available autoselect: {list(config.autoselect.keys())}")
|
|
244
|
+
raise HTTPException(
|
|
245
|
+
status_code=400,
|
|
246
|
+
detail=f"Model '{body.model}' not found. Available autoselect: {list(config.autoselect.keys())}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
logger.info(f"Model '{body.model}' found in autoselect")
|
|
250
|
+
logger.debug("Handling autoselect request")
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
if body.stream:
|
|
254
|
+
logger.debug("Handling streaming autoselect request")
|
|
255
|
+
return await autoselect_handler.handle_autoselect_streaming_request(body.model, body_dict)
|
|
256
|
+
else:
|
|
257
|
+
logger.debug("Handling non-streaming autoselect request")
|
|
258
|
+
result = await autoselect_handler.handle_autoselect_request(body.model, body_dict)
|
|
259
|
+
logger.debug(f"Autoselect response result: {result}")
|
|
260
|
+
return result
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"Error handling autoselect chat_completions: {str(e)}", exc_info=True)
|
|
263
|
+
raise
|
|
264
|
+
|
|
265
|
+
@app.get("/api/autoselect/models")
|
|
266
|
+
async def list_autoselect_models():
|
|
267
|
+
"""List all models across all autoselect configurations"""
|
|
268
|
+
logger.info("=== LIST AUTOSELECT MODELS REQUEST ===")
|
|
269
|
+
all_models = []
|
|
270
|
+
for autoselect_id, autoselect_config in config.autoselect.items():
|
|
271
|
+
for model_info in autoselect_config.available_models:
|
|
272
|
+
all_models.append({
|
|
273
|
+
"id": model_info.model_id,
|
|
274
|
+
"name": autoselect_id, # Use autoselect name as the model name for selection
|
|
275
|
+
"object": "model",
|
|
276
|
+
"owned_by": "autoselect",
|
|
277
|
+
"autoselect_id": autoselect_id,
|
|
278
|
+
"description": model_info.description,
|
|
279
|
+
"fallback": autoselect_config.fallback
|
|
280
|
+
})
|
|
281
|
+
logger.info(f"Total autoselect models available: {len(all_models)}")
|
|
282
|
+
return {"data": all_models}
|
|
283
|
+
|
|
284
|
+
@app.post("/api/{provider_id}/chat/completions")
|
|
285
|
+
async def chat_completions(provider_id: str, request: Request, body: ChatCompletionRequest):
|
|
286
|
+
logger.info(f"=== CHAT COMPLETION REQUEST START ===")
|
|
287
|
+
logger.info(f"Request path: {request.url.path}")
|
|
288
|
+
logger.info(f"Provider ID: {provider_id}")
|
|
289
|
+
logger.info(f"Request headers: {dict(request.headers)}")
|
|
290
|
+
logger.info(f"Request body: {body}")
|
|
291
|
+
logger.info(f"Available providers: {list(config.providers.keys())}")
|
|
292
|
+
logger.info(f"Available rotations: {list(config.rotations.keys())}")
|
|
293
|
+
logger.info(f"Available autoselect: {list(config.autoselect.keys())}")
|
|
294
|
+
logger.debug(f"Request headers: {dict(request.headers)}")
|
|
295
|
+
logger.debug(f"Request body: {body}")
|
|
296
|
+
|
|
297
|
+
body_dict = body.model_dump()
|
|
298
|
+
|
|
299
|
+
# Check if it's an autoselect
|
|
300
|
+
if provider_id in config.autoselect:
|
|
301
|
+
logger.debug("Handling autoselect request")
|
|
302
|
+
try:
|
|
303
|
+
if body.stream:
|
|
304
|
+
logger.debug("Handling streaming autoselect request")
|
|
305
|
+
return await autoselect_handler.handle_autoselect_streaming_request(provider_id, body_dict)
|
|
306
|
+
else:
|
|
307
|
+
logger.debug("Handling non-streaming autoselect request")
|
|
308
|
+
result = await autoselect_handler.handle_autoselect_request(provider_id, body_dict)
|
|
309
|
+
logger.debug(f"Autoselect response result: {result}")
|
|
310
|
+
return result
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.error(f"Error handling autoselect: {str(e)}", exc_info=True)
|
|
313
|
+
raise
|
|
314
|
+
|
|
315
|
+
# Check if it's a rotation
|
|
316
|
+
if provider_id in config.rotations:
|
|
317
|
+
logger.info(f"Provider ID '{provider_id}' found in rotations")
|
|
318
|
+
logger.debug("Handling rotation request")
|
|
319
|
+
return await rotation_handler.handle_rotation_request(provider_id, body_dict)
|
|
320
|
+
|
|
321
|
+
# Check if it's a provider
|
|
322
|
+
if provider_id not in config.providers:
|
|
323
|
+
logger.error(f"Provider ID '{provider_id}' not found in providers")
|
|
324
|
+
logger.error(f"Available providers: {list(config.providers.keys())}")
|
|
325
|
+
logger.error(f"Available rotations: {list(config.rotations.keys())}")
|
|
326
|
+
logger.error(f"Available autoselect: {list(config.autoselect.keys())}")
|
|
327
|
+
raise HTTPException(status_code=400, detail=f"Provider {provider_id} not found")
|
|
328
|
+
|
|
329
|
+
logger.info(f"Provider ID '{provider_id}' found in providers")
|
|
330
|
+
|
|
331
|
+
provider_config = config.get_provider(provider_id)
|
|
332
|
+
logger.debug(f"Provider config: {provider_config}")
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
if body.stream:
|
|
336
|
+
logger.debug("Handling streaming chat completion")
|
|
337
|
+
return await request_handler.handle_streaming_chat_completion(request, provider_id, body_dict)
|
|
338
|
+
else:
|
|
339
|
+
logger.debug("Handling non-streaming chat completion")
|
|
340
|
+
result = await request_handler.handle_chat_completion(request, provider_id, body_dict)
|
|
341
|
+
logger.debug(f"Response result: {result}")
|
|
342
|
+
return result
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.error(f"Error handling chat_completions: {str(e)}", exc_info=True)
|
|
345
|
+
raise
|
|
346
|
+
|
|
347
|
+
@app.get("/api/{provider_id}/models")
|
|
348
|
+
async def list_models(request: Request, provider_id: str):
|
|
349
|
+
logger.debug(f"Received list_models request for provider: {provider_id}")
|
|
350
|
+
|
|
351
|
+
# Check if it's an autoselect
|
|
352
|
+
if provider_id in config.autoselect:
|
|
353
|
+
logger.debug("Handling autoselect model list request")
|
|
354
|
+
try:
|
|
355
|
+
result = await autoselect_handler.handle_autoselect_model_list(provider_id)
|
|
356
|
+
logger.debug(f"Autoselect models result: {result}")
|
|
357
|
+
return result
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.error(f"Error handling autoselect model list: {str(e)}", exc_info=True)
|
|
360
|
+
raise
|
|
361
|
+
|
|
362
|
+
# Check if it's a rotation
|
|
363
|
+
if provider_id in config.rotations:
|
|
364
|
+
logger.info(f"Provider ID '{provider_id}' found in rotations")
|
|
365
|
+
logger.debug("Handling rotation model list request")
|
|
366
|
+
return await rotation_handler.handle_rotation_model_list(provider_id)
|
|
367
|
+
|
|
368
|
+
# Check if it's a provider
|
|
369
|
+
if provider_id not in config.providers:
|
|
370
|
+
logger.error(f"Provider ID '{provider_id}' not found in providers")
|
|
371
|
+
logger.error(f"Available providers: {list(config.providers.keys())}")
|
|
372
|
+
logger.error(f"Available rotations: {list(config.rotations.keys())}")
|
|
373
|
+
logger.error(f"Available autoselect: {list(config.autoselect.keys())}")
|
|
374
|
+
raise HTTPException(status_code=400, detail=f"Provider {provider_id} not found")
|
|
375
|
+
|
|
376
|
+
logger.info(f"Provider ID '{provider_id}' found in providers")
|
|
377
|
+
|
|
378
|
+
provider_config = config.get_provider(provider_id)
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
logger.debug("Handling model list request")
|
|
382
|
+
result = await request_handler.handle_model_list(request, provider_id)
|
|
383
|
+
logger.debug(f"Models result: {result}")
|
|
384
|
+
return result
|
|
385
|
+
except Exception as e:
|
|
386
|
+
logger.error(f"Error handling list_models: {str(e)}", exc_info=True)
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
@app.post("/api/{provider_id}")
|
|
390
|
+
async def catch_all_post(provider_id: str, request: Request):
|
|
391
|
+
"""Catch-all for POST requests to help debug routing issues"""
|
|
392
|
+
logger.info(f"=== CATCH-ALL POST REQUEST ===")
|
|
393
|
+
logger.info(f"Request path: {request.url.path}")
|
|
394
|
+
logger.info(f"Provider ID: {provider_id}")
|
|
395
|
+
logger.info(f"Request headers: {dict(request.headers)}")
|
|
396
|
+
logger.info(f"Available providers: {list(config.providers.keys())}")
|
|
397
|
+
logger.info(f"Available rotations: {list(config.rotations.keys())}")
|
|
398
|
+
logger.info(f"Available autoselect: {list(config.autoselect.keys())}")
|
|
399
|
+
|
|
400
|
+
error_msg = f"""
|
|
401
|
+
Invalid endpoint: {request.url.path}
|
|
402
|
+
|
|
403
|
+
The correct endpoint format is: /api/{{provider_id}}/chat/completions
|
|
404
|
+
|
|
405
|
+
Available providers: {list(config.providers.keys())}
|
|
406
|
+
Available rotations: {list(config.rotations.keys())}
|
|
407
|
+
Available autoselect: {list(config.autoselect.keys())}
|
|
408
|
+
|
|
409
|
+
Example: POST /api/ollama/chat/completions
|
|
410
|
+
"""
|
|
411
|
+
logger.error(error_msg)
|
|
412
|
+
raise HTTPException(status_code=404, detail=error_msg.strip())
|
|
413
|
+
|
|
414
|
+
def main():
|
|
415
|
+
"""Main entry point for the AISBF server"""
|
|
416
|
+
import uvicorn
|
|
417
|
+
logger.info("Starting AI Proxy Server on http://localhost:8000")
|
|
418
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
419
|
+
|
|
420
|
+
if __name__ == "__main__":
|
|
421
|
+
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aisbf
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations
|
|
5
5
|
Home-page: https://git.nexlab.net/nexlab/aisbf.git
|
|
6
6
|
Author: AISBF Contributors
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
cli.py,sha256=SpjVC1iBdDhQXuhJcjVFkODu4BH-nj_1hNFD_d8wPbw,2503
|
|
2
|
+
aisbf/__init__.py,sha256=D3-tZRWCu31CltN_pjx8IikwPl0OGEJkvoASm8QjvcQ,2156
|
|
3
|
+
aisbf/config.py,sha256=xXcUQG3OcteCi8__qm6-yEnwjwFVpLBYLrs-zMnDfl0,9643
|
|
4
|
+
aisbf/handlers.py,sha256=tXYOQYkhIK6X1EmgivVyO6fElkrRj1cqNQfQ5KGldO8,31557
|
|
5
|
+
aisbf/models.py,sha256=LT1NaQVAw0VWXL-j3hdfNlXCA9HeiET_O3GDj3t9XC4,1883
|
|
6
|
+
aisbf/providers.py,sha256=gh3qe-bthAxb-hRPPpp_YLgGzbdTKRN5HKVjf-Yvnp0,19920
|
|
7
|
+
aisbf-0.2.4.data/data/share/aisbf/aisbf.sh,sha256=ntI4UPefBtU2jrTwMR3hddHEPG_pDyJyO0J3SD7e5PA,4574
|
|
8
|
+
aisbf-0.2.4.data/data/share/aisbf/autoselect.json,sha256=Anud0hTE1mehonmMmhOTPK2ANUxfruE2yMdLqiEkEUA,659
|
|
9
|
+
aisbf-0.2.4.data/data/share/aisbf/autoselect.md,sha256=F8PilhaYBs0qdpIxIkokrjtIOdhAx5Bi1tA0hyfnqps,4301
|
|
10
|
+
aisbf-0.2.4.data/data/share/aisbf/main.py,sha256=hCTxLA-txJpSpMTE5mV1XG_j8Kn17ZTHJsnx59nKscg,17241
|
|
11
|
+
aisbf-0.2.4.data/data/share/aisbf/providers.json,sha256=9L5GO6sQ2Z6zndGed0AckvYNV1DMr9r7tSdZ9fJxYlA,3934
|
|
12
|
+
aisbf-0.2.4.data/data/share/aisbf/requirements.txt,sha256=lp6cPakAO3lpTCwQ27THf-PNz_HIpzCELrtpdgo6-2o,133
|
|
13
|
+
aisbf-0.2.4.data/data/share/aisbf/rotations.json,sha256=SzbmMeTRR0vVTrYTMwxSPxjXLVr8zxjaI4HYRxjyExQ,2123
|
|
14
|
+
aisbf-0.2.4.data/data/share/aisbf/aisbf/__init__.py,sha256=D3-tZRWCu31CltN_pjx8IikwPl0OGEJkvoASm8QjvcQ,2156
|
|
15
|
+
aisbf-0.2.4.data/data/share/aisbf/aisbf/config.py,sha256=xXcUQG3OcteCi8__qm6-yEnwjwFVpLBYLrs-zMnDfl0,9643
|
|
16
|
+
aisbf-0.2.4.data/data/share/aisbf/aisbf/handlers.py,sha256=tXYOQYkhIK6X1EmgivVyO6fElkrRj1cqNQfQ5KGldO8,31557
|
|
17
|
+
aisbf-0.2.4.data/data/share/aisbf/aisbf/models.py,sha256=LT1NaQVAw0VWXL-j3hdfNlXCA9HeiET_O3GDj3t9XC4,1883
|
|
18
|
+
aisbf-0.2.4.data/data/share/aisbf/aisbf/providers.py,sha256=gh3qe-bthAxb-hRPPpp_YLgGzbdTKRN5HKVjf-Yvnp0,19920
|
|
19
|
+
aisbf-0.2.4.dist-info/licenses/LICENSE.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
20
|
+
aisbf-0.2.4.dist-info/METADATA,sha256=A_x1Mb5ryEHXXCKpdzfFz8Pgdn7FKgR4POzRKAmbdZU,4190
|
|
21
|
+
aisbf-0.2.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
22
|
+
aisbf-0.2.4.dist-info/entry_points.txt,sha256=dV_E5f6UvgSe9AoyPTzGxBK8IYaIeLR8yTe7EwBZ3F8,35
|
|
23
|
+
aisbf-0.2.4.dist-info/top_level.txt,sha256=odXp1LYymu31EdVSmMGCg3ZYAI5HeB8tZkaXh9Pw3kE,10
|
|
24
|
+
aisbf-0.2.4.dist-info/RECORD,,
|