feat: add marketplace publishing and settlement

parent 67149a8a
This diff is collapsed.
This diff is collapsed.
...@@ -119,6 +119,7 @@ class AccountTier(BaseModel): ...@@ -119,6 +119,7 @@ class AccountTier(BaseModel):
max_autoselections: int = -1 max_autoselections: int = -1
max_rotation_models: int = -1 max_rotation_models: int = -1
max_autoselection_models: int = -1 max_autoselection_models: int = -1
market_fee_percentage: float = 10.0
created_at: Optional[int] = None created_at: Optional[int] = None
updated_at: Optional[int] = None updated_at: Optional[int] = None
...@@ -165,3 +166,50 @@ class PaymentTransaction(BaseModel): ...@@ -165,3 +166,50 @@ class PaymentTransaction(BaseModel):
metadata: Optional[Dict] = None metadata: Optional[Dict] = None
created_at: Optional[int] = None created_at: Optional[int] = None
completed_at: Optional[int] = None completed_at: Optional[int] = None
class MarketListing(BaseModel):
id: Optional[int] = None
owner_user_id: int
owner_username: str
source_scope: str
source_type: str
source_id: str
listing_key: str
title: str
description: Optional[str] = None
provider_id: Optional[str] = None
model_id: Optional[str] = None
endpoint: Optional[str] = None
currency_code: str = "USD"
price_per_million_tokens: float = 0.0
price_per_1000_requests: float = 0.0
provider_price_per_million_tokens: Optional[float] = None
provider_price_per_1000_requests: Optional[float] = None
metadata: Optional[Dict] = None
config_snapshot: Optional[Dict] = None
is_active: bool = True
created_at: Optional[int] = None
updated_at: Optional[int] = None
class MarketVote(BaseModel):
id: Optional[int] = None
listing_id: int
voter_user_id: int
target_type: str
target_key: str
vote: int
created_at: Optional[int] = None
updated_at: Optional[int] = None
class MarketUsageSettlementResult(BaseModel):
charged_amount: float = 0.0
seller_amount: float = 0.0
platform_fee: float = 0.0
platform_revenue: float = 0.0
balance_after: Optional[float] = None
listing_id: int
self_use: bool = False
charged: bool = False
...@@ -23,6 +23,31 @@ def init(config, get_user_handler_fn, rotation_handler): ...@@ -23,6 +23,31 @@ def init(config, get_user_handler_fn, rotation_handler):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _ensure_market_enabled():
settings = DatabaseRegistry.get_config_database().get_market_settings()
if not settings.get('enabled'):
raise HTTPException(status_code=403, detail='Market is disabled')
return settings
def _resolve_market_resource(model: str):
parts = (model or '').split('/')
if len(parts) < 3:
return None
username = parts[0].strip()
resource_type = parts[1].strip()
resource_id = parts[2].strip()
remainder = '/'.join(parts[3:]) if len(parts) > 3 else ''
if not username or resource_type not in {'rotation', 'rotations', 'autoselect', 'autoselections'} and not resource_id:
return None
return {
'username': username,
'resource_type': resource_type,
'resource_id': resource_id,
'model_name': remainder,
}
def parse_provider_from_model(model: str) -> tuple[str, str]: def parse_provider_from_model(model: str) -> tuple[str, str]:
if '/' in model: if '/' in model:
parts = model.split('/', 1) parts = model.split('/', 1)
...@@ -425,6 +450,33 @@ async def embeddings(request: Request, body: dict): ...@@ -425,6 +450,33 @@ async def embeddings(request: Request, body: dict):
def _resolve_provider(model: str, user_id=None, handler=None) -> tuple[str, str]: 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.""" """Resolve provider_id and actual_model. Checks user providers when handler is given."""
market_resource = _resolve_market_resource(model)
if market_resource:
_ensure_market_enabled()
db = DatabaseRegistry.get_config_database()
owner = db.get_user_by_username(market_resource['username'])
if not owner:
raise HTTPException(status_code=404, detail=f"User '{market_resource['username']}' not found")
owner_user_id = owner['id']
if market_resource['resource_type'] in ('rotation', 'rotations'):
owner_rotation_handler = _get_user_handler('rotation', owner_user_id)
if market_resource['resource_id'] not in getattr(owner_rotation_handler, 'rotations', {}):
raise HTTPException(status_code=404, detail=f"Rotation '{market_resource['resource_id']}' not found")
provider_id, actual_model = owner_rotation_handler._select_provider_and_model(market_resource['resource_id'])
return provider_id, actual_model
if market_resource['resource_type'] in ('autoselect', 'autoselections'):
owner_autoselect_handler = _get_user_handler('autoselect', owner_user_id)
autoselect_cfg = getattr(owner_autoselect_handler, 'user_autoselects', {}).get(market_resource['resource_id'])
if not autoselect_cfg:
raise HTTPException(status_code=404, detail=f"Autoselect '{market_resource['resource_id']}' not found")
fallback = autoselect_cfg.get('fallback', '') if isinstance(autoselect_cfg, dict) else getattr(autoselect_cfg, 'fallback', '')
if '/' in fallback:
return tuple(fallback.split('/', 1))
if fallback:
owner_rotation_handler = _get_user_handler('rotation', owner_user_id)
provider_id, actual_model = owner_rotation_handler._select_provider_and_model(fallback)
return provider_id, actual_model
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'") raise HTTPException(status_code=400, detail="Model must be in format 'provider/model'")
......
...@@ -457,6 +457,7 @@ async def api_create_tier(request: Request): ...@@ -457,6 +457,7 @@ async def api_create_tier(request: Request):
max_autoselections=body.get('max_autoselections', -1), max_autoselections=body.get('max_autoselections', -1),
max_rotation_models=body.get('max_rotation_models', -1), max_rotation_models=body.get('max_rotation_models', -1),
max_autoselection_models=body.get('max_autoselection_models', -1), max_autoselection_models=body.get('max_autoselection_models', -1),
market_fee_percentage=body.get('market_fee_percentage', 10.0),
is_active=body.get('is_active', True), is_active=body.get('is_active', True),
is_visible=body.get('is_visible', True) is_visible=body.get('is_visible', True)
) )
...@@ -502,6 +503,8 @@ async def api_update_tier(request: Request, tier_id: int): ...@@ -502,6 +503,8 @@ async def api_update_tier(request: Request, tier_id: int):
update_kwargs['max_rotation_models'] = body['max_rotation_models'] update_kwargs['max_rotation_models'] = body['max_rotation_models']
if 'max_autoselection_models' in body: if 'max_autoselection_models' in body:
update_kwargs['max_autoselection_models'] = body['max_autoselection_models'] update_kwargs['max_autoselection_models'] = body['max_autoselection_models']
if 'market_fee_percentage' in body:
update_kwargs['market_fee_percentage'] = body['market_fee_percentage']
if 'is_active' in body: if 'is_active' in body:
update_kwargs['is_active'] = body['is_active'] update_kwargs['is_active'] = body['is_active']
if 'is_visible' in body: if 'is_visible' in body:
...@@ -603,6 +606,7 @@ async def dashboard_admin_tier_save(request: Request): ...@@ -603,6 +606,7 @@ async def dashboard_admin_tier_save(request: Request):
'max_autoselections': int(form.get('max_autoselections', -1)), 'max_autoselections': int(form.get('max_autoselections', -1)),
'max_rotation_models': int(form.get('max_rotation_models', -1)), 'max_rotation_models': int(form.get('max_rotation_models', -1)),
'max_autoselection_models': int(form.get('max_autoselection_models', -1)), 'max_autoselection_models': int(form.get('max_autoselection_models', -1)),
'market_fee_percentage': float(form.get('market_fee_percentage', 10.0)),
'is_active': form.get('is_active') == '1', 'is_active': form.get('is_active') == '1',
'is_visible': form.get('is_visible') == '1' 'is_visible': form.get('is_visible') == '1'
} }
...@@ -1504,10 +1508,38 @@ async def dashboard_admin_payment_settings(request: Request): ...@@ -1504,10 +1508,38 @@ async def dashboard_admin_payment_settings(request: Request):
context={ context={
"request": request, "request": request,
"session": request.session, "session": request.session,
"currency_symbol": DatabaseRegistry.get_config_database().get_currency_settings().get('currency_symbol', '$') "currency_symbol": DatabaseRegistry.get_config_database().get_currency_settings().get('currency_symbol', '$'),
"market_settings": DatabaseRegistry.get_config_database().get_market_settings(),
} }
) )
@router.get("/api/admin/settings/market")
async def api_get_market_settings(request: Request):
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
return JSONResponse(db.get_market_settings())
@router.post("/api/admin/settings/market")
async def api_save_market_settings(request: Request):
auth_check = require_api_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
db.save_market_settings({
'enabled': bool(body.get('enabled', False)),
'allow_user_publish': bool(body.get('allow_user_publish', True)),
'allow_admin_publish': bool(body.get('allow_admin_publish', True)),
'allow_import': bool(body.get('allow_import', True)),
})
return JSONResponse({'success': True, 'message': 'Market settings saved'})
except Exception as e:
logger.error(f"Error saving market settings: {e}")
return JSONResponse({'error': str(e)}, status_code=500)
@router.get("/dashboard/pricing") @router.get("/dashboard/pricing")
async def dashboard_pricing(request: Request): async def dashboard_pricing(request: Request):
"""Pricing plans page for users""" """Pricing plans page for users"""
......
This diff is collapsed.
...@@ -18,6 +18,31 @@ def init(config, get_user_handler_fn): ...@@ -18,6 +18,31 @@ def init(config, get_user_handler_fn):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _resolve_market_provider_alias(model: str):
parts = (model or '').split('/')
if len(parts) < 3:
return None
username = parts[0].strip()
provider_id = parts[1].strip()
model_id = '/'.join(parts[2:]).strip()
if not username or not provider_id or not model_id:
return None
return username, provider_id, model_id
def _resolve_market_share_alias(model: str):
parts = (model or '').split('/')
if len(parts) < 3:
return None
username = parts[0].strip()
share_type = parts[1].strip()
share_id = parts[2].strip()
trailing = '/'.join(parts[3:]).strip() if len(parts) > 3 else ''
if share_type not in {'provider', 'rotation', 'autoselect'}:
return None
return {'username': username, 'share_type': share_type, 'share_id': share_id, 'trailing': trailing}
def parse_provider_from_model(model: str) -> tuple[str, str]: def parse_provider_from_model(model: str) -> tuple[str, str]:
if '/' in model: if '/' in model:
parts = model.split('/', 1) parts = model.split('/', 1)
...@@ -257,6 +282,67 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl ...@@ -257,6 +282,67 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
authenticated_user = db.get_user_by_id(user_id) authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username: if authenticated_user and authenticated_user['username'] != username:
raise HTTPException(status_code=403, detail="Access denied. Username in URL must match authenticated user.") raise HTTPException(status_code=403, detail="Access denied. Username in URL must match authenticated user.")
market_share = _resolve_market_share_alias(body.model)
if market_share and market_share['username'] != username:
db = DatabaseRegistry.get_config_database()
owner_username = market_share['username']
owner = db.get_user_by_username(owner_username)
if not owner:
raise HTTPException(status_code=404, detail=f"User '{owner_username}' not found")
listing = db.get_market_listing_for_share(owner_username, market_share['share_type'], market_share['share_id'])
if not listing:
raise HTTPException(status_code=404, detail='Market listing not found for requested resource')
if not user_id:
raise HTTPException(status_code=401, detail='Authentication required for market usage')
body_dict = body.model_dump()
share_type = market_share['share_type']
actual_model = market_share['trailing']
if share_type == 'provider':
provider_id = market_share['share_id']
owner_provider = db.get_user_provider(owner['id'], provider_id)
if not owner_provider:
raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not found for user '{owner_username}'")
owner_handler = _get_user_handler('request', owner['id'])
body_dict['model'] = actual_model
result = await owner_handler.handle_chat_completion(request, provider_id, body_dict) if not body.stream else await owner_handler.handle_streaming_chat_completion(request, provider_id, body_dict)
elif share_type == 'rotation':
owner_handler = _get_user_handler('rotation', owner['id'])
body_dict['model'] = market_share['share_id']
result = await owner_handler.handle_rotation_request(market_share['share_id'], body_dict, owner['id'], getattr(request.state, 'token_id', None))
elif share_type == 'autoselect':
owner_handler = _get_user_handler('autoselect', owner['id'])
body_dict['model'] = market_share['share_id']
result = await owner_handler.handle_autoselect_request(market_share['share_id'], body_dict, owner['id'], getattr(request.state, 'token_id', None)) if not body.stream else await owner_handler.handle_autoselect_streaming_request(market_share['share_id'], body_dict)
else:
raise HTTPException(status_code=400, detail='Unsupported market share type')
return result
market_alias = _resolve_market_provider_alias(body.model)
if market_alias and market_alias[0] != username:
owner_username, provider_id, actual_model = market_alias
db = DatabaseRegistry.get_config_database()
owner = db.get_user_by_username(owner_username)
if not owner:
raise HTTPException(status_code=404, detail=f"User '{owner_username}' not found")
owner_provider = db.get_user_provider(owner['id'], provider_id)
if not owner_provider:
raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not found for user '{owner_username}'")
listing = db.get_market_listing_for_share(owner_username, 'provider', provider_id)
if not listing:
raise HTTPException(status_code=404, detail='Market listing not found for requested provider')
if not user_id:
raise HTTPException(status_code=401, detail='Authentication required for market usage')
owner_handler = _get_user_handler('request', owner['id'])
body_dict = body.model_dump()
body_dict['model'] = actual_model
provider_config = owner_provider['config']
result = await owner_handler.handle_chat_completion(request, provider_id, body_dict) if not body.stream else await owner_handler.handle_streaming_chat_completion(request, provider_id, body_dict)
return result
provider_id, actual_model = parse_provider_from_model(body.model) provider_id, actual_model = parse_provider_from_model(body.model)
if not provider_id: if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', 'autoselect/name', 'user-provider/model', 'user-rotation/name', or 'user-autoselect/name'") raise HTTPException(status_code=400, detail="Model must be in format 'provider/model', 'rotation/name', 'autoselect/name', 'user-provider/model', 'user-rotation/name', or 'user-autoselect/name'")
......
...@@ -196,6 +196,7 @@ import aisbf.routes.dashboard.settings as _dash_settings ...@@ -196,6 +196,7 @@ import aisbf.routes.dashboard.settings as _dash_settings
import aisbf.routes.dashboard.admin as _dash_admin import aisbf.routes.dashboard.admin as _dash_admin
import aisbf.routes.dashboard.payments as _dash_payments import aisbf.routes.dashboard.payments as _dash_payments
import aisbf.routes.dashboard.provider_auth as _dash_provider_auth import aisbf.routes.dashboard.provider_auth as _dash_provider_auth
import aisbf.routes.dashboard.market as _dash_market
app.include_router(_auth_routes.router) app.include_router(_auth_routes.router)
app.include_router(_api_routes.router) app.include_router(_api_routes.router)
...@@ -207,6 +208,7 @@ app.include_router(_dash_settings.router) ...@@ -207,6 +208,7 @@ app.include_router(_dash_settings.router)
app.include_router(_dash_admin.router) app.include_router(_dash_admin.router)
app.include_router(_dash_payments.router) app.include_router(_dash_payments.router)
app.include_router(_dash_provider_auth.router) app.include_router(_dash_provider_auth.router)
app.include_router(_dash_market.router)
# Wallet routes # Wallet routes
try: try:
...@@ -230,6 +232,7 @@ def _init_all_routers(): ...@@ -230,6 +232,7 @@ def _init_all_routers():
_dash_admin.init(config, templates) _dash_admin.init(config, templates)
_dash_payments.init(config, templates) _dash_payments.init(config, templates)
_dash_provider_auth.init(config, templates) _dash_provider_auth.init(config, templates)
_dash_market.init(config, templates)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
......
...@@ -834,6 +834,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -834,6 +834,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/rotations') }}" {% if '/rotations' in request.path %}class="active"{% endif %} data-i18n="nav.rotations">Rotations</a> <a href="{{ url_for(request, '/dashboard/rotations') }}" {% if '/rotations' in request.path %}class="active"{% endif %} data-i18n="nav.rotations">Rotations</a>
<a href="{{ url_for(request, '/dashboard/autoselect') }}" {% if '/autoselect' in request.path %}class="active"{% endif %} data-i18n="nav.autoselect">Autoselect</a> <a href="{{ url_for(request, '/dashboard/autoselect') }}" {% if '/autoselect' in request.path %}class="active"{% endif %} data-i18n="nav.autoselect">Autoselect</a>
<a href="{{ url_for(request, '/dashboard/studio') }}" {% if '/studio' in request.path %}class="active"{% endif %} data-i18n="nav.studio">Studio</a> <a href="{{ url_for(request, '/dashboard/studio') }}" {% if '/studio' in request.path %}class="active"{% endif %} data-i18n="nav.studio">Studio</a>
<a href="{{ url_for(request, '/dashboard/market') }}" {% if '/market' in request.path and '/admin/market' not in request.path %}class="active"{% endif %}>Market</a>
<a href="{{ url_for(request, '/dashboard/prompts') }}" {% if '/prompts' in request.path %}class="active"{% endif %} data-i18n="nav.prompts">Prompts</a> <a href="{{ url_for(request, '/dashboard/prompts') }}" {% if '/prompts' in request.path %}class="active"{% endif %} data-i18n="nav.prompts">Prompts</a>
<a href="{{ url_for(request, '/dashboard/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %} data-i18n="nav.analytics">Analytics</a> <a href="{{ url_for(request, '/dashboard/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %} data-i18n="nav.analytics">Analytics</a>
{% if request.session.user_id %} {% if request.session.user_id %}
...@@ -845,6 +846,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -845,6 +846,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/users') }}" {% if '/users' in request.path %}class="active"{% endif %} data-i18n="nav.users">Users</a> <a href="{{ url_for(request, '/dashboard/users') }}" {% if '/users' in request.path %}class="active"{% endif %} data-i18n="nav.users">Users</a>
<a href="{{ url_for(request, '/dashboard/settings') }}" {% if '/settings' in request.path %}class="active"{% endif %} data-i18n="nav.settings">Settings</a> <a href="{{ url_for(request, '/dashboard/settings') }}" {% if '/settings' in request.path %}class="active"{% endif %} data-i18n="nav.settings">Settings</a>
<a href="{{ url_for(request, '/dashboard/admin/tiers') }}" {% if '/admin/tiers' in request.path %}class="active"{% endif %} data-i18n="nav.tiers">Tiers</a> <a href="{{ url_for(request, '/dashboard/admin/tiers') }}" {% if '/admin/tiers' in request.path %}class="active"{% endif %} data-i18n="nav.tiers">Tiers</a>
<a href="{{ url_for(request, '/dashboard/admin/market') }}" {% if '/admin/market' in request.path %}class="active"{% endif %}>Market Admin</a>
<a href="{{ url_for(request, '/dashboard/admin/payment-settings') }}" {% if '/admin/payment-settings' in request.path %}class="active"{% endif %} data-i18n="nav.payment_settings">Payment Settings</a> <a href="{{ url_for(request, '/dashboard/admin/payment-settings') }}" {% if '/admin/payment-settings' in request.path %}class="active"{% endif %} data-i18n="nav.payment_settings">Payment Settings</a>
{% endif %} {% endif %}
{% if show_upgrade_button %} {% if show_upgrade_button %}
......
{% extends "base.html" %}
{% block title %}Admin Market{% endblock %}
{% block content %}
<h2 style="margin-bottom: 20px;"><i class="fas fa-store-alt" style="color: var(--color-link);"></i> Market Administration</h2>
<div style="background: var(--bg-panel); border: 1px solid var(--color-border); border-radius: 10px; padding: 16px; overflow:auto;">
<table style="width:100%; border-collapse: collapse;">
<thead>
<tr style="background: var(--bg-accent);">
<th style="padding:10px; text-align:left;">ID</th>
<th style="padding:10px; text-align:left;">Title</th>
<th style="padding:10px; text-align:left;">Owner</th>
<th style="padding:10px; text-align:left;">Source</th>
<th style="padding:10px; text-align:left;">Status</th>
<th style="padding:10px; text-align:right;">1M Tokens</th>
<th style="padding:10px; text-align:right;">1K Requests</th>
<th style="padding:10px; text-align:right;">Revenue</th>
<th style="padding:10px; text-align:right;">Requests</th>
<th style="padding:10px; text-align:center;">Active</th>
</tr>
</thead>
<tbody>
{% for listing in market_listings %}
<tr style="border-top:1px solid var(--color-border);">
<td style="padding:10px;">{{ listing.id }}</td>
<td style="padding:10px;">{{ listing.title }}</td>
<td style="padding:10px;">{{ listing.owner_username }}</td>
<td style="padding:10px;">{{ listing.source_type }} / {{ listing.source_id }}</td>
<td style="padding:10px;">{{ 'Online' if listing.online else 'Offline' }}</td>
<td style="padding:10px; text-align:right;">{{ listing.price_per_million_tokens }}</td>
<td style="padding:10px; text-align:right;">{{ listing.price_per_1000_requests }}</td>
<td style="padding:10px; text-align:right;">{{ '%.2f'|format((listing.stats or {}).gross_revenue or 0) }}</td>
<td style="padding:10px; text-align:right;">{{ (listing.stats or {}).total_requests or 0 }}</td>
<td style="padding:10px; text-align:center;">{{ 'Yes' if listing.is_active else 'No' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
...@@ -19,6 +19,31 @@ ...@@ -19,6 +19,31 @@
</div> </div>
</div> </div>
<div style="background: var(--bg-panel); border: 2px solid #2ecc71; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: #2ecc71;">
<i class="fas fa-store me-2"></i>Market Settings
</h3>
<form id="marketSettingsForm">
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 15px; margin-bottom: 16px;">
<label style="display:flex; align-items:center; gap:8px; color: var(--color-text);">
<input type="checkbox" id="marketEnabled" {% if market_settings.enabled %}checked{% endif %}> Enable market
</label>
<label style="display:flex; align-items:center; gap:8px; color: var(--color-text);">
<input type="checkbox" id="marketAllowUserPublish" {% if market_settings.allow_user_publish %}checked{% endif %}> Allow user publish
</label>
<label style="display:flex; align-items:center; gap:8px; color: var(--color-text);">
<input type="checkbox" id="marketAllowAdminPublish" {% if market_settings.allow_admin_publish %}checked{% endif %}> Allow admin publish
</label>
<label style="display:flex; align-items:center; gap:8px; color: var(--color-text);">
<input type="checkbox" id="marketAllowImport" {% if market_settings.allow_import %}checked{% endif %}> Allow import
</label>
</div>
<button type="submit" class="btn" style="background:#2ecc71; color:white;">
<i class="fas fa-save me-2"></i>Save Market Settings
</button>
</form>
</div>
<!-- Global Currency Settings --> <!-- Global Currency Settings -->
<div style="background: var(--bg-panel); border: 2px solid #17a2b8; border-radius: 8px; padding: 20px; margin-bottom: 20px;"> <div style="background: var(--bg-panel); border: 2px solid #17a2b8; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: var(--color-link);"> <h3 style="margin: 0 0 20px 0; color: var(--color-link);">
...@@ -1337,6 +1362,26 @@ document.getElementById('currencySettingsForm').addEventListener('submit', funct ...@@ -1337,6 +1362,26 @@ document.getElementById('currencySettingsForm').addEventListener('submit', funct
}); });
}); });
document.getElementById('marketSettingsForm').addEventListener('submit', function(e) {
e.preventDefault();
fetch('{{ url_for(request, "/api/admin/settings/market") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
enabled: document.getElementById('marketEnabled').checked,
allow_user_publish: document.getElementById('marketAllowUserPublish').checked,
allow_admin_publish: document.getElementById('marketAllowAdminPublish').checked,
allow_import: document.getElementById('marketAllowImport').checked,
})
}).then(r => r.json()).then(data => {
if (data.success) {
showToast('Market settings saved', 'success');
} else {
showToast(data.error || 'Failed to save market settings', 'danger');
}
}).catch(error => showToast('Error saving market settings: ' + error, 'danger'));
});
// Refresh crypto prices manually // Refresh crypto prices manually
function refreshCryptoPrices() { function refreshCryptoPrices() {
['Btc', 'Eth', 'Usdt', 'Usdc'].forEach(crypto => { ['Btc', 'Eth', 'Usdt', 'Usdc'].forEach(crypto => {
......
...@@ -79,6 +79,10 @@ ...@@ -79,6 +79,10 @@
<label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 12px;">Max Models per Autoselection</label> <label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 12px;">Max Models per Autoselection</label>
<input type="number" name="max_autoselection_models" style="width: 100%; padding: 12px; border: 1px solid var(--color-border); border-radius: 5px; background: var(--bg-panel); color: var(--color-text); font-size: 14px;" min="-1" value="{{ tier.max_autoselection_models if tier else '-1' }}"> <input type="number" name="max_autoselection_models" style="width: 100%; padding: 12px; border: 1px solid var(--color-border); border-radius: 5px; background: var(--bg-panel); color: var(--color-text); font-size: 14px;" min="-1" value="{{ tier.max_autoselection_models if tier else '-1' }}">
</div> </div>
<div>
<label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 12px;">Market Fee %</label>
<input type="number" name="market_fee_percentage" style="width: 100%; padding: 12px; border: 1px solid var(--color-border); border-radius: 5px; background: var(--bg-panel); color: var(--color-text); font-size: 14px;" min="0" max="100" step="0.01" value="{{ tier.market_fee_percentage if tier else '10.0' }}">
</div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
<th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_max_autoselections">Max Autoselections</th> <th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_max_autoselections">Max Autoselections</th>
<th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_max_models_rotation">Max Models / Rotation</th> <th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_max_models_rotation">Max Models / Rotation</th>
<th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_max_models_autoselect">Max Models / Autoselect</th> <th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_max_models_autoselect">Max Models / Autoselect</th>
<th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;">Market Fee %</th>
<th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_status">Status</th> <th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_status">Status</th>
<th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_actions">Actions</th> <th style="padding: 12px; text-align: center; border-bottom:1px solid var(--color-border); font-weight: 600;" data-i18n="tiers_page.col_actions">Actions</th>
</tr> </tr>
...@@ -50,6 +51,7 @@ ...@@ -50,6 +51,7 @@
<td>{% if tier.max_autoselections == -1 %}<span data-i18n="tiers_page.unlimited">Unlimited</span>{% elif tier.max_autoselections == 0 %}<span data-i18n="tiers_page.blocked">Blocked</span>{% else %}{{ tier.max_autoselections }}{% endif %}</td> <td>{% if tier.max_autoselections == -1 %}<span data-i18n="tiers_page.unlimited">Unlimited</span>{% elif tier.max_autoselections == 0 %}<span data-i18n="tiers_page.blocked">Blocked</span>{% else %}{{ tier.max_autoselections }}{% endif %}</td>
<td>{% if tier.max_rotation_models == -1 %}<span data-i18n="tiers_page.unlimited">Unlimited</span>{% elif tier.max_rotation_models == 0 %}<span data-i18n="tiers_page.blocked">Blocked</span>{% else %}{{ tier.max_rotation_models }}{% endif %}</td> <td>{% if tier.max_rotation_models == -1 %}<span data-i18n="tiers_page.unlimited">Unlimited</span>{% elif tier.max_rotation_models == 0 %}<span data-i18n="tiers_page.blocked">Blocked</span>{% else %}{{ tier.max_rotation_models }}{% endif %}</td>
<td>{% if tier.max_autoselection_models == -1 %}<span data-i18n="tiers_page.unlimited">Unlimited</span>{% elif tier.max_autoselection_models == 0 %}<span data-i18n="tiers_page.blocked">Blocked</span>{% else %}{{ tier.max_autoselection_models }}{% endif %}</td> <td>{% if tier.max_autoselection_models == -1 %}<span data-i18n="tiers_page.unlimited">Unlimited</span>{% elif tier.max_autoselection_models == 0 %}<span data-i18n="tiers_page.blocked">Blocked</span>{% else %}{{ tier.max_autoselection_models }}{% endif %}</td>
<td>{{ '%.2f'|format(tier.market_fee_percentage or 10.0) }}</td>
<td>{% if tier.is_active %}<span class="badge bg-success" data-i18n="tiers_page.active">Active</span>{% else %}<span class="badge bg-secondary" data-i18n="tiers_page.inactive">Inactive</span>{% endif %}</td> <td>{% if tier.is_active %}<span class="badge bg-success" data-i18n="tiers_page.active">Active</span>{% else %}<span class="badge bg-secondary" data-i18n="tiers_page.inactive">Inactive</span>{% endif %}</td>
<td style="white-space: nowrap;"> <td style="white-space: nowrap;">
<a href="{{ url_for(request, '/dashboard/admin/tiers/edit') }}/{{ tier.id }}" class="btn btn-sm btn-outline-primary me-1" title="Edit this tier"> <a href="{{ url_for(request, '/dashboard/admin/tiers/edit') }}/{{ tier.id }}" class="btn btn-sm btn-outline-primary me-1" title="Edit this tier">
...@@ -404,4 +406,4 @@ function showToast(message, type) { ...@@ -404,4 +406,4 @@ function showToast(message, type) {
}, 5000); }, 5000);
} }
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
This diff is collapsed.
...@@ -13,6 +13,7 @@ import asyncio ...@@ -13,6 +13,7 @@ import asyncio
from decimal import Decimal from decimal import Decimal
from datetime import datetime, timedelta from datetime import datetime, timedelta
from unittest.mock import Mock, patch, AsyncMock from unittest.mock import Mock, patch, AsyncMock
from pathlib import Path
@pytest.fixture @pytest.fixture
...@@ -40,6 +41,85 @@ def db_manager(): ...@@ -40,6 +41,85 @@ def db_manager():
return db return db
@pytest.fixture
def market_db_manager(tmp_path):
"""Create file-backed database for concurrent market settlement tests."""
from aisbf.database import DatabaseManager
from aisbf.payments.migrations import PaymentMigrations
db_path = Path(tmp_path) / "market_settlement_test.db"
db = DatabaseManager({
'type': 'sqlite',
'sqlite_path': str(db_path),
})
migrations = PaymentMigrations(db)
migrations.run_migrations()
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO users (username, email, password_hash, role, email_verified)
VALUES ('buyer', 'buyer@example.com', 'hash', 'user', 1)
"""
)
buyer_id = cursor.lastrowid
cursor.execute(
"""
INSERT INTO users (username, email, password_hash, role, email_verified)
VALUES ('seller', 'seller@example.com', 'hash', 'user', 1)
"""
)
seller_id = cursor.lastrowid
cursor.execute(
"""
INSERT INTO user_wallets (user_id, balance, currency_code, created_at, updated_at)
VALUES (?, 100.00, 'USD', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(buyer_id,)
)
cursor.execute(
"""
INSERT INTO user_wallets (user_id, balance, currency_code, created_at, updated_at)
VALUES (?, 0.00, 'USD', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(seller_id,)
)
conn.commit()
listing_id = db.upsert_market_listing(
seller_id,
'seller',
{
'source_scope': 'user',
'source_type': 'provider',
'source_id': 'shared-provider',
'listing_key': 'provider:shared-provider',
'title': 'Shared Provider',
'description': 'Concurrent settlement test listing',
'provider_id': 'shared-provider',
'model_id': None,
'endpoint': 'https://example.test',
'currency_code': 'USD',
'price_per_million_tokens': 5.0,
'price_per_1000_requests': 0.0,
'provider_price_per_million_tokens': 5.0,
'provider_price_per_1000_requests': 0.0,
'metadata': {'provider_type': 'openai'},
'config_snapshot': {'provider': {'type': 'openai'}},
'is_active': True,
},
)
return {
'db': db,
'buyer_id': buyer_id,
'seller_id': seller_id,
'listing_id': listing_id,
}
@pytest.fixture @pytest.fixture
def payment_config(): def payment_config():
"""Payment service configuration""" """Payment service configuration"""
...@@ -423,5 +503,128 @@ class TestEndToEndFlow: ...@@ -423,5 +503,128 @@ class TestEndToEndFlow:
assert status == 'active' assert status == 'active'
class TestMarketSettlementIdempotency:
def test_duplicate_market_settlement_is_idempotent(self, market_db_manager):
db = market_db_manager['db']
buyer_id = market_db_manager['buyer_id']
seller_id = market_db_manager['seller_id']
listing_id = market_db_manager['listing_id']
async def run_duplicate_settlements():
metadata = {'market_request_id': 'shared-request-1', 'kind': 'chat_completion'}
return await asyncio.gather(
asyncio.to_thread(
db.settle_market_usage,
buyer_id,
listing_id,
500000,
500000,
1,
dict(metadata),
),
asyncio.to_thread(
db.settle_market_usage,
buyer_id,
listing_id,
500000,
500000,
1,
dict(metadata),
),
)
first, second = asyncio.run(run_duplicate_settlements())
assert sorted([first['charged'], second['charged']]) == [False, True]
deduplicated = first if not first['charged'] else second
charged = first if first['charged'] else second
assert deduplicated['deduplicated'] is True
assert charged['charged_amount'] == Decimal('5.00')
assert charged['seller_amount'] == Decimal('4.50')
assert charged['platform_fee'] == Decimal('0.50')
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM market_usage_transactions WHERE listing_id = ?", (listing_id,))
usage_count = cursor.fetchone()[0]
cursor.execute(
"SELECT COUNT(*) FROM wallet_transactions WHERE user_id = ? AND type = 'debit' AND description = ?",
(buyer_id, 'Market usage: Shared Provider')
)
debit_count = cursor.fetchone()[0]
cursor.execute("SELECT balance FROM user_wallets WHERE user_id = ?", (buyer_id,))
buyer_balance = Decimal(str(cursor.fetchone()[0]))
cursor.execute("SELECT balance FROM user_wallets WHERE user_id = ?", (seller_id,))
seller_balance = Decimal(str(cursor.fetchone()[0]))
assert usage_count == 1
assert debit_count == 1
assert buyer_balance == Decimal('95.00')
assert seller_balance == Decimal('4.50')
def test_global_admin_listing_books_platform_revenue_without_seller_credit(self, market_db_manager):
db = market_db_manager['db']
buyer_id = market_db_manager['buyer_id']
admin_listing_id = db.upsert_market_listing(
0,
'admin',
{
'source_scope': 'global',
'source_type': 'provider',
'source_id': 'global-provider',
'listing_key': 'provider:global-provider',
'title': 'Global Provider',
'description': 'Global admin market listing',
'provider_id': 'global-provider',
'model_id': None,
'endpoint': 'https://example.test/global',
'currency_code': 'USD',
'price_per_million_tokens': 6.0,
'price_per_1000_requests': 0.0,
'provider_price_per_million_tokens': 6.0,
'provider_price_per_1000_requests': 0.0,
'metadata': {'provider_type': 'openai'},
'config_snapshot': {'provider': {'type': 'openai'}},
'is_active': True,
},
)
result = db.settle_market_usage(
consumer_user_id=buyer_id,
listing_id=admin_listing_id,
prompt_tokens=500000,
completion_tokens=500000,
requests_count=1,
metadata={'market_request_id': 'global-admin-request-1', 'kind': 'chat_completion'},
)
assert result['charged'] is True
assert result['charged_amount'] == Decimal('6.00')
assert result['seller_amount'] == Decimal('0.00')
assert result['platform_fee'] == Decimal('0.00')
assert result['platform_revenue'] == Decimal('6.00')
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT balance FROM user_wallets WHERE user_id = ?", (buyer_id,))
buyer_balance = Decimal(str(cursor.fetchone()[0]))
cursor.execute("SELECT COUNT(*) FROM wallet_transactions WHERE description = ?", ('Market sale: Global Provider',))
seller_credit_count = cursor.fetchone()[0]
cursor.execute(
"SELECT gross_amount, platform_fee, provider_amount, metadata FROM market_usage_transactions WHERE listing_id = ?",
(admin_listing_id,)
)
txn = cursor.fetchone()
assert buyer_balance == Decimal('94.00')
assert seller_credit_count == 0
assert Decimal(str(txn[0])) == Decimal('6.00')
assert Decimal(str(txn[1])) == Decimal('0.00')
assert Decimal(str(txn[2])) == Decimal('0.00')
assert 'platform_owned_listing' in str(txn[3])
if __name__ == '__main__': if __name__ == '__main__':
pytest.main([__file__, '-v']) pytest.main([__file__, '-v'])
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