feat: add marketplace publishing and settlement

parent 67149a8a
This diff is collapsed.
This diff is collapsed.
......@@ -119,6 +119,7 @@ class AccountTier(BaseModel):
max_autoselections: int = -1
max_rotation_models: int = -1
max_autoselection_models: int = -1
market_fee_percentage: float = 10.0
created_at: Optional[int] = None
updated_at: Optional[int] = None
......@@ -165,3 +166,50 @@ class PaymentTransaction(BaseModel):
metadata: Optional[Dict] = None
created_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):
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]:
if '/' in model:
parts = model.split('/', 1)
......@@ -425,6 +450,33 @@ async def embeddings(request: Request, body: dict):
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."""
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)
if not provider_id:
raise HTTPException(status_code=400, detail="Model must be in format 'provider/model'")
......
......@@ -457,6 +457,7 @@ async def api_create_tier(request: Request):
max_autoselections=body.get('max_autoselections', -1),
max_rotation_models=body.get('max_rotation_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_visible=body.get('is_visible', True)
)
......@@ -502,6 +503,8 @@ async def api_update_tier(request: Request, tier_id: int):
update_kwargs['max_rotation_models'] = body['max_rotation_models']
if 'max_autoselection_models' in body:
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:
update_kwargs['is_active'] = body['is_active']
if 'is_visible' in body:
......@@ -603,6 +606,7 @@ async def dashboard_admin_tier_save(request: Request):
'max_autoselections': int(form.get('max_autoselections', -1)),
'max_rotation_models': int(form.get('max_rotation_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_visible': form.get('is_visible') == '1'
}
......@@ -1504,10 +1508,38 @@ async def dashboard_admin_payment_settings(request: Request):
context={
"request": request,
"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")
async def dashboard_pricing(request: Request):
"""Pricing plans page for users"""
......
This diff is collapsed.
......@@ -18,6 +18,31 @@ def init(config, get_user_handler_fn):
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]:
if '/' in model:
parts = model.split('/', 1)
......@@ -257,6 +282,67 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
authenticated_user = db.get_user_by_id(user_id)
if authenticated_user and authenticated_user['username'] != username:
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)
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'")
......
......@@ -196,6 +196,7 @@ import aisbf.routes.dashboard.settings as _dash_settings
import aisbf.routes.dashboard.admin as _dash_admin
import aisbf.routes.dashboard.payments as _dash_payments
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(_api_routes.router)
......@@ -207,6 +208,7 @@ app.include_router(_dash_settings.router)
app.include_router(_dash_admin.router)
app.include_router(_dash_payments.router)
app.include_router(_dash_provider_auth.router)
app.include_router(_dash_market.router)
# Wallet routes
try:
......@@ -230,6 +232,7 @@ def _init_all_routers():
_dash_admin.init(config, templates)
_dash_payments.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/>.
<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/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/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %} data-i18n="nav.analytics">Analytics</a>
{% if request.session.user_id %}
......@@ -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/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/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>
{% endif %}
{% 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 @@
</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 -->
<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);">
......@@ -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
function refreshCryptoPrices() {
['Btc', 'Eth', 'Usdt', 'Usdc'].forEach(crypto => {
......
......@@ -79,6 +79,10 @@
<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' }}">
</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>
......
......@@ -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_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;">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_actions">Actions</th>
</tr>
......@@ -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_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>{{ '%.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 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">
......@@ -404,4 +406,4 @@ function showToast(message, type) {
}, 5000);
}
</script>
{% endblock %}
\ No newline at end of file
{% endblock %}
This diff is collapsed.
......@@ -13,6 +13,7 @@ import asyncio
from decimal import Decimal
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, AsyncMock
from pathlib import Path
@pytest.fixture
......@@ -40,6 +41,85 @@ def db_manager():
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
def payment_config():
"""Payment service configuration"""
......@@ -423,5 +503,128 @@ class TestEndToEndFlow:
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__':
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