New Model & tokens/credits updates
This commit is contained in:
@@ -3,7 +3,9 @@ Credit Service for managing credit transactions and deductions
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||
from decimal import Decimal
|
||||
import math
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, AIModelConfig
|
||||
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
|
||||
from igny8_core.auth.models import Account
|
||||
@@ -117,7 +119,10 @@ class CreditService:
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||
def deduct_credits(account, amount, operation_type, description, metadata=None,
|
||||
cost_usd_input=None, cost_usd_output=None, cost_usd_total=None,
|
||||
model_config=None, tokens_input=None, tokens_output=None,
|
||||
related_object_type=None, related_object_id=None):
|
||||
"""
|
||||
Deduct credits and log transaction.
|
||||
|
||||
@@ -127,8 +132,10 @@ class CreditService:
|
||||
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
|
||||
description: Description of the transaction
|
||||
metadata: Optional metadata dict
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
cost_usd_input: Optional input cost in USD
|
||||
cost_usd_output: Optional output cost in USD
|
||||
cost_usd_total: Optional total cost in USD
|
||||
model_config: Optional AIModelConfig instance
|
||||
tokens_input: Optional input tokens
|
||||
tokens_output: Optional output tokens
|
||||
related_object_type: Optional related object type
|
||||
@@ -154,25 +161,45 @@ class CreditService:
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
# Create CreditUsageLog
|
||||
CreditUsageLog.objects.create(
|
||||
account=account,
|
||||
operation_type=operation_type,
|
||||
credits_used=amount,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_used or '',
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type or '',
|
||||
related_object_id=related_object_id,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
# Create CreditUsageLog with new model_config FK
|
||||
log_data = {
|
||||
'account': account,
|
||||
'operation_type': operation_type,
|
||||
'credits_used': amount,
|
||||
'tokens_input': tokens_input,
|
||||
'tokens_output': tokens_output,
|
||||
'related_object_type': related_object_type or '',
|
||||
'related_object_id': related_object_id,
|
||||
'metadata': metadata or {},
|
||||
}
|
||||
|
||||
# Add model tracking (new FK)
|
||||
if model_config:
|
||||
log_data['model_config'] = model_config
|
||||
log_data['model_name'] = model_config.model_name
|
||||
|
||||
# Add cost tracking (new fields)
|
||||
if cost_usd_input is not None:
|
||||
log_data['cost_usd_input'] = cost_usd_input
|
||||
if cost_usd_output is not None:
|
||||
log_data['cost_usd_output'] = cost_usd_output
|
||||
if cost_usd_total is not None:
|
||||
log_data['cost_usd_total'] = cost_usd_total
|
||||
|
||||
# Legacy cost_usd field (backward compatibility)
|
||||
if cost_usd_total is not None:
|
||||
log_data['cost_usd'] = cost_usd_total
|
||||
|
||||
CreditUsageLog.objects.create(**log_data)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||
def deduct_credits_for_operation(account, operation_type, amount=None, description=None,
|
||||
metadata=None, cost_usd_input=None, cost_usd_output=None,
|
||||
cost_usd_total=None, model_config=None, tokens_input=None,
|
||||
tokens_output=None, related_object_type=None, related_object_id=None):
|
||||
"""
|
||||
Deduct credits for an operation (convenience method that calculates cost automatically).
|
||||
|
||||
@@ -182,8 +209,10 @@ class CreditService:
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
description: Optional description (auto-generated if not provided)
|
||||
metadata: Optional metadata dict
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
cost_usd_input: Optional input cost in USD
|
||||
cost_usd_output: Optional output cost in USD
|
||||
cost_usd_total: Optional total cost in USD
|
||||
model_config: Optional AIModelConfig instance
|
||||
tokens_input: Optional input tokens
|
||||
tokens_output: Optional output tokens
|
||||
related_object_type: Optional related object type
|
||||
@@ -192,24 +221,33 @@ class CreditService:
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Calculate credit cost
|
||||
credits_required = CreditService.get_credit_cost(operation_type, amount)
|
||||
# Calculate credit cost - use token-based if tokens provided
|
||||
if tokens_input is not None and tokens_output is not None and model_config:
|
||||
credits_required = CreditService.calculate_credits_from_tokens(
|
||||
operation_type, tokens_input, tokens_output, model_config
|
||||
)
|
||||
else:
|
||||
credits_required = CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
# Check sufficient credits
|
||||
CreditService.check_credits(account, operation_type, amount)
|
||||
CreditService.check_credits_legacy(account, credits_required)
|
||||
|
||||
# Auto-generate description if not provided
|
||||
if not description:
|
||||
model_name = model_config.display_name if model_config else "AI"
|
||||
if operation_type == 'clustering':
|
||||
description = f"Clustering operation"
|
||||
description = f"Clustering operation ({model_name})"
|
||||
elif operation_type == 'idea_generation':
|
||||
description = f"Generated {amount or 1} idea(s)"
|
||||
description = f"Generated {amount or 1} idea(s) ({model_name})"
|
||||
elif operation_type == 'content_generation':
|
||||
description = f"Generated content ({amount or 0} words)"
|
||||
if tokens_input and tokens_output:
|
||||
description = f"Generated content ({tokens_input + tokens_output} tokens, {model_name})"
|
||||
else:
|
||||
description = f"Generated content ({amount or 0} words, {model_name})"
|
||||
elif operation_type == 'image_generation':
|
||||
description = f"Generated {amount or 1} image(s)"
|
||||
description = f"Generated {amount or 1} image(s) ({model_name})"
|
||||
else:
|
||||
description = f"{operation_type} operation"
|
||||
description = f"{operation_type} operation ({model_name})"
|
||||
|
||||
return CreditService.deduct_credits(
|
||||
account=account,
|
||||
@@ -217,8 +255,10 @@ class CreditService:
|
||||
operation_type=operation_type,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_used,
|
||||
cost_usd_input=cost_usd_input,
|
||||
cost_usd_output=cost_usd_output,
|
||||
cost_usd_total=cost_usd_total,
|
||||
model_config=model_config,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type,
|
||||
@@ -292,3 +332,152 @@ class CreditService:
|
||||
|
||||
return CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output, model_config):
|
||||
"""
|
||||
Calculate credits based on actual token usage and AI model configuration.
|
||||
This is the new token-aware calculation method.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation (e.g., 'content_generation')
|
||||
tokens_input: Number of input tokens used
|
||||
tokens_output: Number of output tokens used
|
||||
model_config: AIModelConfig instance
|
||||
|
||||
Returns:
|
||||
int: Number of credits to deduct
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If calculation fails
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from igny8_core.business.billing.models import CreditCostConfig
|
||||
|
||||
# Get operation config
|
||||
config = CreditCostConfig.objects.filter(
|
||||
operation_type=operation_type,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if not config:
|
||||
raise CreditCalculationError(f"No active config found for operation: {operation_type}")
|
||||
|
||||
# Check if operation uses token-based billing
|
||||
if config.unit in ['per_100_tokens', 'per_1000_tokens']:
|
||||
total_tokens = tokens_input + tokens_output
|
||||
|
||||
# Get model's tokens-per-credit ratio
|
||||
tokens_per_credit = model_config.tokens_per_credit
|
||||
|
||||
if tokens_per_credit <= 0:
|
||||
raise CreditCalculationError(f"Invalid tokens_per_credit: {tokens_per_credit}")
|
||||
|
||||
# Calculate credits (float)
|
||||
credits_float = Decimal(total_tokens) / Decimal(tokens_per_credit)
|
||||
|
||||
# Apply rounding (always round up to avoid undercharging)
|
||||
credits = math.ceil(credits_float)
|
||||
|
||||
# Apply minimum cost from config (if set)
|
||||
credits = max(credits, config.credits_cost)
|
||||
|
||||
logger.info(
|
||||
f"Token-based calculation: {total_tokens} tokens / {tokens_per_credit} = {credits} credits "
|
||||
f"(model: {model_config.model_name}, operation: {operation_type})"
|
||||
)
|
||||
|
||||
return credits
|
||||
else:
|
||||
# Fall back to legacy calculation for non-token operations
|
||||
logger.warning(
|
||||
f"Operation {operation_type} uses unit {config.unit}, falling back to legacy calculation"
|
||||
)
|
||||
return config.credits_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate credits from tokens: {e}")
|
||||
raise CreditCalculationError(f"Credit calculation failed: {e}")
|
||||
|
||||
@staticmethod
|
||||
def get_model_for_operation(account, operation_type, task_model_override=None):
|
||||
"""
|
||||
Determine which AI model to use for an operation.
|
||||
Priority: Task Override > Account Default > Operation Default > System Default
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
task_model_override: Optional AIModelConfig instance from task
|
||||
|
||||
Returns:
|
||||
AIModelConfig: The model to use
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 1. Task-level override (highest priority)
|
||||
if task_model_override:
|
||||
logger.info(f"Using task-level model override: {task_model_override.model_name}")
|
||||
return task_model_override
|
||||
|
||||
# 2. Account default model (from IntegrationSettings)
|
||||
try:
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
from igny8_core.business.billing.models import CreditCostConfig
|
||||
|
||||
integration = IntegrationSettings.objects.filter(account=account).first()
|
||||
|
||||
if integration:
|
||||
# Determine if this is text or image operation
|
||||
config = CreditCostConfig.objects.filter(
|
||||
operation_type=operation_type,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if config and config.default_model:
|
||||
model_type = config.default_model.model_type
|
||||
|
||||
if model_type == 'text' and integration.default_text_model:
|
||||
logger.info(f"Using account default text model: {integration.default_text_model.model_name}")
|
||||
return integration.default_text_model
|
||||
elif model_type == 'image' and integration.default_image_model:
|
||||
logger.info(f"Using account default image model: {integration.default_image_model.model_name}")
|
||||
return integration.default_image_model
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get account default model: {e}")
|
||||
|
||||
# 3. Operation default model
|
||||
try:
|
||||
from igny8_core.business.billing.models import CreditCostConfig
|
||||
|
||||
config = CreditCostConfig.objects.filter(
|
||||
operation_type=operation_type,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if config and config.default_model:
|
||||
logger.info(f"Using operation default model: {config.default_model.model_name}")
|
||||
return config.default_model
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get operation default model: {e}")
|
||||
|
||||
# 4. System-wide default (fallback)
|
||||
try:
|
||||
default_model = AIModelConfig.objects.filter(
|
||||
is_default=True,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if default_model:
|
||||
logger.info(f"Using system default model: {default_model.model_name}")
|
||||
return default_model
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get system default model: {e}")
|
||||
|
||||
# 5. Hard-coded fallback
|
||||
logger.warning("All model selection failed, using hard-coded fallback: gpt-4o-mini")
|
||||
return AIModelConfig.objects.filter(model_name='gpt-4o-mini').first()
|
||||
|
||||
Reference in New Issue
Block a user