Merge branch 'main' of https://git.igny8.com/salman/igny8
This commit is contained in:
@@ -791,3 +791,238 @@ class AccountPaymentMethod(AccountBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.account_id} - {self.display_name} ({self.type})"
|
||||
|
||||
|
||||
class AIModelConfig(models.Model):
|
||||
"""
|
||||
AI Model Configuration - Database-driven model pricing and capabilities.
|
||||
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
|
||||
|
||||
Two pricing models:
|
||||
- Text models: Cost per 1M tokens (input/output), credits calculated AFTER AI call
|
||||
- Image models: Cost per image, credits calculated BEFORE AI call
|
||||
"""
|
||||
|
||||
MODEL_TYPE_CHOICES = [
|
||||
('text', 'Text Generation'),
|
||||
('image', 'Image Generation'),
|
||||
('embedding', 'Embedding'),
|
||||
]
|
||||
|
||||
PROVIDER_CHOICES = [
|
||||
('openai', 'OpenAI'),
|
||||
('anthropic', 'Anthropic'),
|
||||
('runware', 'Runware'),
|
||||
('google', 'Google'),
|
||||
]
|
||||
|
||||
# Basic Information
|
||||
model_name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')"
|
||||
)
|
||||
|
||||
display_name = models.CharField(
|
||||
max_length=200,
|
||||
help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')"
|
||||
)
|
||||
|
||||
model_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=MODEL_TYPE_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Type of model - determines which pricing fields are used"
|
||||
)
|
||||
|
||||
provider = models.CharField(
|
||||
max_length=50,
|
||||
choices=PROVIDER_CHOICES,
|
||||
db_index=True,
|
||||
help_text="AI provider (OpenAI, Anthropic, etc.)"
|
||||
)
|
||||
|
||||
# Text Model Pricing (Only for model_type='text')
|
||||
input_cost_per_1m = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
||||
help_text="Cost per 1 million input tokens (USD). For text models only."
|
||||
)
|
||||
|
||||
output_cost_per_1m = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
||||
help_text="Cost per 1 million output tokens (USD). For text models only."
|
||||
)
|
||||
|
||||
context_window = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum input tokens (context length). For text models only."
|
||||
)
|
||||
|
||||
max_output_tokens = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum output tokens per request. For text models only."
|
||||
)
|
||||
|
||||
# Image Model Pricing (Only for model_type='image')
|
||||
cost_per_image = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
||||
help_text="Fixed cost per image generation (USD). For image models only."
|
||||
)
|
||||
|
||||
valid_sizes = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.'
|
||||
)
|
||||
|
||||
# Capabilities
|
||||
supports_json_mode = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for models with JSON response format support"
|
||||
)
|
||||
|
||||
supports_vision = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for models that can analyze images"
|
||||
)
|
||||
|
||||
supports_function_calling = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for models with function calling capability"
|
||||
)
|
||||
|
||||
# Status & Configuration
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Enable/disable model without deleting"
|
||||
)
|
||||
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Mark as default model for its type (only one per type)"
|
||||
)
|
||||
|
||||
sort_order = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Control order in dropdown lists (lower numbers first)"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Admin notes about model usage, strengths, limitations"
|
||||
)
|
||||
|
||||
release_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When model was released/added"
|
||||
)
|
||||
|
||||
deprecation_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When model will be removed"
|
||||
)
|
||||
|
||||
# Audit Fields
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='ai_model_updates',
|
||||
help_text="Admin who last updated"
|
||||
)
|
||||
|
||||
# History tracking
|
||||
history = HistoricalRecords()
|
||||
|
||||
class Meta:
|
||||
app_label = 'billing'
|
||||
db_table = 'igny8_ai_model_config'
|
||||
verbose_name = 'AI Model Configuration'
|
||||
verbose_name_plural = 'AI Model Configurations'
|
||||
ordering = ['model_type', 'sort_order', 'model_name']
|
||||
indexes = [
|
||||
models.Index(fields=['model_type', 'is_active']),
|
||||
models.Index(fields=['provider', 'is_active']),
|
||||
models.Index(fields=['is_default', 'model_type']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Ensure only one is_default per model_type"""
|
||||
if self.is_default:
|
||||
# Unset other defaults for same model_type
|
||||
AIModelConfig.objects.filter(
|
||||
model_type=self.model_type,
|
||||
is_default=True
|
||||
).exclude(pk=self.pk).update(is_default=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_cost_for_tokens(self, input_tokens, output_tokens):
|
||||
"""Calculate cost for text models based on token usage"""
|
||||
if self.model_type != 'text':
|
||||
raise ValueError("get_cost_for_tokens only applies to text models")
|
||||
|
||||
if not self.input_cost_per_1m or not self.output_cost_per_1m:
|
||||
raise ValueError(f"Model {self.model_name} missing cost_per_1m values")
|
||||
|
||||
cost = (
|
||||
(Decimal(input_tokens) * self.input_cost_per_1m) +
|
||||
(Decimal(output_tokens) * self.output_cost_per_1m)
|
||||
) / Decimal('1000000')
|
||||
|
||||
return cost
|
||||
|
||||
def get_cost_for_images(self, num_images):
|
||||
"""Calculate cost for image models"""
|
||||
if self.model_type != 'image':
|
||||
raise ValueError("get_cost_for_images only applies to image models")
|
||||
|
||||
if not self.cost_per_image:
|
||||
raise ValueError(f"Model {self.model_name} missing cost_per_image")
|
||||
|
||||
return self.cost_per_image * Decimal(num_images)
|
||||
|
||||
def validate_size(self, size):
|
||||
"""Check if size is valid for this image model"""
|
||||
if self.model_type != 'image':
|
||||
raise ValueError("validate_size only applies to image models")
|
||||
|
||||
if not self.valid_sizes:
|
||||
return True # No size restrictions
|
||||
|
||||
return size in self.valid_sizes
|
||||
|
||||
def get_display_with_pricing(self):
|
||||
"""For dropdowns: show model with pricing"""
|
||||
if self.model_type == 'text':
|
||||
return f"{self.display_name} - ${self.input_cost_per_1m}/${self.output_cost_per_1m} per 1M"
|
||||
elif self.model_type == 'image':
|
||||
return f"{self.display_name} - ${self.cost_per_image} per image"
|
||||
return self.display_name
|
||||
|
||||
@@ -3,97 +3,116 @@ Credit Service for managing credit transactions and deductions
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
import math
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, AIModelConfig
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||
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
|
||||
|
||||
|
||||
class CreditService:
|
||||
"""Service for managing credits"""
|
||||
"""Service for managing credits - Token-based only"""
|
||||
|
||||
@staticmethod
|
||||
def get_credit_cost(operation_type, amount=None):
|
||||
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output):
|
||||
"""
|
||||
Get credit cost for operation.
|
||||
Now checks database config first, falls back to constants.
|
||||
Calculate credits from actual token usage using configured ratio.
|
||||
This is the ONLY way credits are calculated in the system.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation (from CREDIT_COSTS)
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
operation_type: Type of operation
|
||||
tokens_input: Input tokens used
|
||||
tokens_output: Output tokens used
|
||||
|
||||
Returns:
|
||||
int: Number of credits required
|
||||
|
||||
int: Credits to deduct
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If operation type is unknown
|
||||
CreditCalculationError: If configuration error
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
from igny8_core.business.billing.models import CreditCostConfig, BillingConfiguration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to get from database config first
|
||||
try:
|
||||
from igny8_core.business.billing.models import CreditCostConfig
|
||||
|
||||
config = CreditCostConfig.objects.filter(
|
||||
operation_type=operation_type,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if config:
|
||||
base_cost = config.credits_cost
|
||||
|
||||
# Apply unit-based calculation
|
||||
if config.unit == 'per_100_words' and amount:
|
||||
return max(1, int(base_cost * (amount / 100)))
|
||||
elif config.unit == 'per_200_words' and amount:
|
||||
return max(1, int(base_cost * (amount / 200)))
|
||||
elif config.unit in ['per_item', 'per_image'] and amount:
|
||||
return base_cost * amount
|
||||
else:
|
||||
return base_cost
|
||||
# Get operation config (use global default if not found)
|
||||
config = CreditCostConfig.objects.filter(
|
||||
operation_type=operation_type,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get cost from database, using constants: {e}")
|
||||
if not config:
|
||||
# Use global billing config as fallback
|
||||
billing_config = BillingConfiguration.get_config()
|
||||
tokens_per_credit = billing_config.default_tokens_per_credit
|
||||
min_credits = 1
|
||||
logger.info(f"No config for {operation_type}, using default: {tokens_per_credit} tokens/credit")
|
||||
else:
|
||||
tokens_per_credit = config.tokens_per_credit
|
||||
min_credits = config.min_credits
|
||||
|
||||
# Fallback to hardcoded constants
|
||||
base_cost = CREDIT_COSTS.get(operation_type, 0)
|
||||
if base_cost == 0:
|
||||
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
||||
# Calculate total tokens
|
||||
total_tokens = (tokens_input or 0) + (tokens_output or 0)
|
||||
|
||||
# Variable cost operations (legacy logic)
|
||||
if operation_type == 'content_generation' and amount:
|
||||
# Per 100 words
|
||||
return max(1, int(base_cost * (amount / 100)))
|
||||
elif operation_type == 'optimization' and amount:
|
||||
# Per 200 words
|
||||
return max(1, int(base_cost * (amount / 200)))
|
||||
elif operation_type == 'image_generation' and amount:
|
||||
# Per image
|
||||
return base_cost * amount
|
||||
elif operation_type == 'idea_generation' and amount:
|
||||
# Per idea
|
||||
return base_cost * amount
|
||||
# Calculate credits (fractional)
|
||||
if tokens_per_credit <= 0:
|
||||
raise CreditCalculationError(f"Invalid tokens_per_credit: {tokens_per_credit}")
|
||||
|
||||
# Fixed cost operations
|
||||
return base_cost
|
||||
credits_float = total_tokens / tokens_per_credit
|
||||
|
||||
# Get rounding mode from global config
|
||||
billing_config = BillingConfiguration.get_config()
|
||||
rounding_mode = billing_config.credit_rounding_mode
|
||||
|
||||
if rounding_mode == 'up':
|
||||
credits = math.ceil(credits_float)
|
||||
elif rounding_mode == 'down':
|
||||
credits = math.floor(credits_float)
|
||||
else: # nearest
|
||||
credits = round(credits_float)
|
||||
|
||||
# Apply minimum
|
||||
credits = max(credits, min_credits)
|
||||
|
||||
logger.info(
|
||||
f"Calculated credits for {operation_type}: "
|
||||
f"{total_tokens} tokens ({tokens_input} in, {tokens_output} out) "
|
||||
f"÷ {tokens_per_credit} = {credits} credits"
|
||||
)
|
||||
|
||||
return credits
|
||||
|
||||
@staticmethod
|
||||
def check_credits(account, operation_type, amount=None):
|
||||
def check_credits(account, operation_type, estimated_amount=None):
|
||||
"""
|
||||
Check if account has sufficient credits for an operation.
|
||||
For token-based operations, this is an estimate check only.
|
||||
Actual deduction happens after AI call with real token usage.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
estimated_amount: Optional estimated amount (for non-token operations)
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
required = CreditService.get_credit_cost(operation_type, amount)
|
||||
from igny8_core.business.billing.models import CreditCostConfig
|
||||
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||
|
||||
# Get operation config
|
||||
config = CreditCostConfig.objects.filter(
|
||||
operation_type=operation_type,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if config:
|
||||
# Use minimum credits as estimate for token-based operations
|
||||
required = config.min_credits
|
||||
else:
|
||||
# Fallback to constants
|
||||
required = CREDIT_COSTS.get(operation_type, 1)
|
||||
|
||||
if account.credits < required:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {required}, Available: {account.credits}"
|
||||
@@ -101,28 +120,50 @@ class CreditService:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_credits_legacy(account, required_credits):
|
||||
def check_credits_legacy(account, amount):
|
||||
"""
|
||||
Legacy method: Check if account has enough credits (for backward compatibility).
|
||||
Legacy method to check credits for a known amount.
|
||||
Used internally by deduct_credits.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
required_credits: Number of credits required
|
||||
amount: Required credits amount
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
if account.credits < required_credits:
|
||||
if account.credits < amount:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
|
||||
f"Insufficient credits. Required: {amount}, Available: {account.credits}"
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_credits_for_tokens(account, operation_type, estimated_tokens_input, estimated_tokens_output):
|
||||
"""
|
||||
Check if account has sufficient credits based on estimated token usage.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
estimated_tokens_input: Estimated input tokens
|
||||
estimated_tokens_output: Estimated output tokens
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
required = CreditService.calculate_credits_from_tokens(
|
||||
operation_type, estimated_tokens_input, estimated_tokens_output
|
||||
)
|
||||
if account.credits < required:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {required}, Available: {account.credits}"
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
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):
|
||||
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):
|
||||
"""
|
||||
Deduct credits and log transaction.
|
||||
|
||||
@@ -132,10 +173,8 @@ class CreditService:
|
||||
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
|
||||
description: Description of the transaction
|
||||
metadata: Optional metadata dict
|
||||
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
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
tokens_input: Optional input tokens
|
||||
tokens_output: Optional output tokens
|
||||
related_object_type: Optional related object type
|
||||
@@ -161,93 +200,83 @@ class CreditService:
|
||||
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)
|
||||
# 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 {}
|
||||
)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
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):
|
||||
def deduct_credits_for_operation(
|
||||
account,
|
||||
operation_type,
|
||||
tokens_input,
|
||||
tokens_output,
|
||||
description=None,
|
||||
metadata=None,
|
||||
cost_usd=None,
|
||||
model_used=None,
|
||||
related_object_type=None,
|
||||
related_object_id=None
|
||||
):
|
||||
"""
|
||||
Deduct credits for an operation (convenience method that calculates cost automatically).
|
||||
Deduct credits for an operation based on actual token usage.
|
||||
This is the ONLY way to deduct credits in the token-based system.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
tokens_input: REQUIRED - Actual input tokens used
|
||||
tokens_output: REQUIRED - Actual output tokens used
|
||||
description: Optional description (auto-generated if not provided)
|
||||
metadata: Optional metadata dict
|
||||
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
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
related_object_type: Optional related object type
|
||||
related_object_id: Optional related object ID
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
|
||||
Raises:
|
||||
ValueError: If tokens_input or tokens_output not provided
|
||||
"""
|
||||
# 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
|
||||
# Validate token inputs
|
||||
if tokens_input is None or tokens_output is None:
|
||||
raise ValueError(
|
||||
f"tokens_input and tokens_output are REQUIRED for credit deduction. "
|
||||
f"Got: tokens_input={tokens_input}, tokens_output={tokens_output}"
|
||||
)
|
||||
else:
|
||||
credits_required = CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
# Calculate credits from actual token usage
|
||||
credits_required = CreditService.calculate_credits_from_tokens(
|
||||
operation_type, tokens_input, tokens_output
|
||||
)
|
||||
|
||||
# Check sufficient credits
|
||||
CreditService.check_credits_legacy(account, credits_required)
|
||||
if account.credits < credits_required:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
|
||||
)
|
||||
|
||||
# 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 ({model_name})"
|
||||
elif operation_type == 'idea_generation':
|
||||
description = f"Generated {amount or 1} idea(s) ({model_name})"
|
||||
elif operation_type == 'content_generation':
|
||||
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) ({model_name})"
|
||||
else:
|
||||
description = f"{operation_type} operation ({model_name})"
|
||||
total_tokens = tokens_input + tokens_output
|
||||
description = (
|
||||
f"{operation_type}: {total_tokens} tokens "
|
||||
f"({tokens_input} in, {tokens_output} out) = {credits_required} credits"
|
||||
)
|
||||
|
||||
return CreditService.deduct_credits(
|
||||
account=account,
|
||||
@@ -255,10 +284,8 @@ class CreditService:
|
||||
operation_type=operation_type,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
cost_usd_input=cost_usd_input,
|
||||
cost_usd_output=cost_usd_output,
|
||||
cost_usd_total=cost_usd_total,
|
||||
model_config=model_config,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_used,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type,
|
||||
@@ -296,188 +323,4 @@ class CreditService:
|
||||
)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
def calculate_credits_for_operation(operation_type, **kwargs):
|
||||
"""
|
||||
Calculate credits needed for an operation.
|
||||
Legacy method - use get_credit_cost() instead.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation
|
||||
**kwargs: Operation-specific parameters
|
||||
|
||||
Returns:
|
||||
int: Number of credits required
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If calculation fails
|
||||
"""
|
||||
# Map legacy operation types
|
||||
if operation_type == 'ideas':
|
||||
operation_type = 'idea_generation'
|
||||
elif operation_type == 'content':
|
||||
operation_type = 'content_generation'
|
||||
elif operation_type == 'images':
|
||||
operation_type = 'image_generation'
|
||||
|
||||
# Extract amount from kwargs
|
||||
amount = None
|
||||
if 'word_count' in kwargs:
|
||||
amount = kwargs.get('word_count')
|
||||
elif 'image_count' in kwargs:
|
||||
amount = kwargs.get('image_count')
|
||||
elif 'idea_count' in kwargs:
|
||||
amount = kwargs.get('idea_count')
|
||||
|
||||
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()
|
||||
|
||||
@@ -13,6 +13,7 @@ from igny8_core.modules.billing.views import (
|
||||
CreditBalanceViewSet,
|
||||
CreditUsageViewSet,
|
||||
CreditTransactionViewSet,
|
||||
AIModelConfigViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -21,6 +22,8 @@ router.register(r'admin', BillingViewSet, basename='billing-admin')
|
||||
router.register(r'credits/balance', CreditBalanceViewSet, basename='credit-balance')
|
||||
router.register(r'credits/usage', CreditUsageViewSet, basename='credit-usage')
|
||||
router.register(r'credits/transactions', CreditTransactionViewSet, basename='credit-transactions')
|
||||
# AI Models endpoint
|
||||
router.register(r'ai/models', AIModelConfigViewSet, basename='ai-models')
|
||||
# User-facing billing endpoints
|
||||
router.register(r'invoices', InvoiceViewSet, basename='invoices')
|
||||
router.register(r'payments', PaymentViewSet, basename='payments')
|
||||
|
||||
Reference in New Issue
Block a user