Fix rotation edit

parent 3d690b74
......@@ -4,6 +4,7 @@ All ASGI middleware functions extracted from main.py.
import time
import logging
import threading
import hmac as _hmac
from typing import Optional
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse
......@@ -21,7 +22,8 @@ _client_rl_lock = threading.Lock()
def _get_real_client_ip(request: Request) -> str:
xff = request.headers.get('X-Forwarded-For', '')
if xff:
return xff.split(',')[0].strip()
# Use rightmost IP (appended by the trusted upstream proxy) to prevent spoofing
return xff.split(',')[-1].strip()
client = request.scope.get('client')
return client[0] if client else 'unknown'
......@@ -62,7 +64,8 @@ _BLOCK_MESSAGE = "We do not support the Israeli genocide of Palestinian people."
def _get_client_ip(request: Request) -> Optional[str]:
xff = request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
# Use rightmost IP (appended by the trusted upstream proxy) to prevent spoofing
return xff.split(",")[-1].strip()
client = request.scope.get("client")
return client[0] if client else None
......@@ -195,7 +198,10 @@ def make_auth_middleware(get_server_config, get_config, get_db, url_for_fn):
token = auth_header.replace('Bearer ', '')
allowed_tokens = server_config.get('auth_tokens', [])
if token in allowed_tokens:
_token_valid = False
for _t in allowed_tokens:
_token_valid |= _hmac.compare_digest(token, _t)
if _token_valid:
request.state.user_id = None
request.state.token_id = None
request.state.is_global_token = True
......
......@@ -42,39 +42,6 @@ try:
except ImportError:
HAS_CURL_CFFI = False
# Configuration matching the official Claude CLI
# Try to load client_id from credentials file first, fallback to generated UUID
import json
import os
from pathlib import Path
def _load_client_id_from_credentials():
"""Attempt to load client_id from existing Claude credentials file"""
try:
creds_path = Path.home() / ".claude" / ".credentials.json"
if creds_path.exists():
with open(creds_path, 'r') as f:
creds = json.load(f)
# Try to extract client_id from various possible locations
if 'client_id' in creds:
return creds['client_id']
elif 'oauth' in creds and 'client_id' in creds['oauth']:
return creds['oauth']['client_id']
elif 'claudeAiOauth' in creds and 'client_id' in creds['claudeAiOauth']:
return creds['claudeAiOauth']['client_id']
except Exception:
pass
return None
def _generate_client_id():
"""Generate a stable client_id UUID based on machine characteristics"""
# Use machine hostname and platform to generate a stable UUID
import uuid
import platform
machine_id = f"{platform.node()}-{platform.machine()}-claude-code"
# Generate UUID5 (name-based) from the machine ID
return str(uuid.uuid5(uuid.NAMESPACE_DNS, machine_id))
# Claude OAuth2 Configuration
# These values match the official claude-cli implementation
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Official Claude Code client ID
......
......@@ -28,14 +28,11 @@ import logging
# On read, detect format by attempting JSON first so legacy pickle data still works.
def _cache_encode(value: any) -> bytes:
"""Encode a cache value. Prefers JSON; falls back to pickle."""
try:
"""Encode a cache value using JSON only."""
return b'\x00' + json.dumps(value, ensure_ascii=False).encode('utf-8')
except (TypeError, ValueError):
return b'\x01' + pickle.dumps(value)
def _cache_decode(data: bytes) -> any:
"""Decode a cache value encoded by _cache_encode, or legacy raw pickle bytes."""
"""Decode a cache value encoded by _cache_encode. Legacy pickle entries are discarded."""
if isinstance(data, memoryview):
data = bytes(data)
if not data:
......@@ -43,12 +40,15 @@ def _cache_decode(data: bytes) -> any:
if data[0:1] == b'\x00':
return json.loads(data[1:].decode('utf-8'))
if data[0:1] == b'\x01':
return pickle.loads(data[1:])
# Legacy: no prefix — assume raw pickle
# Legacy pickle-encoded entry — discard; will be recalculated on next miss
logger.warning("Discarding legacy pickle-encoded cache entry (will be recalculated)")
return None
# Legacy: no prefix — try JSON, discard if unparseable
try:
return pickle.loads(data)
except Exception:
return json.loads(data.decode('utf-8'))
except Exception:
logger.warning("Discarding unrecognised legacy cache entry (will be recalculated)")
return None
from typing import Any, Optional, Dict, List
from pathlib import Path
import time
......
......@@ -222,7 +222,7 @@ class DatabaseManager:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
return False # never suppress exceptions
return TransactionContext()
......@@ -416,24 +416,11 @@ class DatabaseManager:
completion_tokens: Optional number of output/completion tokens
actual_cost: Optional actual cost returned by provider (in USD)
"""
logger.info(f"💾 DB.record_token_usage ENTERED: provider={provider_id}, tokens={tokens_used}, user_id={user_id}")
logger.debug(f"DB.record_token_usage: provider={provider_id}, tokens={tokens_used}, user_id={user_id}")
try:
# Convert latency to int for storage
latency_int = int(latency_ms) if latency_ms else 0
logger.info(f"🔍 DB.record_token_usage FULL PARAMETERS:")
logger.info(f" provider_id: {provider_id}")
logger.info(f" model_name: {model_name}")
logger.info(f" tokens_used: {tokens_used}")
logger.info(f" user_id: {user_id}")
logger.info(f" success: {success}")
logger.info(f" latency_ms: {latency_ms} → latency_int: {latency_int}")
logger.info(f" error_type: {error_type}")
logger.info(f" token_id: {token_id}")
logger.info(f" prompt_tokens: {prompt_tokens}")
logger.info(f" completion_tokens: {completion_tokens}")
logger.info(f" actual_cost: {actual_cost}")
logger.info(f" db_type: {self.db_type}")
logger.info(f"DB.record_token_usage: About to execute SQL - provider={provider_id}, tokens={tokens_used}, success={success}")
logger.debug(f"DB.record_token_usage params: provider={provider_id}, model={model_name}, tokens={tokens_used}, user={user_id}, success={success}")
with self._get_connection() as conn:
cursor = conn.cursor()
......@@ -451,31 +438,26 @@ class DatabaseManager:
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
'''
params = (user_id, provider_id, model_name, tokens_used, prompt_tokens, completion_tokens, actual_cost, success, latency_int, error_type, token_id, rotation_id, autoselect_id)
logger.info(f"🔍 Trying full INSERT with {len(params)} parameters")
logger.debug(f"🔍 SQL: {sql}")
logger.debug(f"🔍 Params: {params}")
logger.debug(f"Trying full INSERT with {len(params)} parameters")
cursor.execute(sql, params)
logger.info(f"✅ Inserted with full column set, rows affected: {cursor.rowcount}")
logger.debug(f"Inserted with full column set, rows affected: {cursor.rowcount}")
except Exception as full_insert_error:
logger.warning(f"⚠️ Full column insert failed: {full_insert_error}")
logger.warning(f"⚠️ Full insert error type: {type(full_insert_error).__name__}")
import traceback
logger.warning(f"⚠️ Full insert traceback: {traceback.format_exc()}")
logger.info(f"🔍 Falling back to basic insert")
logger.debug("Falling back to basic insert")
# Fallback to basic columns only
sql = f'''
INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, timestamp)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
'''
params = (user_id, provider_id, model_name, tokens_used)
logger.info(f"🔍 Trying basic INSERT with {len(params)} parameters")
logger.debug(f"🔍 SQL: {sql}")
logger.debug(f"🔍 Params: {params}")
cursor.execute(sql, params)
logger.info(f"✅ Inserted with basic column set, rows affected: {cursor.rowcount}")
logger.debug(f"Inserted with basic column set, rows affected: {cursor.rowcount}")
conn.commit()
logger.info(f"✅ Successfully recorded token usage for {provider_id}/{model_name}: {tokens_used} tokens (user_id={user_id})")
logger.info(f"Recorded token usage: {provider_id}/{model_name} {tokens_used} tokens (user_id={user_id})")
except Exception as e:
logger.error(f"❌ Failed to record token usage for {provider_id}/{model_name}: {e}")
logger.error(f"Error details - user_id={user_id}, tokens={tokens_used}, success={success}")
......@@ -485,7 +467,7 @@ class DatabaseManager:
test_cursor = test_conn.cursor()
test_cursor.execute("INSERT INTO token_usage (provider_id, model_name, tokens_used, success) VALUES (?, 'test', 1, 1)" if self.db_type == 'sqlite' else "INSERT INTO token_usage (provider_id, model_name, tokens_used, success) VALUES (%s, 'test', 1, 1)", (f"test-{provider_id}",))
test_conn.commit()
logger.info("✅ Test database insert succeeded")
logger.debug("Test database insert succeeded")
except Exception as test_e:
logger.error(f"❌ Even test database insert failed: {test_e}")
raise
......
......@@ -540,7 +540,6 @@ class RequestHandler:
# Apply rate limiting
logger.info("Applying rate limiting...")
await handler.apply_rate_limit()
await handler.apply_rate_limit()
logger.info("Rate limiting applied")
logger.info(f"Sending request to provider handler...")
......@@ -729,7 +728,14 @@ class RequestHandler:
else:
provider_config = self.config.get_provider(provider_id)
if provider_config.api_key_required:
if isinstance(provider_config, dict):
api_key_required = provider_config.get('api_key_required', False)
_provider_type = provider_config.get('type', '')
else:
api_key_required = provider_config.api_key_required
_provider_type = provider_config.type
if api_key_required:
api_key = request_data.get('api_key') or request.headers.get('Authorization', '').replace('Bearer ', '')
if not api_key:
raise HTTPException(status_code=401, detail="API key required")
......@@ -807,12 +813,12 @@ class RequestHandler:
# Check if this is a Google streaming response by checking provider type from config
# This is more reliable than checking response iterability which can cause false positives
is_google_stream = provider_config.type == 'google'
is_kiro_stream = provider_config.type == 'kiro'
is_kilo_stream = provider_config.type in ('kilo', 'kilocode')
logger.info(f"Is Google streaming response: {is_google_stream} (provider type: {provider_config.type})")
logger.info(f"Is Kiro streaming response: {is_kiro_stream} (provider type: {provider_config.type})")
logger.info(f"Is Kilo streaming response: {is_kilo_stream} (provider type: {provider_config.type})")
is_google_stream = _provider_type == 'google'
is_kiro_stream = _provider_type == 'kiro'
is_kilo_stream = _provider_type in ('kilo', 'kilocode')
logger.info(f"Is Google streaming response: {is_google_stream} (provider type: {_provider_type})")
logger.info(f"Is Kiro streaming response: {is_kiro_stream} (provider type: {_provider_type})")
logger.info(f"Is Kilo streaming response: {is_kilo_stream} (provider type: {_provider_type})")
if is_kilo_stream:
# Handle Kilo/KiloCode streaming response
......@@ -1797,6 +1803,56 @@ class RequestHandler:
return capabilities
async def handle_generic_proxy(self, request: Request, provider_id: str, endpoint_path: str, body: dict, method: str = "POST") -> JSONResponse:
"""Forward a request to the provider's native endpoint and return the response."""
import httpx
import logging
logger = logging.getLogger(__name__)
# Support user-defined providers (dict format) and global providers (object format)
if self.user_id and provider_id in self.user_providers:
provider_config = self.user_providers[provider_id]
base_url = (provider_config.get('endpoint') or '').rstrip('/')
api_key_required = provider_config.get('api_key_required', False)
config_api_key = provider_config.get('api_key')
else:
provider_config = self.config.get_provider(provider_id)
base_url = (getattr(provider_config, 'endpoint', '') or '').rstrip('/')
api_key_required = getattr(provider_config, 'api_key_required', False)
config_api_key = getattr(provider_config, 'api_key', None)
# Strip trailing /chat/completions or /completions to get the real base
for suffix in ['/chat/completions', '/completions']:
if base_url.endswith(suffix):
base_url = base_url[:-len(suffix)]
break
url = f"{base_url}/{endpoint_path.lstrip('/')}"
headers = {'Content-Type': 'application/json'}
if api_key_required:
api_key = request.headers.get('Authorization', '').replace('Bearer ', '') or config_api_key
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
logger.info(f"Generic proxy [{method}]: {provider_id} -> {url}")
try:
async with httpx.AsyncClient(timeout=300) as client:
if method == "GET":
resp = await client.get(url, headers=headers)
elif method == "DELETE":
resp = await client.delete(url, headers=headers)
else:
resp = await client.post(url, json=body, headers=headers)
try:
content = resp.json()
except Exception:
content = {"detail": resp.text}
return JSONResponse(status_code=resp.status_code, content=content)
except Exception as e:
logger.error(f"Generic proxy error: {e}", exc_info=True)
raise HTTPException(status_code=502, detail=str(e))
async def handle_audio_transcription(self, request: Request, provider_id: str, form_data) -> Dict:
"""Handle audio transcription requests"""
import logging
......
......@@ -429,9 +429,27 @@ class PayPalPaymentHandler:
'Wallet top up via PayPal')
async def _handle_order_approved(self, resource: dict):
"""Handle approved order (capture pending)."""
"""Handle approved order — record pending capture state."""
order_id = resource.get('id')
logger.info(f"PayPal order approved: {order_id}")
logger.info(f"PayPal order approved (awaiting capture): {order_id}")
try:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
INSERT OR IGNORE INTO payment_transactions
(gateway, gateway_transaction_id, status, created_at)
VALUES ({placeholder}, {placeholder}, 'pending_capture', CURRENT_TIMESTAMP)
ON CONFLICT(gateway_transaction_id) DO UPDATE SET status='pending_capture'
""", ('paypal', order_id)) if self.db.db_type == 'sqlite' else cursor.execute(f"""
INSERT INTO payment_transactions
(gateway, gateway_transaction_id, status, created_at)
VALUES ({placeholder}, {placeholder}, 'pending_capture', CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE status='pending_capture'
""", ('paypal', order_id))
conn.commit()
except Exception as e:
logger.warning(f"PayPal: could not record approved order {order_id}: {e}")
async def _handle_payment_capture_completed(self, resource: dict):
"""Handle completed payment capture — credit wallet."""
......@@ -501,9 +519,24 @@ class PayPalPaymentHandler:
logger.warning(f"PayPal refund: cannot apply refund {refund_id} — missing user_id/amount")
async def _handle_vault_token_created(self, resource: dict):
"""Handle vault token creation."""
"""Handle vault token creation — store as a payment method."""
token_id = resource.get('id')
logger.info(f"PayPal vault token created: {token_id}")
customer = resource.get('customer', {})
merchant_customer_id = customer.get('merchant_customer_id') or resource.get('metadata', {}).get('merchant_customer_id')
if not (token_id and merchant_customer_id):
logger.warning(f"PayPal vault token {token_id}: missing merchant_customer_id, skipping save")
return
try:
user_id = int(merchant_customer_id)
except (ValueError, TypeError):
logger.warning(f"PayPal vault token {token_id}: invalid merchant_customer_id {merchant_customer_id!r}")
return
try:
self.db.add_payment_method(user_id, 'paypal', token_id, is_default=False, metadata={'paypal_vault_token': token_id})
logger.info(f"Stored PayPal vault token {token_id} as payment method for user {user_id}")
except Exception as e:
logger.error(f"PayPal: failed to store vault token {token_id} for user {user_id}: {e}")
async def _handle_vault_token_deleted(self, resource: dict):
"""Handle vault token deletion — deactivate matching payment method."""
......@@ -590,41 +623,36 @@ class PayPalPaymentHandler:
logger.error(f"Error creating PayPal top up order: {e}")
return {'success': False, 'error': str(e)}
async def _handle_order_completed(self, resource: dict):
"""Handle completed order (Vault v3)"""
order_id = resource.get('id')
logger.info(f"PayPal order completed: {order_id}")
# Check if this is a top up order
purchase_units = resource.get('purchase_units', [])
if purchase_units and 'Wallet top up' in purchase_units[0].get('description', ''):
amount = Decimal(purchase_units[0]['amount']['value'])
user_id = int(resource.get('custom_id', 0))
if user_id > 0:
from aisbf.payments.wallet.manager import WalletManager
from sqlalchemy.ext.asyncio import AsyncSession
async with AsyncSession(self.db.engine) as session:
wallet_manager = WalletManager(session)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=amount,
transaction_details={
'payment_gateway': 'paypal',
'gateway_transaction_id': order_id,
'description': 'Wallet top up via PayPal',
'metadata': {'order_id': order_id}
}
)
await session.commit()
logger.info(f"Wallet credited successfully for user {user_id}, amount {amount}")
async def _handle_payment_completed(self, resource: dict):
"""Handle completed payment (legacy)"""
logger.info(f"PayPal payment completed: {resource.get('id')}")
"""Handle completed payment (legacy PAYMENT.SALE.COMPLETED) — credit wallet if applicable."""
payment_id = resource.get('id')
logger.info(f"PayPal payment completed: {payment_id}")
custom_id = resource.get('custom', '') or resource.get('custom_id', '')
amount_obj = resource.get('amount', {})
try:
amount = Decimal(amount_obj.get('total', amount_obj.get('value', '0')))
user_id = int(custom_id) if custom_id else 0
except (ValueError, TypeError):
user_id = 0
if user_id > 0 and amount > 0:
await self._credit_wallet_for_paypal(user_id, amount, payment_id, 'Payment via PayPal')
else:
logger.debug(f"PayPal PAYMENT.SALE.COMPLETED {payment_id}: no user_id/amount to credit")
async def _handle_payment_denied(self, resource: dict):
"""Handle denied payment (legacy)"""
logger.warning(f"PayPal payment denied: {resource.get('id')}")
"""Handle denied payment (legacy PAYMENT.SALE.DENIED) — queue for retry."""
payment_id = resource.get('id')
logger.warning(f"PayPal payment denied: {payment_id}")
try:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
INSERT INTO payment_retry_queue
(gateway, gateway_transaction_id, status, next_retry_at, created_at)
VALUES ({placeholder}, {placeholder}, 'pending',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", ('paypal', payment_id))
conn.commit()
except Exception as e:
logger.error(f"PayPal: failed to queue denied payment {payment_id} for retry: {e}")
......@@ -319,5 +319,21 @@ class StripePaymentHandler:
return {"success": False, "error": str(e)}
async def _handle_payment_failed(self, payment_intent: dict):
"""Handle failed payment"""
logger.warning(f"Payment failed: {payment_intent['id']}")
"""Handle failed payment — queue for retry and log the failure reason."""
intent_id = payment_intent.get('id', '')
error = payment_intent.get('last_payment_error', {}) or {}
reason = error.get('message', 'unknown')
logger.warning(f"Stripe payment failed: {intent_id} — {reason}")
try:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
INSERT INTO payment_retry_queue
(gateway, gateway_transaction_id, status, next_retry_at, created_at)
VALUES ({placeholder}, {placeholder}, 'pending',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", ('stripe', intent_id))
conn.commit()
except Exception as e:
logger.error(f"Stripe: failed to queue failed payment {intent_id} for retry: {e}")
......@@ -88,19 +88,45 @@ class CodexProviderHandler(BaseProviderHandler):
)
# Determine mode: API key mode or OAuth2 mode
# Treat empty strings and placeholder values as "no key"
def _is_real_key(k):
return bool(k) and str(k).strip() not in ('', 'placeholder', 'YOUR_API_KEY', 'none', 'null')
_cfg_api_key = (provider_config.get('api_key') if isinstance(provider_config, dict)
else getattr(provider_config, 'api_key', None)) if provider_config else None
self._use_api_key_mode = bool(api_key or _cfg_api_key)
self._use_api_key_mode = _is_real_key(api_key) or _is_real_key(_cfg_api_key)
self._account_id = None # Will be extracted from ID token in OAuth2 mode
# Base URL for API requests
_endpoint = (provider_config.get('endpoint') if isinstance(provider_config, dict)
else getattr(provider_config, 'endpoint', None)) if provider_config else None
self.base_url = (_endpoint or 'https://chatgpt.com/backend-api').rstrip('/')
CHATGPT_BACKEND = 'https://chatgpt.com/backend-api'
OPENAI_API = 'https://api.openai.com/v1'
def _is_chatgpt_backend(url: str) -> bool:
return url.rstrip('/').startswith(CHATGPT_BACKEND.rstrip('/'))
if self._use_api_key_mode:
# In API key mode, use OpenAI API for any chatgpt.com/backend-api URL
# (including subpaths like /codex) — the ChatGPT backend does not support
# the standard OpenAI /chat/completions format.
if _endpoint and not _is_chatgpt_backend(_endpoint):
self.base_url = _endpoint.rstrip('/')
else:
self.base_url = OPENAI_API
else:
# In OAuth2 mode, always use the bare ChatGPT backend base URL.
# Any /codex or other suffix in the configured endpoint is stripped here;
# the specific API path (/codex/responses) is appended later at call time.
if _endpoint and not _is_chatgpt_backend(_endpoint):
self.base_url = _endpoint.rstrip('/')
else:
self.base_url = CHATGPT_BACKEND
# Initialize OpenAI client for API key mode
if self._use_api_key_mode:
effective_key = api_key or _cfg_api_key
effective_key = (api_key if _is_real_key(api_key) else None) or (_cfg_api_key if _is_real_key(_cfg_api_key) else None)
self.client = OpenAI(api_key=effective_key, base_url=self.base_url)
else:
self.client = None
......
......@@ -130,9 +130,8 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
else:
return await handler.handle_chat_completion(request, provider_id, body_dict)
@router.get("/api/models")
async def list_all_models(request: Request):
logger.info("=== LIST ALL MODELS REQUEST ===")
async def _build_model_list(request: Request) -> dict:
"""Shared model listing logic used by all /models endpoints."""
all_models = []
user_id = None
auth_header = request.headers.get("Authorization")
......@@ -164,39 +163,15 @@ async def list_all_models(request: Request):
logger.info(f"Returning {len(all_models)} total models")
return {"object": "list", "data": all_models}
@router.get("/api/models")
async def list_all_models(request: Request):
logger.info("=== LIST ALL MODELS REQUEST ===")
return await _build_model_list(request)
@router.get("/api/v1/models")
async def v1_list_all_models(request: Request):
logger.info("=== V1 LIST ALL MODELS REQUEST ===")
all_models = []
user_id = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
db = DatabaseRegistry.get_config_database()
token = auth_header.split(" ")[1]
user = db.get_user_by_token(token)
if user:
user_id = user.get("id")
except Exception as e:
logger.debug(f"Auth check failed for models request: {e}")
for provider_id, provider_config in _config.providers.items():
try:
provider_models = await get_provider_models(provider_id, provider_config, _config, user_id=user_id)
all_models.extend(provider_models)
except Exception as e:
logger.warning(f"Error listing models for provider {provider_id}: {e}")
for rotation_id, rotation_config in _config.rotations.items():
try:
all_models.append({'id': f"rotation/{rotation_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-rotation', 'type': 'rotation', 'rotation_id': rotation_id, 'model_name': rotation_config.model_name, 'capabilities': getattr(rotation_config, 'capabilities', [])})
except Exception as e:
logger.warning(f"Error listing rotation {rotation_id}: {e}")
for autoselect_id, autoselect_config in _config.autoselect.items():
try:
all_models.append({'id': f"autoselect/{autoselect_id}", 'object': 'model', 'created': int(time.time()), 'owned_by': 'aisbf-autoselect', 'type': 'autoselect', 'autoselect_id': autoselect_id, 'model_name': autoselect_config.model_name, 'description': autoselect_config.description, 'capabilities': getattr(autoselect_config, 'capabilities', [])})
except Exception as e:
logger.warning(f"Error listing autoselect {autoselect_id}: {e}")
logger.info(f"Returning {len(all_models)} total models")
return {"object": "list", "data": all_models}
return await _build_model_list(request)
@router.get("/v1/models")
async def v1_list_all_models_alias(request: Request):
......@@ -214,34 +189,9 @@ async def models_root_alias(request: Request):
async def v1_audio_transcriptions(request: Request):
logger.info("=== V1 AUDIO TRANSCRIPTION REQUEST ===")
form = await request.form()
model = form.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(form.get('model', ''), user_id=user_id, handler=handler)
from starlette.datastructures import FormData
updated_form = FormData()
for key, value in form.items():
......@@ -251,103 +201,28 @@ async def v1_audio_transcriptions(request: Request):
@router.post("/api/v1/audio/speech")
async def v1_audio_speech(request: Request, body: dict):
logger.info("=== V1 TEXT-TO-SPEECH REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(body.get('model', ''), user_id=user_id, handler=handler)
body['model'] = actual_model
return await handler.handle_text_to_speech(request, provider_id, body)
@router.post("/api/v1/images/generations")
async def v1_image_generations(request: Request, body: dict):
logger.info("=== V1 IMAGE GENERATION REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(body.get('model', ''), user_id=user_id, handler=handler)
body['model'] = actual_model
return await handler.handle_image_generation(request, provider_id, body)
@router.post("/api/v1/embeddings")
async def v1_embeddings(request: Request, body: dict):
logger.info("=== V1 EMBEDDINGS REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(body.get('model', ''), user_id=user_id, handler=handler)
body['model'] = actual_model
return await handler.handle_embeddings(request, provider_id, body)
@router.get("/api/rotations")
......@@ -481,34 +356,9 @@ async def list_models(request: Request, provider_id: str):
async def audio_transcriptions(request: Request):
logger.info("=== AUDIO TRANSCRIPTION REQUEST ===")
form = await request.form()
model = form.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(form.get('model', ''), user_id=user_id, handler=handler)
from starlette.datastructures import FormData
updated_form = FormData()
for key, value in form.items():
......@@ -518,104 +368,548 @@ async def audio_transcriptions(request: Request):
@router.post("/api/audio/speech")
async def audio_speech(request: Request, body: dict):
logger.info("=== TEXT-TO-SPEECH REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(body.get('model', ''), user_id=user_id, handler=handler)
body['model'] = actual_model
return await handler.handle_text_to_speech(request, provider_id, body)
@router.post("/api/images/generations")
async def image_generations(request: Request, body: dict):
logger.info("=== IMAGE GENERATION REQUEST ===")
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
body['model'] = actual_model
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(body.get('model', ''), user_id=user_id, handler=handler)
body['model'] = actual_model
return await handler.handle_image_generation(request, provider_id, body)
@router.post("/api/embeddings")
async def embeddings(request: Request, body: dict):
logger.info("=== EMBEDDINGS REQUEST ===")
model = body.get('model', '')
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(body.get('model', ''), user_id=user_id, handler=handler)
body['model'] = actual_model
return await handler.handle_embeddings(request, provider_id, body)
def _resolve_provider(model: str, user_id=None, handler=None) -> tuple[str, str]:
"""Resolve provider_id and actual_model. Checks user providers when handler is given."""
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'")
if provider_id == "rotation":
if actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model)
provider_id = selected_provider
actual_model = selected_model
elif provider_id == "autoselect":
if actual_model not in _config.autoselect:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}")
autoselect_config = _config.autoselect[actual_model]
fallback = autoselect_config.fallback
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model'")
if provider_id in ("rotation", "rotations"):
rot_handler = _get_user_handler('rotation', user_id) if user_id else _rotation_handler
if actual_model not in _config.rotations and actual_model not in getattr(rot_handler, 'rotations', {}):
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found")
provider_id, actual_model = rot_handler._select_provider_and_model(actual_model)
elif provider_id in ("autoselect", "autoselections"):
asel_handler = _get_user_handler('autoselect', user_id) if user_id else None
asel_cfg = _config.autoselect.get(actual_model) or (asel_handler and getattr(asel_handler, 'user_autoselects', {}).get(actual_model))
if not asel_cfg:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found")
fallback = asel_cfg.fallback if hasattr(asel_cfg, 'fallback') else asel_cfg.get('fallback', '')
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
elif fallback in _config.rotations:
provider_id, actual_model = _rotation_handler._select_provider_and_model(fallback)
else:
if fallback in _config.rotations:
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback)
provider_id = selected_provider
actual_model = selected_model
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback configuration for autoselect '{actual_model}'")
if provider_id not in _config.providers:
raise HTTPException(status_code=400, detail=f"Provider '{provider_id}' not found. Available: {list(_config.providers.keys())}")
raise HTTPException(status_code=400, detail=f"Invalid fallback for autoselect '{actual_model}'")
user_providers = getattr(handler, 'user_providers', {}) if handler else {}
if provider_id not in _config.providers and provider_id not in user_providers:
raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not found")
return provider_id, actual_model
async def _generic_proxy(request: Request, body: dict, endpoint_path: str, method: str = "POST") -> JSONResponse:
"""Resolve provider from body['model'] and forward to provider endpoint."""
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
provider_id, actual_model = _resolve_provider(body.get('model', ''), user_id=user_id, handler=handler)
body['model'] = actual_model
return await handler.handle_generic_proxy(request, provider_id, endpoint_path, body, method=method)
# ── Images ────────────────────────────────────────────────────────────────────
@router.post("/api/v1/images/edits")
async def v1_image_edits(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/edits")
@router.post("/api/v1/images/variations")
async def v1_image_variations(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/variations")
@router.post("/api/v1/images/upscale")
async def v1_image_upscale(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/upscale")
@router.post("/api/v1/images/inpaint")
async def v1_image_inpaint(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/inpaint")
@router.post("/api/v1/images/outpaint")
async def v1_image_outpaint(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/outpaint")
@router.post("/api/v1/images/caption")
async def v1_image_caption(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/caption")
@router.post("/api/v1/images/detect")
async def v1_image_detect(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/detect")
@router.post("/api/v1/images/segment")
async def v1_image_segment(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/segment")
@router.post("/api/v1/images/restore")
async def v1_image_restore(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/restore")
@router.post("/api/v1/images/colorize")
async def v1_image_colorize(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/colorize")
@router.post("/api/v1/images/style-transfer")
async def v1_image_style_transfer(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/style-transfer")
@router.post("/api/v1/images/remove-bg")
async def v1_image_remove_bg(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/remove-bg")
# ── Video ─────────────────────────────────────────────────────────────────────
@router.post("/api/v1/video/generations")
async def v1_video_generations(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/generations")
@router.post("/api/v1/video/animations")
async def v1_video_animations(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/animations")
@router.post("/api/v1/video/edits")
async def v1_video_edits(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/edits")
@router.post("/api/v1/video/descriptions")
async def v1_video_descriptions(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/descriptions")
@router.post("/api/v1/video/transcriptions")
async def v1_video_transcriptions(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/transcriptions")
@router.post("/api/v1/video/upscale")
async def v1_video_upscale(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/upscale")
# ── Audio ─────────────────────────────────────────────────────────────────────
@router.post("/api/v1/audio/generations")
async def v1_audio_generations(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/generations")
@router.post("/api/v1/audio/translations")
async def v1_audio_translations(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/translations")
@router.post("/api/v1/audio/identify")
async def v1_audio_identify(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/identify")
@router.post("/api/v1/audio/split")
async def v1_audio_split(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/split")
@router.post("/api/v1/audio/denoise")
async def v1_audio_denoise(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/denoise")
@router.post("/api/v1/audio/label")
async def v1_audio_label(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/label")
@router.post("/api/v1/audio/diarize")
async def v1_audio_diarize(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/diarize")
@router.post("/api/v1/audio/translate")
async def v1_audio_translate(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/translate")
# ── Text / NLP ────────────────────────────────────────────────────────────────
@router.post("/api/v1/moderations")
async def v1_moderations(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/moderations")
@router.post("/api/v1/translate")
async def v1_translate(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/translate")
@router.post("/api/v1/summarize")
async def v1_summarize(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/summarize")
@router.post("/api/v1/classify")
async def v1_classify(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/classify")
@router.post("/api/v1/sentiment")
async def v1_sentiment(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/sentiment")
@router.post("/api/v1/ner")
async def v1_ner(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/ner")
@router.post("/api/v1/answers")
async def v1_answers(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/answers")
@router.post("/api/v1/reasoning")
async def v1_reasoning(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/reasoning")
@router.post("/api/v1/search")
async def v1_search(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/search")
@router.post("/api/v1/tools")
async def v1_tools(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/tools")
@router.post("/api/v1/function-call")
async def v1_function_call(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/function-call")
@router.post("/api/v1/parse")
async def v1_parse(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/parse")
# ── Code ──────────────────────────────────────────────────────────────────────
@router.post("/api/v1/code/generate")
async def v1_code_generate(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/code/generate")
@router.post("/api/v1/code/complete")
async def v1_code_complete(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/code/complete")
@router.post("/api/v1/code/explain")
async def v1_code_explain(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/code/explain")
@router.post("/api/v1/code/refactor")
async def v1_code_refactor(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/code/refactor")
@router.post("/api/v1/code/review")
async def v1_code_review(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/code/review")
@router.post("/api/v1/code/test")
async def v1_code_test(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/code/test")
@router.post("/api/v1/math")
async def v1_math(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/math")
@router.post("/api/v1/reason")
async def v1_reason(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/reason")
# ── Vision / Multimodal ───────────────────────────────────────────────────────
@router.post("/api/v1/vision/describe")
async def v1_vision_describe(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/vision/describe")
@router.post("/api/v1/vision/ocr")
async def v1_vision_ocr(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/vision/ocr")
@router.post("/api/v1/vision/analyze")
async def v1_vision_analyze(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/vision/analyze")
@router.post("/api/v1/vision/detect")
async def v1_vision_detect(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/vision/detect")
@router.post("/api/v1/depth")
async def v1_depth(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/depth")
@router.post("/api/v1/pose")
async def v1_pose(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/pose")
# ── 3D & Advanced ─────────────────────────────────────────────────────────────
@router.post("/api/v1/3d/generate")
async def v1_3d_generate(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/3d/generate")
@router.post("/api/v1/3d/convert")
async def v1_3d_convert(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/3d/convert")
@router.post("/api/v1/animate")
async def v1_animate(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/animate")
@router.post("/api/v1/avatar")
async def v1_avatar(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/avatar")
@router.post("/api/v1/face-swap")
async def v1_face_swap(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/face-swap")
@router.post("/api/v1/face-restore")
async def v1_face_restore(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/face-restore")
# ── Fine-tuning ───────────────────────────────────────────────────────────────
async def _get_handler_for_provider(request: Request, provider: str):
"""Resolve a provider_id from query param and return (provider_id, handler)."""
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
return await handler.handle_embeddings(request, provider_id, body)
user_providers = getattr(handler, 'user_providers', {})
if not provider or (provider not in _config.providers and provider not in user_providers):
available = list(_config.providers.keys()) + list(user_providers.keys())
raise HTTPException(status_code=400, detail=f"Query param 'provider' required. Available: {available}")
return provider, handler
# ── Fine-tuning ───────────────────────────────────────────────────────────────
@router.get("/api/v1/fine-tunes")
async def v1_list_fine_tunes(request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, "v1/fine-tunes", {}, method="GET")
@router.post("/api/v1/fine-tunes")
async def v1_create_fine_tune(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/fine-tunes")
@router.get("/api/v1/fine-tunes/{job_id}")
async def v1_get_fine_tune(job_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/fine-tunes/{job_id}", {}, method="GET")
@router.post("/api/v1/fine-tunes/{job_id}/cancel")
async def v1_cancel_fine_tune(job_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/fine-tunes/{job_id}/cancel", {})
# ── Files ─────────────────────────────────────────────────────────────────────
@router.get("/api/v1/files")
async def v1_list_files(request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, "v1/files", {}, method="GET")
@router.post("/api/v1/files")
async def v1_upload_file(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/files")
@router.get("/api/v1/files/{file_id}")
async def v1_get_file(file_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/files/{file_id}", {}, method="GET")
@router.delete("/api/v1/files/{file_id}")
async def v1_delete_file(file_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/files/{file_id}", {}, method="DELETE")
# ── Assistants ────────────────────────────────────────────────────────────────
@router.get("/api/v1/assistants")
async def v1_list_assistants(request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, "v1/assistants", {}, method="GET")
@router.post("/api/v1/assistants")
async def v1_create_assistant(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/assistants")
@router.get("/api/v1/assistants/{assistant_id}")
async def v1_get_assistant(assistant_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/assistants/{assistant_id}", {}, method="GET")
@router.delete("/api/v1/assistants/{assistant_id}")
async def v1_delete_assistant(assistant_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/assistants/{assistant_id}", {}, method="DELETE")
# ── Threads ───────────────────────────────────────────────────────────────────
@router.get("/api/v1/threads")
async def v1_list_threads(request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, "v1/threads", {}, method="GET")
@router.post("/api/v1/threads")
async def v1_create_thread(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/threads")
@router.get("/api/v1/threads/{thread_id}")
async def v1_get_thread(thread_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/threads/{thread_id}", {}, method="GET")
@router.post("/api/v1/threads/{thread_id}/runs")
async def v1_create_run(thread_id: str, request: Request, body: dict):
return await _generic_proxy(request, body, f"v1/threads/{thread_id}/runs")
@router.get("/api/v1/threads/{thread_id}/runs")
async def v1_list_runs(thread_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/threads/{thread_id}/runs", {}, method="GET")
# ── Vector stores ─────────────────────────────────────────────────────────────
@router.get("/api/v1/vector-stores")
async def v1_list_vector_stores(request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, "v1/vector-stores", {}, method="GET")
@router.post("/api/v1/vector-stores")
async def v1_create_vector_store(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/vector-stores")
@router.get("/api/v1/vector-stores/{store_id}")
async def v1_get_vector_store(store_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/vector-stores/{store_id}", {}, method="GET")
@router.delete("/api/v1/vector-stores/{store_id}")
async def v1_delete_vector_store(store_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/vector-stores/{store_id}", {}, method="DELETE")
# ── Batch ─────────────────────────────────────────────────────────────────────
@router.post("/api/v1/batch")
async def v1_create_batch(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/batch")
@router.get("/api/v1/batch/{batch_id}")
async def v1_get_batch(batch_id: str, request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, f"v1/batch/{batch_id}", {}, method="GET")
# ── Analytics ─────────────────────────────────────────────────────────────────
@router.get("/api/v1/usage")
async def v1_usage(request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, "v1/usage", {}, method="GET")
@router.get("/api/v1/usage/costs")
async def v1_usage_costs(request: Request, provider: str = ""):
pid, handler = await _get_handler_for_provider(request, provider)
return await handler.handle_generic_proxy(request, pid, "v1/usage/costs", {}, method="GET")
@router.get("/api/v1/providers/health")
async def v1_providers_health():
health = {}
for provider_id, provider_config in _config.providers.items():
from aisbf.providers import get_provider_handler
try:
h = get_provider_handler(provider_id, getattr(provider_config, 'api_key', None))
health[provider_id] = {"status": "unavailable" if h.is_rate_limited() else "ok"}
except Exception as e:
health[provider_id] = {"status": "error", "detail": str(e)}
return health
@router.get("/api/v1/cache/stats")
async def v1_cache_stats():
from aisbf.cache import get_response_cache
cache = get_response_cache()
try:
return cache.stats() if hasattr(cache, 'stats') else {"status": "unavailable"}
except Exception:
return {"status": "unavailable"}
# ── MCP config endpoints ──────────────────────────────────────────────────────
@router.get("/api/autoselect/{autoselect_id}")
async def get_autoselect_config(autoselect_id: str):
if autoselect_id not in _config.autoselect:
raise HTTPException(status_code=404, detail=f"Autoselect '{autoselect_id}' not found")
cfg = _config.autoselect[autoselect_id]
return {"id": autoselect_id, "model_name": cfg.model_name, "description": cfg.description,
"fallback": cfg.fallback, "available_models": [{"model_id": m.model_id, "description": m.description} for m in cfg.available_models]}
@router.get("/api/rotations/{rotation_id}")
async def get_rotation_config(rotation_id: str):
if rotation_id not in _config.rotations:
raise HTTPException(status_code=404, detail=f"Rotation '{rotation_id}' not found")
cfg = _config.rotations[rotation_id]
return {"id": rotation_id, "model_name": cfg.model_name,
"providers": [{"provider_id": p["provider_id"], "models": p["models"]} for p in cfg.providers]}
@router.get("/api/v1/providers")
async def get_providers_config(request: Request):
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
user_providers = getattr(handler, 'user_providers', {})
providers = {pid: {"endpoint": pc.endpoint, "type": getattr(pc, 'type', 'unknown')}
for pid, pc in _config.providers.items()}
for pid, pc in user_providers.items():
providers[pid] = {"endpoint": pc.get('endpoint', ''), "type": pc.get('type', 'unknown'), "user_defined": True}
return {"providers": providers}
# ── Legacy OpenAI engines format ──────────────────────────────────────────────
@router.post("/api/v1/engines/{engine}/embeddings")
async def v1_engines_embeddings(engine: str, request: Request, body: dict):
# engine is in format "provider--model" or just used as model; normalise to provider/model
if '--' in engine:
provider_id, model = engine.split('--', 1)
else:
provider_id, model = parse_provider_from_model(engine)
if not provider_id:
raise HTTPException(status_code=400, detail="Engine must be in format 'provider--model' or 'provider/model'")
body['model'] = f"{provider_id}/{model}"
return await _generic_proxy(request, body, "v1/embeddings")
@router.post("/api/v1/engines/{engine}/completions")
async def v1_engines_completions(engine: str, request: Request, body: dict):
if '--' in engine:
provider_id, model = engine.split('--', 1)
else:
provider_id, model = parse_provider_from_model(engine)
if not provider_id:
raise HTTPException(status_code=400, detail="Engine must be in format 'provider--model' or 'provider/model'")
body['model'] = f"{provider_id}/{model}"
return await _generic_proxy(request, body, "v1/completions")
@router.post("/api/{provider_id}")
async def catch_all_post(provider_id: str, request: Request):
......
from fastapi import APIRouter, Request, Form, Query, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response
from typing import Optional
import time, logging, secrets, hashlib, os, re
import time, logging, secrets, hashlib, os, re, hmac
from pathlib import Path
from datetime import datetime, timedelta
from aisbf.database import DatabaseRegistry
......@@ -753,7 +753,7 @@ async def dashboard_change_password_save(request: Request, current_password: str
try:
if not db.verify_user_password(user_id, current_password):
return RedirectResponse(url=url_for(request, "/dashboard/change-password?error=Current password is incorrect"), status_code=303)
db.update_user_password(user_id, new_password)
db.update_user_password(user_id, _db_hash_password(new_password))
return RedirectResponse(url=url_for(request, "/dashboard/change-password?success=Password changed successfully"), status_code=303)
except Exception as e:
return RedirectResponse(url=url_for(request, f"/dashboard/change-password?error=Failed to change password: {str(e)}"), status_code=303)
......@@ -961,7 +961,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
redirect_uri = f"{base_url}/auth/oauth2/google/callback"
session_state = request.session.get('oauth2_google', {}).get('state')
if state != session_state:
if not hmac.compare_digest(state, session_state or ''):
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Invalid authentication state"})
......@@ -1093,7 +1093,7 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
redirect_uri = f"{base_url}/auth/oauth2/github/callback"
session_state = request.session.get('oauth2_github', {}).get('state')
if state != session_state:
if not hmac.compare_digest(state, session_state or ''):
return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Invalid authentication state"})
......
......@@ -20,13 +20,20 @@ except ImportError:
router = APIRouter()
_config = None
_templates = None
_payment_service = None
logger = logging.getLogger(__name__)
def init(config, templates):
global _config, _templates
def init(config, templates, payment_service=None):
global _config, _templates, _payment_service
_config = config
_templates = templates
_payment_service = payment_service
def set_payment_service(service):
global _payment_service
_payment_service = service
@router.get("/dashboard/billing/add-method", response_class=HTMLResponse)
......@@ -99,8 +106,8 @@ async def dashboard_add_payment_method_stripe(request: Request):
try:
# Attach the PM to the Stripe customer so it can be charged later
if payment_service and payment_service.stripe_handler:
customer_id = await payment_service.stripe_handler._get_or_create_customer(user_id)
if _payment_service and _payment_service.stripe_handler:
customer_id = await _payment_service.stripe_handler._get_or_create_customer(user_id)
import stripe as _stripe
import asyncio as _asyncio
try:
......
......@@ -98,6 +98,14 @@ async def user_list_models(request: Request, username: str):
logger.warning(f"Error listing user autoselect {autoselect_id}: {e}")
return {"object": "list", "data": all_models}
@router.get("/api/u/{username}/models/{model_id}")
async def user_get_model(model_id: str, request: Request, username: str):
result = await user_list_models(request, username)
for model in (result.get("data", []) if isinstance(result, dict) else []):
if model.get("id") == model_id:
return model
raise HTTPException(status_code=404, detail=f"Model '{model_id}' not found")
@router.get("/api/u/{username}/providers")
async def user_list_providers(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None)
......@@ -367,3 +375,522 @@ async def user_list_provider_models_by_username(request: Request, username: str,
except Exception as e:
logging.getLogger(__name__).warning(f"Error listing models for user provider {user_provider_id}: {e}")
return {"data": all_models}
# ── Shared helpers ────────────────────────────────────────────────────────────
def _check_user_access(request: Request, username: str):
"""Extract user_id. Access control (global token rejection, username match) is enforced by middleware."""
user_id = getattr(request.state, 'user_id', None)
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required.")
return user_id
async def _user_generic_proxy(request: Request, username: str, body: dict, endpoint_path: str, method: str = "POST") -> JSONResponse:
"""Resolve provider from body['model'] scoped to the user and forward to provider endpoint."""
user_id = _check_user_access(request, username)
handler = _get_user_handler('request', user_id)
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model'")
# Resolve rotation/autoselect
if provider_id in ("rotation", "rotations"):
rot_handler = _get_user_handler('rotation', user_id)
if actual_model not in rot_handler.rotations and actual_model not in _config.rotations:
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found")
provider_id, actual_model = rot_handler._select_provider_and_model(actual_model)
elif provider_id in ("autoselect", "autoselections"):
asel_handler = _get_user_handler('autoselect', user_id)
asel_cfg = _config.autoselect.get(actual_model) or asel_handler.user_autoselects.get(actual_model)
if not asel_cfg:
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found")
fallback = asel_cfg.fallback if hasattr(asel_cfg, 'fallback') else asel_cfg.get('fallback', '')
if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1)
elif fallback in _config.rotations:
from aisbf.handlers import RotationHandler
provider_id, actual_model = RotationHandler()._select_provider_and_model(fallback)
else:
raise HTTPException(status_code=400, detail=f"Invalid fallback for autoselect '{actual_model}'")
user_providers = getattr(handler, 'user_providers', {})
if provider_id not in _config.providers and provider_id not in user_providers:
raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not found")
body['model'] = actual_model
return await handler.handle_generic_proxy(request, provider_id, endpoint_path, body, method=method)
async def _user_get_proxy(request: Request, username: str, provider: str, endpoint_path: str) -> JSONResponse:
"""Forward a GET request to the provider endpoint for a specific user."""
user_id = _check_user_access(request, username)
handler = _get_user_handler('request', user_id)
user_providers = getattr(handler, 'user_providers', {})
if not provider or (provider not in _config.providers and provider not in user_providers):
available = list(_config.providers.keys()) + list(user_providers.keys())
raise HTTPException(status_code=400, detail=f"Query param 'provider' required. Available: {available}")
return await handler.handle_generic_proxy(request, provider, endpoint_path, {}, method="GET")
async def _user_delete_proxy(request: Request, username: str, provider: str, endpoint_path: str) -> JSONResponse:
user_id = _check_user_access(request, username)
handler = _get_user_handler('request', user_id)
user_providers = getattr(handler, 'user_providers', {})
if not provider or (provider not in _config.providers and provider not in user_providers):
raise HTTPException(status_code=400, detail="Query param 'provider' required")
return await handler.handle_generic_proxy(request, provider, endpoint_path, {}, method="DELETE")
# ── Audio ─────────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/audio/transcriptions")
async def user_audio_transcriptions(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/transcriptions")
@router.post("/api/u/{username}/audio/speech")
async def user_audio_speech(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/speech")
@router.post("/api/u/{username}/audio/translations")
async def user_audio_translations(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/translations")
@router.post("/api/u/{username}/audio/generations")
async def user_audio_generations(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/generations")
@router.post("/api/u/{username}/audio/translate")
async def user_audio_translate(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/translate")
@router.post("/api/u/{username}/audio/identify")
async def user_audio_identify(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/identify")
@router.post("/api/u/{username}/audio/split")
async def user_audio_split(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/split")
@router.post("/api/u/{username}/audio/denoise")
async def user_audio_denoise(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/denoise")
@router.post("/api/u/{username}/audio/label")
async def user_audio_label(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/label")
@router.post("/api/u/{username}/audio/diarize")
async def user_audio_diarize(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/diarize")
# ── Images ────────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/images/generations")
async def user_image_generations(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/generations")
@router.post("/api/u/{username}/images/edits")
async def user_image_edits(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/edits")
@router.post("/api/u/{username}/images/variations")
async def user_image_variations(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/variations")
@router.post("/api/u/{username}/images/upscale")
async def user_image_upscale(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/upscale")
@router.post("/api/u/{username}/images/inpaint")
async def user_image_inpaint(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/inpaint")
@router.post("/api/u/{username}/images/outpaint")
async def user_image_outpaint(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/outpaint")
@router.post("/api/u/{username}/images/caption")
async def user_image_caption(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/caption")
@router.post("/api/u/{username}/images/detect")
async def user_image_detect(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/detect")
@router.post("/api/u/{username}/images/segment")
async def user_image_segment(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/segment")
@router.post("/api/u/{username}/images/restore")
async def user_image_restore(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/restore")
@router.post("/api/u/{username}/images/colorize")
async def user_image_colorize(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/colorize")
@router.post("/api/u/{username}/images/style-transfer")
async def user_image_style_transfer(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/style-transfer")
@router.post("/api/u/{username}/images/remove-bg")
async def user_image_remove_bg(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/remove-bg")
# ── Video ─────────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/video/generations")
async def user_video_generations(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/generations")
@router.post("/api/u/{username}/video/animations")
async def user_video_animations(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/animations")
@router.post("/api/u/{username}/video/edits")
async def user_video_edits(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/edits")
@router.post("/api/u/{username}/video/descriptions")
async def user_video_descriptions(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/descriptions")
@router.post("/api/u/{username}/video/transcriptions")
async def user_video_transcriptions(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/transcriptions")
@router.post("/api/u/{username}/video/upscale")
async def user_video_upscale(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/upscale")
# ── Embeddings ────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/embeddings")
async def user_embeddings(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/embeddings")
# ── Text / NLP ────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/moderations")
async def user_moderations(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/moderations")
@router.post("/api/u/{username}/translate")
async def user_translate(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/translate")
@router.post("/api/u/{username}/summarize")
async def user_summarize(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/summarize")
@router.post("/api/u/{username}/classify")
async def user_classify(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/classify")
@router.post("/api/u/{username}/sentiment")
async def user_sentiment(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/sentiment")
@router.post("/api/u/{username}/ner")
async def user_ner(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/ner")
@router.post("/api/u/{username}/answers")
async def user_answers(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/answers")
@router.post("/api/u/{username}/reasoning")
async def user_reasoning(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/reasoning")
@router.post("/api/u/{username}/search")
async def user_search(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/search")
@router.post("/api/u/{username}/tools")
async def user_tools(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/tools")
@router.post("/api/u/{username}/function-call")
async def user_function_call(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/function-call")
@router.post("/api/u/{username}/parse")
async def user_parse(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/parse")
# ── Code ──────────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/code/generate")
async def user_code_generate(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/code/generate")
@router.post("/api/u/{username}/code/complete")
async def user_code_complete(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/code/complete")
@router.post("/api/u/{username}/code/explain")
async def user_code_explain(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/code/explain")
@router.post("/api/u/{username}/code/refactor")
async def user_code_refactor(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/code/refactor")
@router.post("/api/u/{username}/code/review")
async def user_code_review(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/code/review")
@router.post("/api/u/{username}/code/test")
async def user_code_test(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/code/test")
@router.post("/api/u/{username}/math")
async def user_math(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/math")
@router.post("/api/u/{username}/reason")
async def user_reason(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/reason")
# ── Vision / Multimodal ───────────────────────────────────────────────────────
@router.post("/api/u/{username}/vision/describe")
async def user_vision_describe(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/vision/describe")
@router.post("/api/u/{username}/vision/ocr")
async def user_vision_ocr(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/vision/ocr")
@router.post("/api/u/{username}/vision/analyze")
async def user_vision_analyze(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/vision/analyze")
@router.post("/api/u/{username}/vision/detect")
async def user_vision_detect(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/vision/detect")
@router.post("/api/u/{username}/depth")
async def user_depth(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/depth")
@router.post("/api/u/{username}/pose")
async def user_pose(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/pose")
# ── 3D & Advanced ─────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/3d/generate")
async def user_3d_generate(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/3d/generate")
@router.post("/api/u/{username}/3d/convert")
async def user_3d_convert(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/3d/convert")
@router.post("/api/u/{username}/animate")
async def user_animate(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/animate")
@router.post("/api/u/{username}/avatar")
async def user_avatar(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/avatar")
@router.post("/api/u/{username}/face-swap")
async def user_face_swap(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/face-swap")
@router.post("/api/u/{username}/face-restore")
async def user_face_restore(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/face-restore")
# ── Fine-tuning ───────────────────────────────────────────────────────────────
@router.get("/api/u/{username}/fine-tunes")
async def user_list_fine_tunes(request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, "v1/fine-tunes")
@router.post("/api/u/{username}/fine-tunes")
async def user_create_fine_tune(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/fine-tunes")
@router.get("/api/u/{username}/fine-tunes/{job_id}")
async def user_get_fine_tune(job_id: str, request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, f"v1/fine-tunes/{job_id}")
@router.post("/api/u/{username}/fine-tunes/{job_id}/cancel")
async def user_cancel_fine_tune(job_id: str, request: Request, username: str, provider: str = ""):
user_id = _check_user_access(request, username)
handler = _get_user_handler('request', user_id)
user_providers = getattr(handler, 'user_providers', {})
if not provider or (provider not in _config.providers and provider not in user_providers):
raise HTTPException(status_code=400, detail="Query param 'provider' required")
return await handler.handle_generic_proxy(request, provider, f"v1/fine-tunes/{job_id}/cancel", {})
# ── Files ─────────────────────────────────────────────────────────────────────
@router.get("/api/u/{username}/files")
async def user_list_files(request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, "v1/files")
@router.post("/api/u/{username}/files")
async def user_upload_file(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/files")
@router.get("/api/u/{username}/files/{file_id}")
async def user_get_file(file_id: str, request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, f"v1/files/{file_id}")
@router.delete("/api/u/{username}/files/{file_id}")
async def user_delete_file(file_id: str, request: Request, username: str, provider: str = ""):
return await _user_delete_proxy(request, username, provider, f"v1/files/{file_id}")
# ── Assistants ────────────────────────────────────────────────────────────────
@router.get("/api/u/{username}/assistants")
async def user_list_assistants(request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, "v1/assistants")
@router.post("/api/u/{username}/assistants")
async def user_create_assistant(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/assistants")
@router.get("/api/u/{username}/assistants/{assistant_id}")
async def user_get_assistant(assistant_id: str, request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, f"v1/assistants/{assistant_id}")
@router.delete("/api/u/{username}/assistants/{assistant_id}")
async def user_delete_assistant(assistant_id: str, request: Request, username: str, provider: str = ""):
return await _user_delete_proxy(request, username, provider, f"v1/assistants/{assistant_id}")
# ── Threads ───────────────────────────────────────────────────────────────────
@router.get("/api/u/{username}/threads")
async def user_list_threads(request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, "v1/threads")
@router.post("/api/u/{username}/threads")
async def user_create_thread(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/threads")
@router.get("/api/u/{username}/threads/{thread_id}")
async def user_get_thread(thread_id: str, request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, f"v1/threads/{thread_id}")
@router.post("/api/u/{username}/threads/{thread_id}/runs")
async def user_create_run(thread_id: str, request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, f"v1/threads/{thread_id}/runs")
@router.get("/api/u/{username}/threads/{thread_id}/runs")
async def user_list_runs(thread_id: str, request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, f"v1/threads/{thread_id}/runs")
# ── Vector stores ─────────────────────────────────────────────────────────────
@router.get("/api/u/{username}/vector-stores")
async def user_list_vector_stores(request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, "v1/vector-stores")
@router.post("/api/u/{username}/vector-stores")
async def user_create_vector_store(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/vector-stores")
@router.get("/api/u/{username}/vector-stores/{store_id}")
async def user_get_vector_store(store_id: str, request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, f"v1/vector-stores/{store_id}")
@router.delete("/api/u/{username}/vector-stores/{store_id}")
async def user_delete_vector_store(store_id: str, request: Request, username: str, provider: str = ""):
return await _user_delete_proxy(request, username, provider, f"v1/vector-stores/{store_id}")
# ── Batch ─────────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/batch")
async def user_create_batch(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/batch")
@router.get("/api/u/{username}/batch/{batch_id}")
async def user_get_batch(batch_id: str, request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, f"v1/batch/{batch_id}")
# ── Completions (legacy) ──────────────────────────────────────────────────────
@router.post("/api/u/{username}/completions")
async def user_completions(request: Request, username: str, body: dict):
prompt = body.get("prompt", "")
body.setdefault("messages", [{"role": "user", "content": prompt}])
return await _user_generic_proxy(request, username, body, "v1/completions")
@router.post("/api/u/{username}/engines/{engine}/completions")
async def user_engines_completions(engine: str, request: Request, username: str, body: dict):
if '--' in engine:
provider_id, model = engine.split('--', 1)
else:
provider_id, model = parse_provider_from_model(engine)
if not provider_id:
raise HTTPException(status_code=400, detail="Engine must be in format 'provider--model' or 'provider/model'")
body['model'] = f"{provider_id}/{model}"
return await _user_generic_proxy(request, username, body, "v1/completions")
@router.post("/api/u/{username}/engines/{engine}/embeddings")
async def user_engines_embeddings(engine: str, request: Request, username: str, body: dict):
if '--' in engine:
provider_id, model = engine.split('--', 1)
else:
provider_id, model = parse_provider_from_model(engine)
if not provider_id:
raise HTTPException(status_code=400, detail="Engine must be in format 'provider--model' or 'provider/model'")
body['model'] = f"{provider_id}/{model}"
return await _user_generic_proxy(request, username, body, "v1/embeddings")
# ── Analytics & monitoring ────────────────────────────────────────────────────
@router.get("/api/u/{username}/usage")
async def user_usage(request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, "v1/usage")
@router.get("/api/u/{username}/usage/costs")
async def user_usage_costs(request: Request, username: str, provider: str = ""):
return await _user_get_proxy(request, username, provider, "v1/usage/costs")
@router.get("/api/u/{username}/providers/health")
async def user_providers_health(request: Request, username: str):
user_id = _check_user_access(request, username)
handler = _get_user_handler('request', user_id)
from aisbf.providers import get_provider_handler as _get_ph
health = {}
all_providers = {**_config.providers, **getattr(handler, 'user_providers', {})}
for provider_id, provider_config in all_providers.items():
try:
api_key = provider_config.get('api_key') if isinstance(provider_config, dict) else getattr(provider_config, 'api_key', None)
h = _get_ph(provider_id, api_key, user_id=user_id)
health[provider_id] = {"status": "unavailable" if h.is_rate_limited() else "ok"}
except Exception as e:
health[provider_id] = {"status": "error", "detail": str(e)}
return health
@router.get("/api/u/{username}/cache/stats")
async def user_cache_stats(request: Request, username: str):
_check_user_access(request, username)
from aisbf.cache import get_response_cache
cache = get_response_cache()
try:
return cache.stats() if hasattr(cache, 'stats') else {"status": "unavailable"}
except Exception:
return {"status": "unavailable"}
......@@ -137,8 +137,8 @@ else
fi
# Remove _share directory (PyPI packaging artifacts)
if [ -d "_share" ]; then
echo "Removing _share/ directory..."
if [ -d "aisbf/_share" ]; then
echo "Removing aisbf/_share/ directory..."
rm -rf _share
echo " ✓ _share/ removed"
else
......
......@@ -254,15 +254,15 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
const opts = availableProviders.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join('');
return `<select id="${uid}" onchange="${onChangeFn}(this.value)" required>
return `<select id="${uid}" onchange="(${onChangeFn})(this.value)" required>
<option value="">${window.i18n.t('rotations.select_provider')}</option>${opts}</select>`;
} else {
const dlOpts = availableProviders.map(p => `<option value="${escHtmlAttr(p)}">`).join('');
return `<div style="position:relative;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="${window.i18n.t('rotations.type_search_provider')}"
oninput="handleProviderInput('${uid}', this.value, ${onChangeFn})"
onchange="handleProviderInput('${uid}', this.value, ${onChangeFn})"
oninput="handleProviderInput('${uid}', this.value, (${onChangeFn}))"
onchange="handleProviderInput('${uid}', this.value, (${onChangeFn}))"
style="width:100%;padding:8px;border:1px solid var(--color-border);border-radius:3px;background:var(--bg-page);color:var(--color-text);font-size:14px;">
<datalist id="${uid}-dl">${dlOpts}</datalist>
</div>`;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment