Fix rotation edit

parent 3d690b74
...@@ -4,6 +4,7 @@ All ASGI middleware functions extracted from main.py. ...@@ -4,6 +4,7 @@ All ASGI middleware functions extracted from main.py.
import time import time
import logging import logging
import threading import threading
import hmac as _hmac
from typing import Optional from typing import Optional
from fastapi import Request from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
...@@ -21,7 +22,8 @@ _client_rl_lock = threading.Lock() ...@@ -21,7 +22,8 @@ _client_rl_lock = threading.Lock()
def _get_real_client_ip(request: Request) -> str: def _get_real_client_ip(request: Request) -> str:
xff = request.headers.get('X-Forwarded-For', '') xff = request.headers.get('X-Forwarded-For', '')
if xff: 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') client = request.scope.get('client')
return client[0] if client else 'unknown' return client[0] if client else 'unknown'
...@@ -62,7 +64,8 @@ _BLOCK_MESSAGE = "We do not support the Israeli genocide of Palestinian people." ...@@ -62,7 +64,8 @@ _BLOCK_MESSAGE = "We do not support the Israeli genocide of Palestinian people."
def _get_client_ip(request: Request) -> Optional[str]: def _get_client_ip(request: Request) -> Optional[str]:
xff = request.headers.get("X-Forwarded-For") xff = request.headers.get("X-Forwarded-For")
if xff: 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") client = request.scope.get("client")
return client[0] if client else None 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): ...@@ -195,7 +198,10 @@ def make_auth_middleware(get_server_config, get_config, get_db, url_for_fn):
token = auth_header.replace('Bearer ', '') token = auth_header.replace('Bearer ', '')
allowed_tokens = server_config.get('auth_tokens', []) 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.user_id = None
request.state.token_id = None request.state.token_id = None
request.state.is_global_token = True request.state.is_global_token = True
......
...@@ -42,39 +42,6 @@ try: ...@@ -42,39 +42,6 @@ try:
except ImportError: except ImportError:
HAS_CURL_CFFI = False 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 # Claude OAuth2 Configuration
# These values match the official claude-cli implementation # These values match the official claude-cli implementation
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Official Claude Code client ID CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Official Claude Code client ID
......
...@@ -28,14 +28,11 @@ import logging ...@@ -28,14 +28,11 @@ import logging
# On read, detect format by attempting JSON first so legacy pickle data still works. # On read, detect format by attempting JSON first so legacy pickle data still works.
def _cache_encode(value: any) -> bytes: def _cache_encode(value: any) -> bytes:
"""Encode a cache value. Prefers JSON; falls back to pickle.""" """Encode a cache value using JSON only."""
try: return b'\x00' + json.dumps(value, ensure_ascii=False).encode('utf-8')
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: 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): if isinstance(data, memoryview):
data = bytes(data) data = bytes(data)
if not data: if not data:
...@@ -43,12 +40,15 @@ def _cache_decode(data: bytes) -> any: ...@@ -43,12 +40,15 @@ def _cache_decode(data: bytes) -> any:
if data[0:1] == b'\x00': if data[0:1] == b'\x00':
return json.loads(data[1:].decode('utf-8')) return json.loads(data[1:].decode('utf-8'))
if data[0:1] == b'\x01': if data[0:1] == b'\x01':
return pickle.loads(data[1:]) # Legacy pickle-encoded entry — discard; will be recalculated on next miss
# Legacy: no prefix — assume raw pickle logger.warning("Discarding legacy pickle-encoded cache entry (will be recalculated)")
return None
# Legacy: no prefix — try JSON, discard if unparseable
try: try:
return pickle.loads(data)
except Exception:
return json.loads(data.decode('utf-8')) 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 typing import Any, Optional, Dict, List
from pathlib import Path from pathlib import Path
import time import time
......
...@@ -222,7 +222,7 @@ class DatabaseManager: ...@@ -222,7 +222,7 @@ class DatabaseManager:
async def __aenter__(self): async def __aenter__(self):
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
pass return False # never suppress exceptions
return TransactionContext() return TransactionContext()
...@@ -416,24 +416,11 @@ class DatabaseManager: ...@@ -416,24 +416,11 @@ class DatabaseManager:
completion_tokens: Optional number of output/completion tokens completion_tokens: Optional number of output/completion tokens
actual_cost: Optional actual cost returned by provider (in USD) 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: try:
# Convert latency to int for storage # Convert latency to int for storage
latency_int = int(latency_ms) if latency_ms else 0 latency_int = int(latency_ms) if latency_ms else 0
logger.info(f"🔍 DB.record_token_usage FULL PARAMETERS:") logger.debug(f"DB.record_token_usage params: provider={provider_id}, model={model_name}, tokens={tokens_used}, user={user_id}, success={success}")
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}")
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
...@@ -451,31 +438,26 @@ class DatabaseManager: ...@@ -451,31 +438,26 @@ class DatabaseManager:
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP) 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) 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"Trying full INSERT with {len(params)} parameters")
logger.debug(f"🔍 SQL: {sql}")
logger.debug(f"🔍 Params: {params}")
cursor.execute(sql, params) 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: except Exception as full_insert_error:
logger.warning(f"⚠️ Full column insert failed: {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__}") logger.warning(f"⚠️ Full insert error type: {type(full_insert_error).__name__}")
import traceback import traceback
logger.warning(f"⚠️ Full insert traceback: {traceback.format_exc()}") 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 # Fallback to basic columns only
sql = f''' sql = f'''
INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, timestamp) INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, timestamp)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP) VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''' '''
params = (user_id, provider_id, model_name, tokens_used) 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) 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() 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: except Exception as e:
logger.error(f"❌ Failed to record token usage for {provider_id}/{model_name}: {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}") logger.error(f"Error details - user_id={user_id}, tokens={tokens_used}, success={success}")
...@@ -485,7 +467,7 @@ class DatabaseManager: ...@@ -485,7 +467,7 @@ class DatabaseManager:
test_cursor = test_conn.cursor() 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_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() test_conn.commit()
logger.info("✅ Test database insert succeeded") logger.debug("Test database insert succeeded")
except Exception as test_e: except Exception as test_e:
logger.error(f"❌ Even test database insert failed: {test_e}") logger.error(f"❌ Even test database insert failed: {test_e}")
raise raise
......
...@@ -540,7 +540,6 @@ class RequestHandler: ...@@ -540,7 +540,6 @@ class RequestHandler:
# Apply rate limiting # Apply rate limiting
logger.info("Applying rate limiting...") logger.info("Applying rate limiting...")
await handler.apply_rate_limit() await handler.apply_rate_limit()
await handler.apply_rate_limit()
logger.info("Rate limiting applied") logger.info("Rate limiting applied")
logger.info(f"Sending request to provider handler...") logger.info(f"Sending request to provider handler...")
...@@ -729,7 +728,14 @@ class RequestHandler: ...@@ -729,7 +728,14 @@ class RequestHandler:
else: else:
provider_config = self.config.get_provider(provider_id) 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 ', '') api_key = request_data.get('api_key') or request.headers.get('Authorization', '').replace('Bearer ', '')
if not api_key: if not api_key:
raise HTTPException(status_code=401, detail="API key required") raise HTTPException(status_code=401, detail="API key required")
...@@ -745,11 +751,11 @@ class RequestHandler: ...@@ -745,11 +751,11 @@ class RequestHandler:
# If seed is present in request, generate unique fingerprint per request # If seed is present in request, generate unique fingerprint per request
seed = request_data.get('seed') seed = request_data.get('seed')
system_fingerprint = generate_system_fingerprint(provider_id, seed) system_fingerprint = generate_system_fingerprint(provider_id, seed)
# Get context configuration and calculate effective context # Get context configuration and calculate effective context
model = request_data.get('model') model = request_data.get('model')
messages = request_data.get('messages', []) messages = request_data.get('messages', [])
context_config = get_context_config_for_model( context_config = get_context_config_for_model(
model_name=model, model_name=model,
provider_config=provider_config, provider_config=provider_config,
...@@ -807,12 +813,12 @@ class RequestHandler: ...@@ -807,12 +813,12 @@ class RequestHandler:
# Check if this is a Google streaming response by checking provider type from config # 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 # This is more reliable than checking response iterability which can cause false positives
is_google_stream = provider_config.type == 'google' is_google_stream = _provider_type == 'google'
is_kiro_stream = provider_config.type == 'kiro' is_kiro_stream = _provider_type == 'kiro'
is_kilo_stream = provider_config.type in ('kilo', 'kilocode') is_kilo_stream = _provider_type in ('kilo', 'kilocode')
logger.info(f"Is Google streaming response: {is_google_stream} (provider type: {provider_config.type})") 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_config.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_config.type})") logger.info(f"Is Kilo streaming response: {is_kilo_stream} (provider type: {_provider_type})")
if is_kilo_stream: if is_kilo_stream:
# Handle Kilo/KiloCode streaming response # Handle Kilo/KiloCode streaming response
...@@ -1797,6 +1803,56 @@ class RequestHandler: ...@@ -1797,6 +1803,56 @@ class RequestHandler:
return capabilities 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: async def handle_audio_transcription(self, request: Request, provider_id: str, form_data) -> Dict:
"""Handle audio transcription requests""" """Handle audio transcription requests"""
import logging import logging
......
...@@ -429,9 +429,27 @@ class PayPalPaymentHandler: ...@@ -429,9 +429,27 @@ class PayPalPaymentHandler:
'Wallet top up via PayPal') 'Wallet top up via PayPal')
async def _handle_order_approved(self, resource: dict): 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') 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): async def _handle_payment_capture_completed(self, resource: dict):
"""Handle completed payment capture — credit wallet.""" """Handle completed payment capture — credit wallet."""
...@@ -501,9 +519,24 @@ class PayPalPaymentHandler: ...@@ -501,9 +519,24 @@ class PayPalPaymentHandler:
logger.warning(f"PayPal refund: cannot apply refund {refund_id} — missing user_id/amount") logger.warning(f"PayPal refund: cannot apply refund {refund_id} — missing user_id/amount")
async def _handle_vault_token_created(self, resource: dict): 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') token_id = resource.get('id')
logger.info(f"PayPal vault token created: {token_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): async def _handle_vault_token_deleted(self, resource: dict):
"""Handle vault token deletion — deactivate matching payment method.""" """Handle vault token deletion — deactivate matching payment method."""
...@@ -590,41 +623,36 @@ class PayPalPaymentHandler: ...@@ -590,41 +623,36 @@ class PayPalPaymentHandler:
logger.error(f"Error creating PayPal top up order: {e}") logger.error(f"Error creating PayPal top up order: {e}")
return {'success': False, 'error': str(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): async def _handle_payment_completed(self, resource: dict):
"""Handle completed payment (legacy)""" """Handle completed payment (legacy PAYMENT.SALE.COMPLETED) — credit wallet if applicable."""
logger.info(f"PayPal payment completed: {resource.get('id')}") 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): async def _handle_payment_denied(self, resource: dict):
"""Handle denied payment (legacy)""" """Handle denied payment (legacy PAYMENT.SALE.DENIED) — queue for retry."""
logger.warning(f"PayPal payment denied: {resource.get('id')}") 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: ...@@ -319,5 +319,21 @@ class StripePaymentHandler:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
async def _handle_payment_failed(self, payment_intent: dict): async def _handle_payment_failed(self, payment_intent: dict):
"""Handle failed payment""" """Handle failed payment — queue for retry and log the failure reason."""
logger.warning(f"Payment failed: {payment_intent['id']}") 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): ...@@ -88,19 +88,45 @@ class CodexProviderHandler(BaseProviderHandler):
) )
# Determine mode: API key mode or OAuth2 mode # 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) _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 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 self._account_id = None # Will be extracted from ID token in OAuth2 mode
# Base URL for API requests # Base URL for API requests
_endpoint = (provider_config.get('endpoint') if isinstance(provider_config, dict) _endpoint = (provider_config.get('endpoint') if isinstance(provider_config, dict)
else getattr(provider_config, 'endpoint', None)) if provider_config else None 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 # Initialize OpenAI client for API key mode
if self._use_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) self.client = OpenAI(api_key=effective_key, base_url=self.base_url)
else: else:
self.client = None self.client = None
......
...@@ -130,9 +130,8 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest): ...@@ -130,9 +130,8 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
else: else:
return await handler.handle_chat_completion(request, provider_id, body_dict) return await handler.handle_chat_completion(request, provider_id, body_dict)
@router.get("/api/models") async def _build_model_list(request: Request) -> dict:
async def list_all_models(request: Request): """Shared model listing logic used by all /models endpoints."""
logger.info("=== LIST ALL MODELS REQUEST ===")
all_models = [] all_models = []
user_id = None user_id = None
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
...@@ -164,39 +163,15 @@ async def list_all_models(request: Request): ...@@ -164,39 +163,15 @@ async def list_all_models(request: Request):
logger.info(f"Returning {len(all_models)} total models") logger.info(f"Returning {len(all_models)} total models")
return {"object": "list", "data": all_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") @router.get("/api/v1/models")
async def v1_list_all_models(request: Request): async def v1_list_all_models(request: Request):
logger.info("=== V1 LIST ALL MODELS REQUEST ===") logger.info("=== V1 LIST ALL MODELS REQUEST ===")
all_models = [] return await _build_model_list(request)
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}
@router.get("/v1/models") @router.get("/v1/models")
async def v1_list_all_models_alias(request: Request): async def v1_list_all_models_alias(request: Request):
...@@ -214,34 +189,9 @@ async def models_root_alias(request: Request): ...@@ -214,34 +189,9 @@ async def models_root_alias(request: Request):
async def v1_audio_transcriptions(request: Request): async def v1_audio_transcriptions(request: Request):
logger.info("=== V1 AUDIO TRANSCRIPTION REQUEST ===") logger.info("=== V1 AUDIO TRANSCRIPTION REQUEST ===")
form = await request.form() 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) user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id) 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 from starlette.datastructures import FormData
updated_form = FormData() updated_form = FormData()
for key, value in form.items(): for key, value in form.items():
...@@ -251,103 +201,28 @@ async def v1_audio_transcriptions(request: Request): ...@@ -251,103 +201,28 @@ async def v1_audio_transcriptions(request: Request):
@router.post("/api/v1/audio/speech") @router.post("/api/v1/audio/speech")
async def v1_audio_speech(request: Request, body: dict): async def v1_audio_speech(request: Request, body: dict):
logger.info("=== V1 TEXT-TO-SPEECH REQUEST ===") 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) user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id) 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) return await handler.handle_text_to_speech(request, provider_id, body)
@router.post("/api/v1/images/generations") @router.post("/api/v1/images/generations")
async def v1_image_generations(request: Request, body: dict): async def v1_image_generations(request: Request, body: dict):
logger.info("=== V1 IMAGE GENERATION REQUEST ===") 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) user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id) 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) return await handler.handle_image_generation(request, provider_id, body)
@router.post("/api/v1/embeddings") @router.post("/api/v1/embeddings")
async def v1_embeddings(request: Request, body: dict): async def v1_embeddings(request: Request, body: dict):
logger.info("=== V1 EMBEDDINGS REQUEST ===") 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) user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id) 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) return await handler.handle_embeddings(request, provider_id, body)
@router.get("/api/rotations") @router.get("/api/rotations")
...@@ -481,34 +356,9 @@ async def list_models(request: Request, provider_id: str): ...@@ -481,34 +356,9 @@ async def list_models(request: Request, provider_id: str):
async def audio_transcriptions(request: Request): async def audio_transcriptions(request: Request):
logger.info("=== AUDIO TRANSCRIPTION REQUEST ===") logger.info("=== AUDIO TRANSCRIPTION REQUEST ===")
form = await request.form() 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) user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id) 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 from starlette.datastructures import FormData
updated_form = FormData() updated_form = FormData()
for key, value in form.items(): for key, value in form.items():
...@@ -518,104 +368,548 @@ async def audio_transcriptions(request: Request): ...@@ -518,104 +368,548 @@ async def audio_transcriptions(request: Request):
@router.post("/api/audio/speech") @router.post("/api/audio/speech")
async def audio_speech(request: Request, body: dict): async def audio_speech(request: Request, body: dict):
logger.info("=== TEXT-TO-SPEECH REQUEST ===") 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) user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id) 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) return await handler.handle_text_to_speech(request, provider_id, body)
@router.post("/api/images/generations") @router.post("/api/images/generations")
async def image_generations(request: Request, body: dict): async def image_generations(request: Request, body: dict):
logger.info("=== IMAGE GENERATION REQUEST ===") 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) user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id) 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) return await handler.handle_image_generation(request, provider_id, body)
@router.post("/api/embeddings") @router.post("/api/embeddings")
async def embeddings(request: Request, body: dict): async def embeddings(request: Request, body: dict):
logger.info("=== EMBEDDINGS REQUEST ===") 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) provider_id, actual_model = parse_provider_from_model(model)
if not provider_id: if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', or 'autoselect/name'") raise HTTPException(status_code=400, detail="Model must be in format 'provider/model'")
if provider_id == "rotation": if provider_id in ("rotation", "rotations"):
if actual_model not in _config.rotations: rot_handler = _get_user_handler('rotation', user_id) if user_id else _rotation_handler
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found. Available: {list(_config.rotations.keys())}") if actual_model not in _config.rotations and actual_model not in getattr(rot_handler, 'rotations', {}):
selected_provider, selected_model = _rotation_handler._select_provider_and_model(actual_model) raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found")
provider_id = selected_provider provider_id, actual_model = rot_handler._select_provider_and_model(actual_model)
actual_model = selected_model elif provider_id in ("autoselect", "autoselections"):
elif provider_id == "autoselect": asel_handler = _get_user_handler('autoselect', user_id) if user_id else None
if actual_model not in _config.autoselect: asel_cfg = _config.autoselect.get(actual_model) or (asel_handler and getattr(asel_handler, 'user_autoselects', {}).get(actual_model))
raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found. Available: {list(_config.autoselect.keys())}") if not asel_cfg:
autoselect_config = _config.autoselect[actual_model] raise HTTPException(status_code=400, detail=f"Autoselect '{actual_model}' not found")
fallback = autoselect_config.fallback fallback = asel_cfg.fallback if hasattr(asel_cfg, 'fallback') else asel_cfg.get('fallback', '')
if '/' in fallback: if '/' in fallback:
provider_id, actual_model = fallback.split('/', 1) provider_id, actual_model = fallback.split('/', 1)
elif fallback in _config.rotations:
provider_id, actual_model = _rotation_handler._select_provider_and_model(fallback)
else: else:
if fallback in _config.rotations: raise HTTPException(status_code=400, detail=f"Invalid fallback for autoselect '{actual_model}'")
selected_provider, selected_model = _rotation_handler._select_provider_and_model(fallback) user_providers = getattr(handler, 'user_providers', {}) if handler else {}
provider_id = selected_provider if provider_id not in _config.providers and provider_id not in user_providers:
actual_model = selected_model raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not found")
else: return provider_id, actual_model
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())}") 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 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) user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id) 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}") @router.post("/api/{provider_id}")
async def catch_all_post(provider_id: str, request: Request): async def catch_all_post(provider_id: str, request: Request):
......
from fastapi import APIRouter, Request, Form, Query, UploadFile, File, HTTPException from fastapi import APIRouter, Request, Form, Query, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Response
from typing import Optional from typing import Optional
import time, logging, secrets, hashlib, os, re import time, logging, secrets, hashlib, os, re, hmac
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
from aisbf.database import DatabaseRegistry from aisbf.database import DatabaseRegistry
...@@ -753,7 +753,7 @@ async def dashboard_change_password_save(request: Request, current_password: str ...@@ -753,7 +753,7 @@ async def dashboard_change_password_save(request: Request, current_password: str
try: try:
if not db.verify_user_password(user_id, current_password): 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) 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) return RedirectResponse(url=url_for(request, "/dashboard/change-password?success=Password changed successfully"), status_code=303)
except Exception as e: except Exception as e:
return RedirectResponse(url=url_for(request, f"/dashboard/change-password?error=Failed to change password: {str(e)}"), status_code=303) 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 ...@@ -961,7 +961,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
redirect_uri = f"{base_url}/auth/oauth2/google/callback" redirect_uri = f"{base_url}/auth/oauth2/google/callback"
session_state = request.session.get('oauth2_google', {}).get('state') 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", return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Invalid authentication state"}) context={"request": request, "config": _config, "error": "Invalid authentication state"})
...@@ -1093,7 +1093,7 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), 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" redirect_uri = f"{base_url}/auth/oauth2/github/callback"
session_state = request.session.get('oauth2_github', {}).get('state') 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", return _templates.TemplateResponse(request=request, name="dashboard/login.html",
context={"request": request, "config": _config, "error": "Invalid authentication state"}) context={"request": request, "config": _config, "error": "Invalid authentication state"})
......
...@@ -20,13 +20,20 @@ except ImportError: ...@@ -20,13 +20,20 @@ except ImportError:
router = APIRouter() router = APIRouter()
_config = None _config = None
_templates = None _templates = None
_payment_service = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def init(config, templates): def init(config, templates, payment_service=None):
global _config, _templates global _config, _templates, _payment_service
_config = config _config = config
_templates = templates _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) @router.get("/dashboard/billing/add-method", response_class=HTMLResponse)
...@@ -99,8 +106,8 @@ async def dashboard_add_payment_method_stripe(request: Request): ...@@ -99,8 +106,8 @@ async def dashboard_add_payment_method_stripe(request: Request):
try: try:
# Attach the PM to the Stripe customer so it can be charged later # Attach the PM to the Stripe customer so it can be charged later
if payment_service and payment_service.stripe_handler: if _payment_service and _payment_service.stripe_handler:
customer_id = await payment_service.stripe_handler._get_or_create_customer(user_id) customer_id = await _payment_service.stripe_handler._get_or_create_customer(user_id)
import stripe as _stripe import stripe as _stripe
import asyncio as _asyncio import asyncio as _asyncio
try: try:
......
...@@ -98,6 +98,14 @@ async def user_list_models(request: Request, username: str): ...@@ -98,6 +98,14 @@ async def user_list_models(request: Request, username: str):
logger.warning(f"Error listing user autoselect {autoselect_id}: {e}") logger.warning(f"Error listing user autoselect {autoselect_id}: {e}")
return {"object": "list", "data": all_models} 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") @router.get("/api/u/{username}/providers")
async def user_list_providers(request: Request, username: str): async def user_list_providers(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
...@@ -367,3 +375,522 @@ async def user_list_provider_models_by_username(request: Request, username: str, ...@@ -367,3 +375,522 @@ async def user_list_provider_models_by_username(request: Request, username: str,
except Exception as e: except Exception as e:
logging.getLogger(__name__).warning(f"Error listing models for user provider {user_provider_id}: {e}") logging.getLogger(__name__).warning(f"Error listing models for user provider {user_provider_id}: {e}")
return {"data": all_models} 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 ...@@ -137,8 +137,8 @@ else
fi fi
# Remove _share directory (PyPI packaging artifacts) # Remove _share directory (PyPI packaging artifacts)
if [ -d "_share" ]; then if [ -d "aisbf/_share" ]; then
echo "Removing _share/ directory..." echo "Removing aisbf/_share/ directory..."
rm -rf _share rm -rf _share
echo " ✓ _share/ removed" echo " ✓ _share/ removed"
else else
......
...@@ -254,15 +254,15 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) { ...@@ -254,15 +254,15 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
const opts = availableProviders.map(p => const opts = availableProviders.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>` `<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join(''); ).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>`; <option value="">${window.i18n.t('rotations.select_provider')}</option>${opts}</select>`;
} else { } else {
const dlOpts = availableProviders.map(p => `<option value="${escHtmlAttr(p)}">`).join(''); const dlOpts = availableProviders.map(p => `<option value="${escHtmlAttr(p)}">`).join('');
return `<div style="position:relative;"> return `<div style="position:relative;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl" <input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="${window.i18n.t('rotations.type_search_provider')}" placeholder="${window.i18n.t('rotations.type_search_provider')}"
oninput="handleProviderInput('${uid}', this.value, ${onChangeFn})" oninput="handleProviderInput('${uid}', this.value, (${onChangeFn}))"
onchange="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;"> 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> <datalist id="${uid}-dl">${dlOpts}</datalist>
</div>`; </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