Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
A
aisbf
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
nexlab
aisbf
Commits
c5907326
Commit
c5907326
authored
May 11, 2026
by
Stefy Lanza (nextime / spora )
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add marketplace publishing and settlement
parent
67149a8a
Changes
15
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
2356 additions
and
22 deletions
+2356
-22
database.py
aisbf/database.py
+804
-17
handlers.py
aisbf/handlers.py
+149
-3
models.py
aisbf/models.py
+48
-0
api.py
aisbf/routes/api.py
+52
-0
admin.py
aisbf/routes/dashboard/admin.py
+33
-1
market.py
aisbf/routes/dashboard/market.py
+686
-0
user_api.py
aisbf/routes/user_api.py
+86
-0
main.py
main.py
+3
-0
base.html
templates/base.html
+2
-0
admin_market.html
templates/dashboard/admin_market.html
+41
-0
admin_payment_settings.html
templates/dashboard/admin_payment_settings.html
+45
-0
admin_tier_form.html
templates/dashboard/admin_tier_form.html
+4
-0
admin_tiers.html
templates/dashboard/admin_tiers.html
+3
-1
market.html
templates/dashboard/market.html
+197
-0
test_integration.py
tests/payments/test_integration.py
+203
-0
No files found.
aisbf/database.py
View file @
c5907326
This diff is collapsed.
Click to expand it.
aisbf/handlers.py
View file @
c5907326
This diff is collapsed.
Click to expand it.
aisbf/models.py
View file @
c5907326
...
...
@@ -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
aisbf/routes/api.py
View file @
c5907326
...
...
@@ -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'"
)
...
...
aisbf/routes/dashboard/admin.py
View file @
c5907326
...
...
@@ -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"""
...
...
aisbf/routes/dashboard/market.py
0 → 100644
View file @
c5907326
This diff is collapsed.
Click to expand it.
aisbf/routes/user_api.py
View file @
c5907326
...
...
@@ -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'"
)
...
...
main.py
View file @
c5907326
...
...
@@ -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
)
# ---------------------------------------------------------------------------
...
...
templates/base.html
View file @
c5907326
...
...
@@ -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 %}
...
...
templates/dashboard/admin_market.html
0 → 100644
View file @
c5907326
{% 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 %}
templates/dashboard/admin_payment_settings.html
View file @
c5907326
...
...
@@ -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
=>
{
...
...
templates/dashboard/admin_tier_form.html
View file @
c5907326
...
...
@@ -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>
...
...
templates/dashboard/admin_tiers.html
View file @
c5907326
...
...
@@ -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 %}
templates/dashboard/market.html
0 → 100644
View file @
c5907326
This diff is collapsed.
Click to expand it.
tests/payments/test_integration.py
View file @
c5907326
...
...
@@ -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'
])
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment