Fix missing market/usage helpers on Rotation & Autoselect handlers; bump to 0.99.69

Add _record_dashboard_proxy_event, _settle_market_result,
_get_market_source_details, _market_request_id and
_extract_usage_from_sse_chunk to RotationHandler, and the market
settlement cluster to AutoselectHandler. These were defined only on
RequestHandler, so rotation/autoselect requests raised AttributeError
(crash on the dashboard event path, silently swallowed elsewhere),
dropping market settlement and streaming usage capture.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent f78ec200
......@@ -55,7 +55,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.66"
__version__ = "0.99.69"
__all__ = [
# Config
"config",
......
......@@ -104,7 +104,7 @@ def _feature_mode_default(mode: str) -> FeatureModeConfig:
class ProviderConfig(BaseModel):
id: str
name: str
endpoint: str
endpoint: str = "" # May be empty for pre-configured handler types that supply their own default
type: str
api_key_required: bool
rate_limit: float = 0.0
......
......@@ -997,10 +997,17 @@ class DatabaseManager:
''', (inactivity_days,))
user_ids = [row[0] for row in cursor.fetchall()]
# Delete outside the SELECT's connection; each delete_user runs in its
# own transaction, so one failing user can't abort the whole batch.
deleted = 0
for user_id in user_ids:
try:
self.delete_user(user_id)
deleted += 1
except Exception as exc:
logger.warning(f"Failed to delete stale signup user {user_id}: {exc}")
return len(user_ids)
return deleted
def get_user_by_email(self, email: str) -> Optional[Dict]:
"""
......@@ -1656,9 +1663,58 @@ class DatabaseManager:
'total': total
}
# Child tables referencing users(id), ordered so that dependents are
# deleted before the rows they point at (FK-safe on MySQL/InnoDB, where
# constraints are enforced per-statement). Each entry is (table, column).
# Tables missing from a given deployment are skipped gracefully.
_USER_CHILD_TABLES = [
# Level A: leaf dependents (reference other child tables and/or users)
('wallet_transactions', 'user_id'),
('crypto_transactions', 'user_id'),
('market_import_references', 'user_id'),
('market_imports', 'user_id'),
('market_usage_transactions', 'consumer_user_id'),
('market_usage_transactions', 'provider_user_id'),
('market_votes', 'voter_user_id'),
('payment_transactions', 'user_id'),
('payment_retry_queue', 'user_id'),
('user_token_usage', 'user_id'),
('email_notification_queue', 'user_id'),
('api_requests', 'user_id'),
('studio_assets', 'user_id'),
('studio_pipelines', 'user_id'),
('user_providers', 'user_id'),
('user_rotations', 'user_id'),
('user_autoselects', 'user_id'),
('user_notifications', 'user_id'),
('user_oauth', 'user_id'),
('user_prompts', 'user_id'),
('user_cache_settings', 'user_id'),
('user_auth_files', 'user_id'),
# Level B: referenced by level A, themselves reference level C
('user_wallets', 'user_id'),
('subscriptions', 'user_id'),
('user_subscriptions', 'user_id'),
('user_crypto_addresses', 'user_id'),
('user_crypto_addresses_new', 'user_id'),
('user_crypto_wallets', 'user_id'),
('market_listings', 'owner_user_id'),
('user_api_tokens', 'user_id'),
# Level C: referenced by level B
('payment_methods', 'user_id'),
]
@staticmethod
def _is_missing_table_error(exc: Exception) -> bool:
"""True if the exception is a 'table does not exist' error (MySQL 1146 / SQLite)."""
if getattr(exc, 'errno', None) == 1146:
return True
msg = str(exc).lower()
return 'no such table' in msg or "doesn't exist" in msg
def delete_user(self, user_id: int):
"""
Delete a user and all their configurations.
Delete a user and all their related rows across every child table.
Args:
user_id: User ID to delete
......@@ -1666,14 +1722,18 @@ class DatabaseManager:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
# Delete user configurations first (due to foreign key constraints)
cursor.execute(f'DELETE FROM user_providers WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_rotations WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_autoselects WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_api_tokens WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_token_usage WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_notifications WHERE user_id = {placeholder}', (user_id,))
# Delete the user
# Delete all child rows first (FK constraints are enforced on MySQL).
for table, column in self._USER_CHILD_TABLES:
try:
cursor.execute(
f'DELETE FROM {table} WHERE {column} = {placeholder}',
(user_id,),
)
except Exception as exc:
if self._is_missing_table_error(exc):
continue
raise
# Finally delete the user itself.
cursor.execute(f'DELETE FROM users WHERE id = {placeholder}', (user_id,))
conn.commit()
......@@ -4365,6 +4425,72 @@ class DatabaseManager:
conn.commit()
return cursor.lastrowid
def create_market_import_reference(self, user_id: int, listing_id: int, reference_type: str,
display_name: str, owner_username: str, source_type: str,
source_id: str) -> int:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = self.placeholder
cursor.execute(
f'''
INSERT INTO market_import_references
(user_id, listing_id, reference_type, display_name, owner_username, source_type, source_id,
is_active, created_at, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder},
{placeholder}, {placeholder}, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
''',
(user_id, listing_id, reference_type, display_name, owner_username, source_type, source_id)
)
conn.commit()
return cursor.lastrowid
def get_market_import_reference(self, reference_id: int) -> Optional[Dict[str, Any]]:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = self.placeholder
cursor.execute(
f'''
SELECT id, user_id, listing_id, reference_type, display_name, owner_username,
source_type, source_id, is_active, created_at, updated_at
FROM market_import_references
WHERE id = {placeholder}
''',
(reference_id,)
)
row = cursor.fetchone()
if not row:
return None
return {
'id': row[0], 'user_id': row[1], 'listing_id': row[2],
'reference_type': row[3], 'display_name': row[4], 'name': row[4],
'owner_username': row[5], 'source_type': row[6], 'source_id': row[7],
'is_active': bool(row[8]), 'created_at': row[9], 'updated_at': row[10],
}
def list_market_import_references(self, user_id: int) -> List[Dict[str, Any]]:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = self.placeholder
cursor.execute(
f'''
SELECT id, user_id, listing_id, reference_type, display_name, owner_username,
source_type, source_id, is_active, created_at, updated_at
FROM market_import_references
WHERE user_id = {placeholder}
ORDER BY created_at DESC
''',
(user_id,)
)
return [
{
'id': row[0], 'user_id': row[1], 'listing_id': row[2],
'reference_type': row[3], 'display_name': row[4], 'name': row[4],
'owner_username': row[5], 'source_type': row[6], 'source_id': row[7],
'is_active': bool(row[8]), 'created_at': row[9], 'updated_at': row[10],
}
for row in cursor.fetchall()
]
def get_market_listing_for_share(self, owner_username: str, resource_type: str, resource_id: str) -> Optional[Dict[str, Any]]:
with self._get_connection() as conn:
cursor = conn.cursor()
......
......@@ -2470,6 +2470,110 @@ class RotationHandler:
for rotation in self.user_rotations:
self.rotations[rotation['rotation_id']] = rotation['config']
def _record_dashboard_proxy_event(self, request, provider_id: str, model_name: str, success: bool, latency_ms: float, metadata: Dict | None = None):
try:
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
status_code = (metadata or {}).get('status_code')
if status_code is None:
status_code = 200 if success else 500
db.record_dashboard_event(
event_type='request_proxied',
path=request.url.path,
user_id=getattr(request.state, 'user_id', None),
username=request.session.get('username') if hasattr(request, 'session') else None,
method=request.method,
status_code=status_code,
provider_id=provider_id,
rotation_id=(metadata or {}).get('rotation_id'),
autoselect_id=(metadata or {}).get('autoselect_id'),
metadata={
'model_name': model_name,
'latency_ms': round(float(latency_ms or 0), 2),
'stream': bool((metadata or {}).get('stream')),
**(metadata or {}),
},
)
except Exception:
logger.debug("Failed to record proxied request dashboard event", exc_info=True)
def _get_market_source_details(self, provider_id: str):
provider_config = None
if self.user_id and provider_id in getattr(self, 'user_providers', {}):
provider_config = self.user_providers.get(provider_id)
elif provider_id in getattr(self.config, 'providers', {}):
cfg = self.config.get_provider(provider_id)
provider_config = cfg.model_dump() if hasattr(cfg, 'model_dump') else cfg
if isinstance(provider_config, dict):
return provider_config.get('market_source')
return None
@staticmethod
def _market_request_id(request_data: Optional[Dict], fallback_provider_id: str) -> str:
payload = {
'provider_id': fallback_provider_id,
'model': (request_data or {}).get('model'),
'messages': (request_data or {}).get('messages'),
'input': (request_data or {}).get('input'),
'prompt': (request_data or {}).get('prompt'),
'voice': (request_data or {}).get('voice'),
'endpoint_path': (request_data or {}).get('_market_endpoint_path'),
}
serialized = json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
def _settle_market_result(self, provider_id: str, usage: Optional[Dict], requests_count: int = 1, metadata: Optional[Dict] = None, request_data: Optional[Dict] = None):
market_source = self._get_market_source_details(provider_id)
if not self.user_id or not market_source:
return None
listing_id = market_source.get('listing_id')
if not listing_id:
return None
db = DatabaseRegistry.get_config_database()
settlement_metadata = {
'provider_id': provider_id,
'market_imported': True,
'market_request_id': self._market_request_id(request_data, provider_id),
}
if metadata:
settlement_metadata.update(metadata)
return db.settle_market_usage(
consumer_user_id=self.user_id,
listing_id=int(listing_id),
prompt_tokens=(usage or {}).get('prompt_tokens', 0),
completion_tokens=(usage or {}).get('completion_tokens', 0),
requests_count=requests_count,
metadata=settlement_metadata,
)
def _extract_usage_from_sse_chunk(self, chunk_payload) -> Optional[Dict[str, int]]:
try:
if isinstance(chunk_payload, bytes):
chunk_payload = chunk_payload.decode('utf-8', errors='ignore')
if isinstance(chunk_payload, str):
for line in chunk_payload.splitlines():
line = line.strip()
if not line.startswith('data: '):
continue
raw = line[6:].strip()
if not raw or raw == '[DONE]':
continue
try:
parsed = json.loads(raw)
except Exception:
continue
if isinstance(parsed, dict) and isinstance(parsed.get('usage'), dict):
usage = parsed.get('usage') or {}
if usage.get('total_tokens') is not None or usage.get('prompt_tokens') is not None or usage.get('completion_tokens') is not None:
return usage
elif isinstance(chunk_payload, dict):
usage = chunk_payload.get('usage')
if isinstance(usage, dict):
return usage
except Exception:
return None
return None
def _get_provider_type(self, provider_id: str) -> str:
"""Get the provider type from configuration"""
provider_config = self.config.get_provider(provider_id)
......@@ -4460,6 +4564,55 @@ class AutoselectHandler:
self.user_autoselects = {}
self.autoselects = self.config.autoselect if hasattr(self.config, 'autoselect') else {}
def _get_market_source_details(self, provider_id: str):
provider_config = None
if self.user_id and provider_id in getattr(self, 'user_providers', {}):
provider_config = self.user_providers.get(provider_id)
elif provider_id in getattr(self.config, 'providers', {}):
cfg = self.config.get_provider(provider_id)
provider_config = cfg.model_dump() if hasattr(cfg, 'model_dump') else cfg
if isinstance(provider_config, dict):
return provider_config.get('market_source')
return None
@staticmethod
def _market_request_id(request_data: Optional[Dict], fallback_provider_id: str) -> str:
payload = {
'provider_id': fallback_provider_id,
'model': (request_data or {}).get('model'),
'messages': (request_data or {}).get('messages'),
'input': (request_data or {}).get('input'),
'prompt': (request_data or {}).get('prompt'),
'voice': (request_data or {}).get('voice'),
'endpoint_path': (request_data or {}).get('_market_endpoint_path'),
}
serialized = json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
def _settle_market_result(self, provider_id: str, usage: Optional[Dict], requests_count: int = 1, metadata: Optional[Dict] = None, request_data: Optional[Dict] = None):
market_source = self._get_market_source_details(provider_id)
if not self.user_id or not market_source:
return None
listing_id = market_source.get('listing_id')
if not listing_id:
return None
db = DatabaseRegistry.get_config_database()
settlement_metadata = {
'provider_id': provider_id,
'market_imported': True,
'market_request_id': self._market_request_id(request_data, provider_id),
}
if metadata:
settlement_metadata.update(metadata)
return db.settle_market_usage(
consumer_user_id=self.user_id,
listing_id=int(listing_id),
prompt_tokens=(usage or {}).get('prompt_tokens', 0),
completion_tokens=(usage or {}).get('completion_tokens', 0),
requests_count=requests_count,
metadata=settlement_metadata,
)
def _get_response_cache_backend(self):
aisbf_config = self.config.get_aisbf_config()
if not (aisbf_config and aisbf_config.response_cache and aisbf_config.response_cache.enabled):
......
......@@ -44,10 +44,93 @@ from .codex import CodexProviderHandler
from .coderai import CoderAIProviderHandler
from .qwen import QwenProviderHandler
from .runpod import RunpodProviderHandler
from .preconfigured_openai import (
# Major inference API providers
GroqProviderHandler,
TogetherAIProviderHandler,
FireworksAIProviderHandler,
MistralProviderHandler,
CodestralProviderHandler,
DeepSeekProviderHandler,
PerplexityProviderHandler,
DeepInfraProviderHandler,
CerebrasProviderHandler,
SambaNovaProviderHandler,
XAIProviderHandler,
MoonshotProviderHandler,
DashScopeProviderHandler,
NvidiaNIMProviderHandler,
NScaleProviderHandler,
FeatherlessAIProviderHandler,
OpenRouterProviderHandler,
ScalewayProviderHandler,
VolcEngineProviderHandler,
FriendliAIProviderHandler,
HyperbolicProviderHandler,
NebiusProviderHandler,
NovitaProviderHandler,
LambdaAIProviderHandler,
OVHCloudProviderHandler,
AIMLAPIProviderHandler,
CometAPIProviderHandler,
GaladrielProviderHandler,
MorphProviderHandler,
GitHubModelsProviderHandler,
AI21ProviderHandler,
NLPCloudProviderHandler,
ClarifaiProviderHandler,
EmpowerProviderHandler,
GradientAIProviderHandler,
CompactifAIProviderHandler,
MariTalkProviderHandler,
MetaLlamaProviderHandler,
PredibaseProviderHandler,
ZAIProviderHandler,
VoyageAIProviderHandler,
WandBInferenceProviderHandler,
CohereProviderHandler,
MiniMaxProviderHandler,
PublicAIProviderHandler,
HeliconeProviderHandler,
VeniceAIProviderHandler,
AIHubMixProviderHandler,
CharityEngineProviderHandler,
PoeProviderHandler,
ChutesProviderHandler,
SyntheticProviderHandler,
AssemblyAILLMProviderHandler,
GMIProviderHandler,
SarvamProviderHandler,
NanoGPTProviderHandler,
LlamaGateProviderHandler,
AbliterationProviderHandler,
CrusoeProviderHandler,
XiaomiMimoProviderHandler,
ApertisProviderHandler,
VercelAIGatewayProviderHandler,
BasetenProviderHandler,
JinaAIProviderHandler,
HuggingFaceProviderHandler,
# Local / self-hosted runtimes
LMStudioProviderHandler,
LlamafileProviderHandler,
VLLMProviderHandler,
XinferenceProviderHandler,
InfinityProviderHandler,
OobaboogaProviderHandler,
DockerModelRunnerProviderHandler,
TabbyAPIProviderHandler,
# Cloud providers with user-configured endpoints
AzureOpenAIProviderHandler,
DatabricksProviderHandler,
SnowflakeProviderHandler,
HerokuProviderHandler,
)
from ..config import config
PROVIDER_HANDLERS = {
# --- existing special-protocol handlers ---
'google': GoogleProviderHandler,
'openai': OpenAIProviderHandler,
'anthropic': AnthropicProviderHandler,
......@@ -55,11 +138,92 @@ PROVIDER_HANDLERS = {
'kiro': KiroProviderHandler,
'claude': ClaudeProviderHandler,
'kilo': KiloProviderHandler,
'kilocode': KiloProviderHandler, # Kilocode provider with OAuth2 support
'codex': CodexProviderHandler, # Codex provider with OAuth2 support (OpenAI protocol)
'coderai': CoderAIProviderHandler, # CoderAI provider with HTTP/WebSocket bridge support
'qwen': QwenProviderHandler, # Qwen provider with OAuth2 support (OpenAI-compatible)
'kilocode': KiloProviderHandler,
'codex': CodexProviderHandler,
'coderai': CoderAIProviderHandler,
'qwen': QwenProviderHandler,
'runpod': RunpodProviderHandler,
# --- pre-configured OpenAI-compatible providers ---
'groq': GroqProviderHandler,
'together_ai': TogetherAIProviderHandler,
'fireworks_ai': FireworksAIProviderHandler,
'mistral': MistralProviderHandler,
'codestral': CodestralProviderHandler,
'deepseek': DeepSeekProviderHandler,
'perplexity': PerplexityProviderHandler,
'deepinfra': DeepInfraProviderHandler,
'cerebras': CerebrasProviderHandler,
'sambanova': SambaNovaProviderHandler,
'xai': XAIProviderHandler,
'moonshot': MoonshotProviderHandler,
'dashscope': DashScopeProviderHandler,
'nvidia_nim': NvidiaNIMProviderHandler,
'nscale': NScaleProviderHandler,
'featherless_ai': FeatherlessAIProviderHandler,
'openrouter': OpenRouterProviderHandler,
'scaleway': ScalewayProviderHandler,
'volcengine': VolcEngineProviderHandler,
'friendliai': FriendliAIProviderHandler,
'hyperbolic': HyperbolicProviderHandler,
'nebius': NebiusProviderHandler,
'novita': NovitaProviderHandler,
'lambda_ai': LambdaAIProviderHandler,
'ovhcloud': OVHCloudProviderHandler,
'aiml': AIMLAPIProviderHandler,
'cometapi': CometAPIProviderHandler,
'galadriel': GaladrielProviderHandler,
'morph': MorphProviderHandler,
'github_models': GitHubModelsProviderHandler,
'ai21': AI21ProviderHandler,
'nlp_cloud': NLPCloudProviderHandler,
'clarifai': ClarifaiProviderHandler,
'empower': EmpowerProviderHandler,
'gradient_ai': GradientAIProviderHandler,
'compactifai': CompactifAIProviderHandler,
'maritalk': MariTalkProviderHandler,
'meta_llama': MetaLlamaProviderHandler,
'predibase': PredibaseProviderHandler,
'zai': ZAIProviderHandler,
'voyage': VoyageAIProviderHandler,
'wandb_inference': WandBInferenceProviderHandler,
'cohere': CohereProviderHandler,
'minimax': MiniMaxProviderHandler,
'publicai': PublicAIProviderHandler,
'helicone': HeliconeProviderHandler,
'veniceai': VeniceAIProviderHandler,
'aihubmix': AIHubMixProviderHandler,
'charity_engine': CharityEngineProviderHandler,
'poe': PoeProviderHandler,
'chutes': ChutesProviderHandler,
'synthetic': SyntheticProviderHandler,
'assemblyai_llm': AssemblyAILLMProviderHandler,
'gmi': GMIProviderHandler,
'sarvam': SarvamProviderHandler,
'nano_gpt': NanoGPTProviderHandler,
'llamagate': LlamaGateProviderHandler,
'abliteration': AbliterationProviderHandler,
'crusoe': CrusoeProviderHandler,
'xiaomi_mimo': XiaomiMimoProviderHandler,
'apertis': ApertisProviderHandler,
'vercel_ai_gateway': VercelAIGatewayProviderHandler,
'baseten': BasetenProviderHandler,
'jina_ai': JinaAIProviderHandler,
'huggingface': HuggingFaceProviderHandler,
# local runtimes
'lm_studio': LMStudioProviderHandler,
'llamafile': LlamafileProviderHandler,
'vllm': VLLMProviderHandler,
'xinference': XinferenceProviderHandler,
'infinity': InfinityProviderHandler,
'oobabooga': OobaboogaProviderHandler,
'docker_model_runner': DockerModelRunnerProviderHandler,
'tabbyapi': TabbyAPIProviderHandler,
# cloud providers with user-configured endpoints
'azure_openai': AzureOpenAIProviderHandler,
'databricks': DatabricksProviderHandler,
'snowflake': SnowflakeProviderHandler,
'heroku': HerokuProviderHandler,
}
......
......@@ -822,7 +822,12 @@ class BaseProviderHandler:
"disabled_until": None
}
else:
self.error_tracking = config.error_tracking[provider_id]
# Fall back to defaults for pre-configured handler types not in providers.json
self.error_tracking = config.error_tracking.get(provider_id, {
"failures": 0,
"last_failure": None,
"disabled_until": None,
})
self.last_request_time = 0
......@@ -834,7 +839,8 @@ class BaseProviderHandler:
# Default rate limit for user providers
self.rate_limit = 60
else:
self.rate_limit = config.providers[provider_id].rate_limit
_pcfg = config.providers.get(provider_id)
self.rate_limit = _pcfg.rate_limit if _pcfg else 0
# Add model-level rate limit tracking
self.model_last_request_time = {} # {model_name: timestamp}
# Token usage tracking for rate limits
......
This diff is collapsed.
......@@ -32,12 +32,20 @@ logger = logging.getLogger(__name__)
def cleanup_stale_signup_users() -> int:
"""Delete self-registered users who never logged in within 14 days."""
"""Delete self-registered users who never logged in within 14 days.
Best-effort housekeeping: never propagate errors to the caller, since this
runs on the login path and must not be able to break authentication.
"""
try:
db = DatabaseRegistry.get_config_database()
deleted_count = db.delete_stale_unverified_signup_users(inactivity_days=14)
if deleted_count:
logger.info(f"Deleted {deleted_count} stale self-registered user(s) with no login activity")
return deleted_count
except Exception as exc:
logger.warning(f"Stale signup user cleanup failed (ignored): {exc}", exc_info=True)
return 0
@router.get("/dashboard/profile-pic")
......
......@@ -61,16 +61,16 @@ def count_messages_tokens(messages: List[Dict], model: str) -> int:
content = msg.get('content', '')
if content:
if isinstance(content, str):
total_tokens += len(encoding.encode(content))
total_tokens += len(encoding.encode(content, disallowed_special=()))
elif isinstance(content, list):
# Handle complex content (e.g., with images)
for item in content:
if isinstance(item, dict):
text = item.get('text', '')
if text:
total_tokens += len(encoding.encode(text))
total_tokens += len(encoding.encode(text, disallowed_special=()))
elif isinstance(item, str):
total_tokens += len(encoding.encode(item))
total_tokens += len(encoding.encode(item, disallowed_special=()))
logger.debug(f"Token count for model {model}: {total_tokens}")
return total_tokens
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.66"
version = "0.99.69"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -106,7 +106,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.66",
version="0.99.69",
author="AISBF Contributors",
author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
This diff is collapsed.
This diff is collapsed.
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