mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Mention free MiniMax usage and drop free credits (#12918)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -54,7 +54,7 @@ The experience will be familiar to anyone who has used Devin or Jules.
|
||||
### OpenHands Cloud
|
||||
This is a deployment of OpenHands GUI, running on hosted infrastructure.
|
||||
|
||||
You can try it with a free $10 credit by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
|
||||
You can try it for free using the Minimax model by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
|
||||
|
||||
OpenHands Cloud comes with source-available features and integrations:
|
||||
- Integrations with Slack, Jira, and Linear
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Drop pending_free_credits column from org table.
|
||||
|
||||
Revision ID: 095
|
||||
Revises: 094
|
||||
Create Date: 2025-02-18 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '095'
|
||||
down_revision: Union[str, None] = '094'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the pending_free_credits column from org table.
|
||||
# This column was used for tracking free credit eligibility but is no longer needed.
|
||||
op.drop_column('org', 'pending_free_credits')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Re-add pending_free_credits column with default false.
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column(
|
||||
'pending_free_credits',
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
),
|
||||
)
|
||||
@@ -61,8 +61,6 @@ SUBSCRIPTION_PRICE_DATA = {
|
||||
},
|
||||
}
|
||||
|
||||
FREE_CREDIT_THRESHOLD = float(os.environ.get('FREE_CREDIT_THRESHOLD', '10'))
|
||||
FREE_CREDIT_AMOUNT = float(os.environ.get('FREE_CREDIT_AMOUNT', '10'))
|
||||
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
|
||||
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')
|
||||
|
||||
|
||||
@@ -9,11 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from integrations import stripe_service
|
||||
from pydantic import BaseModel
|
||||
from server.constants import (
|
||||
FREE_CREDIT_AMOUNT,
|
||||
FREE_CREDIT_THRESHOLD,
|
||||
STRIPE_API_KEY,
|
||||
)
|
||||
from server.constants import STRIPE_API_KEY
|
||||
from server.logger import logger
|
||||
from starlette.datastructures import URL
|
||||
from storage.billing_session import BillingSession
|
||||
@@ -151,7 +147,7 @@ async def create_customer_setup_session(
|
||||
customer=customer_info['customer_id'],
|
||||
mode='setup',
|
||||
payment_method_types=['card'],
|
||||
success_url=f'{base_url}?free_credits=success',
|
||||
success_url=f'{base_url}?setup=success',
|
||||
cancel_url=f'{base_url}',
|
||||
)
|
||||
return CreateBillingSessionResponse(redirect_url=checkout_session.url)
|
||||
@@ -260,24 +256,6 @@ async def success_callback(session_id: str, request: Request):
|
||||
org = session.query(Org).filter(Org.id == user.current_org_id).first()
|
||||
new_max_budget = max_budget + add_credits
|
||||
|
||||
# Grant free credits if:
|
||||
# 1. The org has pending free credits (new org, eligible)
|
||||
# 2. The budget after this purchase meets the threshold
|
||||
should_grant_free_credits = (
|
||||
org and org.pending_free_credits and new_max_budget >= FREE_CREDIT_THRESHOLD
|
||||
)
|
||||
if should_grant_free_credits:
|
||||
new_max_budget += FREE_CREDIT_AMOUNT
|
||||
org.pending_free_credits = False
|
||||
logger.info(
|
||||
'free_credits_granted',
|
||||
extra={
|
||||
'user_id': billing_session.user_id,
|
||||
'org_id': str(user.current_org_id),
|
||||
'free_credit_amount': FREE_CREDIT_AMOUNT,
|
||||
},
|
||||
)
|
||||
|
||||
await LiteLlmManager.update_team_and_users_budget(
|
||||
str(user.current_org_id), new_max_budget
|
||||
)
|
||||
@@ -299,7 +277,6 @@ async def success_callback(session_id: str, request: Request):
|
||||
'org_id': str(user.current_org_id),
|
||||
'checkout_session_id': billing_session.id,
|
||||
'stripe_customer_id': stripe_session.customer,
|
||||
'free_credits_granted': should_grant_free_credits,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@@ -47,7 +47,6 @@ class Org(Base): # type: ignore
|
||||
conversation_expiration = Column(Integer, nullable=True)
|
||||
condenser_max_size = Column(Integer, nullable=True)
|
||||
byor_export_enabled = Column(Boolean, nullable=False, default=False)
|
||||
pending_free_credits = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
|
||||
@@ -112,7 +112,6 @@ class OrgService:
|
||||
contact_email=contact_email,
|
||||
org_version=ORG_SETTINGS_VERSION,
|
||||
default_llm_model=get_default_litellm_model(),
|
||||
pending_free_credits=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -59,7 +59,6 @@ class UserStore:
|
||||
or user_info.get('preferred_username', ''),
|
||||
contact_email=user_info['email'],
|
||||
v1_enabled=True,
|
||||
pending_free_credits=True,
|
||||
)
|
||||
session.add(org)
|
||||
|
||||
@@ -196,7 +195,6 @@ class UserStore:
|
||||
or user_info.get('username', ''),
|
||||
contact_email=user_info['email'],
|
||||
byor_export_enabled=has_completed_billing,
|
||||
pending_free_credits=not has_completed_billing,
|
||||
)
|
||||
session.add(org)
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ async def test_success_callback_stripe_incomplete():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_callback_success():
|
||||
"""Test successful payment completion and credit update (bonus already granted)."""
|
||||
"""Test successful payment completion and credit update."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
@@ -300,7 +300,6 @@ async def test_success_callback_success():
|
||||
mock_billing_session.user_id = 'mock_user'
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.pending_free_credits = False # Not eligible (old org or already granted)
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
@@ -347,10 +346,10 @@ async def test_success_callback_success():
|
||||
== 'https://test.com/settings/billing?checkout=success'
|
||||
)
|
||||
|
||||
# Verify LiteLLM API calls - no bonus since not eligible
|
||||
# Verify LiteLLM API calls
|
||||
mock_update_budget.assert_called_once_with(
|
||||
'mock_org_id',
|
||||
125.0, # 100 + 25.00 (no bonus)
|
||||
125.0, # 100 + 25.00
|
||||
)
|
||||
|
||||
# Verify BYOR export is enabled for the org (updated in same session)
|
||||
@@ -363,92 +362,6 @@ async def test_success_callback_success():
|
||||
mock_db_session.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'initial_budget,purchase_cents,pending_credits,expected_final_budget,expected_pending_after',
|
||||
[
|
||||
# New user buys $10 -> gets free credits, pending becomes False
|
||||
(0, 1000, True, 20.0, False),
|
||||
# New user buys $5 -> below threshold, no free credits yet, pending stays True
|
||||
(0, 500, True, 5.0, True),
|
||||
# User with $5 buys $5 more -> reaches threshold, gets free credits
|
||||
(5.0, 500, True, 20.0, False),
|
||||
# User with $5 buys $3 -> below threshold, no free credits yet
|
||||
(5.0, 300, True, 8.0, True),
|
||||
# Old user (not pending) buys $25 -> no free credits, stays False
|
||||
(20.0, 2500, False, 45.0, False),
|
||||
],
|
||||
ids=[
|
||||
'new_user_buys_10_gets_free_credits',
|
||||
'new_user_buys_5_below_threshold',
|
||||
'user_with_5_buys_5_reaches_threshold',
|
||||
'user_with_5_buys_3_below_threshold',
|
||||
'old_user_not_eligible',
|
||||
],
|
||||
)
|
||||
async def test_success_callback_free_credits(
|
||||
initial_budget,
|
||||
purchase_cents,
|
||||
pending_credits,
|
||||
expected_final_budget,
|
||||
expected_pending_after,
|
||||
):
|
||||
"""Test free credits are granted only when pending and threshold is met."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
mock_billing_session = MagicMock()
|
||||
mock_billing_session.status = 'in_progress'
|
||||
mock_billing_session.user_id = 'mock_user'
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.pending_free_credits = pending_credits
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
patch('stripe.checkout.Session.retrieve') as mock_stripe_retrieve,
|
||||
patch(
|
||||
'storage.user_store.UserStore.get_user_by_id_async',
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(current_org_id='mock_org_id'),
|
||||
),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.get_user_team_info',
|
||||
return_value={
|
||||
'spend': 0,
|
||||
'litellm_budget_table': {'max_budget': initial_budget},
|
||||
},
|
||||
),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.update_team_and_users_budget'
|
||||
) as mock_update_budget,
|
||||
patch('server.routes.billing.FREE_CREDIT_THRESHOLD', 10.0),
|
||||
patch('server.routes.billing.FREE_CREDIT_AMOUNT', 10.0),
|
||||
):
|
||||
mock_db_session = MagicMock()
|
||||
mock_query_chain_billing = MagicMock()
|
||||
mock_query_chain_billing.filter.return_value.filter.return_value.first.return_value = mock_billing_session
|
||||
mock_query_chain_org = MagicMock()
|
||||
mock_query_chain_org.filter.return_value.first.return_value = mock_org
|
||||
mock_db_session.query.side_effect = [
|
||||
mock_query_chain_billing,
|
||||
mock_query_chain_org,
|
||||
]
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_db_session
|
||||
|
||||
mock_stripe_retrieve.return_value = MagicMock(
|
||||
status='complete',
|
||||
amount_subtotal=purchase_cents,
|
||||
customer='mock_customer_id',
|
||||
)
|
||||
|
||||
response = await success_callback('test_session_id', mock_request)
|
||||
|
||||
assert response.status_code == 302
|
||||
mock_update_budget.assert_called_once_with('mock_org_id', expected_final_budget)
|
||||
assert mock_org.pending_free_credits is expected_pending_after
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_callback_lite_llm_error():
|
||||
"""Test handling of LiteLLM API errors during success callback."""
|
||||
@@ -491,11 +404,10 @@ async def test_success_callback_lite_llm_error():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_callback_lite_llm_update_budget_error_rollback():
|
||||
"""Test that pending_free_credits change is not committed when update_team_and_users_budget fails.
|
||||
"""Test that database changes are not committed when update_team_and_users_budget fails.
|
||||
|
||||
This test verifies that if LiteLlmManager.update_team_and_users_budget raises an exception
|
||||
after pending_free_credits has been set to False, the database transaction rolls back and
|
||||
pending_free_credits remains True.
|
||||
This test verifies that if LiteLlmManager.update_team_and_users_budget raises an exception,
|
||||
the database transaction rolls back.
|
||||
"""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
@@ -505,7 +417,6 @@ async def test_success_callback_lite_llm_update_budget_error_rollback():
|
||||
mock_billing_session.user_id = 'mock_user'
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.pending_free_credits = True
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
@@ -526,8 +437,6 @@ async def test_success_callback_lite_llm_update_budget_error_rollback():
|
||||
'storage.lite_llm_manager.LiteLlmManager.update_team_and_users_budget',
|
||||
side_effect=Exception('LiteLLM API Error'),
|
||||
),
|
||||
patch('server.routes.billing.FREE_CREDIT_THRESHOLD', 10.0),
|
||||
patch('server.routes.billing.FREE_CREDIT_AMOUNT', 10.0),
|
||||
):
|
||||
mock_db_session = MagicMock()
|
||||
mock_query_chain_billing = MagicMock()
|
||||
@@ -540,7 +449,6 @@ async def test_success_callback_lite_llm_update_budget_error_rollback():
|
||||
]
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_db_session
|
||||
|
||||
# Purchase $10 to reach threshold
|
||||
mock_stripe_retrieve.return_value = MagicMock(
|
||||
status='complete',
|
||||
amount_subtotal=1000, # $10
|
||||
@@ -671,6 +579,6 @@ async def test_create_customer_setup_session_success():
|
||||
customer='mock-customer-id',
|
||||
mode='setup',
|
||||
payment_method_types=['card'],
|
||||
success_url='https://test.com/?free_credits=success',
|
||||
success_url='https://test.com/?setup=success',
|
||||
cancel_url='https://test.com/',
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def test_create_customer_setup_session_uses_customer_id():
|
||||
customer=customer_id,
|
||||
mode='setup',
|
||||
payment_method_types=['card'],
|
||||
success_url=f'{request.base_url}?free_credits=success',
|
||||
success_url=f'{request.base_url}?setup=success',
|
||||
cancel_url=f'{request.base_url}',
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user